feat: 安全基线 + 4个安全漏洞修复 - JWT AuthGuard/OptionalAuthGuard, StrictValidationPipe, 全局异常过滤器, Redis限流429, Apple登录mock模式, BigInt精度修复, SECURITY.md
This commit is contained in:
parent
35de65e99b
commit
ef7c1f1bc9
12
.env.example
12
.env.example
@ -1,8 +1,9 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@81.70.187.179:3306/zhixi"
|
||||
DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@localhost:3306/zhixi"
|
||||
|
||||
REDIS_HOST=81.70.187.179
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
@ -12,9 +13,12 @@ AI_API_KEY=
|
||||
AI_BASE_URL=
|
||||
|
||||
JWT_SECRET=change_me_in_production
|
||||
|
||||
NODE_ENV=development
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
ENABLE_SWAGGER=true
|
||||
SWAGGER_USER=admin
|
||||
SWAGGER_PASSWORD=change_me
|
||||
|
||||
STORAGE_DRIVER=local
|
||||
STORAGE_LOCAL_PATH=./uploads
|
||||
|
||||
169
SECURITY.md
Normal file
169
SECURITY.md
Normal file
@ -0,0 +1,169 @@
|
||||
# 知习 api-server 安全基线
|
||||
|
||||
> v0.1 安全设计文档。本后端存储用户资料、知识库、上传文件、主动回忆回答、AI 分析结果和学习记录,第一版必须建立基础安全边界。
|
||||
|
||||
---
|
||||
|
||||
## 1. 全局安全中间件
|
||||
|
||||
| 措施 | 实现 | 文件 |
|
||||
|------|------|------|
|
||||
| helmet | `app.use(helmet())` 设置安全 HTTP 头 | `src/main.ts` |
|
||||
| CORS | 仅允许配置域名。生产环境仅允许 `longde.cloud` | `src/main.ts` |
|
||||
| body size limit | JSON 请求体最大 10MB | `src/main.ts` |
|
||||
| 异常过滤 | 生产环境不返回 stack trace | `src/common/filters/global-exception.filter.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 认证与 Token
|
||||
|
||||
### JWT
|
||||
|
||||
- `accessToken`: JWT,1 小时过期
|
||||
- `refreshToken`: 128 位随机 hex,入库只存 SHA-256 hash
|
||||
- logout 时 `revokedAt = now()` 撤销所有 refresh token
|
||||
- `/users/me` 及其所有子路由强制 `@UseGuards(JwtAuthGuard)`
|
||||
|
||||
```
|
||||
POST /auth/apple → 返回 accessToken + refreshToken
|
||||
POST /auth/refresh → 消耗旧 refreshToken,发放新 token pair(rotation)
|
||||
POST /auth/logout → 撤销该用户所有 refresh token
|
||||
```
|
||||
|
||||
### 存储安全
|
||||
|
||||
```
|
||||
refresh_tokens.tokenHash = SHA-256(实际 token)
|
||||
数据库中永远不存明文 refreshToken
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限与越权防护
|
||||
|
||||
### 资源归属校验
|
||||
|
||||
所有用户资源操作必须校验 `userId` 归属:
|
||||
|
||||
```ts
|
||||
// src/common/utils/security.util.ts
|
||||
export async function findByIdAndUserId(delegate, id, userId, resourceName)
|
||||
export function ensureOwnership(record, userId, resourceName)
|
||||
```
|
||||
|
||||
### 需校验的资源
|
||||
|
||||
| 资源 | 校验字段 |
|
||||
|------|---------|
|
||||
| KnowledgeBase | `userId` |
|
||||
| KnowledgeItem | `userId` |
|
||||
| LearningSession | `userId` |
|
||||
| ActiveRecallAnswer | `userId` |
|
||||
| AiAnalysisJob | `userId` |
|
||||
| AiAnalysisResult | `userId` |
|
||||
| FocusItem | `userId` |
|
||||
| ReviewCard | `userId` |
|
||||
| ReviewLog | `userId` |
|
||||
| DocumentImport | `userId` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 参数校验
|
||||
|
||||
- 全局 `StrictValidationPipe`:
|
||||
- `whitelist: true` — 自动剥离未声明字段
|
||||
- `forbidNonWhitelisted: true` — 未知字段返回 400
|
||||
- 字符串字段最大长度 5000 字符
|
||||
- 分页 DTO: page≥1, limit 1-100
|
||||
|
||||
---
|
||||
|
||||
## 5. 限流(Redis)
|
||||
|
||||
| 场景 | Key | 限制 |
|
||||
|------|-----|------|
|
||||
| 登录 | `rate:ip:{ip}:login:{date}` | 20次/IP/天 |
|
||||
| 反馈 | `rate:ip:{ip}:feedback:hourly` | 5次/IP/时 |
|
||||
| AI 分析 | `rate:user:{userId}:ai:daily:{date}` | 50次/用户/天 |
|
||||
| 文件上传 | `rate:user:{userId}:upload:hourly` | 10次/用户/时 |
|
||||
|
||||
实现: `src/common/utils/rate-limit.service.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件上传安全
|
||||
|
||||
| 措施 | 说明 |
|
||||
|------|------|
|
||||
| 类型白名单 | PDF, Word, Excel, 纯文本, Markdown, CSV, PNG, JPEG, WebP |
|
||||
| 大小限制 | 最大 20MB |
|
||||
| 随机文件名 | `sanitizeFilename()` 生成随机 key,不信任用户原始文件名 |
|
||||
| 默认私有 | 所有文件默认私有访问 |
|
||||
| 路径隔离 | `users/{userId}/...` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Redis 安全使用
|
||||
|
||||
- 不存核心业务结果(用户资料/知识点/AI分析结果等必须在 MySQL)
|
||||
- 队列任务只存 `jobId`/`userId` 等引用 ID
|
||||
- 所有临时 key 必须设置 TTL
|
||||
- 防重复提交锁必须有 TTL,解锁校验 token
|
||||
- 不在 Redis 中存 token 明文
|
||||
|
||||
---
|
||||
|
||||
## 8. COS 安全使用
|
||||
|
||||
- Bucket 默认私有读写
|
||||
- 后端不向前端暴露 SecretId/SecretKey
|
||||
- 下载私有文件通过签名 URL
|
||||
- 上传路径按 `users/{userId}/{randomKey}` 组织
|
||||
- 预留临时上传 URL(STS)机制
|
||||
|
||||
---
|
||||
|
||||
## 9. Swagger 安全
|
||||
|
||||
- 开发环境默认开启
|
||||
- 生产环境默认关闭
|
||||
- 生产环境如需开启,必须配置 Basic Auth(`SWAGGER_USER`/`SWAGGER_PASSWORD`)
|
||||
- 生产环境手动设置 `ENABLE_SWAGGER=true`
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据库安全
|
||||
|
||||
- 不使用 root 连接业务
|
||||
- 业务账号 `zhixi_user` 仅需 SELECT/INSERT/UPDATE/DELETE
|
||||
- 迁移账号和业务账号分离(`prisma db push` 与运行时连接帐号可不同)
|
||||
- 数据库自动备份建议: `mysqldump zhixi | gzip > backup-$(date +%Y%m%d).sql.gz`
|
||||
|
||||
### 日志中禁止打印
|
||||
|
||||
```
|
||||
DATABASE_URL(含密码)
|
||||
JWT_SECRET
|
||||
AI_API_KEY
|
||||
COS SecretKey
|
||||
用户完整 refreshToken
|
||||
用户上传文件的完整内容
|
||||
Authorization header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 安全检查清单
|
||||
|
||||
- [x] helmet 已启用
|
||||
- [x] CORS 仅允许白名单域名
|
||||
- [x] JWT + refresh token rotation + hash 存储
|
||||
- [x] logout 撤销 refresh token
|
||||
- [x] 所有用户数据接口需要认证
|
||||
- [x] 资源所有权校验工具已就绪
|
||||
- [x] StrictValidationPipe 全局启用(whitelist + forbidNonWhitelisted)
|
||||
- [x] Redis 限流已实现
|
||||
- [x] 文件类型/大小白名单
|
||||
- [x] 全局异常过滤器生产环境不暴露 stack trace
|
||||
- [x] Swagger 生产环境默认关闭
|
||||
- [x] 敏感信息不在日志中打印原则已确立
|
||||
137
package-lock.json
generated
137
package-lock.json
generated
@ -15,11 +15,17 @@
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
@ -31,9 +37,11 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"bullmq": "^5.76.6",
|
||||
"eslint": "^9.18.0",
|
||||
@ -2577,6 +2585,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "11.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/platform-express/-/platform-express-11.1.19.tgz",
|
||||
@ -2683,6 +2701,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@ -2956,6 +2985,13 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@ -3124,6 +3160,38 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmmirror.com/@types/passport/-/passport-1.0.17.tgz",
|
||||
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmmirror.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
|
||||
@ -4349,6 +4417,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
|
||||
@ -6380,6 +6457,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@ -8471,6 +8557,43 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/passport": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -8548,6 +8671,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -10287,6 +10415,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@ -26,11 +26,17 @@
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
@ -42,9 +48,11 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"bullmq": "^5.76.6",
|
||||
"eslint": "^9.18.0",
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { PrismaModule } from './infrastructure/database/prisma.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { QueueModule } from './infrastructure/queue/queue.module';
|
||||
import { AiModule } from './infrastructure/ai/ai.module';
|
||||
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||
import { LoggerModule } from './infrastructure/logger/logger.module';
|
||||
|
||||
import { SystemModule } from './modules/system/system.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@ -22,6 +26,9 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
||||
import { FeedbackModule } from './modules/feedback/feedback.module';
|
||||
import { WaitlistModule } from './modules/waitlist/waitlist.module';
|
||||
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||
|
||||
import appConfig from './config/app.config';
|
||||
import databaseConfig from './config/database.config';
|
||||
import redisConfig from './config/redis.config';
|
||||
@ -42,6 +49,15 @@ import storageConfig from './config/storage.config';
|
||||
storageConfig,
|
||||
],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('jwt.secret'),
|
||||
signOptions: { expiresIn: config.get<string>('jwt.expiresIn', '1h') as any },
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
QueueModule,
|
||||
@ -64,5 +80,9 @@ import storageConfig from './config/storage.config';
|
||||
FeedbackModule,
|
||||
WaitlistModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
52
src/common/filters/global-exception.filter.ts
Normal file
52
src/common/filters/global-exception.filter.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const isProduction =
|
||||
this.configService.get<string>('app.nodeEnv') === 'production';
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = '服务器内部错误';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exception.message;
|
||||
if (Array.isArray(message)) message = message.join('; ');
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`[${request.method}] ${request.url} -> ${status}: ${message}`,
|
||||
isProduction ? undefined : (exception as any)?.stack,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
message,
|
||||
...(isProduction ? {} : { path: request.url }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.message
|
||||
: 'Internal server error';
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,42 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
return !!request.user;
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('请先登录');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
request.user = { id: String(payload.sub), email: payload.email };
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已过期,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return undefined;
|
||||
return authHeader.split(' ')[1];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,27 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
canActivate(_context: ExecutionContext): boolean {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return true;
|
||||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
request.user = { id: String(payload.sub), email: payload.email };
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,47 @@
|
||||
import {
|
||||
PipeTransform,
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
@Injectable()
|
||||
export class ValidationPipe implements PipeTransform<any> {
|
||||
export class StrictValidationPipe implements PipeTransform<any> {
|
||||
private readonly maxStringLength = 5000;
|
||||
|
||||
async transform(value: any, { metatype }: ArgumentMetadata) {
|
||||
if (!metatype || !this.toValidate(metatype)) {
|
||||
return value;
|
||||
}
|
||||
const object = plainToInstance(metatype, value);
|
||||
const errors = await validate(object);
|
||||
const errors = await validate(object, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((err) =>
|
||||
Object.values(err.constraints || {}).join(', '),
|
||||
);
|
||||
throw new BadRequestException(messages);
|
||||
}
|
||||
return value;
|
||||
this.validateStringLengths(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
private toValidate(metatype: Function): boolean {
|
||||
const types: Function[] = [String, Boolean, Number, Array, Object];
|
||||
return !types.includes(metatype);
|
||||
}
|
||||
|
||||
private validateStringLengths(obj: any) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (typeof obj[key] === 'string' && obj[key].length > this.maxStringLength) {
|
||||
throw new BadRequestException(
|
||||
`字段 ${key} 长度不能超过 ${this.maxStringLength} 字符`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
export interface UserPayload {
|
||||
id: number;
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
46
src/common/utils/rate-limit.service.ts
Normal file
46
src/common/utils/rate-limit.service.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class RateLimitService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async checkLimit(
|
||||
key: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number,
|
||||
): Promise<void> {
|
||||
const count = await this.redis.incr(key);
|
||||
if (count === 1) {
|
||||
await this.redis.expire(key, windowSeconds);
|
||||
}
|
||||
if (count > maxRequests) {
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: `请求过于频繁,请${windowSeconds}秒后再试`,
|
||||
retryAfter: windowSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loginLimit(ip: string): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await this.checkLimit(`rate:ip:${ip}:login:${today}`, 20, 1800);
|
||||
}
|
||||
|
||||
async feedbackLimit(ip: string): Promise<void> {
|
||||
await this.checkLimit(`rate:ip:${ip}:feedback:hourly`, 5, 3600);
|
||||
}
|
||||
|
||||
async aiAnalysisLimit(userId: string): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await this.checkLimit(`rate:user:${userId}:ai:daily:${today}`, 50, 86400);
|
||||
}
|
||||
|
||||
async fileUploadLimit(userId: string): Promise<void> {
|
||||
await this.checkLimit(`rate:user:${userId}:upload:hourly`, 10, 3600);
|
||||
}
|
||||
}
|
||||
80
src/common/utils/security.util.ts
Normal file
80
src/common/utils/security.util.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type Delegate = {
|
||||
findUnique(args: { where: any }): Promise<any>;
|
||||
findFirst(args: { where: any }): Promise<any>;
|
||||
};
|
||||
|
||||
export async function findByIdAndUserId<T extends Delegate>(
|
||||
delegate: T,
|
||||
id: number | bigint,
|
||||
userId: number | bigint,
|
||||
resourceName: string,
|
||||
) {
|
||||
const record = await delegate.findUnique({ where: { id } } as any);
|
||||
if (!record) {
|
||||
throw new BadRequestException(`${resourceName}不存在`);
|
||||
}
|
||||
if (record.userId !== userId) {
|
||||
throw new ForbiddenException(`无权访问该${resourceName}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function ensureOwnership(
|
||||
record: any,
|
||||
userId: number | bigint,
|
||||
resourceName: string,
|
||||
) {
|
||||
if (!record) {
|
||||
throw new BadRequestException(`${resourceName}不存在`);
|
||||
}
|
||||
if (record.userId !== userId) {
|
||||
throw new ForbiddenException(`无权访问该${resourceName}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(originalName: string): string {
|
||||
const ext = originalName.split('.').pop()?.toLowerCase() || '';
|
||||
const safeExt = ext.replace(/[^a-z0-9]/g, '');
|
||||
const randomName =
|
||||
Date.now().toString(36) + Math.random().toString(36).substring(2, 15);
|
||||
return `${randomName}.${safeExt}`;
|
||||
}
|
||||
|
||||
export const ALLOWED_FILE_TYPES = [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
export function validateFileUpload(
|
||||
mimeType: string,
|
||||
sizeBytes: number,
|
||||
): void {
|
||||
if (!ALLOWED_FILE_TYPES.includes(mimeType)) {
|
||||
throw new BadRequestException(
|
||||
`不支持的文件类型: ${mimeType},仅支持 PDF/Word/Excel/文本/图片`,
|
||||
);
|
||||
}
|
||||
if (sizeBytes > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException(
|
||||
`文件大小不能超过 ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string): string {
|
||||
if (!secret || secret.length < 8) return '***';
|
||||
return secret.slice(0, 4) + '***' + secret.slice(-4);
|
||||
}
|
||||
@ -1,7 +1,21 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'change_me_in_production',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
}));
|
||||
export default registerAs('jwt', () => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret || secret === 'change_me_in_production') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'生产环境必须设置环境变量 JWT_SECRET,不能使用默认值',
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
'\n⚠️ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' +
|
||||
' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n',
|
||||
);
|
||||
}
|
||||
return {
|
||||
secret: secret || 'change_me_in_production',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
};
|
||||
});
|
||||
|
||||
105
src/main.ts
105
src/main.ts
@ -1,51 +1,90 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import helmet from 'helmet';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
const isProduction = configService.get('app.nodeEnv') === 'production';
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
app.enableCors({
|
||||
origin: ['https://longde.cloud', 'http://localhost:4321'],
|
||||
origin: isProduction
|
||||
? [configService.get('app.allowedOrigin', 'https://longde.cloud')]
|
||||
: ['https://longde.cloud', 'http://localhost:4321', 'http://localhost:5173'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
credentials: true,
|
||||
maxAge: 86400,
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('知习 API')
|
||||
.setDescription('知习 AI-first 系统化学习产品后端 API(v0.1)')
|
||||
.setVersion('0.1.0')
|
||||
.addTag('health', '服务健康检查')
|
||||
.addTag('auth', '用户认证')
|
||||
.addTag('users', '用户管理')
|
||||
.addTag('knowledge-base', '知识库')
|
||||
.addTag('knowledge-items', '知识点')
|
||||
.addTag('document-import', '资料导入')
|
||||
.addTag('learning-session', '学习会话')
|
||||
.addTag('active-recall', '主动回忆')
|
||||
.addTag('ai-analysis', 'AI 分析')
|
||||
.addTag('review', '复习管理')
|
||||
.addTag('focus-items', '待巩固项')
|
||||
.addTag('learning-activity', '学习活跃')
|
||||
.addTag('notifications', '消息通知')
|
||||
.addTag('feedback', '用户反馈')
|
||||
.addTag('waitlist', '等待名单')
|
||||
.build();
|
||||
app.useBodyParser('json', { limit: '10mb' });
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: '知习 API 文档',
|
||||
});
|
||||
const swaggerEnabled = !isProduction || configService.get('app.enableSwagger') === true;
|
||||
if (swaggerEnabled) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('知习 API')
|
||||
.setDescription('知习 AI-first 系统化学习产品后端 API')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('health', '服务健康检查')
|
||||
.addTag('auth', '用户认证')
|
||||
.addTag('users', '用户管理')
|
||||
.addTag('knowledge-base', '知识库')
|
||||
.addTag('knowledge-items', '知识点')
|
||||
.addTag('document-import', '资料导入')
|
||||
.addTag('learning-session', '学习会话')
|
||||
.addTag('active-recall', '主动回忆')
|
||||
.addTag('ai-analysis', 'AI 分析')
|
||||
.addTag('review', '复习管理')
|
||||
.addTag('focus-items', '待巩固项')
|
||||
.addTag('learning-activity', '学习活跃')
|
||||
.addTag('notifications', '消息通知')
|
||||
.addTag('feedback', '用户反馈')
|
||||
.addTag('waitlist', '等待名单')
|
||||
.build();
|
||||
|
||||
app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => {
|
||||
res.json(document);
|
||||
});
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
if (isProduction) {
|
||||
const swaggerUser = configService.get('app.swaggerUser', 'admin');
|
||||
const swaggerPassword = configService.get('app.swaggerPassword');
|
||||
if (swaggerPassword) {
|
||||
app.use('/api-docs', (req: any, res: any, next: any) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith('Basic ')) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic');
|
||||
return res.status(401).send('Authentication required');
|
||||
}
|
||||
const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64')
|
||||
.toString()
|
||||
.split(':');
|
||||
if (user === swaggerUser && pass === swaggerPassword) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).send('Invalid credentials');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SwaggerModule.setup('api-docs', app, document, {
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: '知习 API 文档',
|
||||
});
|
||||
|
||||
app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => {
|
||||
res.json(document);
|
||||
});
|
||||
|
||||
console.log('[Swagger] API 文档已启用');
|
||||
}
|
||||
|
||||
const port = configService.get<number>('app.port', 3000);
|
||||
await app.listen(port);
|
||||
console.log(`[API] Server running on http://localhost:${port}`);
|
||||
console.log(`[API] Swagger docs at http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { AiAnalysisRepository } from './ai-analysis.repository';
|
||||
import { AiService } from '../../infrastructure/ai/ai.service';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
@ -23,7 +23,7 @@ export class AiAnalysisService {
|
||||
const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`;
|
||||
const lockToken = await this.redis.lock(lockKey, 300);
|
||||
if (!lockToken) {
|
||||
throw new Error('同一学习会话的 AI 分析正在处理中,请稍候');
|
||||
throw new HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const job = await this.repository.createJob(userId, body);
|
||||
@ -45,7 +45,10 @@ export class AiAnalysisService {
|
||||
await this.redis.expire(rateKey, 86400);
|
||||
}
|
||||
if (count > DAILY_AI_LIMIT) {
|
||||
throw new Error(`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`);
|
||||
throw new HttpException(
|
||||
`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,32 @@
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { Request } from 'express';
|
||||
import { IsString, Allow, IsOptional } from 'class-validator';
|
||||
|
||||
class AppleLoginDto {
|
||||
@IsString()
|
||||
identityToken: string;
|
||||
|
||||
@IsString()
|
||||
authorizationCode: string;
|
||||
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
||||
|
||||
@Allow()
|
||||
@IsOptional()
|
||||
user?: any;
|
||||
}
|
||||
|
||||
class RefreshDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@ -15,25 +36,34 @@ export class AuthController {
|
||||
|
||||
@Post('apple')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Apple 登录', description: '使用 Sign in with Apple 登录,返回访问令牌' })
|
||||
@ApiOperation({ summary: 'Apple 登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
@ApiResponse({ status: 401, description: '身份验证失败' })
|
||||
async appleLogin(@Body() body: AppleLoginDto) {
|
||||
return this.authService.appleLogin(body);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新令牌', description: '使用刷新令牌获取新的访问令牌' })
|
||||
@ApiResponse({ status: 200, description: '令牌刷新成功' })
|
||||
async refresh(@Body('refreshToken') refreshToken: string) {
|
||||
return this.authService.refresh(refreshToken);
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
@ApiResponse({ status: 401, description: '刷新令牌无效' })
|
||||
async refresh(@Body() body: RefreshDto) {
|
||||
if (!body.refreshToken) {
|
||||
throw new BadRequestException('缺少 refreshToken');
|
||||
}
|
||||
return this.authService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户退出', description: '使当前会话失效' })
|
||||
@ApiOperation({ summary: '退出登录' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async logout() {
|
||||
return this.authService.logout();
|
||||
async logout(@Req() req: Request) {
|
||||
const user = (req as any).user;
|
||||
if (user?.id) {
|
||||
await this.authService.logout(user.id);
|
||||
}
|
||||
return { success: true, message: '已退出登录' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthRepository],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
appleUserId: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
private users: AuthUser[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
async findByAppleUserId(appleUserId: string): Promise<AuthUser | undefined> {
|
||||
return this.users.find((u) => u.appleUserId === appleUserId);
|
||||
}
|
||||
|
||||
async createUser(data: Partial<AuthUser>): Promise<AuthUser> {
|
||||
const user: AuthUser = {
|
||||
id: data.id || this.nextId++,
|
||||
appleUserId: data.appleUserId || '',
|
||||
email: data.email,
|
||||
displayName: data.displayName,
|
||||
};
|
||||
this.users.push(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,158 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import * as crypto from 'crypto';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly authRepository: AuthRepository) {}
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async appleLogin(params: {
|
||||
identityToken: string;
|
||||
authorizationCode: string;
|
||||
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
||||
}) {
|
||||
const appleUserId = `apple_${params.identityToken.substring(0, 20)}`;
|
||||
let user = await this.authRepository.findByAppleUserId(appleUserId);
|
||||
if (!user) {
|
||||
const appleUserId = await this.verifyAppleIdentity(
|
||||
params.identityToken,
|
||||
params.authorizationCode,
|
||||
params.user?.email,
|
||||
);
|
||||
|
||||
let account = await this.prisma.authAccount.findUnique({
|
||||
where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
const displayName =
|
||||
params.user?.name
|
||||
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}`
|
||||
: undefined;
|
||||
user = await this.authRepository.createUser({
|
||||
appleUserId,
|
||||
email: params.user?.email,
|
||||
displayName,
|
||||
account = await this.prisma.authAccount.create({
|
||||
data: {
|
||||
provider: 'apple',
|
||||
providerUserId: appleUserId,
|
||||
email: params.user?.email,
|
||||
user: {
|
||||
create: {
|
||||
email: params.user?.email,
|
||||
nickname: displayName || undefined,
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
const accessToken = `mock_token_${Date.now()}`;
|
||||
const refreshToken = `mock_refresh_${Date.now()}`;
|
||||
|
||||
const userIdStr = String(account.user.id);
|
||||
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: userIdStr,
|
||||
email: account.user.email,
|
||||
});
|
||||
|
||||
const refreshToken = crypto.randomBytes(48).toString('hex');
|
||||
const refreshTokenHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(refreshToken)
|
||||
.digest('hex');
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: account.user.id,
|
||||
tokenHash: refreshTokenHash,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken, expiresIn: 3600 };
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const accessToken = `mock_token_${Date.now()}`;
|
||||
return { accessToken, expiresIn: 3600 };
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const stored = await this.prisma.refreshToken.findFirst({
|
||||
where: { tokenHash: hash, revokedAt: null },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('刷新令牌无效或已过期');
|
||||
}
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
const newRefreshToken = crypto.randomBytes(48).toString('hex');
|
||||
const newHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(newRefreshToken)
|
||||
.digest('hex');
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: stored.userId,
|
||||
tokenHash: newHash,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400000),
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: String(stored.user.id),
|
||||
email: stored.user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresIn: 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return { success: true, message: '已退出登录' };
|
||||
async logout(userId: number) {
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyAppleIdentity(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
email?: string | null,
|
||||
): Promise<string> {
|
||||
if (this.isMockMode()) {
|
||||
return this.verifyMockApple(identityToken, email);
|
||||
}
|
||||
return this.verifyRealApple(identityToken, authorizationCode);
|
||||
}
|
||||
|
||||
private isMockMode(): boolean {
|
||||
return this.configService.get<string>('app.nodeEnv') !== 'production';
|
||||
}
|
||||
|
||||
private verifyMockApple(identityToken: string, email?: string | null): string {
|
||||
if (!identityToken || identityToken.trim().length < 4) {
|
||||
throw new UnauthorizedException('identityToken 无效');
|
||||
}
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`apple-mock:${identityToken}:${email || 'no-email'}`)
|
||||
.digest('hex')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
private async verifyRealApple(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
): Promise<string> {
|
||||
throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateFeedbackDto {
|
||||
@ApiPropertyOptional({ description: '用户 ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '反馈类型', enum: ['bug', 'feature', 'general'] })
|
||||
@IsString()
|
||||
@IsIn(['bug', 'feature', 'general'])
|
||||
type: 'bug' | 'feature' | 'general';
|
||||
|
||||
@ApiProperty({ description: '反馈内容' })
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '联系方式' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contact?: string;
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ export class KnowledgeBaseService {
|
||||
|
||||
async findOne(userId: string, id: string) {
|
||||
const kb = await this.repository.findById(id);
|
||||
if (!kb || kb.userId !== userId) {
|
||||
if (!kb || String(kb.userId) !== userId) {
|
||||
throw new NotFoundException('知识库不存在');
|
||||
}
|
||||
return kb;
|
||||
@ -28,7 +28,7 @@ export class KnowledgeBaseService {
|
||||
|
||||
async update(userId: string, id: string, dto: any) {
|
||||
const kb = await this.repository.findById(id);
|
||||
if (!kb || kb.userId !== userId) {
|
||||
if (!kb || String(kb.userId) !== userId) {
|
||||
throw new NotFoundException('知识库不存在');
|
||||
}
|
||||
return this.repository.update(id, dto);
|
||||
@ -36,7 +36,7 @@ export class KnowledgeBaseService {
|
||||
|
||||
async remove(userId: string, id: string) {
|
||||
const kb = await this.repository.findById(id);
|
||||
if (!kb || kb.userId !== userId) {
|
||||
if (!kb || String(kb.userId) !== userId) {
|
||||
throw new NotFoundException('知识库不存在');
|
||||
}
|
||||
return this.repository.softDelete(id);
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller()
|
||||
@ -37,4 +38,14 @@ export class SystemController {
|
||||
} catch {}
|
||||
return { status: 'ok', database: dbStatus, redis: redisStatus };
|
||||
}
|
||||
|
||||
@Get('secure-test')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '认证测试端点' })
|
||||
@ApiResponse({ status: 200, description: '已认证' })
|
||||
@ApiResponse({ status: 401, description: '未认证' })
|
||||
secureTest() {
|
||||
return { message: '你已通过认证' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SystemController } from './system.controller';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RedisModule } from '../../infrastructure/redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule],
|
||||
controllers: [SystemController],
|
||||
})
|
||||
export class SystemModule {}
|
||||
|
||||
@ -1,32 +1,33 @@
|
||||
import { Controller, Get, Patch, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { UserPayload } from '../../common/types';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: '获取当前用户信息', description: '获取当前登录用户的资料' })
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
@ApiResponse({ status: 200, description: '用户信息' })
|
||||
async getProfile(@CurrentUser() user: UserPayload | undefined) {
|
||||
return this.usersService.getProfile(String(user?.id || 'anonymous'));
|
||||
async getProfile(@CurrentUser() user: UserPayload) {
|
||||
return this.usersService.getProfile(String(user.id));
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@ApiOperation({ summary: '更新用户资料', description: '更新当前用户的资料信息' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async updateProfile(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
|
||||
return this.usersService.updateProfile(String(user?.id || 'anonymous'), body);
|
||||
@ApiOperation({ summary: '更新用户资料' })
|
||||
async updateProfile(@CurrentUser() user: UserPayload, @Body() body: any) {
|
||||
return this.usersService.updateProfile(String(user.id), body);
|
||||
}
|
||||
|
||||
@Patch('me/preferences')
|
||||
@ApiOperation({ summary: '更新用户偏好', description: '更新当前用户的学习偏好设置' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
async updatePreferences(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
|
||||
return this.usersService.updatePreferences(String(user?.id || 'anonymous'), body);
|
||||
@ApiOperation({ summary: '更新用户偏好' })
|
||||
async updatePreferences(@CurrentUser() user: UserPayload, @Body() body: any) {
|
||||
return this.usersService.updatePreferences(String(user.id), body);
|
||||
}
|
||||
}
|
||||
|
||||
184
test-crud.ts
184
test-crud.ts
@ -1,184 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('=== MySQL CRUD 测试 ===\n');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'test-crud@zhixi.com',
|
||||
nickname: 'CRUD测试用户',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT user: id=${user.id}, email=${user.email}, nickname=${user.nickname}`);
|
||||
|
||||
const profile = await prisma.userProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
learningIdentity: '知识工作者',
|
||||
learningDirection: '产品设计',
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT profile: userId=${profile.userId}, direction=${profile.learningDirection}`);
|
||||
|
||||
const kb = await prisma.knowledgeBase.create({
|
||||
data: { userId: user.id, title: '测试知识库', description: 'CRUD测试' },
|
||||
});
|
||||
console.log(`✅ INSERT knowledgeBase: id=${kb.id}, title=${kb.title}`);
|
||||
|
||||
const item = await prisma.knowledgeItem.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
knowledgeBaseId: kb.id,
|
||||
itemType: 'lesson',
|
||||
title: '测试知识点-数据结构入门',
|
||||
content: '## 数组与链表\n数组是连续内存...',
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT knowledgeItem: id=${item.id}, title=${item.title}`);
|
||||
|
||||
const foundKB = await prisma.knowledgeBase.findUnique({ where: { id: kb.id } });
|
||||
console.log(`✅ SELECT knowledgeBase: ${foundKB?.title} (status=${foundKB?.status})`);
|
||||
|
||||
const items = await prisma.knowledgeItem.findMany({ where: { knowledgeBaseId: kb.id } });
|
||||
console.log(`✅ SELECT knowledgeItems: ${items.length} 条`);
|
||||
|
||||
const session = await prisma.learningSession.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
knowledgeBaseId: kb.id,
|
||||
knowledgeItemId: item.id,
|
||||
mode: 'reading',
|
||||
status: 'active',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT learningSession: id=${session.id}, mode=${session.mode}`);
|
||||
|
||||
const updated = await prisma.learningSession.update({
|
||||
where: { id: session.id },
|
||||
data: { status: 'completed', endedAt: new Date(), durationSeconds: 300 },
|
||||
});
|
||||
console.log(`✅ UPDATE learningSession: status=${updated.status}, duration=${updated.durationSeconds}s`);
|
||||
|
||||
const job = await prisma.aiAnalysisJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sessionId: session.id,
|
||||
jobType: 'active_recall_analysis',
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT aiAnalysisJob: id=${job.id}, type=${job.jobType}`);
|
||||
|
||||
await prisma.aiAnalysisJob.update({
|
||||
where: { id: job.id },
|
||||
data: { status: 'success', completedAt: new Date(), progress: 100 },
|
||||
});
|
||||
console.log(`✅ UPDATE aiAnalysisJob: success`);
|
||||
|
||||
await prisma.aiAnalysisResult.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
jobId: job.id,
|
||||
summary: '用户掌握了数组的基本概念',
|
||||
masteryScore: 85,
|
||||
strengths: JSON.stringify(['理解清晰', '举例恰当']),
|
||||
weaknesses: JSON.stringify(['链表概念有混淆']),
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT aiAnalysisResult: score=85`);
|
||||
|
||||
const focus = await prisma.focusItem.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
knowledgeItemId: item.id,
|
||||
title: '链表与数组的区别需要巩固',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT focusItem: id=${focus.id}, title=${focus.title}`);
|
||||
|
||||
await prisma.focusItem.update({
|
||||
where: { id: focus.id },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
console.log(`✅ UPDATE focusItem: completed`);
|
||||
|
||||
const card = await prisma.reviewCard.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
knowledgeItemId: item.id,
|
||||
frontText: '数组和链表的主要区别是什么?',
|
||||
backText: '数组是连续内存分配...',
|
||||
nextReviewAt: new Date(Date.now() + 86400000),
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT reviewCard: id=${card.id}`);
|
||||
|
||||
await prisma.reviewLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
reviewCardId: card.id,
|
||||
rating: 'good',
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT reviewLog: rating=good`);
|
||||
|
||||
await prisma.feedback.create({
|
||||
data: { userId: user.id, category: 'feature', content: '希望支持Markdown导入', status: 'open' },
|
||||
});
|
||||
console.log(`✅ INSERT feedback`);
|
||||
|
||||
await prisma.notification.create({
|
||||
data: { userId: user.id, type: 'ai_analysis_done', title: 'AI分析完成', content: '你的学习分析已生成' },
|
||||
});
|
||||
console.log(`✅ INSERT notification`);
|
||||
|
||||
await prisma.dailyLearningActivity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
activityDate: new Date(),
|
||||
durationSeconds: 300,
|
||||
sessionsCount: 1,
|
||||
activityLevel: 2,
|
||||
},
|
||||
});
|
||||
console.log(`✅ INSERT dailyLearningActivity`);
|
||||
|
||||
// 关联查询 - KnowledgeBase + Items
|
||||
const kbWithItems = await prisma.knowledgeBase.findUnique({
|
||||
where: { id: kb.id },
|
||||
include: { items: true },
|
||||
});
|
||||
console.log(`✅ RELATION: knowledgeBase '${kbWithItems?.title}' 包含 ${kbWithItems?.items.length} 个知识点`);
|
||||
|
||||
// ----- 清理测试数据 -----
|
||||
await prisma.reviewLog.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.reviewCard.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.focusItem.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.aiAnalysisResult.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.aiAnalysisJob.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.learningSession.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.knowledgeItem.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.knowledgeBase.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.userProfile.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.feedback.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.notification.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.dailyLearningActivity.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
console.log('\n✅ DELETE 清理完成 - 无残留数据');
|
||||
console.log('=== MySQL CRUD 全部通过 ===');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ FAILED:', e.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Loading…
x
Reference in New Issue
Block a user