chore: mqsrv backend
This commit is contained in:
20
.env
Normal file
20
.env
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://maqt:maqt123456@localhost:5432/maqt?schema=public"
|
||||||
|
|
||||||
|
# JWT 密钥
|
||||||
|
JWT_SECRET="maqt-jwt-secret-key-2026-change-in-production"
|
||||||
|
|
||||||
|
# JWT 过期时间
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# 数据加密密钥(32字节)
|
||||||
|
ENCRYPTION_KEY="maqt-encryption-key-32bytes!"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# 环境
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# VIP 卡密批次密钥
|
||||||
|
BATCH_SECRET="maqt-batch-secret-2026"
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/maqt?schema=public"
|
||||||
|
|
||||||
|
# JWT 密钥(请修改为随机字符串)
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
|
||||||
|
|
||||||
|
# JWT 过期时间
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# 数据加密密钥(32字节,请修改)
|
||||||
|
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# 环境
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# VIP 卡密批次密钥(用于生成卡密签名)
|
||||||
|
BATCH_SECRET="your-batch-secret-key"
|
||||||
192
API_FULL_ANALYSIS.md
Normal file
192
API_FULL_ANALYSIS.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 码枪堂 API 全线分析报告
|
||||||
|
|
||||||
|
> 基于 Fiddler 抓包 ss3.saz(原版 API)+ ss4.saz(自定义服务器)对比
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、API 域名概览
|
||||||
|
|
||||||
|
| 域名 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `https://maqt.top/api/*` | 主业务 API |
|
||||||
|
| `https://pop.maqt.top/api/*` | 弹窗广告 API |
|
||||||
|
| `https://update.maqt.top/*` | 更新服务 |
|
||||||
|
| `https://tuku.maqt.top/*` | 图片托管 |
|
||||||
|
| `http://100.105.17.52:3001/api/*` | 自定义后端(解包版) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、完整 API 端点清单
|
||||||
|
|
||||||
|
### 🔐 认证
|
||||||
|
| 方法 | 端点 | 请求体 | 当前状态 |
|
||||||
|
|------|------|--------|----------|
|
||||||
|
| POST | `/api/login` | `{"username","password","installId","deviceHash","platform","osVersion","appVersion"}` | ✅ 已实现 |
|
||||||
|
| POST | `/api/register` | 需要抓包确认 | ❌ 待实现 |
|
||||||
|
| POST | `/api/activate-vip` | 需要抓包确认 | ✅ 已实现 |
|
||||||
|
|
||||||
|
### 📊 会话 & 状态
|
||||||
|
| 方法 | 端点 | 当前状态 |
|
||||||
|
|------|------|----------|
|
||||||
|
| GET | `/api/session-status` | ✅ 已实现 |
|
||||||
|
| GET | `/api/vip-status` | ❌ 待实现 |
|
||||||
|
| GET | `/api/user/stats/{userId}` | ❌ 待实现 |
|
||||||
|
| GET | `/api/user/favorited-count/{userId}` | ❌ 待实现 |
|
||||||
|
| GET | `/api/user/limits/{userId}` | ❌ 待实现 |
|
||||||
|
| GET | `/api/user/schemes/{userId}` | ❌ 待实现 |
|
||||||
|
|
||||||
|
### 🎯 改枪方案
|
||||||
|
| 方法 | 端点 | 参数 | 当前状态 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| GET | `/api/category/{type}` | AR/SMG/SR/LMG/SG/Pistol/Launcher | ✅ 已实现 |
|
||||||
|
| GET | `/api/schemes` | `sort=hot&page=1&limit=12&source=...` | ✅ 已实现 |
|
||||||
|
| GET | `/api/schemes` | `sort=hot&page=1&limit=12&weaponCategory&weaponName` | ✅ 已实现 |
|
||||||
|
| POST | `/api/schemes/{id}/use` | 需要抓包确认 | ❌ 待实现 |
|
||||||
|
|
||||||
|
### ❤️ 收藏
|
||||||
|
| 方法 | 端点 | 当前状态 |
|
||||||
|
|------|------|----------|
|
||||||
|
| GET | `/api/favorites/count` | ✅ 已实现 |
|
||||||
|
| GET | `/api/favorites/check?schemeId={id}&source=...` | ✅ 已实现 |
|
||||||
|
|
||||||
|
### 🎯 弹出广告
|
||||||
|
| 方法 | 端点 | 当前状态 |
|
||||||
|
|------|------|----------|
|
||||||
|
| GET | `/api/popups/test_beta_01` | ❌ 待实现 |
|
||||||
|
| GET | `/api/aftersale-tutorial-popup` | ❌ 待实现 |
|
||||||
|
| GET | `/api/software-version-ad` | ❌ 待实现 |
|
||||||
|
|
||||||
|
### 🔄 更新服务
|
||||||
|
| 方法 | 端点 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/latest.yml` | Electron auto-updater 配置 |
|
||||||
|
| GET | `/update-config.json` | 更新配置 |
|
||||||
|
|
||||||
|
### 🖼️ 图片托管
|
||||||
|
| 方法 | 端点 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `tuku.maqt.top/i/2026/03/22/*.png` | 静态图片资源 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、ss3 vs ss4 请求对比(关键差异)
|
||||||
|
|
||||||
|
### 原版(ss3)完整流程:
|
||||||
|
```
|
||||||
|
1. 检查更新: GET update.maqt.top/{latest.yml,update-config.json}
|
||||||
|
2. 弹窗广告: GET pop.maqt.top/api/software-version-ad
|
||||||
|
3. 会话状态: GET maqt.top/api/session-status (高频轮询!)
|
||||||
|
4. 注册/登录: POST maqt.top/api/register
|
||||||
|
5. 获取分类: GET maqt.top/api/category/{type}
|
||||||
|
6. 获取方案: GET maqt.top/api/schemes?...
|
||||||
|
7. 收藏检查: GET maqt.top/api/favorites/check?schemeId=...
|
||||||
|
8. 激活VIP: POST maqt.top/api/activate-vip
|
||||||
|
9. VIP状态: GET maqt.top/api/vip-status
|
||||||
|
10. 用户数据: GET maqt.top/api/user/{stats,favorited-count,limits,schemes}/{id}
|
||||||
|
11. 方案使用: POST maqt.top/api/schemes/{id}/use
|
||||||
|
12. 图片加载: GET tuku.maqt.top/... (持续轮询)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义服务器(ss4 后半段)请求序列:
|
||||||
|
```
|
||||||
|
162: GET /api/popups/test_beta_01
|
||||||
|
163: GET /api/session-status
|
||||||
|
164: GET /api/software-version-ad
|
||||||
|
165: GET /api/aftersale-tutorial-popup
|
||||||
|
166: GET /update-config.json
|
||||||
|
169: GET /api/software-version-ad
|
||||||
|
170: GET /api/popups/test_beta_01
|
||||||
|
171: GET /api/aftersale-tutorial-popup
|
||||||
|
172: GET /update-config.json
|
||||||
|
173: GET /api/category/AR
|
||||||
|
174: GET /api/schemes?sort=hot&page=1&limit=12&source=...
|
||||||
|
175: GET /api/category/SMG
|
||||||
|
176: GET /api/category/SR
|
||||||
|
177: GET /api/category/LMG
|
||||||
|
178: GET /api/category/SG
|
||||||
|
179: GET /api/category/Pistol
|
||||||
|
180: GET /api/category/Launcher
|
||||||
|
181: POST /api/login
|
||||||
|
182: GET /api/session-status
|
||||||
|
183: GET /api/favorites/count
|
||||||
|
184: GET /api/schemes?...
|
||||||
|
185: GET /api/schemes?...
|
||||||
|
186: GET /api/favorites/count
|
||||||
|
187: GET /api/session-status
|
||||||
|
190: GET /api/session-status
|
||||||
|
194-199: GET /api/session-status (轮询...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❗ 自定义服务器缺失的端点
|
||||||
|
以下端点在原版中有调用,但在自定义服务器请求中从未出现:
|
||||||
|
- **POST `/api/register`** — 注册功能
|
||||||
|
- **POST `/api/activate-vip`** — VIP 激活(端点存在但可能未被调用)
|
||||||
|
- **GET `/api/vip-status`** — VIP 状态查询
|
||||||
|
- **GET `/api/user/stats/{id}`** — 用户统计
|
||||||
|
- **GET `/api/user/favorited-count/{id}`** — 用户收藏数
|
||||||
|
- **GET `/api/user/limits/{id}`** — 用户限制
|
||||||
|
- **GET `/api/user/schemes/{id}`** — 用户方案列表
|
||||||
|
- **POST `/api/schemes/{id}/use`** — 方案使用记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、重新封装开发计划
|
||||||
|
|
||||||
|
### 阶段 1:API 补充(先补齐缺失端点)
|
||||||
|
|
||||||
|
**优先级 P0(应用启动必需):**
|
||||||
|
- [ ] `GET /api/vip-status` — 登录后立刻调用,缺了前端一直没 VIP
|
||||||
|
- [ ] `GET /api/user/stats/:id`
|
||||||
|
- [ ] `GET /api/user/favorited-count/:id`
|
||||||
|
- [ ] `GET /api/user/limits/:id`
|
||||||
|
- [ ] `GET /api/user/schemes/:id`
|
||||||
|
- [ ] `POST /api/schemes/:id/use`
|
||||||
|
|
||||||
|
**优先级 P1(弹窗广告,不影响核心功能):**
|
||||||
|
- [ ] `GET /api/software-version-ad`
|
||||||
|
- [ ] `GET /api/popups/test_beta_01`
|
||||||
|
- [ ] `GET /api/aftersale-tutorial-popup`
|
||||||
|
- [ ] `GET /api/update-config.json`
|
||||||
|
|
||||||
|
**优先级 P2(注册,一次性的):**
|
||||||
|
- [ ] `POST /api/register`(含 installId, deviceHash)
|
||||||
|
|
||||||
|
### 阶段 2:桌面端复刻(Electron + React)
|
||||||
|
|
||||||
|
等 API 补全后,桌面端从头写:
|
||||||
|
|
||||||
|
```
|
||||||
|
maqt-desktop/
|
||||||
|
├── electron/
|
||||||
|
│ ├── main.ts # 窗口管理
|
||||||
|
│ └── preload.ts # electronAPI
|
||||||
|
├── src/
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Home.tsx # 桌面首页
|
||||||
|
│ │ ├── Login.tsx
|
||||||
|
│ │ ├── Schemes.tsx # 改枪方案
|
||||||
|
│ │ ├── Filters.tsx # 画面滤镜
|
||||||
|
│ │ ├── Optimization.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Dock.tsx # 底部导航
|
||||||
|
│ │ ├── DesktopIcons.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── hooks/
|
||||||
|
│ ├── useAuth.ts
|
||||||
|
│ └── useApi.ts
|
||||||
|
├── native/ # 原生程序
|
||||||
|
│ ├── MaqiangTangh1.exe
|
||||||
|
│ ├── nvidiaProfileInspector.exe
|
||||||
|
│ └── tools/
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 3:原生功能对接
|
||||||
|
|
||||||
|
- [ ] spawn `MaqiangTangh1.exe` 执行系统优化
|
||||||
|
- [ ] spawn `MaqiangTangXiXiOverlay.exe` 游戏内 Overlay
|
||||||
|
- [ ] 调用 PowerShell 脚本 Gamma 校准
|
||||||
|
- [ ] NVIDIA Profile Inspector 配置
|
||||||
|
- [ ] 硬件监控面板
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 第一阶段: 构建
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装必要的系统依赖(Prisma 需要)
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npx tsc
|
||||||
|
|
||||||
|
# 第二阶段: 运行
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 OpenSSL(Prisma 运行时依赖)
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
167
README.md
Normal file
167
README.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# 码枪堂后端 API
|
||||||
|
|
||||||
|
基于 Express + Prisma + PostgreSQL 的后端服务。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
# 或
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 数据库连接
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/maqt?schema=public"
|
||||||
|
|
||||||
|
# JWT 密钥(请修改为随机字符串)
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
|
||||||
|
|
||||||
|
# 数据加密密钥(32字节)
|
||||||
|
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 Prisma Client
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# 同步数据库结构
|
||||||
|
pnpm db:push
|
||||||
|
|
||||||
|
# 生成种子数据(VIP卡密等)
|
||||||
|
pnpm db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发模式(热重载)
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 生产模式
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/register` | POST | 注册用户 |
|
||||||
|
| `/api/login` | POST | 用户登录 |
|
||||||
|
| `/api/session-status` | GET | 会话状态 |
|
||||||
|
| `/api/vip-status` | GET | VIP 状态 |
|
||||||
|
| `/api/activate-vip` | POST | VIP 卡密激活 |
|
||||||
|
|
||||||
|
### 用户管理
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/user/username` | PUT | 更新用户名 |
|
||||||
|
| `/api/user/email` | PUT | 更新邮箱 |
|
||||||
|
| `/api/user/avatar` | PUT | 更新头像 |
|
||||||
|
| `/api/user/stats/:userId` | GET | 用户统计 |
|
||||||
|
| `/api/user/schemes/:userId` | GET | 用户方案列表 |
|
||||||
|
|
||||||
|
### 方案分享
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/schemes/` | GET | 烽火地带方案列表 |
|
||||||
|
| `/api/schemes/:id` | GET | 方案详情 |
|
||||||
|
| `/api/schemes/` | POST | 创建方案 |
|
||||||
|
| `/api/schemes/:id` | DELETE | 删除方案 |
|
||||||
|
| `/api/schemes_aob/` | GET | 全面战场方案列表 |
|
||||||
|
| `/api/schemes_aob/:id` | GET | 方案详情 |
|
||||||
|
| `/api/schemes_aob/` | POST | 创建方案 |
|
||||||
|
|
||||||
|
### 滤镜分享
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/filter-shares/` | GET | 滤镜列表 |
|
||||||
|
| `/api/filter-shares/:id` | GET | 滤镜详情 |
|
||||||
|
| `/api/filter-shares/` | POST | 创建滤镜 |
|
||||||
|
| `/api/filter-shares/:id` | DELETE | 删除滤镜 |
|
||||||
|
|
||||||
|
### 收藏
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/favorites/` | GET | 收藏列表 |
|
||||||
|
| `/api/favorites/` | POST | 添加收藏 |
|
||||||
|
| `/api/favorites/:id` | DELETE | 取消收藏 |
|
||||||
|
| `/api/favorites/check` | GET | 检查是否收藏 |
|
||||||
|
|
||||||
|
## VIP 卡密管理
|
||||||
|
|
||||||
|
### 生成卡密
|
||||||
|
|
||||||
|
运行种子脚本会自动生成卡密:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的卡密格式:
|
||||||
|
- 月卡:`VIP30-XXXX-XXXX-XXXX-XXXX`
|
||||||
|
- 季卡:`VIP90-XXXX-XXXX-XXXX-XXXX`
|
||||||
|
- 年卡:`VIP365-XXXX-XXXX-XXXX-XXXX`
|
||||||
|
|
||||||
|
### 查询卡密
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 Prisma Studio 可视化管理数据库。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
maqt-backend/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # 数据库模型
|
||||||
|
│ └── seed.ts # 种子数据
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # 主入口
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── auth.ts # 认证中间件
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.ts # 认证路由
|
||||||
|
│ │ ├── user.ts # 用户路由
|
||||||
|
│ │ ├── schemes.ts # 烽火地带方案
|
||||||
|
│ │ ├── schemesAob.ts# 全面战场方案
|
||||||
|
│ │ ├── filters.ts # 滤镜分享
|
||||||
|
│ │ ├── favorites.ts # 收藏
|
||||||
|
│ │ └── vip.ts # VIP 激活
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── jwt.ts # JWT 工具
|
||||||
|
│ └── encryption.ts# 加密工具
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **生产环境**请务必修改 `.env` 中的密钥
|
||||||
|
2. **数据库**推荐使用 PostgreSQL
|
||||||
|
3. **部署**建议配合 PM2 或 Docker
|
||||||
1882
package-lock.json
generated
Normal file
1882
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "maqt-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "码枪堂后端 API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.15.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.3.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/node": "^20.14.2",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"prisma": "^5.15.0",
|
||||||
|
"tsx": "^4.15.4",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
279
prisma/schema.prisma
Normal file
279
prisma/schema.prisma
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 用户表
|
||||||
|
// ============================================
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
username String @unique @db.VarChar(50)
|
||||||
|
email String @unique @db.VarChar(100)
|
||||||
|
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||||
|
avatar String? @db.VarChar(255)
|
||||||
|
|
||||||
|
// VIP 状态
|
||||||
|
isVip Boolean @default(false) @map("is_vip")
|
||||||
|
vipLevel Int @default(0) @map("vip_level")
|
||||||
|
vipExpireAt DateTime? @map("vip_expire_at")
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
schemesCount Int @default(0) @map("schemes_count")
|
||||||
|
favoritesCount Int @default(0) @map("favorites_count")
|
||||||
|
|
||||||
|
// 设备绑定
|
||||||
|
installId String? @map("install_id") @db.VarChar(64)
|
||||||
|
deviceHash String? @map("device_hash") @db.VarChar(64)
|
||||||
|
lastDeviceCheck DateTime? @map("last_device_check")
|
||||||
|
|
||||||
|
// 元数据
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
// 关系
|
||||||
|
schemes Scheme[]
|
||||||
|
schemesAob SchemeAob[]
|
||||||
|
filterShares FilterShare[]
|
||||||
|
favorites Favorite[]
|
||||||
|
likes Like[]
|
||||||
|
logs UserLog[]
|
||||||
|
usedVipCards VipCard[] @relation("UsedCards")
|
||||||
|
deviceBindings DeviceBinding[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// VIP 卡密表
|
||||||
|
// ============================================
|
||||||
|
model VipCard {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
cardKey String @unique @map("card_key") @db.VarChar(50)
|
||||||
|
|
||||||
|
cardType String @map("card_type") @db.VarChar(20)
|
||||||
|
days Int
|
||||||
|
|
||||||
|
status String @default("UNUSED") @db.VarChar(20)
|
||||||
|
usedBy String? @map("used_by")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
|
||||||
|
batchId String? @map("batch_id")
|
||||||
|
generatedAt DateTime @default(now()) @map("generated_at")
|
||||||
|
|
||||||
|
originalPrice Decimal? @map("original_price") @db.Decimal(10, 2)
|
||||||
|
salePrice Decimal? @map("sale_price") @db.Decimal(10, 2)
|
||||||
|
|
||||||
|
user User? @relation("UsedCards", fields: [usedBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("vip_cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 方案表 - 烽火地带
|
||||||
|
// ============================================
|
||||||
|
model Scheme {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
|
||||||
|
title String? @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
weaponName String? @map("weapon_name") @db.VarChar(100)
|
||||||
|
category String? @db.VarChar(50)
|
||||||
|
|
||||||
|
schemeContent String @map("scheme_content") @db.Text
|
||||||
|
contentEncrypted Boolean @default(true) @map("content_encrypted")
|
||||||
|
|
||||||
|
price Int @default(0)
|
||||||
|
|
||||||
|
viewsCount Int @default(0) @map("views_count")
|
||||||
|
downloadsCount Int @default(0) @map("downloads_count")
|
||||||
|
likesCount Int @default(0) @map("likes_count")
|
||||||
|
favoritesCount Int @default(0) @map("favorites_count")
|
||||||
|
|
||||||
|
gpuModel String? @map("gpu_model") @db.VarChar(100)
|
||||||
|
driverVersion String? @map("driver_version") @db.VarChar(50)
|
||||||
|
appVersion String? @map("app_version") @db.VarChar(20)
|
||||||
|
|
||||||
|
status String @default("DRAFT") @db.VarChar(20)
|
||||||
|
isOfficial Boolean @default(false) @map("is_official")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([weaponName])
|
||||||
|
@@index([category])
|
||||||
|
@@index([status])
|
||||||
|
@@map("schemes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 方案表 - 全面战场 (AOB)
|
||||||
|
// ============================================
|
||||||
|
model SchemeAob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
|
||||||
|
title String? @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
weaponName String? @map("weapon_name") @db.VarChar(100)
|
||||||
|
category String? @db.VarChar(50)
|
||||||
|
|
||||||
|
schemeContent String @map("scheme_content") @db.Text
|
||||||
|
contentEncrypted Boolean @default(true) @map("content_encrypted")
|
||||||
|
|
||||||
|
price Int @default(0)
|
||||||
|
|
||||||
|
viewsCount Int @default(0) @map("views_count")
|
||||||
|
downloadsCount Int @default(0) @map("downloads_count")
|
||||||
|
likesCount Int @default(0) @map("likes_count")
|
||||||
|
favoritesCount Int @default(0) @map("favorites_count")
|
||||||
|
|
||||||
|
status String @default("DRAFT") @db.VarChar(20)
|
||||||
|
isOfficial Boolean @default(false) @map("is_official")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([weaponName])
|
||||||
|
@@map("schemes_aob")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 滤镜分享表
|
||||||
|
// ============================================
|
||||||
|
model FilterShare {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
|
||||||
|
title String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
category String? @db.VarChar(50)
|
||||||
|
|
||||||
|
filterContent String @map("filter_content") @db.Text
|
||||||
|
contentFormat String @default("MQTS1") @map("content_format") @db.VarChar(20)
|
||||||
|
|
||||||
|
viewsCount Int @default(0) @map("views_count")
|
||||||
|
likesCount Int @default(0) @map("likes_count")
|
||||||
|
|
||||||
|
status String @default("DRAFT") @db.VarChar(20)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("filter_shares")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 收藏表
|
||||||
|
// ============================================
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
targetType String @map("target_type") @db.VarChar(20)
|
||||||
|
targetId String @map("target_id")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, targetType, targetId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("favorites")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 点赞表
|
||||||
|
// ============================================
|
||||||
|
model Like {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
targetType String @map("target_type") @db.VarChar(20)
|
||||||
|
targetId String @map("target_id")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, targetType, targetId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
@@map("likes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 分类表
|
||||||
|
// ============================================
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
type String @db.VarChar(20)
|
||||||
|
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 用户日志表
|
||||||
|
// ============================================
|
||||||
|
model UserLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String? @map("user_id")
|
||||||
|
|
||||||
|
action String @db.VarChar(50)
|
||||||
|
targetType String? @map("target_type") @db.VarChar(50)
|
||||||
|
targetId String? @map("target_id")
|
||||||
|
|
||||||
|
installId String? @map("install_id") @db.VarChar(64)
|
||||||
|
deviceHash String? @map("device_hash") @db.VarChar(64)
|
||||||
|
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([action])
|
||||||
|
@@map("user_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 设备绑定表
|
||||||
|
// ============================================
|
||||||
|
model DeviceBinding {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
installId String @map("install_id") @db.VarChar(64)
|
||||||
|
deviceHash String @map("device_hash") @db.VarChar(64)
|
||||||
|
|
||||||
|
boundAt DateTime @default(now()) @map("bound_at")
|
||||||
|
lastActiveAt DateTime? @map("last_active_at")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, installId])
|
||||||
|
@@map("device_bindings")
|
||||||
|
}
|
||||||
90
prisma/seed.ts
Normal file
90
prisma/seed.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 VIP 卡密
|
||||||
|
*/
|
||||||
|
function generateCardKey(days: number): string {
|
||||||
|
const prefix = `VIP${days}`;
|
||||||
|
const segments = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
segments.push(crypto.randomBytes(2).toString('hex').toUpperCase());
|
||||||
|
}
|
||||||
|
return `${prefix}-${segments.join('-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 开始种子数据初始化...');
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 创建分类
|
||||||
|
// ============================================
|
||||||
|
const categories = [
|
||||||
|
// 烽火地带
|
||||||
|
{ name: '突击步枪', type: 'SCHEME' },
|
||||||
|
{ name: '冲锋枪', type: 'SCHEME' },
|
||||||
|
{ name: '狙击步枪', type: 'SCHEME' },
|
||||||
|
{ name: '轻机枪', type: 'SCHEME' },
|
||||||
|
{ name: '霰弹枪', type: 'SCHEME' },
|
||||||
|
{ name: '手枪', type: 'SCHEME' },
|
||||||
|
|
||||||
|
// 全面战场
|
||||||
|
{ name: '突击步枪', type: 'SCHEME_AOB' },
|
||||||
|
{ name: '冲锋枪', type: 'SCHEME_AOB' },
|
||||||
|
{ name: '狙击步枪', type: 'SCHEME_AOB' },
|
||||||
|
{ name: '轻机枪', type: 'SCHEME_AOB' },
|
||||||
|
{ name: '霰弹枪', type: 'SCHEME_AOB' },
|
||||||
|
|
||||||
|
// 滤镜
|
||||||
|
{ name: '烽火地带', type: 'FILTER' },
|
||||||
|
{ name: '全面战场', type: 'FILTER' },
|
||||||
|
{ name: '通用', type: 'FILTER' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
await prisma.category.create({
|
||||||
|
data: cat,
|
||||||
|
}).catch(() => {}); // 忽略重复错误
|
||||||
|
}
|
||||||
|
console.log(`✅ 创建 ${categories.length} 个分类`);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 生成 VIP 卡密
|
||||||
|
// ============================================
|
||||||
|
const cardConfigs = [
|
||||||
|
{ type: 'MONTH', days: 30, count: 100 },
|
||||||
|
{ type: 'QUARTER', days: 90, count: 50 },
|
||||||
|
{ type: 'YEAR', days: 365, count: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let totalCards = 0;
|
||||||
|
for (const config of cardConfigs) {
|
||||||
|
const cards = [];
|
||||||
|
for (let i = 0; i < config.count; i++) {
|
||||||
|
cards.push({
|
||||||
|
cardKey: generateCardKey(config.days),
|
||||||
|
cardType: config.type,
|
||||||
|
days: config.days,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.vipCard.createMany({ data: cards, skipDuplicates: true });
|
||||||
|
totalCards += config.count;
|
||||||
|
console.log(`✅ 生成 ${config.type} 卡密 ${config.count} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 种子数据初始化完成!`);
|
||||||
|
console.log(` - 分类:${categories.length} 个`);
|
||||||
|
console.log(` - VIP 卡密:${totalCards} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ 种子数据初始化失败:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
212
src/index.ts
Normal file
212
src/index.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import userRoutes from './routes/user';
|
||||||
|
import schemeRoutes from './routes/schemes';
|
||||||
|
import schemeAobRoutes from './routes/schemesAob';
|
||||||
|
import filterRoutes from './routes/filters';
|
||||||
|
import vipRoutes from './routes/vip';
|
||||||
|
import favoriteRoutes from './routes/favorites';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 中间件
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 安全头 - 开发模式禁用 CSP
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors({
|
||||||
|
origin: true, // 允许所有来源(开发模式)
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// JSON 解析
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// 速率限制
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 分钟
|
||||||
|
max: 100, // 每个 IP 最多 100 次请求
|
||||||
|
message: { success: false, message: '请求过于频繁,请稍后再试' },
|
||||||
|
});
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// 登录接口更严格的限制
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
message: { success: false, message: '登录尝试过于频繁,请稍后再试' },
|
||||||
|
});
|
||||||
|
app.use('/api/login', loginLimiter);
|
||||||
|
app.use('/api/register', loginLimiter);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 路由
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.get('/api/activity/ping', (req, res) => {
|
||||||
|
res.json({ success: true, message: 'pong', timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api', authRoutes);
|
||||||
|
app.use('/api/user', userRoutes);
|
||||||
|
app.use('/api/schemes', schemeRoutes);
|
||||||
|
app.use('/api/schemes_aob', schemeAobRoutes);
|
||||||
|
app.use('/api/filter-shares', filterRoutes);
|
||||||
|
app.use('/api', vipRoutes);
|
||||||
|
// 收藏计数(放在 favorites 路由之前,避免 auth 中间件拦截)
|
||||||
|
app.get('/api/favorites/count', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const count = await prisma.favorite.count();
|
||||||
|
res.json({ success: true, count });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: true, count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/favorites', favoriteRoutes);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 缺失的存根接口(防止前端 404)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 二维码
|
||||||
|
app.get('/api/qrcode/public/current', (req, res) => {
|
||||||
|
res.json({ success: true, data: { qrcode: null, url: '' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
app.get('/api/popups/', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 售后教程弹窗
|
||||||
|
app.get('/api/aftersale-tutorial-popup', (req, res) => {
|
||||||
|
res.json({ success: true, data: { shown: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 软件版本广告(空数据,dock 无广告项)
|
||||||
|
app.get('/api/software-version-ad', (req, res) => {
|
||||||
|
res.json({ success: true, data: { items: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 游戏地图密码缓存
|
||||||
|
app.get('/api/game/map-password/cached', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 筛选分类
|
||||||
|
app.get('/api/filter-share/categories', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 武器调谐窗 (weapon-tuner) 所需的 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 武器分类列表
|
||||||
|
app.get('/api/weapon-categories', (req, res) => {
|
||||||
|
const categories = [
|
||||||
|
{ category: 'AR', name: '突击步枪' },
|
||||||
|
{ category: 'SMG', name: '冲锋枪' },
|
||||||
|
{ category: 'SR', name: '狙击步枪' },
|
||||||
|
{ category: 'LMG', name: '轻机枪' },
|
||||||
|
{ category: 'SG', name: '霰弹枪' },
|
||||||
|
{ category: 'Pistol', name: '手枪' },
|
||||||
|
{ category: 'Launcher', name: '发射器' },
|
||||||
|
];
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 武器列表(按分类筛选)
|
||||||
|
app.get('/api/weapons', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分类下的武器
|
||||||
|
app.get('/api/category/:code', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 广告列表
|
||||||
|
app.get('/api/adverts/list', (req, res) => {
|
||||||
|
res.json({ success: true, data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '码枪堂2.0 新版发布',
|
||||||
|
description: '全新界面,更多功能,一键优化三角洲行动游戏体验!',
|
||||||
|
author: '码枪堂官方',
|
||||||
|
avatar: null,
|
||||||
|
image_url: '',
|
||||||
|
link_url: 'https://wwamt.lanzout.com/b00odpq4wb',
|
||||||
|
isAdvert: true,
|
||||||
|
isVip: true,
|
||||||
|
shareTime: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 广告点击
|
||||||
|
app.post('/api/adverts/:id/click', (req, res) => {
|
||||||
|
res.json({ success: true, message: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 头像列表
|
||||||
|
app.get('/api/avatars', (req, res) => {
|
||||||
|
res.json({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 错误处理
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
if (err.name === 'UnauthorizedError') {
|
||||||
|
return res.status(401).json({ success: false, message: '访问令牌无效', code: 'INVALID_TOKEN' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'ZodError') {
|
||||||
|
return res.status(400).json({ success: false, message: '参数验证失败', errors: err.errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ success: false, message: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ success: false, message: '接口不存在' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 启动
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 码枪堂 API 运行在 http://localhost:${PORT}`);
|
||||||
|
console.log(`📋 可用接口:`);
|
||||||
|
console.log(` POST /api/login`);
|
||||||
|
console.log(` POST /api/register`);
|
||||||
|
console.log(` POST /api/activate-vip`);
|
||||||
|
console.log(` GET /api/session-status`);
|
||||||
|
console.log(` GET /api/vip-status`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
114
src/middleware/auth.ts
Normal file
114
src/middleware/auth.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { verifyToken, extractToken } from '../utils/jwt';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 扩展 Request 类型
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
isVip: boolean;
|
||||||
|
vipLevel: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证中间件
|
||||||
|
*/
|
||||||
|
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const token = extractToken(req.headers.authorization);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '访问令牌缺失,请先登录',
|
||||||
|
code: 'NO_TOKEN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '访问令牌无效或已过期',
|
||||||
|
code: 'INVALID_TOKEN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在且未被删除
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.userId },
|
||||||
|
select: { id: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在或已被禁用',
|
||||||
|
code: 'USER_NOT_FOUND',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息附加到请求对象
|
||||||
|
req.user = payload;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '认证服务异常',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选认证中间件(不强制要求登录)
|
||||||
|
*/
|
||||||
|
export async function optionalAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const token = extractToken(req.headers.authorization);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (payload) {
|
||||||
|
req.user = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIP 认证中间件(要求 VIP 用户)
|
||||||
|
*/
|
||||||
|
export async function vipMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '请先登录',
|
||||||
|
code: 'NO_TOKEN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.isVip) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '此功能仅限 VIP 用户使用',
|
||||||
|
code: 'VIP_REQUIRED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
269
src/routes/auth.ts
Normal file
269
src/routes/auth.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { generateToken } from '../utils/jwt';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 注册
|
||||||
|
// ============================================
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(3).max(50),
|
||||||
|
password: z.string().min(6).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
installId: z.string().optional(),
|
||||||
|
deviceHash: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/register', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = registerSchema.parse(req.body);
|
||||||
|
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
const existingUsername = await prisma.user.findUnique({
|
||||||
|
where: { username: body.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户名已被使用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查邮箱是否已存在
|
||||||
|
const existingEmail = await prisma.user.findUnique({
|
||||||
|
where: { email: body.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '邮箱已被注册',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码哈希
|
||||||
|
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: body.username,
|
||||||
|
email: body.email,
|
||||||
|
passwordHash,
|
||||||
|
installId: body.installId,
|
||||||
|
deviceHash: body.deviceHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 Token
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipLevel: user.vipLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
await prisma.userLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'REGISTER',
|
||||||
|
installId: body.installId,
|
||||||
|
deviceHash: body.deviceHash,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatar: user.avatar,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipExpireAt: user.vipExpireAt,
|
||||||
|
vipLevel: user.vipLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '参数验证失败',
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Register error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '注册失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 登录
|
||||||
|
// ============================================
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string(), // 可以是用户名或邮箱
|
||||||
|
password: z.string(),
|
||||||
|
installId: z.string().optional(),
|
||||||
|
deviceHash: z.string().optional(),
|
||||||
|
platform: z.string().optional(),
|
||||||
|
osVersion: z.string().optional(),
|
||||||
|
appVersion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
// 查找用户(支持用户名或邮箱登录)
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: body.username },
|
||||||
|
{ email: body.username },
|
||||||
|
],
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户名或密码错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const validPassword = await bcrypt.compare(body.password, user.passwordHash);
|
||||||
|
|
||||||
|
if (!validPassword) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户名或密码错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后登录时间和设备信息
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
installId: body.installId || user.installId,
|
||||||
|
deviceHash: body.deviceHash || user.deviceHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 Token
|
||||||
|
const token = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipLevel: user.vipLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
await prisma.userLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'LOGIN',
|
||||||
|
installId: body.installId,
|
||||||
|
deviceHash: body.deviceHash,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatar: user.avatar,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipExpireAt: user.vipExpireAt,
|
||||||
|
vipLevel: user.vipLevel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '参数验证失败',
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '登录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 会话状态
|
||||||
|
// ============================================
|
||||||
|
router.get('/session-status', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
valid: true,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// VIP 状态
|
||||||
|
// ============================================
|
||||||
|
router.get('/vip-status', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: { isVip: true, vipExpireAt: true, vipLevel: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 VIP 是否过期
|
||||||
|
let isVip = user.isVip;
|
||||||
|
if (user.vipExpireAt && new Date() > user.vipExpireAt) {
|
||||||
|
isVip = false;
|
||||||
|
// 更新数据库
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { isVip: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysRemaining = user.vipExpireAt
|
||||||
|
? Math.max(0, Math.ceil((user.vipExpireAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isVip,
|
||||||
|
vipExpireAt: user.vipExpireAt,
|
||||||
|
vipLevel: user.vipLevel,
|
||||||
|
daysRemaining,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('VIP status error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取 VIP 状态失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
167
src/routes/favorites.ts
Normal file
167
src/routes/favorites.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
const TARGET_TYPES = ['SCHEME', 'SCHEME_AOB', 'FILTER'] as const;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取收藏列表
|
||||||
|
// ============================================
|
||||||
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, page = 1, limit = 20 } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = { userId: req.user!.userId };
|
||||||
|
if (type && TARGET_TYPES.includes(type as any)) {
|
||||||
|
where.targetType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await prisma.favorite.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: favorites });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get favorites error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 添加收藏
|
||||||
|
// ============================================
|
||||||
|
const addFavoriteSchema = z.object({
|
||||||
|
targetType: z.enum(TARGET_TYPES),
|
||||||
|
targetId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = addFavoriteSchema.parse(req.body);
|
||||||
|
|
||||||
|
// 检查是否已收藏
|
||||||
|
const existing = await prisma.favorite.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
targetType: body.targetType,
|
||||||
|
targetId: body.targetId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ success: false, message: '已收藏' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证目标是否存在
|
||||||
|
let targetExists = false;
|
||||||
|
if (body.targetType === 'SCHEME') {
|
||||||
|
targetExists = !!(await prisma.scheme.findFirst({
|
||||||
|
where: { id: body.targetId, status: 'PUBLISHED' },
|
||||||
|
}));
|
||||||
|
} else if (body.targetType === 'SCHEME_AOB') {
|
||||||
|
targetExists = !!(await prisma.schemeAob.findFirst({
|
||||||
|
where: { id: body.targetId, status: 'PUBLISHED' },
|
||||||
|
}));
|
||||||
|
} else if (body.targetType === 'FILTER') {
|
||||||
|
targetExists = !!(await prisma.filterShare.findFirst({
|
||||||
|
where: { id: body.targetId, status: 'PUBLISHED' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetExists) {
|
||||||
|
return res.status(404).json({ success: false, message: '目标不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建收藏
|
||||||
|
await prisma.favorite.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
targetType: body.targetType,
|
||||||
|
targetId: body.targetId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新用户收藏数
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { favoritesCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '收藏成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Add favorite error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '收藏失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 取消收藏
|
||||||
|
// ============================================
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const favorite = await prisma.favorite.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!favorite || favorite.userId !== req.user!.userId) {
|
||||||
|
return res.status(404).json({ success: false, message: '收藏不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.favorite.delete({ where: { id } });
|
||||||
|
|
||||||
|
// 更新用户收藏数
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { favoritesCount: { decrement: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '已取消收藏' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remove favorite error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '取消收藏失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 检查是否已收藏
|
||||||
|
// ============================================
|
||||||
|
router.get('/check', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { targetType, targetId } = req.query;
|
||||||
|
|
||||||
|
if (!targetType || !targetId) {
|
||||||
|
return res.status(400).json({ success: false, message: '参数缺失' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorite = await prisma.favorite.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
targetType: String(targetType),
|
||||||
|
targetId: String(targetId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isFavorited: !!favorite,
|
||||||
|
favoriteId: favorite?.id || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check favorite error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '检查失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
135
src/routes/filters.ts
Normal file
135
src/routes/filters.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { authMiddleware, optionalAuth } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取滤镜列表
|
||||||
|
// ============================================
|
||||||
|
router.get('/', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, category } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = { status: 'PUBLISHED' };
|
||||||
|
if (category) where.category = String(category);
|
||||||
|
|
||||||
|
const filters = await prisma.filterShare.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
category: true,
|
||||||
|
viewsCount: true,
|
||||||
|
likesCount: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, username: true, avatar: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: filters });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get filter_shares error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取单个滤镜详情
|
||||||
|
// ============================================
|
||||||
|
router.get('/:id', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const filter = await prisma.filterShare.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, username: true, avatar: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filter || (filter.status !== 'PUBLISHED' && filter.userId !== req.user?.userId)) {
|
||||||
|
return res.status(404).json({ success: false, message: '滤镜不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.filterShare.update({
|
||||||
|
where: { id },
|
||||||
|
data: { viewsCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: filter });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get filter_share error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 创建滤镜
|
||||||
|
// ============================================
|
||||||
|
router.post('/', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, description, category, filterContent, contentFormat = 'MQTS1' } = req.body;
|
||||||
|
|
||||||
|
if (!title || !filterContent) {
|
||||||
|
return res.status(400).json({ success: false, message: '标题和内容不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = await prisma.filterShare.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
filterContent,
|
||||||
|
contentFormat,
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '滤镜创建成功', data: filter });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create filter_share error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '创建失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 删除滤镜
|
||||||
|
// ============================================
|
||||||
|
router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const filter = await prisma.filterShare.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filter) {
|
||||||
|
return res.status(404).json({ success: false, message: '滤镜不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.userId !== req.user!.userId) {
|
||||||
|
return res.status(403).json({ success: false, message: '无权删除此滤镜' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.filterShare.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '滤镜已删除' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete filter_share error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '删除失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
278
src/routes/schemes.ts
Normal file
278
src/routes/schemes.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authMiddleware, optionalAuth } from '../middleware/auth';
|
||||||
|
import { encrypt } from '../utils/encryption';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取方案列表
|
||||||
|
// ============================================
|
||||||
|
router.get('/', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
weapon,
|
||||||
|
category,
|
||||||
|
sort = 'newest',
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = { status: 'PUBLISHED' };
|
||||||
|
if (weapon) where.weaponName = { contains: String(weapon) };
|
||||||
|
if (category) where.category = String(category);
|
||||||
|
|
||||||
|
let orderBy: any = { createdAt: 'desc' };
|
||||||
|
if (sort === 'popular') orderBy = { viewsCount: 'desc' };
|
||||||
|
if (sort === 'downloads') orderBy = { downloadsCount: 'desc' };
|
||||||
|
|
||||||
|
const schemes = await prisma.scheme.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
weaponName: true,
|
||||||
|
category: true,
|
||||||
|
price: true,
|
||||||
|
viewsCount: true,
|
||||||
|
downloadsCount: true,
|
||||||
|
likesCount: true,
|
||||||
|
isOfficial: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: schemes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get schemes error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取单个方案详情
|
||||||
|
// ============================================
|
||||||
|
router.get('/:id', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const scheme = await prisma.scheme.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scheme) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '方案不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.status !== 'PUBLISHED' && scheme.userId !== req.user?.userId) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '方案不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加浏览量
|
||||||
|
await prisma.scheme.update({
|
||||||
|
where: { id },
|
||||||
|
data: { viewsCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否收藏
|
||||||
|
let isFavorited = false;
|
||||||
|
if (req.user) {
|
||||||
|
const fav = await prisma.favorite.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user.userId,
|
||||||
|
targetType: 'SCHEME',
|
||||||
|
targetId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
isFavorited = !!fav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密方案内容
|
||||||
|
const encryptedContent = encrypt(scheme.schemeContent);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...scheme,
|
||||||
|
schemeContent: encryptedContent,
|
||||||
|
isFavorited,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get scheme error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 创建方案
|
||||||
|
// ============================================
|
||||||
|
const createSchemeSchema = z.object({
|
||||||
|
title: z.string().min(1).max(100),
|
||||||
|
description: z.string().optional(),
|
||||||
|
weaponName: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
schemeContent: z.string().min(1),
|
||||||
|
price: z.number().int().min(0).default(0),
|
||||||
|
gpuModel: z.string().optional(),
|
||||||
|
driverVersion: z.string().optional(),
|
||||||
|
appVersion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = createSchemeSchema.parse(req.body);
|
||||||
|
|
||||||
|
const scheme = await prisma.scheme.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
weaponName: body.weaponName,
|
||||||
|
category: body.category,
|
||||||
|
schemeContent: body.schemeContent,
|
||||||
|
price: body.price,
|
||||||
|
gpuModel: body.gpuModel,
|
||||||
|
driverVersion: body.driverVersion,
|
||||||
|
appVersion: body.appVersion,
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新用户方案数
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: { schemesCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '方案创建成功',
|
||||||
|
data: scheme,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create scheme error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '创建失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 删除方案
|
||||||
|
// ============================================
|
||||||
|
router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const scheme = await prisma.scheme.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scheme) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '方案不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme.userId !== req.user!.userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '无权删除此方案',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.scheme.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '方案已删除',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete scheme error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '删除失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录方案使用(增加下载计数)
|
||||||
|
router.post('/:id/use', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { source } = req.body;
|
||||||
|
|
||||||
|
const scheme = await prisma.scheme.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, downloadsCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scheme) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '方案不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用计数 +1(增加 downloads 计数)
|
||||||
|
const updated = await prisma.scheme.update({
|
||||||
|
where: { id },
|
||||||
|
data: { downloadsCount: { increment: 1 } },
|
||||||
|
select: { downloadsCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
await prisma.userLog.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'SchemeUse',
|
||||||
|
targetType: 'Scheme',
|
||||||
|
targetId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
downloadsCount: updated.downloadsCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scheme use error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '记录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
122
src/routes/schemesAob.ts
Normal file
122
src/routes/schemesAob.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { authMiddleware, optionalAuth } from '../middleware/auth';
|
||||||
|
import { encrypt } from '../utils/encryption';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取全面战场方案列表
|
||||||
|
// ============================================
|
||||||
|
router.get('/', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, weapon, category, sort = 'newest' } = req.query;
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = { status: 'PUBLISHED' };
|
||||||
|
if (weapon) where.weaponName = { contains: String(weapon) };
|
||||||
|
if (category) where.category = String(category);
|
||||||
|
|
||||||
|
let orderBy: any = { createdAt: 'desc' };
|
||||||
|
if (sort === 'popular') orderBy = { viewsCount: 'desc' };
|
||||||
|
if (sort === 'downloads') orderBy = { downloadsCount: 'desc' };
|
||||||
|
|
||||||
|
const schemes = await prisma.schemeAob.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
weaponName: true,
|
||||||
|
category: true,
|
||||||
|
price: true,
|
||||||
|
viewsCount: true,
|
||||||
|
downloadsCount: true,
|
||||||
|
likesCount: true,
|
||||||
|
isOfficial: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: {
|
||||||
|
select: { id: true, username: true, avatar: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: schemes });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get schemes_aob error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取单个方案详情
|
||||||
|
// ============================================
|
||||||
|
router.get('/:id', optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const scheme = await prisma.schemeAob.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, username: true, avatar: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scheme || (scheme.status !== 'PUBLISHED' && scheme.userId !== req.user?.userId)) {
|
||||||
|
return res.status(404).json({ success: false, message: '方案不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.schemeAob.update({
|
||||||
|
where: { id },
|
||||||
|
data: { viewsCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedContent = encrypt(scheme.schemeContent);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { ...scheme, schemeContent: encryptedContent },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get scheme_aob error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 创建方案
|
||||||
|
// ============================================
|
||||||
|
router.post('/', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, description, weaponName, category, schemeContent, price = 0 } = req.body;
|
||||||
|
|
||||||
|
if (!title || !schemeContent) {
|
||||||
|
return res.status(400).json({ success: false, message: '标题和内容不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = await prisma.schemeAob.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
weaponName,
|
||||||
|
category,
|
||||||
|
schemeContent,
|
||||||
|
price,
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: '方案创建成功', data: scheme });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create scheme_aob error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '创建失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
337
src/routes/user.ts
Normal file
337
src/routes/user.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 所有路由都需要认证
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 更新用户名
|
||||||
|
// ============================================
|
||||||
|
const updateUsernameSchema = z.object({
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
username: z.string().min(3).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/username', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = updateUsernameSchema.parse(req.body);
|
||||||
|
|
||||||
|
// 验证用户 ID
|
||||||
|
if (body.userId !== req.user!.userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '无权修改其他用户信息',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { username: body.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户名已被使用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查修改次数限制
|
||||||
|
const limit = await prisma.userLog.count({
|
||||||
|
where: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'UPDATE_USERNAME',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (limit >= 4) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户名修改次数已达上限',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户名
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: body.userId },
|
||||||
|
data: { username: body.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
await prisma.userLog.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'UPDATE_USERNAME',
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '用户名更新成功',
|
||||||
|
username: body.username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update username error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 更新邮箱
|
||||||
|
// ============================================
|
||||||
|
const updateEmailSchema = z.object({
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/email', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = updateEmailSchema.parse(req.body);
|
||||||
|
|
||||||
|
if (body.userId !== req.user!.userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '无权修改其他用户信息',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查邮箱是否已存在
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: body.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '邮箱已被使用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: body.userId },
|
||||||
|
data: { email: body.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '邮箱更新成功',
|
||||||
|
email: body.email,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update email error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 更新头像
|
||||||
|
// ============================================
|
||||||
|
const updateAvatarSchema = z.object({
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
avatar: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/avatar', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = updateAvatarSchema.parse(req.body);
|
||||||
|
|
||||||
|
if (body.userId !== req.user!.userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: '无权修改其他用户信息',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: body.userId },
|
||||||
|
data: { avatar: body.avatar },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '头像更新成功',
|
||||||
|
avatar: body.avatar,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update avatar error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取用户统计
|
||||||
|
// ============================================
|
||||||
|
router.get('/stats/:userId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
schemesCount: true,
|
||||||
|
favoritesCount: true,
|
||||||
|
isVip: true,
|
||||||
|
vipExpireAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
schemesCount: user.schemesCount,
|
||||||
|
favoritesCount: user.favoritesCount,
|
||||||
|
isVip: user.isVip,
|
||||||
|
vipExpireAt: user.vipExpireAt,
|
||||||
|
memberSince: user.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user stats error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取用户方案列表
|
||||||
|
// ============================================
|
||||||
|
router.get('/schemes/:userId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { type = 'schemes', page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
let schemes;
|
||||||
|
if (type === 'aob') {
|
||||||
|
schemes = await prisma.schemeAob.findMany({
|
||||||
|
where: { userId, status: 'PUBLISHED' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
select: {
|
||||||
|
id: true, title: true, weaponName: true, category: true,
|
||||||
|
viewsCount: true, downloadsCount: true, likesCount: true, createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
schemes = await prisma.scheme.findMany({
|
||||||
|
where: { userId, status: 'PUBLISHED' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
select: {
|
||||||
|
id: true, title: true, weaponName: true, category: true,
|
||||||
|
viewsCount: true, downloadsCount: true, likesCount: true, createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: schemes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user schemes error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取用户被收藏次数
|
||||||
|
// ============================================
|
||||||
|
router.get('/favorited-count/:userId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { favoritesCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
favoritedCount: user.favoritesCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get favorited count error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 获取用户功能限制信息
|
||||||
|
// ============================================
|
||||||
|
router.get('/limits/:userId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
// 验证用户是否存在
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isVip: true,
|
||||||
|
vipExpireAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 VIP 是否过期
|
||||||
|
const now = new Date();
|
||||||
|
const isVipActive = user.isVip && user.vipExpireAt && user.vipExpireAt > now;
|
||||||
|
|
||||||
|
// 根据 VIP 状态返回不同的限制
|
||||||
|
const limits = isVipActive
|
||||||
|
? {
|
||||||
|
maxDailyUses: 500, // VIP: 每日最多使用500次
|
||||||
|
dailyUsesLeft: 500, // 剩余次数(简化处理,实际需要统计当日使用)
|
||||||
|
maxFavorites: 1000, // VIP: 最多收藏1000个
|
||||||
|
maxSchemes: 200, // VIP: 最多创建200个方案
|
||||||
|
canUploadIcc: true, // VIP: 可以上传 ICC
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
maxDailyUses: 50, // 非VIP: 每日最多使用50次
|
||||||
|
dailyUsesLeft: 50, // 剩余次数
|
||||||
|
maxFavorites: 100, // 非VIP: 最多收藏100个
|
||||||
|
maxSchemes: 50, // 非VIP: 最多创建50个方案
|
||||||
|
canUploadIcc: false, // 非VIP: 不能上传 ICC
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...limits,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get user limits error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
112
src/routes/vip.ts
Normal file
112
src/routes/vip.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// VIP 卡密激活
|
||||||
|
// ============================================
|
||||||
|
const activateSchema = z.object({
|
||||||
|
cardKey: z.string().regex(/^VIP\d+-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/activate-vip', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = activateSchema.parse(req.body);
|
||||||
|
|
||||||
|
// 查找卡密
|
||||||
|
const card = await prisma.vipCard.findUnique({
|
||||||
|
where: { cardKey: body.cardKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '卡密不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.status !== 'UNUSED') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '卡密已被使用或已失效',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户当前 VIP 状态
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: { isVip: true, vipExpireAt: true, vipLevel: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '用户不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算新的 VIP 过期时间
|
||||||
|
let newExpireAt: Date;
|
||||||
|
if (user.vipExpireAt && user.isVip && new Date() < user.vipExpireAt) {
|
||||||
|
// 已是 VIP,延长时间
|
||||||
|
newExpireAt = new Date(user.vipExpireAt.getTime() + card.days * 24 * 60 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
// 非 VIP,从现在开始计算
|
||||||
|
newExpireAt = new Date(Date.now() + card.days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户 VIP 状态
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
data: {
|
||||||
|
isVip: true,
|
||||||
|
vipExpireAt: newExpireAt,
|
||||||
|
vipLevel: Math.max(user.vipLevel, 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新卡密状态
|
||||||
|
await prisma.vipCard.update({
|
||||||
|
where: { id: card.id },
|
||||||
|
data: {
|
||||||
|
status: 'USED',
|
||||||
|
usedBy: req.user!.userId,
|
||||||
|
usedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
await prisma.userLog.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'VIP_ACTIVATE',
|
||||||
|
targetId: card.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isVip: true,
|
||||||
|
vipExpireAt: newExpireAt,
|
||||||
|
vipLevel: updatedUser.vipLevel,
|
||||||
|
message: `激活成功,VIP 有效期延长 ${card.days} 天`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '卡密格式无效',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Activate VIP error:', error);
|
||||||
|
res.status(500).json({ success: false, message: '激活失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
64
src/utils/encryption.ts
Normal file
64
src/utils/encryption.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
|
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef';
|
||||||
|
|
||||||
|
export interface EncryptedData {
|
||||||
|
encrypted: boolean;
|
||||||
|
iv: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES 加密
|
||||||
|
*/
|
||||||
|
export function encrypt(text: string): EncryptedData {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const key = Buffer.from(ENCRYPTION_KEY, 'utf-8');
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
let encrypted = cipher.update(text, 'utf-8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: true,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
data: encrypted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES 解密
|
||||||
|
*/
|
||||||
|
export function decrypt(ivHex: string, dataHex: string): string {
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const encryptedData = Buffer.from(dataHex, 'hex');
|
||||||
|
const key = Buffer.from(ENCRYPTION_KEY, 'utf-8');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
let decrypted = decipher.update(encryptedData, undefined, 'utf-8');
|
||||||
|
decrypted += decipher.final('utf-8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码哈希
|
||||||
|
*/
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串
|
||||||
|
*/
|
||||||
|
export function randomString(length: number = 32): string {
|
||||||
|
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成设备哈希
|
||||||
|
*/
|
||||||
|
export function generateDeviceHash(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 64);
|
||||||
|
}
|
||||||
47
src/utils/jwt.ts
Normal file
47
src/utils/jwt.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const jwtSign = jwt.sign as any;
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
isVip: boolean;
|
||||||
|
vipLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 JWT Token
|
||||||
|
*/
|
||||||
|
export function generateToken(payload: JwtPayload): string {
|
||||||
|
return jwtSign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 JWT Token
|
||||||
|
*/
|
||||||
|
export function verifyToken(token: string): JwtPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头提取 Token
|
||||||
|
*/
|
||||||
|
export function extractToken(authHeader?: string): string | null {
|
||||||
|
if (!authHeader) return null;
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user