feat: 安全基线 + 4个安全漏洞修复 - JWT AuthGuard/OptionalAuthGuard, StrictValidationPipe, 全局异常过滤器, Redis限流429, Apple登录mock模式, BigInt精度修复, SECURITY.md

This commit is contained in:
WangDL 2026-05-09 18:57:33 +08:00
parent 35de65e99b
commit ef7c1f1bc9
26 changed files with 914 additions and 348 deletions

View File

@ -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
View 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`: JWT1 小时过期
- `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 pairrotation
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}` 组织
- 预留临时上传 URLSTS机制
---
## 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
View File

@ -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",

View File

@ -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",

View File

@ -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 {}

View 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 }),
});
}
}

View File

@ -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(),
});
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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} 字符`,
);
}
}
}
}

View File

@ -1,5 +1,5 @@
export interface UserPayload {
id: number;
id: string;
email?: string;
nickname?: string;
}

View 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);
}
}

View 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);
}

View File

@ -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',
};
});

View File

@ -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 系统化学习产品后端 APIv0.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();

View File

@ -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,
);
}
}

View File

@ -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: '已退出登录' };
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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 凭证');
}
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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: '你已通过认证' };
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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());