diff --git a/.env.example b/.env.example index d15bd3c..6bc037a 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ PORT=3000 NODE_ENV=development -DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@localhost:3306/zhixi" +DATABASE_URL="mysql://zhixi_user:Zhixi%402026%21App@localhost:3306/zhixi" REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/README.md b/README.md index 7338e6f..50a0996 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,21 @@ AI 学习产品的后端 API 服务(v0.1),基于 NestJS + TypeScript 构 | 模块 | 说明 | 状态 | |------|------|------| | health | 健康检查 | ✅ | -| auth | 用户认证(登录/注册/Token刷新) | ✅ | +| auth | 用户认证(dev-login / Apple / 刷新 / 登出) | ✅ | | users | 用户管理 | ✅ | -| knowledge | 知识库/文章 | ✅ | -| learning | 学习路径/课程/进度 | ✅ | -| ai | AI 分析与对话(Mock) | ✅ | -| review | 间隔重复复习任务 | ✅ | -| feedback | 用户反馈 | ✅ | -| waitlist | 等待名单 | ✅ | +| knowledge-base | 知识库 CRUD → Prisma | ✅ | +| knowledge-items | 知识点 CRUD → Prisma | ✅ | +| ai | AI 三层架构(Provider → Gateway → Workflow)15 文件 | ✅ | +| ai-analysis | AI 分析结果查询 → Prisma | ✅ | +| active-recall | 主动回忆(提交答案触发分析)→ Prisma | ✅ | +| learning-session | 学习会话 → Prisma | ✅ | +| review | 间隔重复复习 → Prisma | ✅ | +| focus-items | 待巩固项 → Prisma | ✅ | +| learning-activity | 学习活跃统计 → Prisma | ✅ | +| document-import | 文档导入 → Prisma | ✅ | +| notifications | 通知 → Prisma | ✅ | +| feedback | 用户反馈 → Prisma | ✅ | +| waitlist | 等待名单 → Prisma | ✅ | ## 快速开始 @@ -123,10 +130,14 @@ SWAGGER_PASSWORD=change_me - `PATCH /learning/progress` - 更新学习进度 ### AI -- `POST /ai/analyze` - AI 学习分析(弱点/进度/推荐) -- `POST /ai/chat` - AI 对话 -- `GET /ai/sessions?userId=` - 获取对话历史 -- `GET /ai/sessions/:sessionId` - 获取指定会话 +- `POST /ai/analyze-recall` - AI 主动回忆分析 +- `GET /ai/models` - 查看模型分流配置 +- `POST /ai-analysis` - 提交 AI 分析 +- `GET /ai-analysis/:id` - 获取分析结果 + +### 主动回忆 +- `GET /active-recalls` - 获取问题列表 +- `POST /active-recalls/:id/submit` - 提交回答(自动触发 AI 分析) ### 复习 - `GET /learning/review?userId=` - 获取复习任务 @@ -154,22 +165,44 @@ SWAGGER_PASSWORD=change_me ``` src/ -├── main.ts # 入口 -├── app.module.ts # 根模块 -├── auth/ # 认证模块 -├── users/ # 用户模块 -├── learning/ # 学习模块 -├── ai/ # AI 模块 -├── feedback/ # 反馈模块 -├── waitlist/ # 等待名单模块 -├── knowledge/ # 知识库模块 -└── ... +├── main.ts # 入口 +├── app.module.ts # 根模块 +├── config/ # 配置(app / jwt / ai / database / redis) +├── common/ # 公共(guard / decorator / filter / pipe) +├── infrastructure/ # 基础设施(Prisma / Redis / Storage / Logger / Queue) +│ └── ai/ # ❌ 已删除,迁至 modules/ai/ +└── modules/ + ├── auth/ # 认证模块(dev-login / Apple / refresh / logout) + ├── users/ # 用户模块 + ├── knowledge-base/ # 知识库模块 + ├── knowledge-items/ # 知识点模块 + ├── active-recall/ # 主动回忆模块(提交答案 → 触发 AI 分析) + ├── ai/ # ✅ 三层 AI 架构(Provider → Gateway → Workflow) + │ ├── gateway/ # AiGatewayService(选模型、记日志、JSON容错、超时重试) + │ ├── providers/ # DeepSeek / MiniMax / Mock + │ ├── prompts/ # System Prompt + Zod Schema + │ ├── usage/ # UsageLog + CostCalculator + │ └── workflows/ # ActiveRecallAnalysisWorkflow + ├── ai-analysis/ # AI 分析结果查询 + ├── review/ # 复习模块 + ├── learning-session/ # 学习会话 + ├── learning-activity/ # 学习活跃 + ├── focus-items/ # 待巩固项 + ├── document-import/ # 文档导入 + ├── notifications/ # 通知 + ├── feedback/ # 用户反馈 + ├── waitlist/ # 等待名单 + └── system/ # 系统状态 ``` ## 后续规划 -- [ ] 接入真实 AI API(OpenAI / Claude) -- [ ] 添加数据库持久化 -- [ ] 实现 JWT 认证中间件 +- [x] 接入真实 AI API(DeepSeek + MiniMax 三层架构) +- [x] JWT 认证中间件 +- [x] 数据库持久化(Prisma + MySQL) +- [x] 12 个业务 Repository 从内存 Map 迁到 Prisma +- [x] 全局 JwtAuthGuard +- [ ] 更多 AI Workflow(知识导入、费曼分析、复习卡片生成) +- [ ] AI 联调 + Prompt 调优 - [ ] 添加 Redis 缓存 -- [ ] iOS SDK 集成文档 \ No newline at end of file +- [ ] iOS 集成 \ No newline at end of file diff --git a/docs/BACKEND-PLAN.md b/docs/BACKEND-PLAN.md deleted file mode 100644 index f14ba85..0000000 --- a/docs/BACKEND-PLAN.md +++ /dev/null @@ -1,1185 +0,0 @@ ---- -source: startup-plan/个人开发者创业 v0.1 + 完全版 + AI回答.md 架构深化方案 -updated: 2026-05-09 ---- - -# 知习 api-server 后端架构规划 - -> 「知习」是一款 AI-first 的系统化学习 App。后端需要同时服务 iOS App 和 Web 官网。 -> 核心功能包括知识库、主动回忆、AI 学习分析、间隔复习、待巩固项、学习活跃记录、消息通知和反馈。 - ---- - -## 1. 架构总览:模块化单体 + Redis + Worker - -**不要微服务。** 当前最适合的是: - -> **模块化单体架构 Monolithic Modular Architecture** -> -> 一个后端项目,内部按业务模块拆清楚。 - -```text -iOS App -Web 官网 - ↓ -统一 API 后端(NestJS 模块化单体) - ↓ -数据库 / Redis / 文件存储 / AI 服务 - ↓ -Worker 异步任务 -``` - ---- - -## 2. 最终技术栈 - -```text -后端框架:NestJS + TypeScript -ORM:Prisma -数据库:PostgreSQL(推荐),MySQL 亦可 -缓存/队列:Redis -队列系统:BullMQ -接口文档:Swagger / OpenAPI -文件存储:本地开发先本地存,后期接对象存储 -AI:Provider 抽象 + 一个真实模型 + MockProvider -部署:Docker Compose -反向代理:Nginx -域名:api.longde.cloud -``` - -Docker Compose 项目服务规划: - -```text -api-server — NestJS 后端 -postgres — 主数据库 -redis — 缓存 + 队列 -worker — 异步任务处理 -nginx — 反向代理 + HTTPS -``` - ---- - -## 3. 为什么选这个技术栈 - -### NestJS - -项目会越来越模块化。NestJS 的模块结构适合长期维护: - -```text -用户 / 知识库 / 学习记录 / AI 分析 / 复习计划 / 待巩固项 / 通知 / 反馈 / 订阅 -``` - -Express 也能做,但后期容易自己手写一堆规范,最后变乱。 - -### Prisma - -- 类型安全的 ORM,自动生成 TypeScript 类型 -- 迁移管理清晰 -- 与 NestJS 集成良好 -- AI Agent 按 schema 生成代码能力好 - -### PostgreSQL(优于 MySQL) - -- 更适合 JSON 数据(AI 分析结果) -- 支持全文检索 -- 后续可扩展 pgvector 做向量检索 -- 如果更熟悉 MySQL,也可以先用 MySQL - -### Redis + BullMQ - -Redis 不只是缓存,在知习系统里做四件事: - -```text -缓存 → 短期数据加速 -队列 → AI 分析 / 资料导入 / 通知任务 -限流 → AI 调用频控 / 用户请求限流 -临时状态 → 任务处理中状态 / 防重复提交锁 -``` - ---- - -## 4. 前后端职责分离 - -| 端 | 职责 | -|---|------| -| iOS 客户端 | 页面展示、用户交互、本地状态、登录入口、学习流程体验 | -| 后端 API | 用户身份、AI API 代理、学习记录、AI 分析、学习画像、复习计划、待巩固项、通知、反馈 | -| Worker | AI 分析任务异步处理、资料导入异步处理、通知分发 | -| AI 模型 | 分析用户输入、生成学习反馈、识别薄弱点、生成复习建议、辅助学习对话 | -| 官网 | 产品介绍、SEO、隐私政策、用户协议、支持页面、等待名单 | - ---- - -## 5. 数据库和 Redis 的分工 - -``` -PostgreSQL / MySQL:长期真实数据 -Redis:短期状态、缓存、队列、限流 -文件存储:PDF、图片、附件 -AI 服务:分析、总结、生成复习题 -``` - -一句话: - -> **数据库存事实,Redis 管状态。** - -### Redis 适合做 - -- AI 分析任务队列 -- 资料导入任务队列 -- 通知任务队列 -- 用户请求限流 -- AI 调用次数计数 -- 任务处理中状态 -- 防重复提交锁 -- 短期缓存 - -### Redis 不适合做主数据 - -这些必须进数据库: - -- 用户资料 -- 知识库 -- 学习记录 -- AI 分析结果 -- 待巩固项 -- 复习计划 -- 学习活跃记录 -- 通知记录 - ---- - -## 6. 后端目录结构 - -```text -api-server/ -├── src/ -│ ├── main.ts -│ ├── app.module.ts -│ │ -│ ├── config/ -│ │ ├── app.config.ts -│ │ ├── database.config.ts -│ │ ├── redis.config.ts -│ │ ├── jwt.config.ts -│ │ ├── ai.config.ts -│ │ └── storage.config.ts -│ │ -│ ├── common/ -│ │ ├── constants/ -│ │ ├── decorators/ -│ │ ├── guards/ -│ │ ├── interceptors/ -│ │ ├── filters/ -│ │ ├── pipes/ -│ │ ├── dto/ -│ │ ├── types/ -│ │ └── utils/ -│ │ -│ ├── infrastructure/ -│ │ ├── database/ -│ │ ├── redis/ -│ │ ├── queue/ -│ │ ├── ai/ -│ │ │ ├── prompts/ -│ │ │ └── providers/ -│ │ ├── storage/ -│ │ └── logger/ -│ │ -│ ├── modules/ -│ │ ├── auth/ -│ │ ├── users/ -│ │ ├── knowledge-base/ -│ │ ├── knowledge-items/ -│ │ ├── document-import/ -│ │ ├── learning-session/ -│ │ ├── active-recall/ -│ │ ├── ai-analysis/ -│ │ ├── review/ -│ │ ├── focus-items/ -│ │ ├── learning-activity/ -│ │ ├── notifications/ -│ │ ├── feedback/ -│ │ └── system/ -│ │ -│ └── workers/ -│ ├── ai-analysis.worker.ts -│ ├── document-import.worker.ts -│ └── notification.worker.ts -│ -├── prisma/ -│ ├── schema.prisma -│ └── migrations/ -│ -├── docker-compose.yml -├── Dockerfile -├── .env.example -└── package.json -``` - ---- - -## 7. 各模块职责 - -### 7.1 Auth 模块 - -负责 Apple 登录、JWT、刷新 Token、退出登录、账号注销。 - -App Store 上架要求 Sign in with Apple,这是必须做的。 - -### 7.2 Users 模块 - -负责用户资料、头像、昵称、学习身份、学习方向、学习偏好。 - -### 7.3 Knowledge Base 模块 - -负责知识库创建、列表、详情、编辑、删除、标签。 - -### 7.4 Knowledge Items 模块 - -负责具体知识点、章节、内容段落、关键概念、标签、关联关系。 - -### 7.5 Document Import 模块 - -负责上传文件、粘贴文本、链接导入、导入状态、解析进度、导入成功/失败。 - -这里非常适合用 Redis Queue 做异步处理。 - -### 7.6 Learning Session 模块 - -负责一次学习过程:开始学习、结束学习、学习时长、学习内容、学习模式、完成状态。 - -这是学习活跃图的数据来源之一。 - -### 7.7 Active Recall 模块 - -负责主动回忆:回忆问题、用户回答、语音回答、回答提交、回答历史。 - -### 7.8 AI Analysis 模块 - -负责提交分析任务、分析状态、AI 分析结果、掌握度、薄弱点、改进建议、下一步建议。 - -**必须走 Redis Queue 异步处理。** - -### 7.9 Focus Items 模块 - -「待巩固项」——类似 GitHub Issue,但在知习里叫待巩固项。 - -负责:AI 自动生成待巩固项、用户手动添加、状态(待处理/已加入复习/已完成)、关联知识点、关联学习记录。 - -### 7.10 Review 模块 - -负责复习卡片、到期复习、间隔复习计划、复习结果、掌握程度选择、下次复习时间。 - -### 7.11 Learning Activity 模块 - -负责学习活跃图:每日学习时长、主动回忆次数、复习次数、AI 分析次数、完成学习闭环次数、学习活跃度等级。 - -活跃图数据最终写入数据库,Redis 可做临时累计。 - -### 7.12 Notifications 模块 - -负责复习提醒、AI 分析完成提醒、学习建议、系统通知、已读/未读。 - -### 7.13 Feedback 模块 - -负责 Web 和 App 反馈:用户反馈、问题类型、邮箱、设备、处理状态。 - -### 7.14 System 模块 - -健康检查、应用状态、基础运维接口。 - ---- - -## 8. 模块优先级 - -### v0.1 必须实现 - -```text -auth -users -knowledge-base -knowledge-items -learning-session -active-recall -ai-analysis -focus-items -review -learning-activity -feedback -system -``` - -### v0.1 暂缓 - -```text -document-import — 先手动录入内容 -notifications — 先不做推送 -billing — 暂无付费 -subscription — 暂无订阅 -admin-dashboard — 暂无管理后台 -team — 单人使用 -``` - ---- - -## 9. API 路由规划 - -```text -GET /api/health - -POST /api/auth/apple -POST /api/auth/refresh -POST /api/auth/logout - -GET /api/users/me -PATCH /api/users/me -PATCH /api/users/me/preferences - -GET /api/knowledge-bases -POST /api/knowledge-bases -GET /api/knowledge-bases/:id -PATCH /api/knowledge-bases/:id -DELETE /api/knowledge-bases/:id -GET /api/knowledge-bases/:id/items - -GET /api/knowledge-items/:id -POST /api/knowledge-items - -POST /api/imports -GET /api/imports/:id/status - -POST /api/learning-sessions -POST /api/learning-sessions/:id/end - -GET /api/active-recalls -POST /api/active-recalls/:id/submit - -POST /api/ai-analysis -GET /api/ai-analysis/:id -GET /api/ai-analysis/jobs/:jobId/status - -GET /api/focus-items -POST /api/focus-items -PATCH /api/focus-items/:id -POST /api/focus-items/:id/complete - -GET /api/reviews/due -POST /api/reviews/:id/submit - -GET /api/activity/heatmap -GET /api/activity/summary - -GET /api/notifications -POST /api/notifications/:id/read - -POST /api/feedback -``` - ---- - -## 10. 关键请求流程 - -核心异步流程详见 [23. 异步模块额外文件 - AI 分析模块完整流程](#23-异步模块额外文件queue--processor)。 - ---- - -## 11. 核心 AI 接口 - -### POST /api/ai-analysis(提交异步任务) - -输入: -```json -{ - "sessionId": "session_001", - "lessonId": "lesson_001", - "userInput": "用户写下的答案或笔记", - "context": { - "lessonTitle": "材料阅读方法", - "objectives": ["理解材料结构", "提取关键要点"], - "keyPoints": ["问题", "原因", "影响", "对策"] - } -} -``` - -返回: -```json -{ - "jobId": "job_abc123", - "status": "pending" -} -``` - -### GET /api/ai-analysis/jobs/:jobId/status(查询状态) - -```json -{ - "jobId": "job_abc123", - "status": "completed", - "result": { - "masteryScore": 3, - "understandingLevel": "基本理解", - "summary": "用户能理解主要内容,但要点不完整。", - "strengths": ["表达清楚"], - "weakPoints": ["遗漏关键要点", "逻辑层次不足"], - "suggestions": ["补充第二个要点", "先概括再展开"], - "reviewNeeded": true, - "nextAction": "明天重新回答本节主动回忆问题。" - } -} -``` - ---- - -## 12. AI Provider 层设计 - -```text -AIService - ├── MiniMaxProvider - ├── DeepSeekProvider - ├── OpenAIProvider - └── MockProvider -``` - -### MockProvider - -v0.1 强烈建议做 MockProvider: -- 没有 API Key 时也能开发 -- AI 服务出问题时能测试流程 -- 降低开发成本 -- 方便录 Demo - -### 候选模型 - -MiniMax / DeepSeek / 通义千问 / OpenAI / Claude / Gemini - -### 模型选择原则 - -1. 中文能力好 -2. 成本可控 -3. API 稳定 -4. 支持结构化输出 -5. 速度可以接受 -6. 容易接入 -7. 可替换 - ---- - -## 13. 核心实体/数据模型 - -```text -User -├── id -├── appleUserId -├── displayName -├── email -├── preferredLanguage -├── learningDirection -├── createdAt -├── lastLoginAt -└── status - -KnowledgeBase -├── id -├── userId -├── title -├── description -├── language -├── tags -├── createdAt -└── updatedAt - -KnowledgeItem -├── id -├── knowledgeBaseId -├── parentId -├── title -├── content -├── type (chapter/section/concept) -├── objectives -├── keyPoints -├── recallQuestions -├── order -└── estimatedMinutes - -DocumentImport -├── id -├── userId -├── knowledgeBaseId -├── sourceType (file/text/link) -├── status (pending/processing/completed/failed) -├── result -├── createdAt -└── completedAt - -LearningSession -├── id -├── userId -├── knowledgeItemId -├── startedAt -├── endedAt -├── durationSeconds -├── mode (reading/recall/review) -└── completedAt - -ActiveRecall -├── id -├── sessionId -├── question -├── userAnswer -├── answerType (text/voice) -├── submittedAt - -AIAnalysis -├── id -├── jobId -├── userId -├── sessionId -├── inputText -├── outputJson -├── masteryScore -├── weakPoints -├── suggestions -├── modelName -├── costEstimate -├── status (pending/processing/completed/failed) -├── createdAt -└── completedAt - -FocusItem -├── id -├── userId -├── knowledgeItemId -├── aiAnalysisId -├── title -├── description -├── status (pending/in_review/completed) -├── createdAt -└── completedAt - -ReviewTask -├── id -├── userId -├── knowledgeItemId -├── focusItemId -├── reviewType -├── scheduledAt -├── completedAt -├── masteryChoice -├── nextReviewAt -└── status - -LearningActivity -├── id -├── userId -├── date -├── durationSeconds -├── recallCount -├── reviewCount -├── analysisCount -├── closedLoopCount -├── activityLevel -└── updatedAt - -Notification -├── id -├── userId -├── type -├── title -├── body -├── data (JSON) -├── isRead -└── createdAt - -Feedback -├── id -├── userId -├── type -├── content -├── email -├── device -├── status -└── createdAt -``` - ---- - -## 14. 掌握度评分(0-5分) - -```text -0 = 没有作答 / 无法判断 -1 = 基本没理解 -2 = 理解较弱 -3 = 基本理解 -4 = 理解较好 -5 = 掌握很好 -``` - -这不是考试分数,是产品内部用于安排复习和学习建议的参考值。 - ---- - -## 15. 账号体系 - -### 第一版登录方式 - -```text -Sign in with Apple -``` - -暂不做:微信登录、手机号登录、邮箱密码登录、Google 登录。 - -### 账号边界 - -第一版账号只解决:登录、识别用户、保存学习记录、保存 AI 分析结果、保存基础偏好。 - -暂不做:好友系统、用户主页、头像上传、多登录方式绑定、复杂权限系统。 - ---- - -## 16. 部署方案 - -### 当前服务器 - -- IP: 81.70.187.179 -- 配置: 4 核 4G,40G SSD -- 当前运行: Gitea(端口 3000)+ Nginx -- SSH: ubuntu 用户 + WangDL.pem 密钥 - -### 推荐部署方式 - -```text -Nginx — 反向代理,已有 -api-server (NestJS) — 端口 3001 -postgres / mysql — 端口 5432 / 3306 -redis — 端口 6379 -worker (BullMQ) — 同代码仓库,独立进程 -gitea — 端口 3000,继续保留 -``` - -建议新增 Nginx 配置 `api.longde.cloud` → 反向代理到 NestJS 端口。 - -### 资源评估 - -Gitea 当前资源占用很低(内存 ~500MB),剩余 ~3GB 内存、33GB 磁盘足够跑 api-server + PostgreSQL + Redis。 - ---- - -## 17. 域名与 Nginx 规划 - -```text -longde.cloud 官网首页 -api.longde.cloud 后端 API(需新增 Nginx 配置) -git.longde.cloud Gitea 代码仓库(已有) -``` - -### 待新增 Nginx 配置(api.longde.cloud) - -```nginx -server { - server_name api.longde.cloud; - location / { - proxy_pass http://127.0.0.1:3001; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - listen 443 ssl; - ssl_certificate /etc/letsencrypt/live/api.longde.cloud/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.longde.cloud/privkey.pem; -} -``` - ---- - -## 18. 技术里程碑 - -### 里程碑 1:最小后端 - -- NestJS 项目初始化 -- Prisma schema 定义 -- PostgreSQL/MySQL 连接 -- 用户表、知识库表 -- AI MockProvider -- Swagger 文档 - -### 里程碑 2:真实 AI 接入 - -- Redis + BullMQ 接入 -- AI Provider 抽象 -- 接入一个真实模型 -- AI 分析异步流程 -- Worker 处理 AI 任务 - -### 里程碑 3:TestFlight 前准备 - -- Sign in with Apple -- 后端用户身份记录 -- 基础错误处理 -- Docker Compose 一键部署 -- Nginx 反向代理配置 - ---- - -## 19. 商业化与支付(未来) - -### 支付流程 - -```text -用户在 iOS App 内付款 → Apple IAP 完成交易 → 后端记录用户权益 → 用户获得权益 -``` - -### 核心 - -不要让 Apple IAP 成为整个商业系统。真正核心的是: - -```text -用户系统 + 订单系统 + 权益系统 -``` - -### 当前暂缓 - -Apple IAP / 月订阅 / 年订阅 / 免费试用 / 订阅权益系统 / AI 成本精算 - ---- - -## 20. 当前技术不做清单 - -- 微服务 / Kubernetes / 多云部署 -- 完整后台管理系统 / CMS -- 完整 RAG / 向量数据库(第一版) -- 多模型自动路由 -- AI Agent 工具调用系统 -- 支付系统 -- 复杂数据分析平台 -- Android / Web 学习端 - ---- - -## 21. 四层代码分层原则 - -整体分四层,每一层有明确的职责边界: - -```text -Controller 层:接收请求,处理参数,返回响应 -Service 层:业务逻辑 -Repository 层:数据库读写 -Infrastructure 层:Redis、Queue、AI、Storage 等基础设施 -``` - -简单理解: - -```text -Controller 不写业务 -Service 不直接写复杂 SQL -Repository 不写业务判断 -Infrastructure 不知道具体业务,只提供能力 -``` - -一句话纪律: - -```text -Controller = 接口 Service = 业务 Repository = 数据库 -Infrastructure = 外部能力 Queue/Processor = 异步任务 DTO = 请求/响应结构 -``` - -第一版不要追求所有业务一次写完,但**架构模板必须先统一**。 - ---- - -## 22. 模块内部统一模板 - -每个业务模块都按这个结构来。以 `knowledge-base` 为例: - -```text -modules/knowledge-base/ -├── knowledge-base.module.ts -├── knowledge-base.controller.ts -├── knowledge-base.service.ts -├── knowledge-base.repository.ts -├── dto/ -│ ├── create-knowledge-base.dto.ts -│ ├── update-knowledge-base.dto.ts -│ ├── query-knowledge-base.dto.ts -│ └── knowledge-base-response.dto.ts -├── types/ -│ └── knowledge-base.types.ts -└── constants/ - └── knowledge-base.constants.ts -``` - -### 22.1 xxx.module.ts - -```text -作用: -- 注册 controller -- 注册 service -- 注册 repository -- 引入依赖模块 -- 导出给其他模块使用 -``` - -```ts -@Module({ - controllers: [KnowledgeBaseController], - providers: [KnowledgeBaseService, KnowledgeBaseRepository], - exports: [KnowledgeBaseService], -}) -export class KnowledgeBaseModule {} -``` - -### 22.2 xxx.controller.ts - -**只做这些事:** -1. 定义路由 -2. 接收参数 -3. 调用 service -4. 返回结果 - -**不要在 Controller 里写业务判断。** - -```ts -@Controller('knowledge-bases') -export class KnowledgeBaseController { - constructor(private readonly service: KnowledgeBaseService) {} - - @Post() - create(@Body() dto: CreateKnowledgeBaseDto, @CurrentUser() user: UserPayload) { - return this.service.create(user.id, dto); - } - - @Get() - findAll(@CurrentUser() user: UserPayload, @Query() query: QueryKnowledgeBaseDto) { - return this.service.findAll(user.id, query); - } - - @Get(':id') - findOne(@Param('id') id: string, @CurrentUser() user: UserPayload) { - return this.service.findOne(user.id, id); - } -} -``` - -### 22.3 xxx.service.ts - -**这里写:** -1. 权限判断 -2. 业务规则 -3. 调用 repository -4. 调用其他模块 service -5. 调用 queue / AI / Redis -6. 组织返回结果 - -```ts -@Injectable() -export class KnowledgeBaseService { - constructor( - private readonly repository: KnowledgeBaseRepository, - ) {} - - async create(userId: string, dto: CreateKnowledgeBaseDto) { - const count = await this.repository.countByUserId(userId); - if (count >= MAX_KNOWLEDGE_BASE_COUNT) { - throw new BadRequestException('知识库数量已达到上限'); - } - return this.repository.create(userId, dto); - } - - async findOne(userId: string, id: string) { - const knowledgeBase = await this.repository.findById(id); - if (!knowledgeBase || knowledgeBase.userId !== userId) { - throw new NotFoundException('知识库不存在'); - } - return knowledgeBase; - } -} -``` - -### 22.4 xxx.repository.ts - -**只写数据库操作,不写复杂业务。** - -```ts -@Injectable() -export class KnowledgeBaseRepository { - constructor(private readonly prisma: PrismaService) {} - - create(userId: string, dto: CreateKnowledgeBaseDto) { - return this.prisma.knowledgeBase.create({ - data: { userId, name: dto.name, description: dto.description }, - }); - } - - findById(id: string) { - return this.prisma.knowledgeBase.findUnique({ where: { id } }); - } - - countByUserId(userId: string) { - return this.prisma.knowledgeBase.count({ where: { userId } }); - } -} -``` - -### 22.5 dto/ - -DTO 就是接口输入输出的数据结构。 - -用途: -1. 校验请求参数 -2. 生成 Swagger 文档 -3. 让接口结构清楚 - -```ts -export class CreateKnowledgeBaseDto { - @ApiProperty({ example: '认知心理学' }) - @IsString() - @IsNotEmpty() - name: string; - - @ApiProperty({ example: '系统学习认知心理学基础概念', required: false }) - @IsOptional() - @IsString() - description?: string; -} -``` - -### 22.6 types/ - -放模块内部类型。如果是数据库模型,不放这里,放 Prisma。 - -```ts -export type KnowledgeBaseStatus = 'active' | 'archived'; - -export interface KnowledgeBaseStats { - itemCount: number; - completedCount: number; - reviewDueCount: number; -} -``` - -### 22.7 constants/ - -放模块常量。 - -```ts -export const MAX_KNOWLEDGE_BASE_COUNT = 20; -export const DEFAULT_KNOWLEDGE_BASE_COVER = 'default-blue'; -``` - ---- - -## 23. 异步模块额外文件(Queue + Processor) - -涉及异步任务的模块,需要在标准模板上加 `queue`、`processor`、`jobs`。 - -以 `ai-analysis` 为例: - -```text -modules/ai-analysis/ -├── ai-analysis.module.ts -├── ai-analysis.controller.ts -├── ai-analysis.service.ts -├── ai-analysis.repository.ts -├── ai-analysis.queue.ts ← 封装 BullMQ 队列 -├── ai-analysis.processor.ts ← 消费任务,调用大模型 -├── dto/ -│ ├── create-ai-analysis.dto.ts -│ ├── query-ai-analysis.dto.ts -│ └── ai-analysis-response.dto.ts -├── jobs/ -│ └── ai-analysis.job.ts ← 队列任务数据结构 -├── types/ -│ └── ai-analysis.types.ts -└── constants/ - └── ai-analysis.constants.ts -``` - -### 文件职责 - -| 文件 | 职责 | -|------|------| -| `controller` | 接收创建分析任务、查询分析结果的接口 | -| `service` | 创建任务、查询状态、组织业务流程 | -| `repository` | 读写 AI 分析任务和分析结果 | -| `queue` | 封装 BullMQ 队列添加任务 | -| `processor` | 真正消费任务,调用大模型 | -| `jobs/xxx.job.ts` | 定义队列任务的数据结构 | - -### AI 分析模块完整流程 - -```text -App 提交回答 - ↓ -Controller 接收请求 - ↓ -Service 创建分析任务 - ↓ -Repository 写入数据库 job 记录 - ↓ -Queue 加入 Redis 队列 - ↓ -Processor 后台消费任务 - ↓ -AI Service 调用大模型 - ↓ -Repository 写入分析结果 - ↓ -生成待巩固项 - ↓ -生成复习计划 - ↓ -通知用户 -``` - ---- - -## 24. AI 层设计:不允许业务模块直接调大模型 SDK - -### 原则 - -后面肯定会换模型、换供应商,所以 AI 不要写死在业务模块里。 - -**不推荐:** -```ts -// 不要直接在业务 service 里写 -await openai.chat.completions.create(...) -``` - -**应该:** -```text -业务模块 → AiService → 具体 Provider -``` - -```ts -@Injectable() -export class AiService { - constructor(private readonly provider: AiProvider) {} - - analyzeActiveRecall(input: ActiveRecallAnalysisInput) { - return this.provider.generateActiveRecallAnalysis(input); - } -} -``` - -这样从 OpenAI 换 DeepSeek、Gemini、Claude,都不用改业务代码。 - ---- - -## 25. Infrastructure 层详细拆解 - -`infrastructure` 是所有模块共用的底层能力。 - -```text -infrastructure/ -├── database/ -│ ├── prisma.module.ts -│ └── prisma.service.ts -│ -├── redis/ -│ ├── redis.module.ts -│ ├── redis.service.ts -│ └── redis.constants.ts -│ -├── queue/ -│ ├── queue.module.ts -│ ├── queue.constants.ts -│ └── queue.service.ts -│ -├── ai/ -│ ├── ai.module.ts -│ ├── ai.service.ts -│ ├── ai-provider.interface.ts -│ ├── providers/ -│ │ ├── openai.provider.ts -│ │ ├── deepseek.provider.ts -│ │ └── mock-ai.provider.ts -│ └── prompts/ -│ ├── active-recall-analysis.prompt.ts -│ └── focus-item-generation.prompt.ts -│ -├── storage/ -│ ├── storage.module.ts -│ ├── storage.service.ts -│ └── local-storage.provider.ts -│ -└── logger/ - ├── logger.module.ts - └── app-logger.service.ts -``` - ---- - -## 26. Common 层详细拆解 - -```text -common/ -├── decorators/ -│ └── current-user.decorator.ts -├── guards/ -│ ├── jwt-auth.guard.ts -│ └── optional-auth.guard.ts -├── interceptors/ -│ └── response.interceptor.ts -├── filters/ -│ └── http-exception.filter.ts -├── pipes/ -│ └── validation.pipe.ts -├── dto/ -│ ├── pagination.dto.ts -│ └── api-response.dto.ts -├── types/ -│ ├── user-payload.type.ts -│ └── pagination.type.ts -├── constants/ -│ └── app.constants.ts -└── utils/ - ├── date.util.ts - ├── hash.util.ts - └── id.util.ts -``` - ---- - -## 27. 模块复杂度分级 - -### 简单模块 - -结构 `module + controller + service + repository + dto` - -适用于:`feedback`、`system`、`notifications`(初版) - -### 中等模块 - -结构 `module + controller + service + repository + dto + types + constants` - -适用于:`users`、`knowledge-base`、`knowledge-items`、`learning-session`、`review`、`focus-items` - -### 复杂模块 - -结构 `module + controller + service + repository + queue + processor + jobs + dto + types + constants` - -适用于:`ai-analysis`、`document-import`、`notifications`(后期) - ---- - -## 28. 初始化顺序 - -第一版不要所有业务都写完。先搭基础设施骨架: - -```text - 1. infrastructure/database - 2. infrastructure/redis - 3. infrastructure/queue - 4. infrastructure/ai - 5. common - 6. system/health - 7. auth skeleton - 8. users skeleton - 9. knowledge-base skeleton -10. ai-analysis skeleton -``` - -这样就够开始开发。 - - diff --git a/docs/DATABASE-DESIGN.md b/docs/DATABASE-DESIGN.md deleted file mode 100644 index e1832f3..0000000 --- a/docs/DATABASE-DESIGN.md +++ /dev/null @@ -1,814 +0,0 @@ ---- -source: AI回答.md -updated: 2026-05-09 ---- - -# 知习 MySQL 数据库表结构设计 - -> 共 27 张表,v0.1 先建 24 张核心表。 - ---- - -## 通用字段规范 - -每张核心表统一使用: - -```sql -id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, -created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -deleted_at DATETIME NULL -``` - -- `id`:内部主键 -- `created_at`:创建时间 -- `updated_at`:更新时间 -- `deleted_at`:软删除 - -状态字段统一用 `VARCHAR(32)`,不用 MySQL ENUM。 - ---- - -## 一、用户与认证表(5 张) - -### 1. users 用户表 - -```sql -users -- id BIGINT UNSIGNED PK -- email VARCHAR(255) NULL -- nickname VARCHAR(100) NULL -- avatar_url VARCHAR(500) NULL -- status VARCHAR(32) NOT NULL DEFAULT 'active' -- onboarding_completed TINYINT(1) NOT NULL DEFAULT 0 -- last_login_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -- deleted_at DATETIME NULL -``` - -索引: -```sql -INDEX idx_users_email (email) -INDEX idx_users_status (status) -``` - ---- - -### 2. auth_accounts 第三方登录账号表 - -```sql -auth_accounts -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- provider VARCHAR(32) NOT NULL -- apple -- provider_user_id VARCHAR(255) NOT NULL -- Apple userIdentifier / sub -- email VARCHAR(255) NULL -- raw_profile_json JSON NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_provider_user (provider, provider_user_id) -INDEX idx_auth_accounts_user_id (user_id) -``` - ---- - -### 3. refresh_tokens 刷新 Token 表 - -```sql -refresh_tokens -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- token_hash VARCHAR(255) NOT NULL -- device_id VARCHAR(255) NULL -- device_name VARCHAR(255) NULL -- expires_at DATETIME NOT NULL -- revoked_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_refresh_tokens_user_id (user_id) -INDEX idx_refresh_tokens_token_hash (token_hash) -``` - ---- - -### 4. user_profiles 用户资料扩展表 - -```sql -user_profiles -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- learning_identity VARCHAR(100) NULL -- 系统学习者 / 备考用户 / 知识工作者 -- learning_direction VARCHAR(255) NULL -- 认知科学 / AIGC / 产品设计 -- bio TEXT NULL -- current_goal VARCHAR(255) NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_user_profiles_user_id (user_id) -``` - ---- - -### 5. user_preferences 用户学习偏好表 - -```sql -user_preferences -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- preferred_methods JSON NULL - -- ["active_recall", "spaced_repetition", "feynman", "retrieval_practice"] -- default_focus_minutes INT NOT NULL DEFAULT 25 -- ai_suggestion_level VARCHAR(32) NOT NULL DEFAULT 'normal' - -- low / normal / high -- language VARCHAR(32) NOT NULL DEFAULT 'zh-CN' -- appearance VARCHAR(32) NOT NULL DEFAULT 'system' -- notification_enabled TINYINT(1) NOT NULL DEFAULT 1 -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_user_preferences_user_id (user_id) -``` - ---- - -## 二、知识库相关表(5 张) - -### 6. knowledge_bases 知识库表 - -```sql -knowledge_bases -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- title VARCHAR(255) NOT NULL -- description TEXT NULL -- cover_key VARCHAR(100) NULL -- status VARCHAR(32) NOT NULL DEFAULT 'active' - -- active / archived / deleted -- item_count INT NOT NULL DEFAULT 0 -- last_studied_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -- deleted_at DATETIME NULL -``` - -索引: -```sql -INDEX idx_knowledge_bases_user_id (user_id) -INDEX idx_knowledge_bases_status (status) -``` - ---- - -### 7. knowledge_items 知识点/内容表 - -```sql -knowledge_items -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_base_id BIGINT UNSIGNED NOT NULL -- parent_id BIGINT UNSIGNED NULL -- item_type VARCHAR(32) NOT NULL - -- chapter / lesson / concept / note / imported_doc -- title VARCHAR(255) NOT NULL -- content LONGTEXT NULL -- summary TEXT NULL -- source_type VARCHAR(32) NULL - -- manual / file / url / ai_generated -- source_ref VARCHAR(500) NULL -- order_index INT NOT NULL DEFAULT 0 -- status VARCHAR(32) NOT NULL DEFAULT 'active' -- created_at DATETIME -- updated_at DATETIME -- deleted_at DATETIME NULL -``` - -索引: -```sql -INDEX idx_knowledge_items_user_id (user_id) -INDEX idx_knowledge_items_kb_id (knowledge_base_id) -INDEX idx_knowledge_items_parent_id (parent_id) -INDEX idx_knowledge_items_type (item_type) -``` - ---- - -### 8. knowledge_item_relations 知识点关联表 - -```sql -knowledge_item_relations -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- source_item_id BIGINT UNSIGNED NOT NULL -- target_item_id BIGINT UNSIGNED NOT NULL -- relation_type VARCHAR(32) NOT NULL - -- related / prerequisite / similar / conflict / extension -- confidence DECIMAL(5,2) NULL -- reason TEXT NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_relations_source (source_item_id) -INDEX idx_relations_target (target_item_id) -``` - ---- - -### 9. tags 标签表 - -```sql -tags -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- name VARCHAR(100) NOT NULL -- color VARCHAR(32) NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_user_tag_name (user_id, name) -``` - ---- - -### 10. knowledge_item_tags 知识点标签关联表 - -```sql -knowledge_item_tags -- id BIGINT UNSIGNED PK -- knowledge_item_id BIGINT UNSIGNED NOT NULL -- tag_id BIGINT UNSIGNED NOT NULL -- created_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_item_tag (knowledge_item_id, tag_id) -``` - ---- - -## 三、资料导入相关表(2 张) - -### 11. uploaded_files 上传文件表 - -```sql -uploaded_files -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- filename VARCHAR(255) NOT NULL -- mime_type VARCHAR(100) NULL -- storage_path VARCHAR(500) NOT NULL -- size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0 -- checksum VARCHAR(255) NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_uploaded_files_user_id (user_id) -``` - ---- - -### 12. document_imports 资料导入任务表 - -```sql -document_imports -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_base_id BIGINT UNSIGNED NULL -- file_id BIGINT UNSIGNED NULL -- source_type VARCHAR(32) NOT NULL - -- file / text / url -- source_name VARCHAR(255) NULL -- source_url VARCHAR(500) NULL -- raw_text LONGTEXT NULL -- status VARCHAR(32) NOT NULL DEFAULT 'pending' - -- pending / processing / success / failed -- progress INT NOT NULL DEFAULT 0 -- error_message TEXT NULL -- result_json JSON NULL -- started_at DATETIME NULL -- completed_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_document_imports_user_id (user_id) -INDEX idx_document_imports_status (status) -``` - ---- - -## 四、学习过程相关表(2 张) - -### 13. learning_sessions 学习会话表 - -```sql -learning_sessions -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_base_id BIGINT UNSIGNED NULL -- knowledge_item_id BIGINT UNSIGNED NULL -- mode VARCHAR(32) NOT NULL - -- reading / active_recall / review / feynman / free_learning -- status VARCHAR(32) NOT NULL DEFAULT 'active' - -- active / completed / cancelled -- started_at DATETIME NOT NULL -- ended_at DATETIME NULL -- duration_seconds INT NOT NULL DEFAULT 0 -- focus_minutes INT NULL -- metadata JSON NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_learning_sessions_user_id (user_id) -INDEX idx_learning_sessions_item_id (knowledge_item_id) -INDEX idx_learning_sessions_started_at (started_at) -``` - ---- - -### 14. learning_records 学习记录表 - -类似 GitHub commit log,语义是学习记录。 - -```sql -learning_records -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- session_id BIGINT UNSIGNED NULL -- record_type VARCHAR(32) NOT NULL - -- read / active_recall / review / ai_analysis / focus_item_completed -- title VARCHAR(255) NOT NULL -- description TEXT NULL -- duration_seconds INT NOT NULL DEFAULT 0 -- occurred_at DATETIME NOT NULL -- metadata JSON NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_learning_records_user_id (user_id) -INDEX idx_learning_records_occurred_at (occurred_at) -``` - ---- - -## 五、主动回忆相关表(2 张) - -### 15. active_recall_questions 主动回忆问题表 - -```sql -active_recall_questions -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_item_id BIGINT UNSIGNED NULL -- question_text TEXT NOT NULL -- difficulty VARCHAR(32) NULL - -- easy / normal / hard -- created_by VARCHAR(32) NOT NULL DEFAULT 'ai' - -- ai / user / system -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_recall_questions_user_id (user_id) -INDEX idx_recall_questions_item_id (knowledge_item_id) -``` - ---- - -### 16. active_recall_answers 主动回忆回答表 - -```sql -active_recall_answers -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- question_id BIGINT UNSIGNED NULL -- session_id BIGINT UNSIGNED NULL -- answer_type VARCHAR(32) NOT NULL DEFAULT 'text' - -- text / voice -- answer_text LONGTEXT NULL -- audio_file_id BIGINT UNSIGNED NULL -- submitted_at DATETIME NOT NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_recall_answers_user_id (user_id) -INDEX idx_recall_answers_question_id (question_id) -INDEX idx_recall_answers_session_id (session_id) -``` - ---- - -## 六、AI 分析相关表(2 张) - -### 17. ai_analysis_jobs AI 分析任务表 - -```sql -ai_analysis_jobs -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- session_id BIGINT UNSIGNED NULL -- answer_id BIGINT UNSIGNED NULL -- job_type VARCHAR(32) NOT NULL - -- active_recall_analysis / weak_point_detection / review_generation -- status VARCHAR(32) NOT NULL DEFAULT 'pending' - -- pending / processing / success / failed -- progress INT NOT NULL DEFAULT 0 -- error_message TEXT NULL -- queued_at DATETIME NULL -- started_at DATETIME NULL -- completed_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_ai_jobs_user_id (user_id) -INDEX idx_ai_jobs_status (status) -INDEX idx_ai_jobs_session_id (session_id) -``` - ---- - -### 18. ai_analysis_results AI 分析结果表 - -```sql -ai_analysis_results -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- job_id BIGINT UNSIGNED NOT NULL -- session_id BIGINT UNSIGNED NULL -- answer_id BIGINT UNSIGNED NULL -- summary TEXT NULL -- mastery_score INT NULL -- 0-100 -- strengths JSON NULL -- weaknesses JSON NULL -- suggestions JSON NULL -- next_actions JSON NULL -- raw_result JSON NULL -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_ai_results_user_id (user_id) -INDEX idx_ai_results_job_id (job_id) -INDEX idx_ai_results_session_id (session_id) -``` - ---- - -## 七、待巩固项表(1 张) - -### 19. focus_items 待巩固项表 - -类似 GitHub issue,学习语义叫「待巩固项」。 - -```sql -focus_items -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_base_id BIGINT UNSIGNED NULL -- knowledge_item_id BIGINT UNSIGNED NULL -- analysis_result_id BIGINT UNSIGNED NULL -- title VARCHAR(255) NOT NULL -- reason TEXT NULL -- suggestion TEXT NULL -- priority VARCHAR(32) NOT NULL DEFAULT 'normal' - -- low / normal / high -- status VARCHAR(32) NOT NULL DEFAULT 'open' - -- open / in_review / completed / ignored -- mastery_score INT NULL -- due_at DATETIME NULL -- completed_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -- deleted_at DATETIME NULL -``` - -索引: -```sql -INDEX idx_focus_items_user_id (user_id) -INDEX idx_focus_items_status (status) -INDEX idx_focus_items_due_at (due_at) -``` - ---- - -## 八、复习相关表(3 张) - -### 20. review_cards 复习卡片表 - -```sql -review_cards -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- knowledge_item_id BIGINT UNSIGNED NULL -- focus_item_id BIGINT UNSIGNED NULL -- front_text TEXT NOT NULL -- back_text TEXT NULL -- difficulty VARCHAR(32) NULL -- status VARCHAR(32) NOT NULL DEFAULT 'active' - -- active / suspended / completed -- next_review_at DATETIME NULL -- interval_days INT NOT NULL DEFAULT 1 -- ease_factor DECIMAL(4,2) NOT NULL DEFAULT 2.50 -- repetition_count INT NOT NULL DEFAULT 0 -- lapse_count INT NOT NULL DEFAULT 0 -- created_at DATETIME -- updated_at DATETIME -- deleted_at DATETIME NULL -``` - -索引: -```sql -INDEX idx_review_cards_user_id (user_id) -INDEX idx_review_cards_next_review_at (next_review_at) -INDEX idx_review_cards_focus_item_id (focus_item_id) -``` - ---- - -### 21. review_logs 复习记录表 - -```sql -review_logs -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- review_card_id BIGINT UNSIGNED NOT NULL -- session_id BIGINT UNSIGNED NULL -- rating VARCHAR(32) NOT NULL - -- again / hard / good / easy -- response_text TEXT NULL -- reviewed_at DATETIME NOT NULL -- next_review_at DATETIME NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_review_logs_user_id (user_id) -INDEX idx_review_logs_card_id (review_card_id) -INDEX idx_review_logs_reviewed_at (reviewed_at) -``` - ---- - -### 22. review_plans 复习计划表 - -```sql -review_plans -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- title VARCHAR(255) NOT NULL -- status VARCHAR(32) NOT NULL DEFAULT 'active' - -- active / completed / cancelled -- scheduled_at DATETIME NULL -- completed_at DATETIME NULL -- card_count INT NOT NULL DEFAULT 0 -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_review_plans_user_id (user_id) -INDEX idx_review_plans_scheduled_at (scheduled_at) -``` - ---- - -## 九、学习活跃记录表(1 张) - -### 23. daily_learning_activities 每日学习活跃表 - -用于个人中心的蓝色学习活跃图。 - -```sql -daily_learning_activities -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- activity_date DATE NOT NULL -- duration_seconds INT NOT NULL DEFAULT 0 -- sessions_count INT NOT NULL DEFAULT 0 -- active_recall_count INT NOT NULL DEFAULT 0 -- review_count INT NOT NULL DEFAULT 0 -- ai_analysis_count INT NOT NULL DEFAULT 0 -- completed_loop_count INT NOT NULL DEFAULT 0 -- activity_level INT NOT NULL DEFAULT 0 -- 0-4,颜色深浅 -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -UNIQUE KEY uk_user_activity_date (user_id, activity_date) -INDEX idx_daily_activity_user_id (user_id) -``` - ---- - -## 十、通知与反馈表(2 张) - -### 24. notifications 消息通知表 - -```sql -notifications -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- type VARCHAR(32) NOT NULL - -- review_due / ai_analysis_done / learning_suggestion / system -- title VARCHAR(255) NOT NULL -- content TEXT NULL -- data JSON NULL -- read_at DATETIME NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_notifications_user_id (user_id) -INDEX idx_notifications_read_at (read_at) -INDEX idx_notifications_type (type) -``` - ---- - -### 25. feedbacks 用户反馈表 - -```sql -feedbacks -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NULL -- email VARCHAR(255) NULL -- category VARCHAR(64) NOT NULL - -- feature / bug / experience / privacy / other -- content TEXT NOT NULL -- device_info JSON NULL -- status VARCHAR(32) NOT NULL DEFAULT 'open' - -- open / processing / resolved / ignored -- created_at DATETIME -- updated_at DATETIME -``` - -索引: -```sql -INDEX idx_feedbacks_user_id (user_id) -INDEX idx_feedbacks_status (status) -``` - ---- - -## 十一、合规与系统表(2 张) - -### 26. user_consents 用户协议同意记录表 - -```sql -user_consents -- id BIGINT UNSIGNED PK -- user_id BIGINT UNSIGNED NOT NULL -- consent_type VARCHAR(32) NOT NULL - -- privacy_policy / terms_of_service -- version VARCHAR(50) NOT NULL -- accepted_at DATETIME NOT NULL -- ip_address VARCHAR(100) NULL -- user_agent VARCHAR(500) NULL -- created_at DATETIME -``` - -索引: -```sql -INDEX idx_user_consents_user_id (user_id) -INDEX idx_user_consents_type (consent_type) -``` - ---- - -### 27. app_changelogs 更新记录表(可选) - -```sql -app_changelogs -- id BIGINT UNSIGNED PK -- version VARCHAR(50) NOT NULL -- title VARCHAR(255) NOT NULL -- content TEXT NOT NULL -- platform VARCHAR(32) NOT NULL DEFAULT 'ios' -- published_at DATETIME NULL -- created_at DATETIME -- updated_at DATETIME -``` - ---- - -## v0.1 建表优先级 - -### 第一批(24 张,必须) - -```text -users -auth_accounts -refresh_tokens -user_profiles -user_preferences - -knowledge_bases -knowledge_items -tags -knowledge_item_tags -document_imports -uploaded_files - -learning_sessions -learning_records -active_recall_questions -active_recall_answers - -ai_analysis_jobs -ai_analysis_results -focus_items - -review_cards -review_logs - -daily_learning_activities - -notifications -feedbacks -user_consents -``` - -### 第二批(3 张,可稍后) - -```text -knowledge_item_relations -review_plans -app_changelogs -``` - ---- - -## 模块与表对应关系 - -```text -auth → users, auth_accounts, refresh_tokens -users → user_profiles, user_preferences, user_consents -knowledge-base → knowledge_bases -knowledge-items → knowledge_items, knowledge_item_relations, tags, knowledge_item_tags -document-import → uploaded_files, document_imports -learning-session → learning_sessions, learning_records -active-recall → active_recall_questions, active_recall_answers -ai-analysis → ai_analysis_jobs, ai_analysis_results -focus-items → focus_items -review → review_cards, review_logs, review_plans -learning-activity → daily_learning_activities -notifications → notifications -feedback → feedbacks -system → app_changelogs -``` - ---- - -## Prisma 生成规范 - -```text -所有表使用 BIGINT UNSIGNED AUTO_INCREMENT 主键 -状态字段使用 VARCHAR,不使用 ENUM -JSON 字段用于存储 AI 分析结构化结果、用户偏好、元数据 -核心表添加 created_at、updated_at、deleted_at -为 user_id、status、created_at、外键字段添加合理索引 -``` diff --git a/docs/REDIS-DESIGN.md b/docs/REDIS-DESIGN.md deleted file mode 100644 index 149d8cb..0000000 --- a/docs/REDIS-DESIGN.md +++ /dev/null @@ -1,262 +0,0 @@ ---- -source: AI回答.md -updated: 2026-05-09 ---- - -# 知习 Redis 设计 - -> Redis 在知习里不是"另一个 MySQL",它是系统的**加速器和调度器**。MySQL 存结果,Redis 管过程。 - ---- - -## 1. Redis 定位 - -Redis 不作为主数据库,只负责: - -1. 缓存 -2. 限流 -3. 队列(BullMQ) -4. 临时任务状态 -5. 分布式锁 -6. 防重复提交 -7. AI 调用次数统计 -8. 短期 Token / 黑名单 -9. 通知任务调度 -10. 学习会话草稿 - ---- - -## 2. Key 命名规范 - -统一格式: - -```text -业务域:对象类型:对象ID:字段 -``` - -示例: - -```text -cache:user:123:profile -rate:user:123:ai:daily:2026-05-09 -lock:ai-analysis:session:987 -job:ai-analysis:abc123:status -``` - -规则: - -1. 全部小写 -2. 用冒号 `:` 分隔 -3. 从大范围到小范围 -4. userId、jobId、sessionId 明确写在 key 里 -5. 带日期的 key 用 `YYYY-MM-DD` -6. 所有临时 key 必须设置 TTL - ---- - -## 3. Key 总表 - -### 缓存类 - -| Key | 用途 | TTL | -|-----|------|-----| -| `cache:user:{userId}:profile` | 用户资料 | 5-10 分钟 | -| `cache:user:{userId}:preferences` | 用户偏好设置 | 10 分钟 | -| `cache:user:{userId}:knowledge-bases` | 用户知识库列表 | 3-5 分钟 | -| `cache:knowledge-base:{kbId}:summary` | 知识库摘要 | 5 分钟 | -| `cache:review:user:{userId}:due-count` | 到期复习数量 | 1-3 分钟 | - -### 限流类 - -| Key | 用途 | TTL | -|-----|------|-----| -| `rate:user:{userId}:ai:daily:{date}` | 用户每日 AI 调用次数 | 到当天结束或 24h | -| `rate:user:{userId}:feedback:hourly` | 用户每小时反馈次数 | 1 小时 | -| `rate:ip:{ip}:request:{minute}` | IP 每分钟请求频率 | 60-120 秒 | -| `rate:ip:{ip}:login:{date}` | IP 每日登录尝试 | 10-30 分钟 | - -### 分布式锁类 - -| Key | 用途 | TTL | -|-----|------|-----| -| `lock:ai-analysis:session:{sessionId}` | 防止重复提交 AI 分析 | 60-300 秒 | -| `lock:ai-analysis:answer:{answerId}` | 防止同回答重复分析 | 60-300 秒 | -| `lock:document-import:{importId}` | 防止重复处理导入 | 5-30 分钟 | -| `lock:review-plan:user:{userId}:item:{itemId}` | 防止重复生成复习计划 | 60-300 秒 | -| `lock:feedback:ip:{ip}` | 防止 IP 刷反馈 | 60-300 秒 | - -### 任务状态类 - -| Key | Value 示例 | TTL | -|-----|-----------|-----| -| `job:ai-analysis:{jobId}:status` | `pending / processing / completed / failed` | 24h | -| `job:ai-analysis:{jobId}:progress` | `0-100` | 24h | -| `job:ai-analysis:{jobId}:error` | 错误信息字符串 | 24h | -| `job:document-import:{importId}:status` | `pending / parsing / chunking / generating / completed / failed` | 24h | -| `job:document-import:{importId}:progress` | `0-100` | 24h | -| `job:document-import:{importId}:message` | `"正在提取关键知识点"` | 24h | -| `job:document-import:{importId}:error` | 错误信息字符串 | 24h | - -### 会话临时状态类 - -| Key | 用途 | TTL | -|-----|------|-----| -| `session:learning:{sessionId}:heartbeat` | 学习会话心跳 | 30 分钟 | -| `session:learning:{sessionId}:current-step` | 当前学习步骤 | 2 小时 | -| `session:active-recall:{sessionId}:draft` | 回答草稿暂存 | 1-24 小时 | - -### Token / 黑名单 - -| Key | 用途 | TTL | -|-----|------|-----| -| `auth:refresh-token:blacklist:{tokenId}` | 注销后刷新 Token 失效 | 到 token 过期 | -| `auth:access-token:blacklist:{jwtId}` | 注销后 JWT 失效 | 到 token 过期 | - -### Set 类(可选) - -| Key | 用途 | -|-----|------| -| `set:user:{userId}:reviewed-items:{date}` | 当天已复习项去重 | - ---- - -## 4. Redis 数据类型选择 - -| 类型 | 用途 | 示例 | -|------|------|------| -| **String** | 最常用:缓存 JSON、计数器、状态、锁 | `rate:user:123:ai:daily:2026-05-09 = 8` | -| **Hash** | 可选,任务多字段频繁更新的场景 | `job:ai-analysis:1001 → status=processing, progress=40` | -| **List/Stream** | 队列,BullMQ 自动管理,不需要手动操作 | - | -| **Set** | 去重,如当天已复习项集合 | `set:user:123:reviewed-items:2026-05-09` | -| **Sorted Set** | 后期按时间排序的复习调度,v0.1 先不做 | - | - ---- - -## 5. 核心流程中 Redis 与 MySQL 的配合 - -### 5.1 AI 分析流程 - -```text - 1. MySQL 创建 ai_analysis_jobs - 2. Redis 加入 ai-analysis 队列(BullMQ) - 3. Redis 存 job:xxx:status = processing - 4. Worker 调用 AI - 5. MySQL 写 ai_analysis_results - 6. MySQL 写 focus_items - 7. MySQL 写 review_cards - 8. Redis 存 job:xxx:status = completed - 9. MySQL 写 notifications -``` - -### 5.2 资料导入流程 - -```text - 1. MySQL 创建 document_imports - 2. Redis 加入 document-import 队列(BullMQ) - 3. Redis 存导入进度 - 4. Worker 解析文件 - 5. MySQL 写 knowledge_items - 6. MySQL 更新 document_imports 为 success - 7. Redis 存状态 completed -``` - -### 5.3 学习活跃图流程 - -```text - 1. 用户完成学习动作 - 2. MySQL 写 learning_records - 3. MySQL 更新 daily_learning_activities - 4. Redis 可短期缓存今日活跃统计 - 5. App 查询活跃图优先查 MySQL,必要时加缓存 -``` - ---- - -## 6. BullMQ 队列 - -BullMQ 自动管理 Redis key,不需要手动建。建议预留 3 个队列: - -| 队列名 | 任务数据 | 处理逻辑 | -|--------|---------|---------| -| `ai-analysis` | `{ jobId, userId, sessionId, answerId, jobType }` | 读取回答 → 调 AI → 写结果 → 生成待巩固项 → 生成复习卡片 → 发通知 | -| `document-import` | `{ importId, userId, knowledgeBaseId, sourceType }` | 解析文件 → 提取文本 → 分段 → 生成知识点 → 写 knowledge_items → 更新状态 | -| `notification` | `{ userId, type, title, data }` | 写 notifications 表,后续可扩展 APNs 推送 | - ---- - -## 7. 哪些绝对不能只放 Redis - -以下全部必须写 MySQL,Redis 只能做缓存,不是唯一来源: - -```text -用户资料 → users, user_profiles -知识库内容 → knowledge_bases -知识点内容 → knowledge_items -学习记录 → learning_records -主动回忆回答 → active_recall_answers -AI 分析结果 → ai_analysis_results -待巩固项 → focus_items -复习卡片 → review_cards -复习记录 → review_logs -学习活跃记录 → daily_learning_activities -通知记录 → notifications -用户设置 → user_preferences -协议同意记录 → user_consents -``` - ---- - -## 8. v0.1 Redis 最小落地范围 - -### 必须做 - -```text -1. Redis 连接 -2. /health 检查 Redis -3. RedisModule + RedisService(get/set/del/exists/expire/ttl/incr/setNx/lock/unlock) -4. AI 每日调用限流(rate:user:{userId}:ai:daily:{date}) -5. AI 分析队列(BullMQ ai-analysis) -6. AI 分析任务状态(job:ai-analysis:{jobId}:status/progress/error) -7. 防重复提交锁(lock:ai-analysis:session:{sessionId}) -8. document-import 队列预留(BullMQ document-import) -9. notification 队列预留(BullMQ notification) -``` - -### 暂时不做 - -```text -复杂缓存策略 -Sorted Set 复习调度 -复杂分布式任务调度 -全量通知推送(APNs) -复杂排行榜 -``` - ---- - -## 9. RedisService 方法清单 - -```text -get(key) — 读取 -set(key, value) — 写入 -del(key) — 删除 -exists(key) — 判断存在 -expire(key, ttl) — 设置过期 -ttl(key) — 查看剩余时间 -incr(key) — 自增(限流计数) -setNx(key, value) — 不存在才写入(锁) -lock(key, ttl) — 获取分布式锁,返回 token -unlock(key, token) — 释放锁,校验 token,防止误删 -``` - -锁的实现注意: - -- 锁必须设置 TTL -- 解锁时必须校验 value/token,不能误删别人的锁 -- 锁的 value 用随机 token,解锁时比对 - ---- - -## 10. 一句话总结 - -> **Redis 在知习里不是"另一个 MySQL",它是系统的加速器和调度器。MySQL 存结果,Redis 管过程。** diff --git a/docs/SECURITY.md b/docs/SECURITY.md deleted file mode 100644 index bc2e1ca..0000000 --- a/docs/SECURITY.md +++ /dev/null @@ -1,169 +0,0 @@ -# 知习 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] 敏感信息不在日志中打印原则已确立 diff --git a/docs/ios登录流程-Apple登录.md b/docs/ios登录流程-Apple登录.md deleted file mode 100644 index 121475e..0000000 --- a/docs/ios登录流程-Apple登录.md +++ /dev/null @@ -1,254 +0,0 @@ -# iOS 登录流程 —— Apple 登录详解 - ---- - -## 一、Apple 登录核心理解 - -**后端不需要 Apple 开发证书。** Apple 登录的公钥是 Apple 公开提供的 JWKS 地址,后端运行时获取即可。 - -``` -iOS 真机运行: 需要 Apple 开发证书 + Provisioning Profile -后端验证 Apple 登录: 不需要证书,只需要 Apple JWKS 公钥 + Bundle ID -``` - ---- - -## 二、环境变量配置 - -```env -JWT_ACCESS_SECRET=你自己的随机强密钥 -JWT_REFRESH_SECRET=你自己的随机强密钥 -APPLE_BUNDLE_ID=cloud.longde.AIStudyApp -APPLE_ISSUER=https://appleid.apple.com -APPLE_JWKS_URL=https://appleid.apple.com/auth/keys -``` - ---- - -## 三、Apple 登录流程 - -### iOS 端 - -iOS 通过 Sign in with Apple 拿到以下数据: - -``` -identityToken ← JWT,唯一必须的值 -authorizationCode ← 可选,后面可能用于完整校验/撤销 -userIdentifier ← 可选,辅助识别,但后端不要完全信任 -email ← 可选,注意:仅首次授权时返回 -fullName ← 可选,注意:仅首次授权时返回 -``` - -### 发给后端 - -```http -POST /api/auth/apple -``` - -```json -{ - "identityToken": "eyJ...", - "authorizationCode": "c123...", - "userIdentifier": "000123.xxxxx", - "email": "xxx@privaterelay.appleid.com", - "fullName": { - "givenName": "Long", - "familyName": "De" - } -} -``` - -最小必填字段只有: - -```json -{ - "identityToken": "eyJ..." -} -``` - ---- - -## 四、Apple Token 校验(核心) - -使用 `jose` 库,不要手写公钥解析: - -```bash -npm install jose -``` - -### AppleAuthService 实现 - -```ts -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; - -@Injectable() -export class AppleAuthService { - private readonly appleIssuer = 'https://appleid.apple.com'; - private readonly appleBundleId = process.env.APPLE_BUNDLE_ID!; - private readonly jwks = createRemoteJWKSet( - new URL('https://appleid.apple.com/auth/keys'), - ); - - async verifyIdentityToken(identityToken: string) { - try { - const { payload } = await jwtVerify(identityToken, this.jwks, { - issuer: this.appleIssuer, - audience: this.appleBundleId, - }); - - return { - appleUserId: payload.sub, // ← Apple 用户唯一 ID,后端核心信任字段 - email: typeof payload.email === 'string' ? payload.email : undefined, - emailVerified: payload.email_verified, - }; - } catch (error) { - throw new UnauthorizedException('Invalid Apple identity token'); - } - } -} -``` - -### jose 自动完成的工作 - -``` -1. 读取 JWT header 里的 kid -2. 请求 Apple JWKS 地址,找到 kid 对应的公钥 -3. 验证 JWT 签名(RSA) -4. 校验 issuer === https://appleid.apple.com -5. 校验 audience === cloud.longde.AIStudyApp -6. 校验 exp 过期时间 -``` - -你不需要手动把 `n`、`e` 转成 RSA 公钥。 - ---- - -## 五、Apple Login 接口实现 - -### Controller - -```ts -@Post('apple') -async loginWithApple(@Body() dto: AppleLoginDto) { - const appleUser = await this.appleAuthService.verifyIdentityToken( - dto.identityToken, - ); - - return this.authService.loginWithProvider({ - provider: 'APPLE', - providerUserId: appleUser.appleUserId, // ← 即 identityToken.sub - email: appleUser.email ?? dto.email, - nickname: dto.fullName?.givenName - ? `${dto.fullName.givenName} ${dto.fullName.familyName ?? ''}` - : undefined, - }); -} -``` - -### DTO - -```ts -export class AppleLoginDto { - @IsString() - @IsNotEmpty() - identityToken: string; - - @IsOptional() - @IsString() - authorizationCode?: string; - - @IsOptional() - @IsString() - userIdentifier?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - fullName?: { - givenName?: string; - familyName?: string; - }; -} -``` - ---- - -## 六、后端信任模型 - -| 字段 | 信任级别 | 说明 | -|------|----------|------| -| `identityToken.sub` | ✅ 信任 | Apple 签名验证通过后的用户唯一 ID | -| `identityToken.aud` | ✅ 信任 | 必须等于你的 Bundle ID | -| `identityToken.iss` | ✅ 信任 | 必须等于 `https://appleid.apple.com` | -| `identityToken.email` | ⚠️ 参考 | Apple 侧校验过的邮箱,但可能为空 | -| `userIdentifier`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | -| `email`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | -| `userId`(请求体) | ❌ 绝对不能信 | 用户 ID 只能从后端 JWT 获取 | - -**核心规则**: - -``` -1. 后端只信 identityToken 里校验出来的 sub -2. 用 sub 去 auth_accounts(provider=APPLE, providerUserId=sub) 查找/创建用户 -3. 不要信前端传的 userIdentifier / email / name 作为唯一标识 -4. 绝对不要让前端传 userId -``` - ---- - -## 七、Apple 登录的特别注意事项 - -### 1. 电子邮件和姓名仅首次返回 - -``` -email / fullName 只在用户第一次授权时返回 -第二次及以后登录,Apple 不会返回这两个字段 -``` - -所以首次登录时需要将 email 和姓名保存到 `auth_accounts` 和 `users` 表中。 - -### 2. Apple 私密邮箱 - -Apple 可能返回 `xxx@privaterelay.appleid.com` 格式的私密中继邮箱,这是正常的。如果用户选择隐藏邮箱,Apple 会生成一个中转邮箱,发到该邮箱的邮件会自动转发到用户真实邮箱。 - -### 3. 什么时候后端才需要 Apple Key? - -只有在后端要主动调用 Apple 服务时才需要 `.p8` 私钥: - -- App Store Server API -- App Store Connect API -- 订阅状态查询 -- IAP 交易验证 -- APNs 推送 - -**登录不需要这些,这些都是后面的事情。** - ---- - -## 八、完整后端校验小结 - -```text - POST /api/auth/apple - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ 1. 拿到 identityToken │ -│ 2. 解析 header 里的 kid │ -│ 3. 请求 Apple JWKS → https://appleid.apple.com/auth/keys -│ 4. 找到 kid 对应的公钥 │ -│ 5. 验证 JWT 签名 │ -│ 6. 校验 iss === https://appleid.apple.com │ -│ 7. 校验 aud === cloud.longde.AIStudyApp │ -│ 8. 校验 exp 未过期 │ -│ 9. 取 sub 作为 Apple 用户唯一 ID │ -│ 10. 查 auth_accounts(provider=APPLE, providerUserId=sub)│ -│ 11. 不存在→创建 User + AuthAccount │ -│ 12. 存在→找到对应 User │ -│ 13. 生成 accessToken + refreshToken │ -│ 14. refreshToken hash 入库 │ -│ 15. 返回 { accessToken, refreshToken, user } │ -└─────────────────────────────────────────────────────┘ -``` diff --git a/docs/ios登录流程-iOS端集成.md b/docs/ios登录流程-iOS端集成.md deleted file mode 100644 index e8d330f..0000000 --- a/docs/ios登录流程-iOS端集成.md +++ /dev/null @@ -1,172 +0,0 @@ -# iOS 登录流程 —— iOS 端集成 - ---- - -## 一、iOS 需要的核心组件 - -``` -AuthService ← 调后端登录接口 -UserService ← 用户信息管理 -TokenStore ← token 存储协议 -KeychainTokenStore ← 基于 Keychain 的安全存储实现 -AppSession ← 管理当前登录态 -``` - ---- - -## 二、数据存储策略 - -| 数据 | 存储位置 | 生命周期 | 原因 | -|------|---------|---------|------| -| `accessToken` | 内存 | App 运行期间 | 短期使用,不需要持久化 | -| `refreshToken` | Keychain | 长期持久化 | 敏感凭证,需安全存储,卸载后也保留 | -| `user` | AppSession / UserStore | App 运行期间 | 用户展示信息 | - ---- - -## 三、App 启动流程 - -``` -App 启动 -→ AppSession.checkSession() -→ 从 Keychain 读取 refreshToken -→ 如果没有 refreshToken - → 进入登录页 -→ 如果有 refreshToken - → 调用 POST /api/auth/refresh - → 成功 - → 存储新的 accessToken + refreshToken - → 调用 GET /api/users/me - → 存储 user 信息 - → 进入主界面 - → 失败 - → 清空 Keychain + 内存 token - → 进入登录页 -``` - ---- - -## 四、登录流程 - -### 开发登录(dev-login) - -``` -用户在登录页输入邮箱/昵称 -→ AuthService.devLogin(email, nickname) -→ POST /api/auth/dev-login -→ 后端返回 { accessToken, refreshToken, user } -→ refreshToken 存 Keychain -→ accessToken 放内存 -→ user 放 AppSession -→ 进入主界面 -``` - -### Apple 登录 - -``` -用户点击 Sign in with Apple -→ iOS 系统弹出 Apple 授权界面 -→ 用户授权成功 -→ 拿到 identityToken + authorizationCode 等 -→ AuthService.appleLogin(identityToken, ...) -→ POST /api/auth/apple -→ 后端验证 Apple token,返回 { accessToken, refreshToken, user } -→ refreshToken 存 Keychain -→ accessToken 放内存 -→ user 放 AppSession -→ 进入主界面 -``` - ---- - -## 五、接口请求拦截 - -所有需要登录的接口都必须携带: - -```http -Authorization: Bearer {accessToken} -``` - -### HTTP Client 封装建议 - -``` -所有请求自动注入 Authorization Header -→ 从 AuthService 获取当前 accessToken -→ 自动添加到请求头 -``` - -### 401 自动处理 - -``` -接口返回 401 -→ 调用 POST /api/auth/refresh -→ 成功 - → 更新 accessToken - → 自动重试原请求 -→ 失败 - → 清空 Keychain + 内存数据 - → 跳转登录页 -``` - -**重要**:重试原请求时注意避免无限循环,设置最多重试 1 次。 - ---- - -## 六、退出登录 - -``` -用户点击退出登录 -→ AuthService.logout() -→ POST /api/auth/logout - Body: { refreshToken: 从 Keychain 取的 refreshToken } - Header: Authorization: Bearer accessToken -→ 后端标记 refreshToken revoked -→ iOS 端: - → 清除 Keychain 中的 refreshToken - → 清除内存中的 accessToken - → 清除 AppSession 中的 user - → 跳转登录页 -``` - ---- - -## 七、Token 存储对比:UserDefaults vs Keychain - -| | UserDefaults | Keychain | -|------|------------|----------| -| 安全性 | 低(明文存储) | 高(系统级加密) | -| 应用卸载后 | 数据被清除 | 可选保留(推荐保留) | -| 备份 | 包含在 iTunes/iCloud 备份中 | 仅加密备份 | -| 适用数据 | 非敏感偏好设置 | 密码、Token 等敏感凭据 | - -**结论:refreshToken 一定要用 Keychain 存储。** - ---- - -## 八、Session 状态机 - -``` - App 启动 - │ - ▼ - ┌─────────────────┐ - │ 检查 Keychain │ - │ 有 refreshToken? │ - └───────┬─────────┘ - │ - ┌───────┴───────┐ - │ 有 │ 无 - ▼ ▼ - ┌─────────┐ ┌──────────┐ - │ 调 refresh │ │ 进入登录页 │ - │ 接口 │ └──────────┘ - └─────┬─────┘ - │ - ┌────┴────┐ - │ 成功 │ 失败 - ▼ ▼ -┌────────┐ ┌──────────┐ -│ 调 /me │ │ 清空数据 │ -│ 进主页 │ │ 进登录页 │ -└────────┘ └──────────┘ -``` diff --git a/docs/ios登录流程-后端接口.md b/docs/ios登录流程-后端接口.md deleted file mode 100644 index 342752c..0000000 --- a/docs/ios登录流程-后端接口.md +++ /dev/null @@ -1,268 +0,0 @@ -# iOS 登录流程 —— 后端接口实现 - -本文覆盖后端的 `dev-login`、`refresh`、`logout`、`/users/me` 四个核心接口的实现要点。Apple 登录单独见 [ios登录流程-Apple登录.md](./ios登录流程-Apple登录.md)。 - ---- - -## 一、dev-login 接口 - -开发阶段用的快速登录接口,**生产环境必须禁止**。 - -### 请求 - -```http -POST /api/auth/dev-login -``` - -```json -{ - "email": "test@zhixi.app", - "nickname": "测试用户", - "devSecret": "你的开发密钥" -} -``` - -### DTO - -```ts -export class DevLoginDto { - @IsEmail() - email: string; - - @IsOptional() - @IsString() - nickname?: string; - - @IsString() - @IsNotEmpty() - devSecret: string; -} -``` - -### 后端逻辑 - -``` -1. 判断 NODE_ENV 不是 production -2. 校验 devSecret -3. 根据 provider=DEV + providerUserId=email 查 AuthAccount -4. 如果没有,创建 User + AuthAccount(provider=DEV, providerUserId=email) -5. 生成 accessToken -6. 生成 refreshToken -7. refreshToken hash 入库 -8. 返回 token + user -``` - -### 生产环境保护 - -```ts -if (process.env.NODE_ENV === 'production') { - throw new ForbiddenException('dev-login is disabled in production'); -} -``` - ---- - -## 二、refresh 接口 - -用于 accessToken 过期后刷新登录态。 - -### 请求 - -```http -POST /api/auth/refresh -``` - -```json -{ - "refreshToken": "eyJ..." -} -``` - -### 后端逻辑 - -``` -1. 校验 refreshToken JWT 签名 -2. 解析出 userId / tokenId -3. 查 refresh_tokens 表,找到 tokenId 对应记录 -4. 对比 tokenHash(SHA-256) -5. 确认 revokedAt 为 null(未撤销) -6. 确认 expiresAt 未过期 -7. 生成新的 accessToken -8. 可选:轮换新的 refreshToken(旧记录 revoke,新记录入库) -9. 返回新 token -``` - -### 响应(第一版可简单) - -```json -{ - "accessToken": "new_access_token", - "refreshToken": "new_refresh_token" -} -``` - -**建议做 refreshToken 轮换**:每次都生成新的 refreshToken,旧 token 标记 revoked,这样即使 refreshToken 泄露也能被检测到。 - ---- - -## 三、logout 接口 - -### 请求 - -```http -POST /api/auth/logout -Authorization: Bearer accessToken -``` - -```json -{ - "refreshToken": "eyJ..." -} -``` - -### 后端逻辑 - -``` -1. 通过 accessToken 拿到 currentUser -2. 解析 refreshToken,拿到 tokenId -3. 查 refresh_tokens 表找到对应记录 -4. 校验该记录属于当前用户(userId 匹配) -5. 设置 revokedAt = now() -6. 返回成功 -``` - -### iOS 侧配合操作 - -``` -清除 Keychain 中的 refreshToken -清除内存中的 accessToken + user -跳转到登录页 -``` - ---- - -## 四、/users/me 接口 - -App 启动后判断登录态的核心接口。 - -### 请求 - -```http -GET /api/users/me -Authorization: Bearer accessToken -``` - -### 响应 - -```json -{ - "id": "user_xxx", - "email": "test@zhixi.app", - "nickname": "测试用户", - "avatarUrl": null, - "role": "USER", - "status": "ACTIVE", - "onboardingCompleted": false -} -``` - -### 后端逻辑 - -``` -1. JwtAuthGuard 校验 accessToken -2. 从 JWT payload 取 currentUser.id -3. 查 users 表返回用户信息 -``` - -**注意**:不要返回敏感字段(如密码哈希、token 等),只返回前端需要的用户展示信息。 - ---- - -## 五、JwtAuthGuard - -全局认证守卫,保护需要登录的接口。 - -```ts -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} -``` - -配合 `jwt.strategy.ts` 从 Authorization Header 解析 JWT,注入 `currentUser`。 - -### CurrentUser 装饰器 - -```ts -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -export const CurrentUser = createParamDecorator( - (data: keyof User | undefined, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - const user = request.user; - return data ? user?.[data] : user; - }, -); -``` - -### 使用示例 - -```ts -@Get('knowledge-bases') -@UseGuards(JwtAuthGuard) -async list(@CurrentUser('id') userId: string) { - return this.service.findByUser(userId); -} -``` - ---- - -## 六、通用 Provider 登录方法 - -`auth.service.ts` 中的通用方法,被 dev-login 和 Apple 登录复用: - -```ts -async loginWithProvider(params: { - provider: AuthProvider; - providerUserId: string; - email?: string; - nickname?: string; -}) { - // 1. 查 auth_account - let authAccount = await this.prisma.authAccount.findUnique({ - where: { - provider_providerUserId: { - provider: params.provider, - providerUserId: params.providerUserId, - }, - }, - include: { user: true }, - }); - - // 2. 没有就创建 - if (!authAccount) { - const user = await this.prisma.user.create({ - data: { - email: params.email, - nickname: params.nickname, - authAccounts: { - create: { - provider: params.provider, - providerUserId: params.providerUserId, - email: params.email, - }, - }, - }, - }); - authAccount = { user, /* ... */ }; - } - - // 3. 签发 token - const accessToken = this.tokenService.generateAccessToken(authAccount.user); - const refreshToken = this.tokenService.generateRefreshToken(authAccount.user); - - // 4. refreshToken hash 入库 - await this.tokenService.saveRefreshToken(authAccount.user.id, refreshToken); - - // 5. 返回 - return { accessToken, refreshToken, user: authAccount.user }; -} -``` diff --git a/docs/ios登录流程-总览.md b/docs/ios登录流程-总览.md deleted file mode 100644 index 00d96e9..0000000 --- a/docs/ios登录流程-总览.md +++ /dev/null @@ -1,179 +0,0 @@ -# iOS 登录流程 —— 总体设计 - ---- - -## 一、核心理解 - -``` -Apple 登录不是你的 App 登录系统本身。 -Apple 只是帮你证明"这个人是谁"。 -真正的登录态,要由你的后端发 accessToken / refreshToken。 -``` - -最终流程: - -``` -iOS 调 Apple 登录 -→ 拿到 Apple identityToken -→ 发给你的 NestJS 后端 -→ 后端校验 Apple token -→ 后端创建 / 查找用户 -→ 后端生成自己的 accessToken + refreshToken -→ iOS 存 Keychain -→ 以后所有接口带 Authorization: Bearer accessToken -``` - -**开发建议**:先做 `dev-login → /users/me → Keychain → 知识库接口`,Apple 登录随后再接,不要让 Apple 流程卡住后端开发。 - ---- - -## 二、认证系统核心模型 - -后端登录系统的本质是建立自己的认证体系: - -``` -users -+ auth_accounts (支持多 provider:DEV、APPLE) -+ refresh_tokens (只存 hash,不存明文) -+ accessToken (短期令牌,JWT) -+ refreshToken (长期令牌,JWT,可轮换/撤销) -+ JwtAuthGuard (全局守卫) -+ /users/me (启动态判定核心接口) -``` - -Apple 登录只是其中一个 provider。核心是通过 `auth_accounts` 表关联第三方身份与本地用户。 - ---- - -## 三、接口清单 - -第一版 5 个接口: - -| 接口 | 用途 | 优先级 | -|------|------|--------| -| `POST /api/auth/dev-login` | 开发调试登录 | ⭐ 先做 | -| `POST /api/auth/refresh` | 刷新登录态 | ⭐ 先做 | -| `GET /api/users/me` | 获取当前用户 | ⭐ 先做 | -| `POST /api/auth/apple` | Apple 正式登录 | 随后接 | -| `POST /api/auth/logout` | 退出登录 | 最后做 | - ---- - -## 四、统一返回格式 - -登录成功后后端统一返回: - -```json -{ - "accessToken": "eyJ...", - "refreshToken": "eyJ...", - "user": { - "id": "user_xxx", - "email": "test@zhixi.app", - "nickname": "测试用户", - "avatarUrl": null, - "role": "USER", - "status": "ACTIVE", - "onboardingCompleted": false - } -} -``` - -iOS 拿到后: - -| 数据 | 存储位置 | 用途 | -|------|---------|------| -| `accessToken` | 内存 | 接口请求 Authorization Header | -| `refreshToken` | Keychain | 恢复登录 | -| `user` | AppSession / UserStore | 用户信息展示 | - ---- - -## 五、后端模块结构 - -``` -src/modules/auth/ -├── auth.controller.ts # 登录/刷新/登出接口 -├── auth.service.ts # 通用登录逻辑(provider 调度) -├── apple-auth.service.ts # Apple identityToken 校验 -├── token.service.ts # JWT 生成/校验 -├── dto/ -│ ├── dev-login.dto.ts -│ ├── apple-login.dto.ts -│ └── refresh-token.dto.ts -├── guards/ -│ └── jwt-auth.guard.ts # 全局认证守卫 -├── decorators/ -│ └── current-user.decorator.ts # 从 JWT 取用户 -└── strategies/ - └── jwt.strategy.ts - -src/modules/users/ -├── users.controller.ts # /users/me -├── users.service.ts -└── dto/ -``` - ---- - -## 六、业务接口安全规则 - -所有业务接口依赖登录体系。**核心规则:不要相信前端传的 `userId`。** - -```http -GET /api/knowledge-bases -Authorization: Bearer accessToken -``` - -后端应从 JWT 拿当前用户: - -```ts -// ✅ 正确:从 token 里取 currentUser.id -where: { - userId: currentUser.id, - deletedAt: null -} - -// ❌ 错误:从请求体取 userId -where: { - userId: body.userId -} -``` - -用户资源接口,只相信 JWT 里的 `currentUser.id`,不允许前端传递 `userId` 参数。 - ---- - -## 七、推荐开发顺序 - -``` -1. Prisma 建 users / auth_accounts / refresh_tokens -2. TokenService:生成 accessToken / refreshToken -3. dev-login 接口 -4. JwtAuthGuard -5. CurrentUser 装饰器 -6. /users/me 接口 -7. iOS 接 dev-login + Keychain + AppSession -8. 知识库接口全部加 JwtAuthGuard -9. Apple 登录接口 -10. refresh 接口 -11. logout 接口 -``` - ---- - -## 八、环境变量最小配置 - -```env -JWT_ACCESS_SECRET=你自己的随机强密钥 -JWT_REFRESH_SECRET=你自己的随机强密钥 -APPLE_BUNDLE_ID=cloud.longde.AIStudyApp -APPLE_ISSUER=https://appleid.apple.com -APPLE_JWKS_URL=https://appleid.apple.com/auth/keys -``` - ---- - -## 九、总结 - -最终一句话:后端登录对接的核心不是"接 Apple 登录按钮",而是先建立自己的认证系统。Apple 登录只是其中一个 provider。先把 `dev-login → token → /users/me → iOS Keychain` 跑通,知识库和学习闭环就不会卡住。 diff --git a/docs/ios登录流程-数据库设计.md b/docs/ios登录流程-数据库设计.md deleted file mode 100644 index 3b2fe7a..0000000 --- a/docs/ios登录流程-数据库设计.md +++ /dev/null @@ -1,153 +0,0 @@ -# iOS 登录流程 —— 数据库设计 - ---- - -## 一、users 表 - -用户主表,存储用户基础信息。 - -```prisma -model User { - id String @id @default(cuid()) - email String? - nickname String? - avatarUrl String? - role UserRole @default(USER) - status UserStatus @default(ACTIVE) - onboardingCompleted Boolean @default(false) - - authAccounts AuthAccount[] - refreshTokens RefreshToken[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum UserRole { - USER - ADMIN - SUPER_ADMIN -} - -enum UserStatus { - ACTIVE - DISABLED - DELETED -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | String (cuid) | 主键 | -| `email` | String? | 邮箱,可选(Apple 登录首次可能提供) | -| `nickname` | String? | 昵称 | -| `avatarUrl` | String? | 头像 URL | -| `role` | UserRole | 角色,默认 USER | -| `status` | UserStatus | 状态,默认 ACTIVE | -| `onboardingCompleted` | Boolean | 是否完成引导,默认 false | -| `authAccounts` | 关联 | 一对多关联 auth_accounts | -| `refreshTokens` | 关联 | 一对多关联 refresh_tokens | - ---- - -## 二、auth_accounts 表 - -记录用户通过什么方式(provider)登录,支持一个用户绑定多个登录方式。 - -```prisma -model AuthAccount { - id String @id @default(cuid()) - userId String - provider AuthProvider - providerUserId String - email String? - - user User @relation(fields: [userId], references: [id]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([provider, providerUserId]) - @@index([userId]) -} - -enum AuthProvider { - DEV - APPLE -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | String (cuid) | 主键 | -| `userId` | String | 关联 users 表 | -| `provider` | AuthProvider | 登录提供商(DEV / APPLE) | -| `providerUserId` | String | 提供商侧的用户唯一 ID | -| `email` | String? | 提供商侧邮箱 | -| `@@unique([provider, providerUserId])` | 约束 | 同一个提供商的用户唯一 | - -**查找逻辑**: - -``` -Apple 登录时: - provider = APPLE - providerUserId = identityToken 里校验出来的 sub - → 如果不存在,创建 User + AuthAccount - → 如果存在,直接找到对应 User -``` - ---- - -## 三、refresh_tokens 表 - -**重要:refreshToken 不要明文存数据库,只存 hash。** - -```prisma -model RefreshToken { - id String @id @default(cuid()) - userId String - tokenHash String - expiresAt DateTime - revokedAt DateTime? - - user User @relation(fields: [userId], references: [id]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | String (cuid) | 主键,同时写入 JWT payload 作为 `tokenId` | -| `userId` | String | 关联 users 表 | -| `tokenHash` | String | refreshToken 的 hash 值(SHA-256) | -| `expiresAt` | DateTime | 过期时间 | -| `revokedAt` | DateTime? | 撤销时间(登出时设置) | - -**刷新时的校验链**: - -``` -1. 解析 refreshToken JWT,拿到 userId + tokenId -2. 查 refresh_tokens 表,找到对应记录 -3. 对比 tokenHash -4. 确认 revokedAt 为 null(未撤销) -5. 确认 expiresAt 未过期 -6. 签发新的 accessToken(可选轮换新的 refreshToken) -``` - -**登出时**:将对应记录的 `revokedAt` 设为当前时间。 - ---- - -## 四、ER 关系总结 - -``` -User (1) ──── (N) AuthAccount 一个用户可有多种登录方式 -User (1) ──── (N) RefreshToken 一个用户可有多个活跃 refreshToken(多设备) -``` - -- 用户与登录方式是解耦的:用户是一个独立实体,通过 `auth_accounts` 关联到具体的第三方身份。 -- 这种设计天然支持未来扩展更多登录方式(如 Google、微信等),只需在 `AuthProvider` 枚举中添加即可。 diff --git a/docs/ios登录流程.md b/docs/ios登录流程.md deleted file mode 100644 index 27a7a71..0000000 --- a/docs/ios登录流程.md +++ /dev/null @@ -1,1003 +0,0 @@ -对,现在后端登录要开始接了。你要先理解一个核心点: - -```text -Apple 登录不是你的 App 登录系统本身。 -Apple 只是帮你证明“这个人是谁”。 -真正的登录态,要由你的后端发 accessToken / refreshToken。 -``` - -也就是说最终流程是: - -```text -iOS 调 Apple 登录 -→ 拿到 Apple identityToken -→ 发给你的 NestJS 后端 -→ 后端校验 Apple token -→ 后端创建 / 查找用户 -→ 后端生成自己的 accessToken + refreshToken -→ iOS 存 Keychain -→ 以后所有接口带 Authorization: Bearer accessToken -``` - -不过你现在后端开发阶段,我建议先做: - -```text -dev-login -→ /users/me -→ Keychain -→ 知识库接口 -``` - -Apple 登录随后再接,不要让 Apple 流程卡住后端开发。 - ---- - -# 一、后端登录模块要做哪些接口 - -第一版只需要这 5 个: - -```http -POST /api/auth/dev-login -POST /api/auth/apple -POST /api/auth/refresh -POST /api/auth/logout -GET /api/users/me -``` - -其中现在最先实现: - -```http -POST /api/auth/dev-login -POST /api/auth/refresh -GET /api/users/me -``` - -等这些通了,再接: - -```http -POST /api/auth/apple -``` - ---- - -# 二、数据库先建这 3 张表 - -## 1. users - -```prisma -model User { - id String @id @default(cuid()) - email String? - nickname String? - avatarUrl String? - role UserRole @default(USER) - status UserStatus @default(ACTIVE) - onboardingCompleted Boolean @default(false) - - authAccounts AuthAccount[] - refreshTokens RefreshToken[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum UserRole { - USER - ADMIN - SUPER_ADMIN -} - -enum UserStatus { - ACTIVE - DISABLED - DELETED -} -``` - -## 2. auth_accounts - -这个表用来记录用户是通过什么方式登录的。 - -```prisma -model AuthAccount { - id String @id @default(cuid()) - userId String - provider AuthProvider - providerUserId String - email String? - - user User @relation(fields: [userId], references: [id]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([provider, providerUserId]) - @@index([userId]) -} - -enum AuthProvider { - DEV - APPLE -} -``` - -## 3. refresh_tokens - -```prisma -model RefreshToken { - id String @id @default(cuid()) - userId String - tokenHash String - expiresAt DateTime - revokedAt DateTime? - - user User @relation(fields: [userId], references: [id]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) -} -``` - -注意:`refreshToken` 不要明文存数据库,只存 hash。 - ---- - -# 三、接口返回格式 - -登录成功后,后端统一返回: - -```json -{ - "accessToken": "eyJ...", - "refreshToken": "eyJ...", - "user": { - "id": "user_xxx", - "email": "test@zhixi.app", - "nickname": "测试用户", - "avatarUrl": null, - "role": "USER", - "status": "ACTIVE", - "onboardingCompleted": false - } -} -``` - -iOS 拿到以后: - -```text -accessToken:内存里用,接口请求带上 -refreshToken:存 Keychain,用来恢复登录 -user:存 AppSession / UserStore -``` - ---- - -# 四、dev-login 怎么做 - -dev-login 是开发阶段用的。 - -请求: - -```http -POST /api/auth/dev-login -``` - -```json -{ - "email": "test@zhixi.app", - "nickname": "测试用户", - "devSecret": "你的开发密钥" -} -``` - -后端逻辑: - -```text -1. 判断 NODE_ENV 不是 production -2. 校验 devSecret -3. 根据 email 查 AuthAccount -4. 如果没有,创建 User + AuthAccount -5. 生成 accessToken -6. 生成 refreshToken -7. refreshToken hash 入库 -8. 返回 token + user -``` - -这个接口必须禁止生产环境使用: - -```ts -if (process.env.NODE_ENV === 'production') { - throw new ForbiddenException('dev-login is disabled in production') -} -``` - ---- - -# 五、Apple 登录怎么接 - -iOS 通过 Sign in with Apple 拿到: - -```text -identityToken -authorizationCode -userIdentifier -email -fullName -``` - -然后发给后端: - -```http -POST /api/auth/apple -``` - -```json -{ - "identityToken": "eyJ...", - "authorizationCode": "...", - "userIdentifier": "000123.xxx", - "email": "xxx@privaterelay.appleid.com", - "fullName": { - "givenName": "Long", - "familyName": "De" - } -} -``` - -后端逻辑: - -```text -1. 校验 identityToken -2. 确认 token 的 aud 是你的 Bundle ID -3. 确认 token 的 issuer 是 Apple -4. 拿 sub 作为 Apple providerUserId -5. 查 auth_accounts(provider=APPLE, providerUserId=sub) -6. 没有就创建 user + auth_account -7. 有就找到 user -8. 生成自己的 accessToken / refreshToken -9. 返回 token + user -``` - -你的 Bundle ID 现在是: - -```text -cloud.longde.AIStudyApp -``` - -所以后端环境变量可以先配: - -```env -APPLE_BUNDLE_ID=cloud.longde.AIStudyApp -``` - -Apple token 里的 `aud` 必须等于这个。 - ---- - -# 六、refresh 怎么做 - -请求: - -```http -POST /api/auth/refresh -``` - -```json -{ - "refreshToken": "eyJ..." -} -``` - -后端逻辑: - -```text -1. 校验 refreshToken 签名 -2. 解析 userId / tokenId -3. 查 refresh_tokens 表 -4. 对比 hash -5. 确认未过期、未 revoked -6. 生成新的 accessToken -7. 可选:轮换新的 refreshToken -8. 返回新 token -``` - -第一版可以先简单: - -```json -{ - "accessToken": "new_access_token", - "refreshToken": "new_refresh_token" -} -``` - -建议做 refreshToken 轮换,这样更安全。 - ---- - -# 七、logout 怎么做 - -请求: - -```http -POST /api/auth/logout -``` - -Header: - -```http -Authorization: Bearer accessToken -``` - -Body: - -```json -{ - "refreshToken": "eyJ..." -} -``` - -后端逻辑: - -```text -1. 找到 refresh_tokens 记录 -2. 设置 revokedAt -3. 返回成功 -``` - -iOS 侧: - -```text -清除 Keychain -清除 AppSession -回到登录页 -``` - ---- - -# 八、/users/me 怎么做 - -请求: - -```http -GET /api/users/me -``` - -Header: - -```http -Authorization: Bearer accessToken -``` - -返回: - -```json -{ - "id": "user_xxx", - "email": "test@zhixi.app", - "nickname": "测试用户", - "avatarUrl": null, - "role": "USER", - "status": "ACTIVE", - "onboardingCompleted": false -} -``` - -这个接口是 App 启动后判断登录态的核心接口。 - ---- - -# 九、iOS 侧怎么接 - -iOS 需要这几个东西: - -```text -AuthService -UserService -TokenStore / KeychainTokenStore -AppSession -``` - -启动流程: - -```text -App 启动 -→ AppSession.checkSession() -→ 从 Keychain 读 refreshToken -→ 如果没有,进入登录页 -→ 如果有,调用 /auth/refresh -→ 成功后调用 /users/me -→ 进入主界面 -→ 失败则清空 token,进入登录页 -``` - -登录流程: - -```text -点击开发登录 / Apple 登录 -→ 调后端登录接口 -→ 后端返回 accessToken + refreshToken + user -→ refreshToken 存 Keychain -→ accessToken 放内存 -→ user 放 AppSession -→ 进入主界面 -``` - -以后所有接口都带: - -```http -Authorization: Bearer accessToken -``` - -如果接口返回 401: - -```text -调用 /auth/refresh -→ 成功后重试原请求 -→ 失败则退出登录 -``` - ---- - -# 十、后端模块结构建议 - -NestJS 可以这样拆: - -```text -src/modules/auth/ -├── auth.controller.ts -├── auth.service.ts -├── apple-auth.service.ts -├── token.service.ts -├── dto/ -│ ├── dev-login.dto.ts -│ ├── apple-login.dto.ts -│ └── refresh-token.dto.ts -├── guards/ -│ └── jwt-auth.guard.ts -├── decorators/ -│ └── current-user.decorator.ts -└── strategies/ - └── jwt.strategy.ts - -src/modules/users/ -├── users.controller.ts -├── users.service.ts -└── dto/ -``` - ---- - -# 十一、后面所有业务接口都依赖这个 - -登录做完后,你的知识库接口应该这样写: - -```http -GET /api/knowledge-bases -Authorization: Bearer accessToken -``` - -后端不要让前端传 `userId`。 - -后端应该从 token 里拿: - -```ts -currentUser.id -``` - -然后查: - -```ts -where: { - userId: currentUser.id, - deletedAt: null -} -``` - -这个规则非常重要: - -```text -用户资源接口,不要相信前端传 userId。 -只相信 JWT 里的 currentUser.id。 -``` - ---- - -# 十二、你现在的开发顺序 - -建议你现在按这个顺序写: - -```text -1. Prisma 建 users / auth_accounts / refresh_tokens -2. 做 TokenService:生成 accessToken / refreshToken -3. 做 dev-login -4. 做 JwtAuthGuard -5. 做 CurrentUser 装饰器 -6. 做 /users/me -7. iOS 接 dev-login + Keychain + AppSession -8. 知识库接口全部加 JwtAuthGuard -9. 再做 Apple 登录 -``` - ---- - -# 最终一句话 - -你后端登录对接的核心不是“接 Apple 登录按钮”,而是先建立自己的认证系统: - -```text -users -+ auth_accounts -+ refresh_tokens -+ accessToken -+ refreshToken -+ JwtAuthGuard -+ /users/me -``` - -然后 Apple 登录只是其中一个 provider。 -现在先把 `dev-login → token → /users/me → iOS Keychain` 跑通,后面知识库和学习闭环就不会卡住。 -不需要你刚才在 Xcode 里弄的那种 **iOS 开发证书 / Provisioning Profile**。 - -你后端接 Apple 登录,主要分两种情况: - -## 1. 只做 iOS App 的 Sign in with Apple 登录 - -**后端不需要 Apple 开发证书。** - -你需要的是: - -```text -Bundle ID -Apple 公钥地址 -后端自己的 JWT Secret -``` - -你的 Bundle ID 现在是: - -```text -cloud.longde.AIStudyApp -``` - -iOS 登录成功后,会给你一个: - -```text -identityToken -``` - -你的后端要做的是: - -```text -1. 拿 identityToken -2. 用 Apple 公钥验证这个 JWT 签名 -3. 校验 iss 是 Apple -4. 校验 aud 等于 cloud.longde.AIStudyApp -5. 校验 exp 没过期 -6. 取 sub 作为 Apple 用户唯一 ID -7. 创建 / 查找你的 users -8. 发你自己的 accessToken / refreshToken -``` - -Apple 官方文档也是这个流程:App 完成 Sign in with Apple 后,会返回 identity token,服务端应验证该 token,确认它没有过期、没有被篡改或重放。([Apple Developer][1]) - -所以这里**不需要 p12 证书、开发证书、发布证书、Provisioning Profile**。 - -## 2. 什么时候后端才需要 Apple 的 Key? - -如果你后端要主动调用 Apple 的一些服务,才可能需要 Apple 后台生成的私钥 `.p8`。 - -比如: - -```text -App Store Server API -App Store Connect API -订阅状态查询 -订阅通知验证 -Apple IAP 交易相关接口 -APNs 推送 -``` - -这些通常需要在 Apple Developer / App Store Connect 里创建 API Key,然后后端用私钥生成 JWT。Apple 官方 App Store Connect API 和 App Store Server API 都是通过 JWT 授权请求。([Apple Developer][2]) - -但这是后面的事情。 - -**你现在做登录,不需要。** - -## 你现在后端登录最小需要配置 - -`.env` 里先放这些就够: - -```env -JWT_ACCESS_SECRET=你自己的随机强密钥 -JWT_REFRESH_SECRET=你自己的随机强密钥 -APPLE_BUNDLE_ID=cloud.longde.AIStudyApp -APPLE_ISSUER=https://appleid.apple.com -``` - -如果你要验证 Apple identityToken,后端会去 Apple 的 JWKS 公钥地址取公钥。你不需要自己申请证书。 - -## 一句话 - -```text -iOS 打包运行需要证书和 Provisioning Profile; -后端验证 Apple 登录不需要这些证书,只需要验证 identityToken,并校验 Bundle ID。 -``` - -所以你后端现在可以直接继续做: - -```text -POST /api/auth/dev-login -POST /api/auth/apple -POST /api/auth/refresh -GET /api/users/me -``` - -Apple 证书这块不会卡你的后端登录接口。 - -[1]: https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple?utm_source=chatgpt.com "Authenticating users with Sign in with Apple" -[2]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api?utm_source=chatgpt.com "Creating API Keys for App Store Connect API" -公钥不在 Xcode,也不在 App Store Connect 里面。 - -**Apple 登录用的公钥是 Apple 公开提供的 JWKS 地址,后端运行时去 Apple 获取。**你不用自己申请,也不用下载证书。Apple 官方文档里这个接口就是用来获取验证 identity token 签名的公钥。([Apple Developer][1]) - -后端用这个地址: - -```text -https://appleid.apple.com/auth/keys -``` - -## 你后端要做的事 - -你的后端收到 iOS 发来的: - -```text -identityToken -``` - -然后后端做: - -```text -1. 解析 identityToken 的 header,拿到 kid -2. 请求 Apple 公钥地址 -3. 从 keys 里找到 kid 对应的公钥 -4. 验证 identityToken 签名 -5. 校验 iss、aud、exp -6. aud 必须等于你的 Bundle ID -``` - -你的 Bundle ID 是: - -```text -cloud.longde.AIStudyApp -``` - -所以后端 `.env` 里可以配: - -```env -APPLE_BUNDLE_ID=cloud.longde.AIStudyApp -APPLE_ISSUER=https://appleid.apple.com -APPLE_JWKS_URL=https://appleid.apple.com/auth/keys -``` - -## 推荐用 `jose` 库,不要自己手写公钥解析 - -NestJS / Node 后端建议装: - -```bash -npm install jose -``` - -然后写一个 Apple token 校验服务: - -```ts -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; - -@Injectable() -export class AppleAuthService { - private readonly appleIssuer = 'https://appleid.apple.com'; - private readonly appleBundleId = process.env.APPLE_BUNDLE_ID!; - private readonly jwks = createRemoteJWKSet( - new URL('https://appleid.apple.com/auth/keys'), - ); - - async verifyIdentityToken(identityToken: string) { - try { - const { payload } = await jwtVerify(identityToken, this.jwks, { - issuer: this.appleIssuer, - audience: this.appleBundleId, - }); - - return { - appleUserId: payload.sub, - email: typeof payload.email === 'string' ? payload.email : undefined, - emailVerified: payload.email_verified, - }; - } catch (error) { - throw new UnauthorizedException('Invalid Apple identity token'); - } - } -} -``` - -这里 `jose` 会自动做: - -```text -读取 token header 里的 kid -去 Apple JWKS 里找对应 key -验证 JWT 签名 -校验 issuer -校验 audience -校验过期时间 -``` - -你不用手动把 `n`、`e` 转成 RSA 公钥。 - -## 后端 Apple 登录接口大概这样 - -```ts -@Post('apple') -async loginWithApple(@Body() dto: AppleLoginDto) { - const appleUser = await this.appleAuthService.verifyIdentityToken( - dto.identityToken, - ); - - return this.authService.loginWithProvider({ - provider: 'APPLE', - providerUserId: appleUser.appleUserId, - email: appleUser.email, - nickname: dto.nickname, - }); -} -``` - -DTO: - -```ts -export class AppleLoginDto { - identityToken: string; - authorizationCode?: string; - userIdentifier?: string; - email?: string; - nickname?: string; -} -``` - -## 你要记住的区别 - -```text -iOS 真机运行: -需要 Apple Development 证书、Provisioning Profile。 - -后端验证 Apple 登录: -不需要证书。 -只需要 Apple JWKS 公钥地址 + Bundle ID。 -``` - -所以现在你已经有: - -```text -Bundle ID:cloud.longde.AIStudyApp -Apple 公钥地址:https://appleid.apple.com/auth/keys -``` - -后端就可以开始写 `/api/auth/apple` 了。 - -[1]: https://developer.apple.com/documentation/signinwithapplerestapi/fetch-apple%27s-public-key-for-verifying-token-signature?utm_source=chatgpt.com "Fetch Apple's public key to verify token signatures" -对,**这里就应该先设计后端接口契约**。 - -你理解得基本对:Apple 登录的核心参数确实就是: - -```text -identityToken -``` - -但我不建议接口只设计成一个裸 token。更合理的是: - -```text -identityToken 必填 -其他 Apple 返回的信息作为可选字段 -``` - -因为 Apple 登录有几个坑: - -```text -email / fullName 只会在用户第一次授权时返回 -后续登录可能拿不到 -authorizationCode 以后可能用于更完整的 Apple 账号校验 / 撤销 -userIdentifier 可以辅助客户端识别,但后端不要完全信它 -``` - -## 推荐接口 - -```http -POST /api/auth/apple -``` - -请求体: - -```json -{ - "identityToken": "eyJ...", - "authorizationCode": "c123...", - "userIdentifier": "000123.xxxxx", - "email": "xxx@privaterelay.appleid.com", - "fullName": { - "givenName": "Long", - "familyName": "Wang" - }, - "nonce": "optional_nonce" -} -``` - -## 最小必填字段 - -真正必须的只有: - -```json -{ - "identityToken": "eyJ..." -} -``` - -所以 DTO 可以这样设计: - -```ts -export class AppleLoginDto { - @IsString() - @IsNotEmpty() - identityToken: string; - - @IsOptional() - @IsString() - authorizationCode?: string; - - @IsOptional() - @IsString() - userIdentifier?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - fullName?: { - givenName?: string; - familyName?: string; - }; - - @IsOptional() - @IsString() - nonce?: string; -} -``` - -## 后端真正信什么? - -后端真正应该信的是: - -```text -identityToken 里校验出来的 sub -``` - -流程是: - -```text -1. iOS 把 identityToken 发给后端 -2. 后端用 Apple 公钥验证 token -3. 校验 aud === cloud.longde.AIStudyApp -4. 校验 iss === https://appleid.apple.com -5. 校验 exp 没过期 -6. 从 payload.sub 拿 Apple 用户唯一 ID -7. 用 sub 去 auth_accounts 里查找或创建用户 -8. 后端生成自己的 accessToken / refreshToken -``` - -不要让前端传: - -```text -userId -``` - -也不要以后端完全信任: - -```text -userIdentifier -email -nickname -``` - -这些都只能作为辅助信息。 - -## 返回值设计 - -登录成功后,后端返回你自己的登录态: - -```json -{ - "accessToken": "your_access_token", - "refreshToken": "your_refresh_token", - "user": { - "id": "user_xxx", - "email": "xxx@privaterelay.appleid.com", - "nickname": "Wang", - "avatarUrl": null, - "role": "USER", - "status": "ACTIVE", - "onboardingCompleted": false - } -} -``` - -iOS 拿到以后: - -```text -accessToken:后续请求放 Authorization Header -refreshToken:存 Keychain -user:存 AppSession -``` - -## 同时保留 dev-login - -你现在开发阶段还应该保留: - -```http -POST /api/auth/dev-login -``` - -用于本地联调: - -```json -{ - "email": "test@zhixi.app", - "nickname": "测试用户", - "devSecret": "xxx" -} -``` - -这样你后端接口可以先跑通: - -```text -dev-login -→ /users/me -→ knowledge-bases -→ knowledge-items -``` - -Apple 登录后面接上来,只是多一个 provider。 - -## 最终登录接口清单 - -第一版设计成这样: - -```text -POST /api/auth/dev-login -POST /api/auth/apple -POST /api/auth/refresh -POST /api/auth/logout -GET /api/users/me -``` - -其中: - -```text -/auth/apple:正式 Apple 登录 -/auth/dev-login:开发调试 -/auth/refresh:刷新登录态 -/auth/logout:退出登录 -/users/me:获取当前用户 -``` - -## 一句话 - -是的,**后端 Apple 登录接口的核心参数就是 identityToken**。 - -但接口最好设计成: - -```text -identityToken 必填 -authorizationCode / userIdentifier / email / fullName / nonce 可选 -``` - -这样现在简单能跑,后面也不用返工。 diff --git a/package-lock.json b/package-lock.json index 2fac95e..7dd2aa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -10844,6 +10845,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 939952a..e683989 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/prisma/migrations/20250516000000_init/migration.sql b/prisma/migrations/20250516000000_init/migration.sql new file mode 100644 index 0000000..3380f2a --- /dev/null +++ b/prisma/migrations/20250516000000_init/migration.sql @@ -0,0 +1,592 @@ +-- CreateTable +CREATE TABLE `User` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(255) NULL, + `nickname` VARCHAR(100) NULL, + `avatarUrl` VARCHAR(500) NULL, + `role` VARCHAR(32) NOT NULL DEFAULT 'USER', + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `onboardingCompleted` BOOLEAN NOT NULL DEFAULT false, + `lastLoginAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + + INDEX `User_email_idx`(`email`), + INDEX `User_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AuthAccount` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `provider` VARCHAR(32) NOT NULL, + `providerUserId` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NULL, + `rawProfileJson` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `AuthAccount_userId_idx`(`userId`), + UNIQUE INDEX `AuthAccount_provider_providerUserId_key`(`provider`, `providerUserId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `RefreshToken` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `tokenHash` VARCHAR(255) NOT NULL, + `deviceId` VARCHAR(255) NULL, + `deviceName` VARCHAR(255) NULL, + `expiresAt` DATETIME(3) NOT NULL, + `revokedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `RefreshToken_userId_idx`(`userId`), + INDEX `RefreshToken_tokenHash_idx`(`tokenHash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `UserProfile` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `learningIdentity` VARCHAR(100) NULL, + `learningDirection` VARCHAR(255) NULL, + `bio` TEXT NULL, + `currentGoal` VARCHAR(255) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `UserProfile_userId_key`(`userId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `UserPreference` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `preferredMethods` JSON NULL, + `defaultFocusMinutes` INTEGER NOT NULL DEFAULT 25, + `aiSuggestionLevel` VARCHAR(32) NOT NULL DEFAULT 'normal', + `language` VARCHAR(32) NOT NULL DEFAULT 'zh-CN', + `appearance` VARCHAR(32) NOT NULL DEFAULT 'system', + `notificationEnabled` BOOLEAN NOT NULL DEFAULT true, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `UserPreference_userId_key`(`userId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `UserConsent` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `consentType` VARCHAR(32) NOT NULL, + `version` VARCHAR(50) NOT NULL, + `acceptedAt` DATETIME(3) NOT NULL, + `ipAddress` VARCHAR(100) NULL, + `userAgent` VARCHAR(500) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `UserConsent_userId_idx`(`userId`), + INDEX `UserConsent_consentType_idx`(`consentType`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `KnowledgeBase` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `coverKey` VARCHAR(100) NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `itemCount` INTEGER NOT NULL DEFAULT 0, + `lastStudiedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + + INDEX `KnowledgeBase_userId_idx`(`userId`), + INDEX `KnowledgeBase_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `KnowledgeItem` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeBaseId` VARCHAR(191) NOT NULL, + `parentId` VARCHAR(191) NULL, + `itemType` VARCHAR(32) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `content` LONGTEXT NULL, + `summary` TEXT NULL, + `sourceType` VARCHAR(32) NULL, + `sourceRef` VARCHAR(500) NULL, + `orderIndex` INTEGER NOT NULL DEFAULT 0, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + + INDEX `KnowledgeItem_userId_idx`(`userId`), + INDEX `KnowledgeItem_knowledgeBaseId_idx`(`knowledgeBaseId`), + INDEX `KnowledgeItem_parentId_idx`(`parentId`), + INDEX `KnowledgeItem_itemType_idx`(`itemType`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `KnowledgeItemRelation` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `sourceItemId` VARCHAR(191) NOT NULL, + `targetItemId` VARCHAR(191) NOT NULL, + `relationType` VARCHAR(32) NOT NULL, + `confidence` DECIMAL(5, 2) NULL, + `reason` TEXT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `KnowledgeItemRelation_sourceItemId_idx`(`sourceItemId`), + INDEX `KnowledgeItemRelation_targetItemId_idx`(`targetItemId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Tag` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `color` VARCHAR(32) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `Tag_userId_name_key`(`userId`, `name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `KnowledgeItemTag` ( + `id` VARCHAR(191) NOT NULL, + `knowledgeItemId` VARCHAR(191) NOT NULL, + `tagId` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `KnowledgeItemTag_knowledgeItemId_tagId_key`(`knowledgeItemId`, `tagId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `UploadedFile` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `mimeType` VARCHAR(100) NULL, + `storagePath` VARCHAR(500) NOT NULL, + `sizeBytes` BIGINT NOT NULL DEFAULT 0, + `checksum` VARCHAR(255) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `UploadedFile_userId_idx`(`userId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `DocumentImport` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeBaseId` VARCHAR(191) NULL, + `fileId` VARCHAR(191) NULL, + `sourceType` VARCHAR(32) NOT NULL, + `sourceName` VARCHAR(255) NULL, + `sourceUrl` VARCHAR(500) NULL, + `rawText` LONGTEXT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'pending', + `progress` INTEGER NOT NULL DEFAULT 0, + `errorMessage` TEXT NULL, + `resultJson` JSON NULL, + `startedAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `DocumentImport_userId_idx`(`userId`), + INDEX `DocumentImport_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `LearningSession` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeBaseId` VARCHAR(191) NULL, + `knowledgeItemId` VARCHAR(191) NULL, + `mode` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `startedAt` DATETIME(3) NOT NULL, + `endedAt` DATETIME(3) NULL, + `durationSeconds` INTEGER NOT NULL DEFAULT 0, + `focusMinutes` INTEGER NULL, + `metadata` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `LearningSession_userId_idx`(`userId`), + INDEX `LearningSession_knowledgeItemId_idx`(`knowledgeItemId`), + INDEX `LearningSession_startedAt_idx`(`startedAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `LearningRecord` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `sessionId` VARCHAR(191) NULL, + `recordType` VARCHAR(32) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `durationSeconds` INTEGER NOT NULL DEFAULT 0, + `occurredAt` DATETIME(3) NOT NULL, + `metadata` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `LearningRecord_userId_idx`(`userId`), + INDEX `LearningRecord_occurredAt_idx`(`occurredAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ActiveRecallQuestion` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeItemId` VARCHAR(191) NULL, + `questionText` TEXT NOT NULL, + `difficulty` VARCHAR(32) NULL, + `createdBy` VARCHAR(32) NOT NULL DEFAULT 'ai', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `ActiveRecallQuestion_userId_idx`(`userId`), + INDEX `ActiveRecallQuestion_knowledgeItemId_idx`(`knowledgeItemId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ActiveRecallAnswer` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `questionId` VARCHAR(191) NULL, + `sessionId` VARCHAR(191) NULL, + `answerType` VARCHAR(32) NOT NULL DEFAULT 'text', + `answerText` LONGTEXT NULL, + `audioFileId` VARCHAR(191) NULL, + `submittedAt` DATETIME(3) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `ActiveRecallAnswer_userId_idx`(`userId`), + INDEX `ActiveRecallAnswer_questionId_idx`(`questionId`), + INDEX `ActiveRecallAnswer_sessionId_idx`(`sessionId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AiAnalysisJob` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `sessionId` VARCHAR(191) NULL, + `answerId` VARCHAR(191) NULL, + `jobType` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'pending', + `progress` INTEGER NOT NULL DEFAULT 0, + `errorMessage` TEXT NULL, + `queuedAt` DATETIME(3) NULL, + `startedAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `AiAnalysisJob_userId_idx`(`userId`), + INDEX `AiAnalysisJob_status_idx`(`status`), + INDEX `AiAnalysisJob_sessionId_idx`(`sessionId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AiAnalysisResult` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `jobId` VARCHAR(191) NOT NULL, + `sessionId` VARCHAR(191) NULL, + `answerId` VARCHAR(191) NULL, + `summary` TEXT NULL, + `masteryScore` INTEGER NULL, + `strengths` JSON NULL, + `weaknesses` JSON NULL, + `suggestions` JSON NULL, + `nextActions` JSON NULL, + `rawResult` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `AiAnalysisResult_userId_idx`(`userId`), + INDEX `AiAnalysisResult_jobId_idx`(`jobId`), + INDEX `AiAnalysisResult_sessionId_idx`(`sessionId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `FocusItem` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeBaseId` VARCHAR(191) NULL, + `knowledgeItemId` VARCHAR(191) NULL, + `analysisResultId` VARCHAR(191) NULL, + `title` VARCHAR(255) NOT NULL, + `reason` TEXT NULL, + `suggestion` TEXT NULL, + `priority` VARCHAR(32) NOT NULL DEFAULT 'normal', + `status` VARCHAR(32) NOT NULL DEFAULT 'open', + `masteryScore` INTEGER NULL, + `dueAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + + INDEX `FocusItem_userId_idx`(`userId`), + INDEX `FocusItem_status_idx`(`status`), + INDEX `FocusItem_dueAt_idx`(`dueAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ReviewCard` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `knowledgeItemId` VARCHAR(191) NULL, + `focusItemId` VARCHAR(191) NULL, + `frontText` TEXT NOT NULL, + `backText` TEXT NULL, + `difficulty` VARCHAR(32) NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `nextReviewAt` DATETIME(3) NULL, + `intervalDays` INTEGER NOT NULL DEFAULT 1, + `easeFactor` DECIMAL(4, 2) NOT NULL DEFAULT 2.50, + `repetitionCount` INTEGER NOT NULL DEFAULT 0, + `lapseCount` INTEGER NOT NULL DEFAULT 0, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `deletedAt` DATETIME(3) NULL, + + INDEX `ReviewCard_userId_idx`(`userId`), + INDEX `ReviewCard_nextReviewAt_idx`(`nextReviewAt`), + INDEX `ReviewCard_focusItemId_idx`(`focusItemId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ReviewLog` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `reviewCardId` VARCHAR(191) NOT NULL, + `sessionId` VARCHAR(191) NULL, + `rating` VARCHAR(32) NOT NULL, + `responseText` TEXT NULL, + `reviewedAt` DATETIME(3) NOT NULL, + `nextReviewAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `ReviewLog_userId_idx`(`userId`), + INDEX `ReviewLog_reviewCardId_idx`(`reviewCardId`), + INDEX `ReviewLog_reviewedAt_idx`(`reviewedAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ReviewPlan` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `scheduledAt` DATETIME(3) NULL, + `completedAt` DATETIME(3) NULL, + `cardCount` INTEGER NOT NULL DEFAULT 0, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `ReviewPlan_userId_idx`(`userId`), + INDEX `ReviewPlan_scheduledAt_idx`(`scheduledAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `DailyLearningActivity` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `activityDate` DATE NOT NULL, + `durationSeconds` INTEGER NOT NULL DEFAULT 0, + `sessionsCount` INTEGER NOT NULL DEFAULT 0, + `activeRecallCount` INTEGER NOT NULL DEFAULT 0, + `reviewCount` INTEGER NOT NULL DEFAULT 0, + `aiAnalysisCount` INTEGER NOT NULL DEFAULT 0, + `completedLoopCount` INTEGER NOT NULL DEFAULT 0, + `activityLevel` INTEGER NOT NULL DEFAULT 0, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `DailyLearningActivity_userId_idx`(`userId`), + UNIQUE INDEX `DailyLearningActivity_userId_activityDate_key`(`userId`, `activityDate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Notification` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `type` VARCHAR(32) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `content` TEXT NULL, + `data` JSON NULL, + `readAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `Notification_userId_idx`(`userId`), + INDEX `Notification_readAt_idx`(`readAt`), + INDEX `Notification_type_idx`(`type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Feedback` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NULL, + `email` VARCHAR(255) NULL, + `category` VARCHAR(64) NOT NULL, + `content` TEXT NOT NULL, + `deviceInfo` JSON NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'open', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `Feedback_userId_idx`(`userId`), + INDEX `Feedback_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `AppChangelog` ( + `id` VARCHAR(191) NOT NULL, + `version` VARCHAR(50) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `content` TEXT NOT NULL, + `platform` VARCHAR(32) NOT NULL DEFAULT 'ios', + `publishedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `AuthAccount` ADD CONSTRAINT `AuthAccount_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UserProfile` ADD CONSTRAINT `UserProfile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UserPreference` ADD CONSTRAINT `UserPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UserConsent` ADD CONSTRAINT `UserConsent_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeBase` ADD CONSTRAINT `KnowledgeBase_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItem` ADD CONSTRAINT `KnowledgeItem_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItem` ADD CONSTRAINT `KnowledgeItem_knowledgeBaseId_fkey` FOREIGN KEY (`knowledgeBaseId`) REFERENCES `KnowledgeBase`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItem` ADD CONSTRAINT `KnowledgeItem_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `KnowledgeItem`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItemRelation` ADD CONSTRAINT `KnowledgeItemRelation_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Tag` ADD CONSTRAINT `Tag_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItemTag` ADD CONSTRAINT `KnowledgeItemTag_knowledgeItemId_fkey` FOREIGN KEY (`knowledgeItemId`) REFERENCES `KnowledgeItem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `KnowledgeItemTag` ADD CONSTRAINT `KnowledgeItemTag_tagId_fkey` FOREIGN KEY (`tagId`) REFERENCES `Tag`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `UploadedFile` ADD CONSTRAINT `UploadedFile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `DocumentImport` ADD CONSTRAINT `DocumentImport_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `LearningSession` ADD CONSTRAINT `LearningSession_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `LearningRecord` ADD CONSTRAINT `LearningRecord_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ActiveRecallQuestion` ADD CONSTRAINT `ActiveRecallQuestion_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ActiveRecallAnswer` ADD CONSTRAINT `ActiveRecallAnswer_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ActiveRecallAnswer` ADD CONSTRAINT `ActiveRecallAnswer_questionId_fkey` FOREIGN KEY (`questionId`) REFERENCES `ActiveRecallQuestion`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AiAnalysisJob` ADD CONSTRAINT `AiAnalysisJob_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AiAnalysisResult` ADD CONSTRAINT `AiAnalysisResult_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `AiAnalysisResult` ADD CONSTRAINT `AiAnalysisResult_jobId_fkey` FOREIGN KEY (`jobId`) REFERENCES `AiAnalysisJob`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FocusItem` ADD CONSTRAINT `FocusItem_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `FocusItem` ADD CONSTRAINT `FocusItem_knowledgeBaseId_fkey` FOREIGN KEY (`knowledgeBaseId`) REFERENCES `KnowledgeBase`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ReviewCard` ADD CONSTRAINT `ReviewCard_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ReviewLog` ADD CONSTRAINT `ReviewLog_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ReviewLog` ADD CONSTRAINT `ReviewLog_reviewCardId_fkey` FOREIGN KEY (`reviewCardId`) REFERENCES `ReviewCard`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ReviewPlan` ADD CONSTRAINT `ReviewPlan_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `DailyLearningActivity` ADD CONSTRAINT `DailyLearningActivity_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Notification` ADD CONSTRAINT `Notification_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Feedback` ADD CONSTRAINT `Feedback_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..9bee74d --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "mysql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8399348..62ed893 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,7 +9,7 @@ datasource db { } model User { - id BigInt @id @default(autoincrement()) + id String @id @default(cuid()) email String? @db.VarChar(255) nickname String? @db.VarChar(100) avatarUrl String? @db.VarChar(500) @@ -45,14 +45,15 @@ model User { dailyLearningActivities DailyLearningActivity[] notifications Notification[] feedbacks Feedback[] + aiUsageLogs AiUsageLog[] @@index([email]) @@index([status]) } model AuthAccount { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String provider String @db.VarChar(32) providerUserId String @db.VarChar(255) email String? @db.VarChar(255) @@ -67,8 +68,8 @@ model AuthAccount { } model RefreshToken { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String tokenHash String @db.VarChar(255) deviceId String? @db.VarChar(255) deviceName String? @db.VarChar(255) @@ -84,8 +85,8 @@ model RefreshToken { } model UserProfile { - id BigInt @id @default(autoincrement()) - userId BigInt @unique + id String @id @default(cuid()) + userId String @unique learningIdentity String? @db.VarChar(100) learningDirection String? @db.VarChar(255) bio String? @db.Text @@ -97,8 +98,8 @@ model UserProfile { } model UserPreference { - id BigInt @id @default(autoincrement()) - userId BigInt @unique + id String @id @default(cuid()) + userId String @unique preferredMethods Json? defaultFocusMinutes Int @default(25) aiSuggestionLevel String @default("normal") @db.VarChar(32) @@ -112,8 +113,8 @@ model UserPreference { } model UserConsent { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String consentType String @db.VarChar(32) version String @db.VarChar(50) acceptedAt DateTime @@ -128,8 +129,8 @@ model UserConsent { } model KnowledgeBase { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String title String @db.VarChar(255) description String? @db.Text coverKey String? @db.VarChar(100) @@ -149,10 +150,10 @@ model KnowledgeBase { } model KnowledgeItem { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeBaseId BigInt - parentId BigInt? + id String @id @default(cuid()) + userId String + knowledgeBaseId String + parentId String? itemType String @db.VarChar(32) title String @db.VarChar(255) content String? @db.LongText @@ -178,10 +179,10 @@ model KnowledgeItem { } model KnowledgeItemRelation { - id BigInt @id @default(autoincrement()) - userId BigInt - sourceItemId BigInt - targetItemId BigInt + id String @id @default(cuid()) + userId String + sourceItemId String + targetItemId String relationType String @db.VarChar(32) confidence Decimal? @db.Decimal(5, 2) reason String? @db.Text @@ -195,8 +196,8 @@ model KnowledgeItemRelation { } model Tag { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String name String @db.VarChar(100) color String? @db.VarChar(32) createdAt DateTime @default(now()) @@ -209,9 +210,9 @@ model Tag { } model KnowledgeItemTag { - id BigInt @id @default(autoincrement()) - knowledgeItemId BigInt - tagId BigInt + id String @id @default(cuid()) + knowledgeItemId String + tagId String createdAt DateTime @default(now()) knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id]) @@ -221,8 +222,8 @@ model KnowledgeItemTag { } model UploadedFile { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String filename String @db.VarChar(255) mimeType String? @db.VarChar(100) storagePath String @db.VarChar(500) @@ -236,10 +237,10 @@ model UploadedFile { } model DocumentImport { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeBaseId BigInt? - fileId BigInt? + id String @id @default(cuid()) + userId String + knowledgeBaseId String? + fileId String? sourceType String @db.VarChar(32) sourceName String? @db.VarChar(255) sourceUrl String? @db.VarChar(500) @@ -260,10 +261,10 @@ model DocumentImport { } model LearningSession { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeBaseId BigInt? - knowledgeItemId BigInt? + id String @id @default(cuid()) + userId String + knowledgeBaseId String? + knowledgeItemId String? mode String @db.VarChar(32) status String @default("active") @db.VarChar(32) startedAt DateTime @@ -282,9 +283,9 @@ model LearningSession { } model LearningRecord { - id BigInt @id @default(autoincrement()) - userId BigInt - sessionId BigInt? + id String @id @default(cuid()) + userId String + sessionId String? recordType String @db.VarChar(32) title String @db.VarChar(255) description String? @db.Text @@ -300,9 +301,9 @@ model LearningRecord { } model ActiveRecallQuestion { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeItemId BigInt? + id String @id @default(cuid()) + userId String + knowledgeItemId String? questionText String @db.Text difficulty String? @db.VarChar(32) createdBy String @default("ai") @db.VarChar(32) @@ -317,13 +318,13 @@ model ActiveRecallQuestion { } model ActiveRecallAnswer { - id BigInt @id @default(autoincrement()) - userId BigInt - questionId BigInt? - sessionId BigInt? + id String @id @default(cuid()) + userId String + questionId String? + sessionId String? answerType String @default("text") @db.VarChar(32) answerText String? @db.LongText - audioFileId BigInt? + audioFileId String? submittedAt DateTime createdAt DateTime @default(now()) @@ -336,10 +337,10 @@ model ActiveRecallAnswer { } model AiAnalysisJob { - id BigInt @id @default(autoincrement()) - userId BigInt - sessionId BigInt? - answerId BigInt? + id String @id @default(cuid()) + userId String + sessionId String? + answerId String? jobType String @db.VarChar(32) status String @default("pending") @db.VarChar(32) progress Int @default(0) @@ -359,11 +360,11 @@ model AiAnalysisJob { } model AiAnalysisResult { - id BigInt @id @default(autoincrement()) - userId BigInt - jobId BigInt - sessionId BigInt? - answerId BigInt? + id String @id @default(cuid()) + userId String + jobId String + sessionId String? + answerId String? summary String? @db.Text masteryScore Int? strengths Json? @@ -383,11 +384,11 @@ model AiAnalysisResult { } model FocusItem { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeBaseId BigInt? - knowledgeItemId BigInt? - analysisResultId BigInt? + id String @id @default(cuid()) + userId String + knowledgeBaseId String? + knowledgeItemId String? + analysisResultId String? title String @db.VarChar(255) reason String? @db.Text suggestion String? @db.Text @@ -409,10 +410,10 @@ model FocusItem { } model ReviewCard { - id BigInt @id @default(autoincrement()) - userId BigInt - knowledgeItemId BigInt? - focusItemId BigInt? + id String @id @default(cuid()) + userId String + knowledgeItemId String? + focusItemId String? frontText String @db.Text backText String? @db.Text difficulty String? @db.VarChar(32) @@ -435,10 +436,10 @@ model ReviewCard { } model ReviewLog { - id BigInt @id @default(autoincrement()) - userId BigInt - reviewCardId BigInt - sessionId BigInt? + id String @id @default(cuid()) + userId String + reviewCardId String + sessionId String? rating String @db.VarChar(32) responseText String? @db.Text reviewedAt DateTime @@ -454,8 +455,8 @@ model ReviewLog { } model ReviewPlan { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String title String @db.VarChar(255) status String @default("active") @db.VarChar(32) scheduledAt DateTime? @@ -471,8 +472,8 @@ model ReviewPlan { } model DailyLearningActivity { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String activityDate DateTime @db.Date durationSeconds Int @default(0) sessionsCount Int @default(0) @@ -491,8 +492,8 @@ model DailyLearningActivity { } model Notification { - id BigInt @id @default(autoincrement()) - userId BigInt + id String @id @default(cuid()) + userId String type String @db.VarChar(32) title String @db.VarChar(255) content String? @db.Text @@ -508,8 +509,8 @@ model Notification { } model Feedback { - id BigInt @id @default(autoincrement()) - userId BigInt? + id String @id @default(cuid()) + userId String? email String? @db.VarChar(255) category String @db.VarChar(64) content String @db.Text @@ -524,8 +525,45 @@ model Feedback { @@index([status]) } +model AiUsageLog { + id String @id @default(cuid()) + userId String + feature String @db.VarChar(64) + provider String @db.VarChar(32) + model String @db.VarChar(100) + tier String @db.VarChar(32) + promptKey String @db.VarChar(128) + promptVersion String @db.VarChar(32) + inputTokens Int @default(0) + outputTokens Int @default(0) + estimatedCost Float @default(0) + latencyMs Int @default(0) + success Boolean @default(true) + errorMessage String? @db.VarChar(500) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([feature]) + @@index([createdAt]) +} + +model WaitlistEntry { + id String @id @default(cuid()) + nickname String @db.VarChar(100) + email String @db.VarChar(255) + devices Json? + interests Json? + painpoint String? @db.Text + willingBeta Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([email]) +} + model AppChangelog { - id BigInt @id @default(autoincrement()) + id String @id @default(cuid()) version String @db.VarChar(50) title String @db.VarChar(255) content String @db.Text diff --git a/src/app.module.ts b/src/app.module.ts index 39af05e..ca768c0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD, 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 { AiModule } from './modules/ai/ai.module'; import { StorageModule } from './infrastructure/storage/storage.module'; import { LoggerModule } from './infrastructure/logger/logger.module'; @@ -26,6 +26,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { FeedbackModule } from './modules/feedback/feedback.module'; import { WaitlistModule } from './modules/waitlist/waitlist.module'; +import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { StrictValidationPipe } from './common/pipes/strict-validation.pipe'; @@ -83,6 +84,7 @@ import appleConfig from './config/apple.config'; WaitlistModule, ], providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: StrictValidationPipe }, ], diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 9b35bfa..89ff697 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -4,18 +4,27 @@ import { ExecutionContext, UnauthorizedException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly reflector: Reflector, ) {} async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + const request = context.switchToHttp().getRequest(); const token = this.extractToken(request); diff --git a/src/common/utils/security.util.ts b/src/common/utils/security.util.ts index 9e04f7d..df41dc4 100644 --- a/src/common/utils/security.util.ts +++ b/src/common/utils/security.util.ts @@ -8,8 +8,8 @@ type Delegate = { export async function findByIdAndUserId( delegate: T, - id: number | bigint, - userId: number | bigint, + id: string, + userId: string, resourceName: string, ) { const record = await delegate.findUnique({ where: { id } } as any); @@ -24,7 +24,7 @@ export async function findByIdAndUserId( export function ensureOwnership( record: any, - userId: number | bigint, + userId: string, resourceName: string, ) { if (!record) { diff --git a/src/config/ai.config.ts b/src/config/ai.config.ts index c542e80..ac421d5 100644 --- a/src/config/ai.config.ts +++ b/src/config/ai.config.ts @@ -2,8 +2,20 @@ import { registerAs } from '@nestjs/config'; export default registerAs('ai', () => ({ provider: process.env.AI_PROVIDER || 'mock', - apiKey: process.env.AI_API_KEY || '', - baseUrl: process.env.AI_BASE_URL || '', - modelName: process.env.AI_MODEL_NAME, - mockEnabled: process.env.AI_MOCK_ENABLED !== 'false', + defaultTier: process.env.AI_DEFAULT_TIER || 'primary', + + deepseek: { + apiKey: process.env.DEEPSEEK_API_KEY || '', + baseUrl: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', + cheapModel: process.env.DEEPSEEK_CHEAP_MODEL || 'deepseek-v4-flash', + strongModel: process.env.DEEPSEEK_STRONG_MODEL || 'deepseek-v4-pro', + }, + + minimax: { + apiKey: process.env.MINIMAX_API_KEY || '', + baseUrl: process.env.MINIMAX_BASE_URL || 'https://api.minimaxi.com', + primaryModel: process.env.MINIMAX_PRIMARY_MODEL || 'minimax-m2.7', + }, + + mockEnabled: process.env.AI_PROVIDER === 'mock', })); diff --git a/src/infrastructure/ai/ai-provider.interface.ts b/src/infrastructure/ai/ai-provider.interface.ts deleted file mode 100644 index e953384..0000000 --- a/src/infrastructure/ai/ai-provider.interface.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface AiProvider { - generateAnalysis(input: { - userInput: string; - context: Record; - }): Promise<{ - masteryScore: number; - understandingLevel: string; - summary: string; - strengths: string[]; - weakPoints: string[]; - suggestions: string[]; - reviewNeeded: boolean; - nextAction: string; - }>; - - generateChatResponse(input: { - message: string; - history: Array<{ role: string; content: string }>; - context?: string; - }): Promise<{ - reply: string; - suggestedActions: string[]; - }>; -} diff --git a/src/infrastructure/ai/ai.module.ts b/src/infrastructure/ai/ai.module.ts deleted file mode 100644 index 701e9f8..0000000 --- a/src/infrastructure/ai/ai.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { AiService } from './ai.service'; -import { MockAiProvider } from './providers/mock-ai.provider'; - -@Global() -@Module({ - providers: [ - AiService, - { provide: 'AI_PROVIDER', useClass: MockAiProvider }, - ], - exports: [AiService], -}) -export class AiModule {} diff --git a/src/infrastructure/ai/ai.service.ts b/src/infrastructure/ai/ai.service.ts deleted file mode 100644 index 94bd8f1..0000000 --- a/src/infrastructure/ai/ai.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import type { AiProvider } from './ai-provider.interface'; - -@Injectable() -export class AiService { - constructor( - @Inject('AI_PROVIDER') private readonly provider: AiProvider, - ) {} - - async analyze(input: { - userInput: string; - context: Record; - }) { - return this.provider.generateAnalysis(input); - } - - async chat(input: { - message: string; - history: Array<{ role: string; content: string }>; - context?: string; - }) { - return this.provider.generateChatResponse(input); - } -} diff --git a/src/infrastructure/ai/providers/mock-ai.provider.ts b/src/infrastructure/ai/providers/mock-ai.provider.ts deleted file mode 100644 index ab59b42..0000000 --- a/src/infrastructure/ai/providers/mock-ai.provider.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AiProvider } from '../ai-provider.interface'; - -@Injectable() -export class MockAiProvider implements AiProvider { - async generateAnalysis(input: { userInput: string; context: Record }) { - const score = 3; - return { - masteryScore: score, - understandingLevel: '基本理解', - summary: '用户能理解主要内容,但要点不完整。', - strengths: ['表达清楚', '有一定基础'], - weakPoints: ['遗漏关键要点', '逻辑层次不足'], - suggestions: ['补充第二个要点', '先概括再展开', '多举例说明'], - reviewNeeded: true, - nextAction: '明天重新回答本节主动回忆问题。', - }; - } - - async generateChatResponse(input: { - message: string; - history: Array<{ role: string; content: string }>; - context?: string; - }) { - return { - reply: '好的,我理解你的问题。作为你的学习助手,我可以帮助你分析和理解这个知识点。请告诉我你想深入了解哪个部分?', - suggestedActions: ['重新解释', '给我一个例子', '检查我的理解'], - }; - } -} diff --git a/src/infrastructure/redis/redis.service.ts b/src/infrastructure/redis/redis.service.ts index a96de0a..8cad47f 100644 --- a/src/infrastructure/redis/redis.service.ts +++ b/src/infrastructure/redis/redis.service.ts @@ -13,13 +13,15 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { const url = this.configService.get('redis.url'); if (url) { - this.client = new Redis(url); + this.client = new Redis(url, { lazyConnect: true, retryStrategy: () => null }); } else { this.client = new Redis({ host: this.configService.get('redis.host', 'localhost'), port: this.configService.get('redis.port', 6379), password: this.configService.get('redis.password'), db: this.configService.get('redis.db', 0), + lazyConnect: true, + retryStrategy: () => null, }); } this.client.on('connect', () => { diff --git a/src/main.ts b/src/main.ts index ca55cb9..2f2dd8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,7 +53,7 @@ async function bootstrap() { 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 basicAuthMiddleware = (req: any, res: any, next: any) => { const auth = req.headers.authorization; if (!auth?.startsWith('Basic ')) { res.setHeader('WWW-Authenticate', 'Basic'); @@ -66,7 +66,10 @@ async function bootstrap() { return next(); } return res.status(401).send('Invalid credentials'); - }); + }; + + app.use('/api-docs', basicAuthMiddleware); + app.use('/api-docs-json', basicAuthMiddleware); } } diff --git a/src/modules/active-recall/active-recall.controller.ts b/src/modules/active-recall/active-recall.controller.ts index 66c5e40..9464e25 100644 --- a/src/modules/active-recall/active-recall.controller.ts +++ b/src/modules/active-recall/active-recall.controller.ts @@ -11,13 +11,13 @@ export class ActiveRecallController { @Get() @ApiOperation({ summary: '获取主动回忆问题列表' }) - async findAll(@CurrentUser() user: UserPayload | undefined) { + async findAll(@CurrentUser() user: UserPayload) { return this.service.findByUserId(String(user?.id || 'anonymous')); } @Post(':id/submit') @ApiOperation({ summary: '提交主动回忆回答' }) - async submit(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string, @Body() body: any) { + async submit(@CurrentUser() user: UserPayload, @Param('id') id: string, @Body() body: any) { return this.service.submit(String(user?.id || 'anonymous'), id, body); } } diff --git a/src/modules/active-recall/active-recall.module.ts b/src/modules/active-recall/active-recall.module.ts index 8737487..48960d4 100644 --- a/src/modules/active-recall/active-recall.module.ts +++ b/src/modules/active-recall/active-recall.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; import { ActiveRecallController } from './active-recall.controller'; import { ActiveRecallService } from './active-recall.service'; import { ActiveRecallRepository } from './active-recall.repository'; @Module({ + imports: [AiModule], controllers: [ActiveRecallController], providers: [ActiveRecallService, ActiveRecallRepository], exports: [ActiveRecallService], diff --git a/src/modules/active-recall/active-recall.repository.ts b/src/modules/active-recall/active-recall.repository.ts index d882f3a..c90a161 100644 --- a/src/modules/active-recall/active-recall.repository.ts +++ b/src/modules/active-recall/active-recall.repository.ts @@ -1,56 +1,47 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface RecallQuestion { - id: string; - userId: string; - knowledgeItemId: string; - questionText: string; - difficulty: string; -} - -export interface RecallAnswer { - id: string; - userId: string; - questionId: string; - answerText: string; - submittedAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class ActiveRecallRepository { - private questions: Map = new Map(); - private answers: RecallAnswer[] = []; + constructor(private readonly prisma: PrismaService) {} - async findByUserId(userId: string): Promise { - return Array.from(this.questions.values()).filter((q) => q.userId === userId); + async findByUserId(userId: string) { + return this.prisma.activeRecallQuestion.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); } - async findById(id: string): Promise { - return this.questions.get(id); + async findById(id: string) { + return this.prisma.activeRecallQuestion.findUnique({ where: { id } }); } - async createQuestion(data: Partial): Promise { - const q: RecallQuestion = { - id: data.id || generateShortId(), - userId: data.userId || '', - knowledgeItemId: data.knowledgeItemId || '', - questionText: data.questionText || '', - difficulty: data.difficulty || 'normal', - }; - this.questions.set(q.id, q); - return q; + async createQuestion(data: { + userId: string; + knowledgeItemId?: string; + questionText: string; + difficulty?: string; + createdBy?: string; + }) { + return this.prisma.activeRecallQuestion.create({ + data: { + userId: data.userId, + knowledgeItemId: data.knowledgeItemId ?? null, + questionText: data.questionText, + difficulty: data.difficulty ?? 'normal', + createdBy: data.createdBy ?? 'ai', + }, + }); } - async createAnswer(userId: string, questionId: string, body: any): Promise { - const answer: RecallAnswer = { - id: generateShortId(), - userId, - questionId, - answerText: body.answerText || '', - submittedAt: new Date(), - }; - this.answers.push(answer); - return answer; + async createAnswer(userId: string, questionId: string, body: { answerText: string }) { + return this.prisma.activeRecallAnswer.create({ + data: { + userId, + questionId, + answerText: body.answerText, + submittedAt: new Date(), + }, + }); } } diff --git a/src/modules/active-recall/active-recall.service.ts b/src/modules/active-recall/active-recall.service.ts index dcaec80..ec2e96a 100644 --- a/src/modules/active-recall/active-recall.service.ts +++ b/src/modules/active-recall/active-recall.service.ts @@ -1,17 +1,37 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ActiveRecallRepository } from './active-recall.repository'; +import { ActiveRecallAnalysisWorkflow } from '../ai/workflows/active-recall-analysis.workflow'; @Injectable() export class ActiveRecallService { - constructor(private readonly repository: ActiveRecallRepository) {} + private readonly logger = new Logger(ActiveRecallService.name); + + constructor( + private readonly repository: ActiveRecallRepository, + private readonly analysisWorkflow: ActiveRecallAnalysisWorkflow, + ) {} async findByUserId(userId: string) { return this.repository.findByUserId(userId); } - async submit(userId: string, questionId: string, body: any) { + async submit(userId: string, questionId: string, body: { answerText: string }) { const question = await this.repository.findById(questionId); if (!question) throw new NotFoundException('问题不存在'); - return this.repository.createAnswer(userId, questionId, body); + + const answer = await this.repository.createAnswer(userId, questionId, body); + + this.analysisWorkflow.execute({ + userId, + questionText: question.questionText, + knowledgeItemContent: '', + userAnswer: body.answerText, + }).then((result) => { + this.logger.log(`Analysis complete for answer ${answer.id}: score=${result.score}`); + }).catch((err) => { + this.logger.error(`Analysis failed for answer ${answer.id}: ${err.message}`); + }); + + return answer; } } diff --git a/src/modules/ai-analysis/ai-analysis.controller.ts b/src/modules/ai-analysis/ai-analysis.controller.ts index 90f0b56..6a78f25 100644 --- a/src/modules/ai-analysis/ai-analysis.controller.ts +++ b/src/modules/ai-analysis/ai-analysis.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { Controller, Post, Get, Body, Param } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AiAnalysisService } from './ai-analysis.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -10,20 +10,17 @@ export class AiAnalysisController { constructor(private readonly service: AiAnalysisService) {} @Post() - @ApiOperation({ summary: '提交 AI 分析任务' }) - async create(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { - return this.service.createJob(String(user?.id || 'anonymous'), body); + @ApiOperation({ summary: '提交主动回忆分析' }) + async analyze( + @CurrentUser() user: UserPayload, + @Body() body: { questionText: string; knowledgeItemContent: string; userAnswer: string }, + ) { + return this.service.analyze(String(user?.id || 'anonymous'), body); } @Get(':id') - @ApiOperation({ summary: '获取 AI 分析结果' }) + @ApiOperation({ summary: '获取分析结果' }) async findOne(@Param('id') id: string) { return this.service.getResult(id); } - - @Get('jobs/:jobId/status') - @ApiOperation({ summary: '查询任务状态' }) - async getJobStatus(@Param('jobId') jobId: string) { - return this.service.getJobStatus(jobId); - } } diff --git a/src/modules/ai-analysis/ai-analysis.module.ts b/src/modules/ai-analysis/ai-analysis.module.ts index 7641fe5..bbf4c75 100644 --- a/src/modules/ai-analysis/ai-analysis.module.ts +++ b/src/modules/ai-analysis/ai-analysis.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; import { AiAnalysisController } from './ai-analysis.controller'; import { AiAnalysisService } from './ai-analysis.service'; import { AiAnalysisRepository } from './ai-analysis.repository'; @Module({ + imports: [AiModule], controllers: [AiAnalysisController], providers: [AiAnalysisService, AiAnalysisRepository], exports: [AiAnalysisService], diff --git a/src/modules/ai-analysis/ai-analysis.repository.ts b/src/modules/ai-analysis/ai-analysis.repository.ts index 16edad7..22a9606 100644 --- a/src/modules/ai-analysis/ai-analysis.repository.ts +++ b/src/modules/ai-analysis/ai-analysis.repository.ts @@ -1,71 +1,37 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface AnalysisJob { - id: string; - userId: string; - sessionId: string; - inputText: string; - status: 'pending' | 'processing' | 'success' | 'failed'; - createdAt: Date; -} - -export interface AnalysisResult { - id: string; - jobId: string; - userId: string; - masteryScore: number; - summary: string; - strengths: string[]; - weakPoints: string[]; - suggestions: string[]; - createdAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class AiAnalysisRepository { - private jobs: Map = new Map(); - private results: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async createJob(userId: string, data: any): Promise { - const job: AnalysisJob = { - id: generateShortId(), - userId, - sessionId: data.sessionId || '', - inputText: data.userInput || '', - status: 'pending', - createdAt: new Date(), - }; - this.jobs.set(job.id, job); - return job; + async createResult(userId: string, aiResult: { + score: number; + masteryLevel: string; + summary: string; + strengths: string[]; + weaknesses: string[]; + missingKeyPoints: string[]; + misconceptions: string[]; + focusItems: Array<{ title: string; reason: string; suggestion?: string; priority: string }>; + reviewSuggestion: { shouldReview: boolean; intervalDays: number; cardFront?: string; cardBack?: string }; + }) { + return this.prisma.aiAnalysisResult.create({ + data: { + userId, + jobId: '', // no job for sync analysis + summary: aiResult.summary, + masteryScore: aiResult.score, + strengths: aiResult.strengths as any, + weaknesses: aiResult.weaknesses as any, + suggestions: aiResult.focusItems as any, + nextActions: aiResult.reviewSuggestion as any, + rawResult: aiResult as any, + }, + }); } - async findJobById(id: string): Promise { - return this.jobs.get(id); - } - - async updateJobStatus(id: string, status: AnalysisJob['status']) { - const job = this.jobs.get(id); - if (job) job.status = status; - } - - async createResult(jobId: string, userId: string, aiResult: any): Promise { - const result: AnalysisResult = { - id: generateShortId(), - jobId, - userId, - masteryScore: aiResult.masteryScore, - summary: aiResult.summary, - strengths: aiResult.strengths, - weakPoints: aiResult.weakPoints, - suggestions: aiResult.suggestions, - createdAt: new Date(), - }; - this.results.set(result.id, result); - return result; - } - - async findResultById(id: string): Promise { - return this.results.get(id); + async findResultById(id: string) { + return this.prisma.aiAnalysisResult.findUnique({ where: { id } }); } } diff --git a/src/modules/ai-analysis/ai-analysis.service.ts b/src/modules/ai-analysis/ai-analysis.service.ts index 1b842f4..e8bff28 100644 --- a/src/modules/ai-analysis/ai-analysis.service.ts +++ b/src/modules/ai-analysis/ai-analysis.service.ts @@ -1,102 +1,33 @@ -import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ActiveRecallAnalysisWorkflow } from '../ai/workflows/active-recall-analysis.workflow'; import { AiAnalysisRepository } from './ai-analysis.repository'; -import { AiService } from '../../infrastructure/ai/ai.service'; -import { RedisService } from '../../infrastructure/redis/redis.service'; -import { QueueService } from '../../infrastructure/queue/queue.service'; - -const DAILY_AI_LIMIT = 50; @Injectable() export class AiAnalysisService { private readonly logger = new Logger(AiAnalysisService.name); constructor( + private readonly workflow: ActiveRecallAnalysisWorkflow, private readonly repository: AiAnalysisRepository, - private readonly aiService: AiService, - private readonly redis: RedisService, - private readonly queue: QueueService, ) {} - async createJob(userId: string, body: any) { - await this.checkRateLimit(userId); + async analyze(userId: string, input: { + questionText: string; + knowledgeItemContent: string; + userAnswer: string; + }) { + const result = await this.workflow.execute({ + userId, + questionText: input.questionText, + knowledgeItemContent: input.knowledgeItemContent, + userAnswer: input.userAnswer, + }); - const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`; - const lockToken = await this.redis.lock(lockKey, 300); - if (!lockToken) { - throw new HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT); - } - - const job = await this.repository.createJob(userId, body); - - await this.redis.set(`job:ai-analysis:${job.id}:status`, 'pending', 86400); - await this.redis.set(`job:ai-analysis:${job.id}:progress`, '0', 86400); - - this.queue.add('ai-analysis', { jobId: job.id, userId, sessionId: body.sessionId }); - this.processJob(job, lockKey, lockToken); - - return { jobId: job.id, status: job.status }; - } - - private async checkRateLimit(userId: string) { - const today = new Date().toISOString().split('T')[0]; - const rateKey = `rate:user:${userId}:ai:daily:${today}`; - const count = await this.redis.incr(rateKey); - if (count === 1) { - await this.redis.expire(rateKey, 86400); - } - if (count > DAILY_AI_LIMIT) { - throw new HttpException( - `每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`, - HttpStatus.TOO_MANY_REQUESTS, - ); - } - } - - private processJob(job: any, lockKey: string, lockToken: string) { - try { - this.repository.updateJobStatus(job.id, 'processing'); - this.redis.set(`job:ai-analysis:${job.id}:status`, 'processing', 86400); - this.redis.set(`job:ai-analysis:${job.id}:progress`, '30', 86400); - - this.aiService.analyze({ - userInput: job.inputText, - context: { lessonTitle: '', objectives: [], keyPoints: [] }, - }).then(async (result) => { - await this.redis.set(`job:ai-analysis:${job.id}:progress`, '80', 86400); - await this.repository.createResult(job.id, job.userId, result); - this.repository.updateJobStatus(job.id, 'success'); - await this.redis.set(`job:ai-analysis:${job.id}:status`, 'completed', 86400); - await this.redis.set(`job:ai-analysis:${job.id}:progress`, '100', 86400); - await this.redis.unlock(lockKey, lockToken); - this.logger.log(`Job ${job.id} completed`); - }).catch(async (err) => { - this.logger.error(`Job ${job.id} failed: ${err.message}`); - this.repository.updateJobStatus(job.id, 'failed'); - await this.redis.set(`job:ai-analysis:${job.id}:status`, 'failed', 86400); - await this.redis.set(`job:ai-analysis:${job.id}:error`, err.message, 86400); - await this.redis.unlock(lockKey, lockToken); - }); - } catch (err) { - this.logger.error(`Job ${job.id} sync error: ${err}`); - this.redis.unlock(lockKey, lockToken); - } + const saved = await this.repository.createResult(userId, result); + return { resultId: saved.id, ...result }; } async getResult(id: string) { return this.repository.findResultById(id); } - - async getJobStatus(jobId: string) { - const redisStatus = await this.redis.get(`job:ai-analysis:${jobId}:status`); - const redisProgress = await this.redis.get(`job:ai-analysis:${jobId}:progress`); - const dbJob = await this.repository.findJobById(jobId); - - if (!dbJob && !redisStatus) return null; - - return { - jobId, - status: redisStatus || dbJob?.status || 'unknown', - progress: redisProgress ? parseInt(redisProgress, 10) : 0, - }; - } } diff --git a/src/modules/ai/ai.controller.ts b/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..a990781 --- /dev/null +++ b/src/modules/ai/ai.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Post, Get, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ActiveRecallAnalysisWorkflow } from './workflows/active-recall-analysis.workflow'; +import { ModelRouter } from './model-router'; +import { Public } from '../../common/decorators/public.decorator'; + +@ApiTags('ai') +@Controller('ai') +export class AiController { + constructor( + private readonly workflow: ActiveRecallAnalysisWorkflow, + private readonly modelRouter: ModelRouter, + ) {} + + @Post('analyze-recall') + @ApiOperation({ summary: '分析主动回忆回答' }) + async analyzeRecall(@Body() body: { + questionText: string; + knowledgeItemContent: string; + userAnswer: string; + userId?: string; + }) { + return this.workflow.execute({ + userId: body.userId || 'anonymous', + questionText: body.questionText, + knowledgeItemContent: body.knowledgeItemContent, + userAnswer: body.userAnswer, + }); + } + + @Public() + @Get('models') + @ApiOperation({ summary: '查看可用模型与分流策略' }) + getModels() { + return ['cheap', 'primary', 'strong'].map((tier) => { + const config = this.modelRouter.resolve(tier as any); + return { + tier, + preferred: config.preferred, + fallback: config.fallback, + maxRetries: config.maxRetries, + }; + }); + } +} diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..370ef1e --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -0,0 +1,70 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ModelRouter } from './model-router'; +import { PromptTemplateService } from './prompts/prompt-template.service'; +import { AiCostCalculatorService } from './usage/ai-cost-calculator.service'; +import { AiUsageLogService } from './usage/ai-usage-log.service'; +import { AiGatewayService } from './gateway/ai-gateway.service'; +import { ActiveRecallAnalysisWorkflow } from './workflows/active-recall-analysis.workflow'; +import { AiController } from './ai.controller'; +import { MockAiProvider } from './providers/mock-ai.provider'; +import { DeepSeekProvider } from './providers/deepseek.provider'; +import { MiniMaxProvider } from './providers/minimax.provider'; +import type { AiProvider } from './providers/ai-provider.interface'; + +@Module({ + imports: [ConfigModule], + controllers: [AiController], + providers: [ + ModelRouter, + PromptTemplateService, + AiCostCalculatorService, + AiUsageLogService, + MockAiProvider, + DeepSeekProvider, + MiniMaxProvider, + { + provide: 'AI_PROVIDERS', + useFactory: ( + mock: MockAiProvider, + deepseek: DeepSeekProvider, + minimax: MiniMaxProvider, + ): Map => { + const map = new Map(); + map.set(mock.name, mock); + map.set(deepseek.name, deepseek); + map.set(minimax.name, minimax); + return map; + }, + inject: [MockAiProvider, DeepSeekProvider, MiniMaxProvider], + }, + { + provide: AiGatewayService, + useFactory: ( + modelRouter: ModelRouter, + promptTemplate: PromptTemplateService, + costCalculator: AiCostCalculatorService, + usageLog: AiUsageLogService, + providers: Map, + ) => { + return new AiGatewayService( + modelRouter, + promptTemplate, + costCalculator, + usageLog, + providers, + ); + }, + inject: [ + ModelRouter, + PromptTemplateService, + AiCostCalculatorService, + AiUsageLogService, + 'AI_PROVIDERS', + ], + }, + ActiveRecallAnalysisWorkflow, + ], + exports: [AiGatewayService, ActiveRecallAnalysisWorkflow], +}) +export class AiModule {} diff --git a/src/modules/ai/gateway/ai-gateway.service.ts b/src/modules/ai/gateway/ai-gateway.service.ts new file mode 100644 index 0000000..afe018a --- /dev/null +++ b/src/modules/ai/gateway/ai-gateway.service.ts @@ -0,0 +1,155 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { ZodSchema } from 'zod'; +import { ModelRouter } from '../model-router'; +import { PromptTemplateService } from '../prompts/prompt-template.service'; +import { AiCostCalculatorService } from '../usage/ai-cost-calculator.service'; +import { AiUsageLogService } from '../usage/ai-usage-log.service'; +import type { AiProvider } from '../providers/ai-provider.interface'; +import type { GatewayRequest, GatewayResponse, ModelTier } from './ai-gateway.types'; + +@Injectable() +export class AiGatewayService { + private readonly logger = new Logger(AiGatewayService.name); + private readonly DEFAULT_TIMEOUT_MS = 30_000; + + constructor( + private readonly modelRouter: ModelRouter, + private readonly promptTemplate: PromptTemplateService, + private readonly costCalculator: AiCostCalculatorService, + private readonly usageLog: AiUsageLogService, + private readonly providers: Map, + ) {} + + async generate(request: GatewayRequest, timeoutMs = this.DEFAULT_TIMEOUT_MS): Promise { + const tierConfig = this.modelRouter.resolve(request.tier); + + const prompt = this.promptTemplate.get(request.promptKey, request.promptVersion); + const messages = [ + { role: 'system' as const, content: this.buildSystemPrompt(prompt.systemPrompt, prompt.outputSchemaDesc) }, + ...request.messages, + ]; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= tierConfig.maxRetries; attempt++) { + const target = attempt === 0 ? tierConfig.preferred : tierConfig.fallback; + const attemptProvider = this.resolveProviderForTarget(target.provider); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const output = await attemptProvider.generate({ + model: target.model, + messages, + temperature: 0.3, + maxTokens: request.maxTokens ?? 4096, + responseFormat: { type: 'json_object' }, + signal: controller.signal, + }); + + const parsed = this.parseJson(output.rawText, request.outputSchema); + const estimatedCost = this.costCalculator.calculate( + target.provider, + target.model, + output.usage.inputTokens, + output.usage.outputTokens, + ); + + this.usageLog.log({ + userId: request.userId, + feature: request.feature, + provider: target.provider, + model: target.model, + tier: request.tier, + promptKey: request.promptKey, + promptVersion: prompt.version, + inputTokens: output.usage.inputTokens, + outputTokens: output.usage.outputTokens, + estimatedCost, + latencyMs: output.latencyMs, + success: true, + }).catch(() => {}); + + clearTimeout(timeoutId); + return { + parsed, + usage: { + provider: target.provider, + model: target.model, + inputTokens: output.usage.inputTokens, + outputTokens: output.usage.outputTokens, + estimatedCost, + latencyMs: output.latencyMs, + }, + }; + } catch (error) { + clearTimeout(timeoutId); + lastError = error as Error; + this.logger.warn( + `AI attempt ${attempt + 1}/${tierConfig.maxRetries + 1} failed (${target.provider}/${target.model}): ${lastError.message}`, + ); + } + } + + this.usageLog.log({ + userId: request.userId, + feature: request.feature, + provider: tierConfig.preferred.provider, + model: tierConfig.preferred.model, + tier: request.tier, + promptKey: request.promptKey, + promptVersion: prompt.version, + inputTokens: 0, + outputTokens: 0, + estimatedCost: 0, + latencyMs: 0, + success: false, + errorMessage: lastError?.message, + }).catch(() => {}); + + throw new Error(`All AI attempts failed: ${lastError?.message}`); + } + + private resolveProvider(tier: ModelTier): AiProvider { + return this.resolveProviderForTarget(this.modelRouter.resolve(tier).preferred.provider); + } + + private resolveProviderForTarget(providerName: string): AiProvider { + const provider = this.providers.get(providerName); + if (!provider) { + throw new Error(`AI provider not found: ${providerName}`); + } + return provider; + } + + private parseJson(raw: string, schema?: ZodSchema): Record { + if (!schema) return {}; + + // Layer 1: direct parse + try { + return schema.parse(JSON.parse(raw)) as Record; + } catch { + // Layer 2: extract from markdown code fences + const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/); + if (fenced) { + try { + return schema.parse(JSON.parse(fenced[1])) as Record; + } catch { + // fall through + } + } + + // Layer 3: extract first JSON object from text + const objMatch = raw.match(/\{[\s\S]*\}/); + if (objMatch) { + return schema.parse(JSON.parse(objMatch[0])) as Record; + } + + throw new Error('No valid JSON found in AI response'); + } + } + + private buildSystemPrompt(systemPrompt: string, schemaDesc: string): string { + return `${systemPrompt}\n\n请严格按照以下 JSON Schema 输出,只输出 JSON,不要包含其他内容:\n${schemaDesc}`; + } +} diff --git a/src/modules/ai/gateway/ai-gateway.types.ts b/src/modules/ai/gateway/ai-gateway.types.ts new file mode 100644 index 0000000..2626db6 --- /dev/null +++ b/src/modules/ai/gateway/ai-gateway.types.ts @@ -0,0 +1,26 @@ +import type { ZodSchema } from 'zod'; + +export type ModelTier = 'cheap' | 'primary' | 'strong'; + +export interface GatewayRequest { + feature: string; + userId: string; + tier: ModelTier; + promptKey: string; + promptVersion: string; + messages: Array<{ role: 'system' | 'user'; content: string }>; + outputSchema?: ZodSchema; + maxTokens?: number; +} + +export interface GatewayResponse { + parsed: Record; + usage: { + provider: string; + model: string; + inputTokens: number; + outputTokens: number; + estimatedCost: number; + latencyMs: number; + }; +} diff --git a/src/modules/ai/model-router.ts b/src/modules/ai/model-router.ts new file mode 100644 index 0000000..d4f6ac0 --- /dev/null +++ b/src/modules/ai/model-router.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { ModelTier } from './gateway/ai-gateway.types'; + +export interface RouterTarget { + provider: 'deepseek' | 'minimax'; + model: string; +} + +export interface TierConfig { + tier: ModelTier; + preferred: RouterTarget; + fallback: RouterTarget; + maxRetries: number; +} + +@Injectable() +export class ModelRouter { + private readonly tiers: Record; + + constructor(private readonly config: ConfigService) { + this.tiers = { + cheap: { + tier: 'cheap', + preferred: { + provider: 'deepseek', + model: this.config.get('ai.deepseek.cheapModel', 'deepseek-v4-flash'), + }, + fallback: { + provider: 'deepseek', + model: this.config.get('ai.deepseek.cheapModel', 'deepseek-v4-flash'), + }, + maxRetries: 2, + }, + primary: { + tier: 'primary', + preferred: { + provider: 'minimax', + model: this.config.get('ai.minimax.primaryModel', 'minimax-m2.7'), + }, + fallback: { + provider: 'deepseek', + model: this.config.get('ai.deepseek.strongModel', 'deepseek-v4-pro'), + }, + maxRetries: 3, + }, + strong: { + tier: 'strong', + preferred: { + provider: 'deepseek', + model: this.config.get('ai.deepseek.strongModel', 'deepseek-v4-pro'), + }, + fallback: { + provider: 'deepseek', + model: this.config.get('ai.deepseek.strongModel', 'deepseek-v4-pro'), + }, + maxRetries: 3, + }, + }; + } + + resolve(tier: ModelTier): TierConfig { + return this.tiers[tier]; + } +} diff --git a/src/modules/ai/prompts/active-recall-analysis.prompt.ts b/src/modules/ai/prompts/active-recall-analysis.prompt.ts new file mode 100644 index 0000000..6e5e022 --- /dev/null +++ b/src/modules/ai/prompts/active-recall-analysis.prompt.ts @@ -0,0 +1,27 @@ +export const ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT = `你是一位专业的学习分析导师,擅长评估学习者对知识点的理解程度。 + +你的任务是:对比【知识点原文】和【用户的主动回忆回答】,分析用户的理解质量。 + +分析维度: +1. 完整性:用户是否覆盖了知识点的所有关键要点 +2. 准确性:用户的理解是否有偏差或误解 +3. 深度:用户是表面记忆还是深层理解 +4. 应用能力:用户能否举出例子或说明应用场景 + +输出要求: +- score:0-100 的整体评分 +- masteryLevel:excellent(90+) / good(70-89) / partial(50-69) / weak(30-49) / none(<30) +- summary:用中文总结用户的理解情况(1-3句话) +- strengths:用户做得好的地方 +- weaknesses:用户做得不好的地方 +- missingKeyPoints:用户遗漏的知识点关键要点(引用原文) +- misconceptions:用户的误解(如果有) +- weaknessTypes:薄弱类型标签,可选值:missing_detail / missing_application / misconception / vague_expression / incomplete_structure / wrong_emphasis +- focusItems:需要巩固的具体项(最多5个),每项包含 title/reason/suggestion/priority +- reviewSuggestion:复习建议,包含是否应该复习、间隔天数、复习卡片正反面 + +重要原则: +- 不要因为表达风格扣分,只关注内容理解 +- 如果用户用自己的话准确表达了概念,即使表述不同也应该认可 +- 明确指出用户遗漏了什么,而不是只说"不完整" +- reviewSuggestion.cardFront 是一个问题,cardBack 是参考答案`; diff --git a/src/modules/ai/prompts/prompt-template.service.ts b/src/modules/ai/prompts/prompt-template.service.ts new file mode 100644 index 0000000..150618d --- /dev/null +++ b/src/modules/ai/prompts/prompt-template.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT } from './active-recall-analysis.prompt'; +import { ACTIVE_RECALL_OUTPUT_SCHEMA_DESC } from './schemas/active-recall-analysis.schema'; + +export interface PromptTemplate { + key: string; + version: string; + systemPrompt: string; + outputSchemaDesc: string; +} + +@Injectable() +export class PromptTemplateService { + private readonly templates: Map = new Map(); + + constructor() { + this.register({ + key: 'active-recall-analysis', + version: '1.0.0', + systemPrompt: ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT, + outputSchemaDesc: ACTIVE_RECALL_OUTPUT_SCHEMA_DESC, + }); + } + + get(key: string, version?: string): PromptTemplate { + const template = this.templates.get(key); + if (!template) { + throw new Error(`Prompt template not found: ${key}`); + } + if (version && template.version !== version) { + throw new Error(`Prompt version mismatch for ${key}: requested ${version}, have ${template.version}`); + } + return template; + } + + private register(template: PromptTemplate): void { + this.templates.set(template.key, template); + } +} diff --git a/src/modules/ai/prompts/schemas/active-recall-analysis.schema.ts b/src/modules/ai/prompts/schemas/active-recall-analysis.schema.ts new file mode 100644 index 0000000..9829f0e --- /dev/null +++ b/src/modules/ai/prompts/schemas/active-recall-analysis.schema.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +export const FocusItemSchema = z.object({ + title: z.string().min(1).max(255), + reason: z.string().min(1).max(1000), + suggestion: z.string().optional(), + priority: z.enum(['high', 'normal', 'low']).default('normal'), +}); + +export const ReviewSuggestionSchema = z.object({ + shouldReview: z.boolean(), + intervalDays: z.number().int().min(1).max(365), + cardFront: z.string().optional(), + cardBack: z.string().optional(), +}); + +export const ActiveRecallAnalysisResultSchema = z.object({ + score: z.number().int().min(0).max(100), + masteryLevel: z.enum(['excellent', 'good', 'partial', 'weak', 'none']), + summary: z.string().min(1).max(2000), + strengths: z.array(z.string().max(500)).max(10).default([]), + weaknesses: z.array(z.string().max(500)).max(10).default([]), + missingKeyPoints: z.array(z.string().max(500)).max(20).default([]), + misconceptions: z.array(z.string().max(500)).max(10).default([]), + weaknessTypes: z.array(z.string().max(50)).max(10).default([]), + focusItems: z.array(FocusItemSchema).max(10).default([]), + reviewSuggestion: ReviewSuggestionSchema, +}); + +export type ActiveRecallAnalysisResult = z.infer; + +export const ACTIVE_RECALL_OUTPUT_SCHEMA_DESC = `{ + "score": 72, + "masteryLevel": "partial", + "summary": "用户理解了核心定义,但缺少应用场景。", + "strengths": ["核心概念理解正确", "能用自己的话表达"], + "weaknesses": ["缺少具体例子", "遗漏了关键应用条件"], + "missingKeyPoints": ["X的应用条件", "X与Y的关系"], + "misconceptions": ["将A误解为B"], + "weaknessTypes": ["missing_detail", "missing_application", "misconception"], + "focusItems": [ + { + "title": "X的应用条件", + "reason": "用户在回答中完全未提及应用条件", + "suggestion": "建议回顾知识点第三段关于应用条件的内容", + "priority": "high" + } + ], + "reviewSuggestion": { + "shouldReview": true, + "intervalDays": 2, + "cardFront": "在什么条件下X不能使用?", + "cardBack": "当Y存在时,X失效。" + } +}`; diff --git a/src/modules/ai/providers/ai-provider.interface.ts b/src/modules/ai/providers/ai-provider.interface.ts new file mode 100644 index 0000000..9390f9b --- /dev/null +++ b/src/modules/ai/providers/ai-provider.interface.ts @@ -0,0 +1,23 @@ +export interface AiGenerateInput { + messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>; + model: string; + temperature?: number; + maxTokens?: number; + responseFormat?: { type: 'json_object' } | { type: 'text' }; + signal?: AbortSignal; +} + +export interface AiGenerateOutput { + rawText: string; + usage: { + model: string; + inputTokens: number; + outputTokens: number; + }; + latencyMs: number; +} + +export interface AiProvider { + readonly name: string; + generate(input: AiGenerateInput): Promise; +} diff --git a/src/modules/ai/providers/deepseek.provider.ts b/src/modules/ai/providers/deepseek.provider.ts new file mode 100644 index 0000000..7be0dab --- /dev/null +++ b/src/modules/ai/providers/deepseek.provider.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { AiProvider, AiGenerateInput, AiGenerateOutput } from './ai-provider.interface'; + +@Injectable() +export class DeepSeekProvider implements AiProvider { + readonly name = 'deepseek'; + private readonly logger = new Logger(DeepSeekProvider.name); + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(private readonly config: ConfigService) { + this.apiKey = this.config.get('ai.deepseek.apiKey', ''); + this.baseUrl = this.config.get('ai.deepseek.baseUrl', 'https://api.deepseek.com'); + } + + async generate(input: AiGenerateInput): Promise { + const start = Date.now(); + + if (!this.apiKey) { + throw new Error('DeepSeek API key not configured'); + } + + const body: Record = { + model: input.model, + messages: input.messages, + temperature: input.temperature ?? 0.3, + max_tokens: input.maxTokens ?? 4096, + }; + + if (input.responseFormat?.type === 'json_object') { + body.response_format = { type: 'json_object' }; + } + + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + signal: input.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'unknown'); + this.logger.error(`DeepSeek API error ${response.status}: ${errorText}`); + throw new Error(`DeepSeek API returned ${response.status}`); + } + + const data = await response.json(); + const latencyMs = Date.now() - start; + + const content = data.choices?.[0]?.message?.content ?? ''; + const usage = data.usage ?? {}; + + return { + rawText: content, + usage: { + model: input.model, + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + }, + latencyMs, + }; + } +} diff --git a/src/modules/ai/providers/minimax.provider.ts b/src/modules/ai/providers/minimax.provider.ts new file mode 100644 index 0000000..8cf3168 --- /dev/null +++ b/src/modules/ai/providers/minimax.provider.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { AiProvider, AiGenerateInput, AiGenerateOutput } from './ai-provider.interface'; + +@Injectable() +export class MiniMaxProvider implements AiProvider { + readonly name = 'minimax'; + private readonly logger = new Logger(MiniMaxProvider.name); + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(private readonly config: ConfigService) { + this.apiKey = this.config.get('ai.minimax.apiKey', ''); + this.baseUrl = this.config.get('ai.minimax.baseUrl', 'https://api.minimaxi.com'); + } + + async generate(input: AiGenerateInput): Promise { + const start = Date.now(); + + if (!this.apiKey) { + throw new Error('MiniMax API key not configured'); + } + + const body: Record = { + model: input.model, + messages: input.messages, + temperature: input.temperature ?? 0.3, + max_tokens: input.maxTokens ?? 4096, + }; + + if (input.responseFormat?.type === 'json_object') { + body.response_format = { type: 'json_object' }; + } + + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + signal: input.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'unknown'); + this.logger.error(`MiniMax API error ${response.status}: ${errorText}`); + throw new Error(`MiniMax API returned ${response.status}`); + } + + const data = await response.json(); + const latencyMs = Date.now() - start; + + const content = data.choices?.[0]?.message?.content ?? ''; + const usage = data.usage ?? {}; + + return { + rawText: content, + usage: { + model: input.model, + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + }, + latencyMs, + }; + } +} diff --git a/src/modules/ai/providers/mock-ai.provider.ts b/src/modules/ai/providers/mock-ai.provider.ts new file mode 100644 index 0000000..dc88be7 --- /dev/null +++ b/src/modules/ai/providers/mock-ai.provider.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import type { AiProvider, AiGenerateInput, AiGenerateOutput } from './ai-provider.interface'; + +@Injectable() +export class MockAiProvider implements AiProvider { + readonly name = 'mock'; + + async generate(input: AiGenerateInput): Promise { + const userInput = input.messages.find((m) => m.role === 'user')?.content ?? ''; + const score = Math.min(95, Math.max(30, userInput.length % 60 + 30)); + + const result = { + score, + masteryLevel: score >= 90 ? 'excellent' : score >= 70 ? 'good' : score >= 50 ? 'partial' : 'weak', + summary: '用户理解了核心定义,但缺少应用场景。', + strengths: ['表达清楚', '有一定基础'], + weaknesses: ['遗漏关键要点', '逻辑层次不足'], + missingKeyPoints: ['具体应用场景', '与相关概念的关联'], + misconceptions: [], + weaknessTypes: ['missing_detail', 'missing_application'], + focusItems: [ + { + title: '补充应用场景', + reason: '回答中缺少实际应用场景的说明', + suggestion: '建议回顾知识点的应用部分', + priority: 'high', + }, + ], + reviewSuggestion: { + shouldReview: true, + intervalDays: 2, + cardFront: '这个知识点的核心应用场景有哪些?', + cardBack: '请重新阅读知识点中关于应用的部分。', + }, + }; + + return { + rawText: JSON.stringify(result), + usage: { + model: 'mock', + inputTokens: 0, + outputTokens: 0, + }, + latencyMs: 10, + }; + } +} diff --git a/src/modules/ai/usage/ai-cost-calculator.service.ts b/src/modules/ai/usage/ai-cost-calculator.service.ts new file mode 100644 index 0000000..6a7a966 --- /dev/null +++ b/src/modules/ai/usage/ai-cost-calculator.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +interface PricingTier { + inputPricePerM: number; // ¥ per million input tokens + outputPricePerM: number; // ¥ per million output tokens +} + +@Injectable() +export class AiCostCalculatorService { + private readonly pricing: Record = { + 'deepseek-v4-flash': { inputPricePerM: 1, outputPricePerM: 2 }, + 'deepseek-v4-pro': { inputPricePerM: 3, outputPricePerM: 6 }, + }; + + calculate(provider: string, model: string, inputTokens: number, outputTokens: number): number { + if (provider === 'mock' || provider === 'minimax') return 0; + + const tier = this.pricing[model]; + if (!tier) return 0; + + const inputCost = (inputTokens / 1_000_000) * tier.inputPricePerM; + const outputCost = (outputTokens / 1_000_000) * tier.outputPricePerM; + + return Math.round((inputCost + outputCost) * 10000) / 10000; + } +} diff --git a/src/modules/ai/usage/ai-usage-log.service.ts b/src/modules/ai/usage/ai-usage-log.service.ts new file mode 100644 index 0000000..e5f1538 --- /dev/null +++ b/src/modules/ai/usage/ai-usage-log.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../../infrastructure/database/prisma.service'; + +export interface UsageLogEntry { + userId: string; + feature: string; + provider: string; + model: string; + tier: string; + promptKey: string; + promptVersion: string; + inputTokens: number; + outputTokens: number; + estimatedCost: number; + latencyMs: number; + success: boolean; + errorMessage?: string; +} + +@Injectable() +export class AiUsageLogService { + private readonly logger = new Logger(AiUsageLogService.name); + + constructor(private readonly prisma: PrismaService) {} + + async log(entry: UsageLogEntry): Promise { + try { + await this.prisma.aiUsageLog.create({ data: entry }); + } catch (error) { + this.logger.error(`Failed to write AI usage log: ${(error as Error).message}`); + } + } +} diff --git a/src/modules/ai/workflows/active-recall-analysis.workflow.ts b/src/modules/ai/workflows/active-recall-analysis.workflow.ts new file mode 100644 index 0000000..56e9a2f --- /dev/null +++ b/src/modules/ai/workflows/active-recall-analysis.workflow.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { AiGatewayService } from '../gateway/ai-gateway.service'; +import { ActiveRecallAnalysisResultSchema } from '../prompts/schemas/active-recall-analysis.schema'; +import type { ActiveRecallAnalysisResult } from '../prompts/schemas/active-recall-analysis.schema'; + +export interface ActiveRecallAnalysisInput { + userId: string; + questionText: string; + knowledgeItemContent: string; + userAnswer: string; +} + +@Injectable() +export class ActiveRecallAnalysisWorkflow { + constructor(private readonly gateway: AiGatewayService) {} + + async execute(input: ActiveRecallAnalysisInput): Promise { + const userMessage = [ + `【知识点原文】`, + input.knowledgeItemContent, + '', + `【用户的主动回忆回答】`, + input.userAnswer, + '', + `请根据以上内容进行分析。`, + ].join('\n'); + + const response = await this.gateway.generate({ + feature: 'active-recall-analysis', + userId: input.userId, + tier: 'primary', + promptKey: 'active-recall-analysis', + promptVersion: '1.0.0', + messages: [ + { role: 'user', content: userMessage }, + ], + outputSchema: ActiveRecallAnalysisResultSchema, + }); + + return response.parsed as unknown as ActiveRecallAnalysisResult; + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index cd1ee70..ff290e7 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,16 +1,8 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, - Req, - UseGuards, -} from '@nestjs/common'; +import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto'; -import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; import type { Request } from 'express'; @ApiTags('auth') @@ -18,6 +10,7 @@ import type { Request } from 'express'; export class AuthController { constructor(private readonly authService: AuthService) {} + @Public() @Post('dev-login') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '开发登录(仅非生产环境)' }) @@ -27,6 +20,7 @@ export class AuthController { return this.authService.devLogin(dto); } + @Public() @Post('apple') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Apple 登录' }) @@ -36,6 +30,7 @@ export class AuthController { return this.authService.appleLogin(dto); } + @Public() @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '刷新令牌' }) @@ -45,7 +40,6 @@ export class AuthController { return this.authService.refresh(dto.refreshToken); } - @UseGuards(JwtAuthGuard) @Post('logout') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '退出登录' }) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 692979f..4c53839 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -133,12 +133,12 @@ export class AuthService { }; } - async logout(userId: bigint | string, refreshToken: string) { + async logout(userId: string, refreshToken: string) { const hash = this.tokenService.hashToken(refreshToken); const stored = await this.prisma.refreshToken.findFirst({ where: { tokenHash: hash, - userId: BigInt(userId), + userId, revokedAt: null, }, }); @@ -152,7 +152,7 @@ export class AuthService { } private async buildLoginResponse(user: { - id: bigint; + id: string; email: string | null; nickname: string | null; avatarUrl: string | null; @@ -181,7 +181,7 @@ export class AuthService { } private serializeUser(user: { - id: bigint; + id: string; email: string | null; nickname: string | null; avatarUrl: string | null; @@ -190,7 +190,7 @@ export class AuthService { onboardingCompleted: boolean; }) { return { - id: String(user.id), + id: user.id, email: user.email, nickname: user.nickname, avatarUrl: user.avatarUrl, diff --git a/src/modules/auth/token.service.ts b/src/modules/auth/token.service.ts index d39f634..8b5c4f1 100644 --- a/src/modules/auth/token.service.ts +++ b/src/modules/auth/token.service.ts @@ -6,9 +6,9 @@ import { JwtService } from '@nestjs/jwt'; export class TokenService { constructor(private readonly jwtService: JwtService) {} - generateAccessToken(user: { id: bigint; email?: string | null; role?: string | null }): Promise { + generateAccessToken(user: { id: string; email?: string | null; role?: string | null }): Promise { return this.jwtService.signAsync({ - sub: String(user.id), + sub: user.id, email: user.email, role: user.role, }); diff --git a/src/modules/document-import/document-import.repository.ts b/src/modules/document-import/document-import.repository.ts index cf38ede..af14b0e 100644 --- a/src/modules/document-import/document-import.repository.ts +++ b/src/modules/document-import/document-import.repository.ts @@ -1,39 +1,29 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface ImportJob { - id: string; - fileName: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; - createdAt: Date; - updatedAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class DocumentImportRepository { - private jobs: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async create(data: any): Promise { - const job: ImportJob = { - id: generateShortId(), - fileName: data.fileName || 'unknown', - status: 'pending', - createdAt: new Date(), - updatedAt: new Date(), - }; - this.jobs.set(job.id, job); - return job; + async create(data: { userId?: string; fileName?: string; sourceType?: string }) { + return this.prisma.documentImport.create({ + data: { + userId: data.userId ?? '', + sourceType: data.sourceType ?? 'upload', + sourceName: data.fileName ?? 'unknown', + status: 'pending', + }, + }); } - async findById(id: string): Promise { - return this.jobs.get(id); + async findById(id: string) { + return this.prisma.documentImport.findUnique({ where: { id } }); } - async updateStatus(id: string, status: ImportJob['status']): Promise { - const job = this.jobs.get(id); - if (job) { - job.status = status; - job.updatedAt = new Date(); - } + async updateStatus(id: string, status: string) { + await this.prisma.documentImport.update({ + where: { id }, + data: { status }, + }); } } diff --git a/src/modules/document-import/document-import.service.ts b/src/modules/document-import/document-import.service.ts index 8020ef9..8430b87 100644 --- a/src/modules/document-import/document-import.service.ts +++ b/src/modules/document-import/document-import.service.ts @@ -68,7 +68,7 @@ export class DocumentImportService { return { id, - fileName: dbJob?.fileName, + fileName: dbJob?.sourceName, status: redisStatus || dbJob?.status || 'unknown', progress: redisProgress ? parseInt(redisProgress, 10) : 0, message: redisMessage || null, diff --git a/src/modules/feedback/feedback.controller.ts b/src/modules/feedback/feedback.controller.ts index 26bfefb..fdc015f 100644 --- a/src/modules/feedback/feedback.controller.ts +++ b/src/modules/feedback/feedback.controller.ts @@ -2,12 +2,14 @@ import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; import { FeedbackService } from './feedback.service'; import { CreateFeedbackDto } from './dto/create-feedback.dto'; +import { Public } from '../../common/decorators/public.decorator'; @ApiTags('feedback') @Controller('feedback') export class FeedbackController { constructor(private readonly feedbackService: FeedbackService) {} + @Public() @Post() @ApiOperation({ summary: '提交反馈' }) @ApiResponse({ status: 201, description: '反馈提交成功' }) diff --git a/src/modules/feedback/feedback.repository.ts b/src/modules/feedback/feedback.repository.ts index cb95143..556fb85 100644 --- a/src/modules/feedback/feedback.repository.ts +++ b/src/modules/feedback/feedback.repository.ts @@ -1,58 +1,64 @@ import { Injectable } from '@nestjs/common'; - -export interface FeedbackEntry { - id: string; - userId?: string; - type: 'bug' | 'feature' | 'general'; - content: string; - contact?: string; - status: 'pending' | 'reviewed' | 'resolved'; - createdAt: Date; - updatedAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class FeedbackRepository { - private feedbacks: FeedbackEntry[] = []; + constructor(private readonly prisma: PrismaService) {} - async create(data: Partial): Promise { - const entry: FeedbackEntry = { - id: `fb_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - userId: data.userId, - type: data.type || 'general', - content: data.content || '', - contact: data.contact, - status: 'pending', - createdAt: new Date(), - updatedAt: new Date(), - }; - this.feedbacks.push(entry); - return entry; + async create(data: { + userId?: string; + email?: string; + type?: string; + content?: string; + contact?: string; + }) { + return this.prisma.feedback.create({ + data: { + userId: data.userId ?? null, + email: data.email ?? data.contact ?? null, + category: data.type ?? 'general', + content: data.content ?? '', + }, + }); } - async findAll(): Promise { - return this.feedbacks; + async findAll() { + return this.prisma.feedback.findMany({ orderBy: { createdAt: 'desc' } }); } - async findByUserId(userId: string): Promise { - return this.feedbacks.filter((f) => f.userId === userId); + async findByUserId(userId: string) { + return this.prisma.feedback.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); } - async updateStatus(id: string, status: string): Promise { - const feedback = this.feedbacks.find((f) => f.id === id); - if (!feedback) return undefined; - feedback.status = status as any; - feedback.updatedAt = new Date(); - return feedback; + async updateStatus(id: string, status: string) { + return this.prisma.feedback.update({ + where: { id }, + data: { status }, + }); } async getStats() { - const byType: Record = {}; - const byStatus: Record = {}; - this.feedbacks.forEach((f) => { - byType[f.type] = (byType[f.type] || 0) + 1; - byStatus[f.status] = (byStatus[f.status] || 0) + 1; + const byCategory = await this.prisma.feedback.groupBy({ + by: ['category'], + _count: true, }); - return { total: this.feedbacks.length, byType, byStatus }; + const byStatus = await this.prisma.feedback.groupBy({ + by: ['status'], + _count: true, + }); + const total = await this.prisma.feedback.count(); + + const byType: Record = {}; + for (const g of byCategory) { + byType[g.category] = g._count; + } + const byStatusObj: Record = {}; + for (const g of byStatus) { + byStatusObj[g.status] = g._count; + } + return { total, byType, byStatus: byStatusObj }; } } diff --git a/src/modules/focus-items/focus-items.controller.ts b/src/modules/focus-items/focus-items.controller.ts index 0565511..70fc328 100644 --- a/src/modules/focus-items/focus-items.controller.ts +++ b/src/modules/focus-items/focus-items.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { FocusItemsService } from './focus-items.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; @ApiTags('focus-items') @Controller('focus-items') @@ -9,14 +11,14 @@ export class FocusItemsController { @Get() @ApiOperation({ summary: '获取待巩固项列表' }) - async findAll() { - return this.focusItemsService.findAll(); + async findAll(@CurrentUser() user: UserPayload) { + return this.focusItemsService.findAll(String(user?.id || 'anonymous')); } @Post() @ApiOperation({ summary: '创建待巩固项' }) - async create(@Body() dto: any) { - return this.focusItemsService.create(dto); + async create(@CurrentUser() user: UserPayload, @Body() dto: any) { + return this.focusItemsService.create(String(user?.id || 'anonymous'), dto); } @Patch(':id') diff --git a/src/modules/focus-items/focus-items.repository.ts b/src/modules/focus-items/focus-items.repository.ts index 728eaff..9e6f75e 100644 --- a/src/modules/focus-items/focus-items.repository.ts +++ b/src/modules/focus-items/focus-items.repository.ts @@ -1,39 +1,45 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; -import { FocusItem } from './types/focus-item.types'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class FocusItemsRepository { - private items: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async findAll(): Promise { - return Array.from(this.items.values()); + async findAll(userId: string) { + return this.prisma.focusItem.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: 'desc' }, + }); } - async findById(id: string): Promise { - return this.items.get(id); + async findById(id: string) { + return this.prisma.focusItem.findUnique({ where: { id } }); } - async create(data: Partial): Promise { - const now = new Date(); - const item: FocusItem = { - id: data.id || generateShortId(), - title: data.title || '', - description: data.description || '', - priority: data.priority || 'normal', - status: data.status || 'open', - createdAt: data.createdAt || now, - updatedAt: now, - completedAt: data.completedAt || null, - }; - this.items.set(item.id, item); - return item; + async create(data: { + userId: string; + title: string; + reason?: string; + suggestion?: string; + priority?: string; + knowledgeBaseId?: string; + knowledgeItemId?: string; + }) { + return this.prisma.focusItem.create({ + data: { + userId: data.userId, + title: data.title, + reason: data.reason ?? '', + suggestion: data.suggestion ?? '', + priority: data.priority ?? 'normal', + status: 'open', + knowledgeBaseId: data.knowledgeBaseId ?? null, + knowledgeItemId: data.knowledgeItemId ?? null, + }, + }); } - async update(id: string, data: Partial): Promise { - const item = this.items.get(id); - if (!item) return undefined; - Object.assign(item, { ...data, updatedAt: new Date() }); - return item; + async update(id: string, data: Record) { + return this.prisma.focusItem.update({ where: { id }, data }); } } diff --git a/src/modules/focus-items/focus-items.service.ts b/src/modules/focus-items/focus-items.service.ts index 243c2f0..8ca400c 100644 --- a/src/modules/focus-items/focus-items.service.ts +++ b/src/modules/focus-items/focus-items.service.ts @@ -1,31 +1,26 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FocusItemsRepository } from './focus-items.repository'; -import { FocusItem } from './types/focus-item.types'; @Injectable() export class FocusItemsService { constructor(private readonly repository: FocusItemsRepository) {} - async findAll(): Promise { - return this.repository.findAll(); + async findAll(userId: string) { + return this.repository.findAll(userId); } - async create(dto: any): Promise { - return this.repository.create(dto); + async create(userId: string, dto: any) { + return this.repository.create({ userId, ...dto }); } - async update(id: string, dto: any): Promise { - const item = await this.repository.update(id, dto); - if (!item) throw new NotFoundException(`Focus item ${id} not found`); - return item; + async update(id: string, dto: any) { + return this.repository.update(id, dto); } - async complete(id: string): Promise { - const item = await this.repository.update(id, { + async complete(id: string) { + return this.repository.update(id, { status: 'completed', completedAt: new Date(), }); - if (!item) throw new NotFoundException(`Focus item ${id} not found`); - return item; } } diff --git a/src/modules/knowledge-base/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts index b566fbd..32988f6 100644 --- a/src/modules/knowledge-base/knowledge-base.controller.ts +++ b/src/modules/knowledge-base/knowledge-base.controller.ts @@ -11,31 +11,31 @@ export class KnowledgeBaseController { @Post() @ApiOperation({ summary: '创建知识库' }) - async create(@CurrentUser() user: UserPayload | undefined, @Body() dto: any) { + async create(@CurrentUser() user: UserPayload, @Body() dto: any) { return this.service.create(String(user?.id || 'anonymous'), dto); } @Get() @ApiOperation({ summary: '获取知识库列表' }) - async findAll(@CurrentUser() user: UserPayload | undefined, @Query() query: any) { + async findAll(@CurrentUser() user: UserPayload, @Query() query: any) { return this.service.findAll(String(user?.id || 'anonymous'), query); } @Get(':id') @ApiOperation({ summary: '获取知识库详情' }) - async findOne(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string) { + async findOne(@CurrentUser() user: UserPayload, @Param('id') id: string) { return this.service.findOne(String(user?.id || 'anonymous'), id); } @Patch(':id') @ApiOperation({ summary: '更新知识库' }) - async update(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string, @Body() dto: any) { + async update(@CurrentUser() user: UserPayload, @Param('id') id: string, @Body() dto: any) { return this.service.update(String(user?.id || 'anonymous'), id, dto); } @Delete(':id') @ApiOperation({ summary: '删除知识库' }) - async remove(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string) { + async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { return this.service.remove(String(user?.id || 'anonymous'), id); } } diff --git a/src/modules/knowledge-base/knowledge-base.repository.ts b/src/modules/knowledge-base/knowledge-base.repository.ts index b20039b..55e2e94 100644 --- a/src/modules/knowledge-base/knowledge-base.repository.ts +++ b/src/modules/knowledge-base/knowledge-base.repository.ts @@ -1,68 +1,51 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; -import { KnowledgeBaseStatus } from './types/knowledge-base.types'; - -export interface KnowledgeBase { - id: string; - userId: string; - title: string; - description: string; - status: KnowledgeBaseStatus; - itemCount: number; - lastStudiedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class KnowledgeBaseRepository { - private items: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async create(userId: string, dto: any): Promise { - const kb: KnowledgeBase = { - id: generateShortId(), - userId, - title: dto.title, - description: dto.description || '', - status: 'active', - itemCount: 0, - lastStudiedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.items.set(kb.id, kb); - return kb; + async create(userId: string, dto: { title: string; description?: string }) { + return this.prisma.knowledgeBase.create({ + data: { + userId, + title: dto.title, + description: dto.description ?? '', + status: 'active', + itemCount: 0, + }, + }); } - async findById(id: string): Promise { - return this.items.get(id); + async findById(id: string) { + return this.prisma.knowledgeBase.findUnique({ where: { id } }); } - async findAllByUserId(userId: string): Promise { - return Array.from(this.items.values()).filter( - (kb) => kb.userId === userId && kb.status !== 'deleted', - ); + async findAllByUserId(userId: string) { + return this.prisma.knowledgeBase.findMany({ + where: { userId, deletedAt: null }, + orderBy: { updatedAt: 'desc' }, + }); } - async countByUserId(userId: string): Promise { - return Array.from(this.items.values()).filter( - (kb) => kb.userId === userId && kb.status !== 'deleted', - ).length; + async countByUserId(userId: string) { + return this.prisma.knowledgeBase.count({ + where: { userId, deletedAt: null }, + }); } - async update(id: string, dto: any): Promise { - const kb = this.items.get(id); - if (!kb) return undefined; - Object.assign(kb, { ...dto, updatedAt: new Date() }); - this.items.set(id, kb); - return kb; + async update(id: string, dto: { title?: string; description?: string }) { + return this.prisma.knowledgeBase.update({ + where: { id }, + data: dto, + }); } - async softDelete(id: string): Promise { - const kb = this.items.get(id); - if (!kb) return false; - kb.status = 'deleted'; - kb.updatedAt = new Date(); + async softDelete(id: string) { + await this.prisma.knowledgeBase.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); return true; } } diff --git a/src/modules/knowledge-items/knowledge-items.controller.ts b/src/modules/knowledge-items/knowledge-items.controller.ts index 18cbb1e..464c43c 100644 --- a/src/modules/knowledge-items/knowledge-items.controller.ts +++ b/src/modules/knowledge-items/knowledge-items.controller.ts @@ -11,7 +11,7 @@ export class KnowledgeItemsController { @Post() @ApiOperation({ summary: '创建知识点' }) - async create(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { + async create(@CurrentUser() user: UserPayload, @Body() body: any) { return this.service.create(String(user?.id || 'anonymous'), body.knowledgeBaseId, body); } diff --git a/src/modules/knowledge-items/knowledge-items.repository.ts b/src/modules/knowledge-items/knowledge-items.repository.ts index cc4f777..d4c8972 100644 --- a/src/modules/knowledge-items/knowledge-items.repository.ts +++ b/src/modules/knowledge-items/knowledge-items.repository.ts @@ -1,53 +1,45 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; -import { KnowledgeItemType } from './types/knowledge-item.types'; - -export interface KnowledgeItem { - id: string; - userId: string; - knowledgeBaseId: string; - parentId: string | null; - itemType: KnowledgeItemType; - title: string; - content: string; - orderIndex: number; - createdAt: Date; - updatedAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class KnowledgeItemsRepository { - private items: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async create(userId: string, knowledgeBaseId: string, dto: any): Promise { - const item: KnowledgeItem = { - id: generateShortId(), - userId, - knowledgeBaseId, - parentId: dto.parentId || null, - itemType: dto.itemType || 'lesson', - title: dto.title || '', - content: dto.content || '', - orderIndex: dto.orderIndex || 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.items.set(item.id, item); - return item; + async create(userId: string, knowledgeBaseId: string, dto: { + title?: string; + content?: string; + parentId?: string; + itemType?: string; + orderIndex?: number; + }) { + return this.prisma.knowledgeItem.create({ + data: { + userId, + knowledgeBaseId, + title: dto.title ?? '', + content: dto.content ?? '', + parentId: dto.parentId ?? null, + itemType: dto.itemType ?? 'lesson', + orderIndex: dto.orderIndex ?? 0, + }, + }); } - async findById(id: string): Promise { - return this.items.get(id); + async findById(id: string) { + return this.prisma.knowledgeItem.findUnique({ where: { id } }); } - async findByKnowledgeBaseId(knowledgeBaseId: string): Promise { - return Array.from(this.items.values()).filter((i) => i.knowledgeBaseId === knowledgeBaseId); + async findByKnowledgeBaseId(knowledgeBaseId: string) { + return this.prisma.knowledgeItem.findMany({ + where: { knowledgeBaseId, deletedAt: null }, + orderBy: { orderIndex: 'asc' }, + }); } - async update(id: string, dto: any): Promise { - const item = this.items.get(id); - if (!item) return undefined; - Object.assign(item, { ...dto, updatedAt: new Date() }); - return item; + async update(id: string, dto: Record) { + return this.prisma.knowledgeItem.update({ + where: { id }, + data: dto, + }); } } diff --git a/src/modules/learning-activity/learning-activity.controller.ts b/src/modules/learning-activity/learning-activity.controller.ts index fcb1865..9b0f693 100644 --- a/src/modules/learning-activity/learning-activity.controller.ts +++ b/src/modules/learning-activity/learning-activity.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { LearningActivityService } from './learning-activity.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; @ApiTags('learning-activity') @Controller('activity') @@ -9,13 +11,13 @@ export class LearningActivityController { @Get('heatmap') @ApiOperation({ summary: '获取学习热力图数据' }) - async getHeatmap() { - return this.activityService.getHeatmap(); + async getHeatmap(@CurrentUser() user: UserPayload) { + return this.activityService.getHeatmap(String(user?.id || 'anonymous')); } @Get('summary') @ApiOperation({ summary: '获取学习统计概览' }) - async getSummary() { - return this.activityService.getSummary(); + async getSummary(@CurrentUser() user: UserPayload) { + return this.activityService.getSummary(String(user?.id || 'anonymous')); } } diff --git a/src/modules/learning-activity/learning-activity.repository.ts b/src/modules/learning-activity/learning-activity.repository.ts index d37ff4c..805fcc9 100644 --- a/src/modules/learning-activity/learning-activity.repository.ts +++ b/src/modules/learning-activity/learning-activity.repository.ts @@ -1,32 +1,14 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; - -export interface DailyActivity { - date: string; - minutes: number; - cardsReviewed: number; -} +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() -export class LearningActivityRepository implements OnModuleInit { - private activities: Map = new Map(); +export class LearningActivityRepository { + constructor(private readonly prisma: PrismaService) {} - onModuleInit() { - const today = new Date(); - for (let i = 6; i >= 0; i--) { - const d = new Date(today); - d.setDate(d.getDate() - i); - const dateStr = d.toISOString().split('T')[0]; - this.activities.set(dateStr, { - date: dateStr, - minutes: Math.floor(Math.random() * 120) + 10, - cardsReviewed: Math.floor(Math.random() * 50) + 5, - }); - } - } - - async findAll(): Promise { - return Array.from(this.activities.values()).sort( - (a, b) => a.date.localeCompare(b.date), - ); + async findAll(userId: string) { + return this.prisma.dailyLearningActivity.findMany({ + where: { userId }, + orderBy: { activityDate: 'asc' }, + }); } } diff --git a/src/modules/learning-activity/learning-activity.service.ts b/src/modules/learning-activity/learning-activity.service.ts index efacbf8..a0f3797 100644 --- a/src/modules/learning-activity/learning-activity.service.ts +++ b/src/modules/learning-activity/learning-activity.service.ts @@ -5,20 +5,25 @@ import { LearningActivityRepository } from './learning-activity.repository'; export class LearningActivityService { constructor(private readonly repository: LearningActivityRepository) {} - async getHeatmap(): Promise> { - const activities = await this.repository.findAll(); + async getHeatmap(userId: string) { + const activities = await this.repository.findAll(userId); const heatmap: Record = {}; for (const a of activities) { - heatmap[a.date] = a.minutes; + const dateStr = a.activityDate instanceof Date + ? a.activityDate.toISOString().split('T')[0] + : String(a.activityDate).split('T')[0]; + heatmap[dateStr] = a.durationSeconds; } return heatmap; } - async getSummary() { - const activities = await this.repository.findAll(); - const totalMinutes = activities.reduce((s, a) => s + a.minutes, 0); - const totalCards = activities.reduce((s, a) => s + a.cardsReviewed, 0); - const activeDays = activities.filter((a) => a.minutes > 0).length; + async getSummary(userId: string) { + const activities = await this.repository.findAll(userId); + const totalMinutes = Math.round( + activities.reduce((s, a) => s + a.durationSeconds, 0) / 60, + ); + const totalCards = activities.reduce((s, a) => s + a.reviewCount, 0); + const activeDays = activities.filter((a) => a.durationSeconds > 0).length; const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0; return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage }; } diff --git a/src/modules/learning-session/learning-session.controller.ts b/src/modules/learning-session/learning-session.controller.ts index 606029f..9ce107c 100644 --- a/src/modules/learning-session/learning-session.controller.ts +++ b/src/modules/learning-session/learning-session.controller.ts @@ -11,7 +11,7 @@ export class LearningSessionController { @Post() @ApiOperation({ summary: '开始学习会话' }) - async start(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { + async start(@CurrentUser() user: UserPayload, @Body() body: any) { return this.service.start(String(user?.id || 'anonymous'), body); } @@ -23,7 +23,7 @@ export class LearningSessionController { @Get() @ApiOperation({ summary: '获取学习会话列表' }) - async findAll(@CurrentUser() user: UserPayload | undefined) { + async findAll(@CurrentUser() user: UserPayload) { return this.service.findByUserId(String(user?.id || 'anonymous')); } } diff --git a/src/modules/learning-session/learning-session.repository.ts b/src/modules/learning-session/learning-session.repository.ts index fb37e07..7164de7 100644 --- a/src/modules/learning-session/learning-session.repository.ts +++ b/src/modules/learning-session/learning-session.repository.ts @@ -1,48 +1,47 @@ import { Injectable } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface LearningSession { - id: string; - userId: string; - knowledgeItemId: string; - mode: string; - status: 'active' | 'completed'; - startedAt: Date; - endedAt: Date | null; - durationSeconds: number; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class LearningSessionRepository { - private sessions: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} - async create(userId: string, dto: any): Promise { - const session: LearningSession = { - id: generateShortId(), - userId, - knowledgeItemId: dto.knowledgeItemId || '', - mode: dto.mode || 'reading', - status: 'active', - startedAt: new Date(), - endedAt: null, - durationSeconds: 0, - }; - this.sessions.set(session.id, session); - return session; + async create(userId: string, dto: { + knowledgeItemId?: string; + knowledgeBaseId?: string; + mode?: string; + }) { + return this.prisma.learningSession.create({ + data: { + userId, + knowledgeItemId: dto.knowledgeItemId ?? null, + knowledgeBaseId: dto.knowledgeBaseId ?? null, + mode: dto.mode ?? 'reading', + status: 'active', + startedAt: new Date(), + }, + }); } - async end(id: string): Promise { - const session = this.sessions.get(id); + async end(id: string) { + const session = await this.prisma.learningSession.findUnique({ where: { id } }); if (!session) return undefined; - session.status = 'completed'; - session.endedAt = new Date(); - session.durationSeconds = Math.floor( - (session.endedAt.getTime() - session.startedAt.getTime()) / 1000, - ); - return session; + + return this.prisma.learningSession.update({ + where: { id }, + data: { + status: 'completed', + endedAt: new Date(), + durationSeconds: Math.floor( + (Date.now() - session.startedAt.getTime()) / 1000, + ), + }, + }); } - async findByUserId(userId: string): Promise { - return Array.from(this.sessions.values()).filter((s) => s.userId === userId); + async findByUserId(userId: string) { + return this.prisma.learningSession.findMany({ + where: { userId }, + orderBy: { startedAt: 'desc' }, + }); } } diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 1a424b7..fe6a693 100644 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Post, Param, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; @ApiTags('notifications') @Controller('notifications') @@ -9,8 +11,8 @@ export class NotificationsController { @Get() @ApiOperation({ summary: '获取通知列表' }) - async list() { - return this.service.list(); + async list(@CurrentUser() user: UserPayload) { + return this.service.list(String(user?.id || 'anonymous')); } @Post(':id/read') diff --git a/src/modules/notifications/notifications.repository.ts b/src/modules/notifications/notifications.repository.ts index addc223..4be59cd 100644 --- a/src/modules/notifications/notifications.repository.ts +++ b/src/modules/notifications/notifications.repository.ts @@ -1,66 +1,36 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface Notification { - id: string; - userId: string; - type: string; - title: string; - body: string; - read: boolean; - createdAt: Date; -} +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() -export class NotificationsRepository implements OnModuleInit { - private notifications: Notification[] = []; +export class NotificationsRepository { + constructor(private readonly prisma: PrismaService) {} - onModuleInit() { - const demos: Partial[] = [ - { title: '欢迎使用', body: '欢迎来到知习,开始你的学习之旅!', type: 'system' }, - { title: '复习提醒', body: '你有 5 张卡片需要复习', type: 'review_due' }, - ]; - for (const demo of demos) { - this.notifications.push({ - id: generateShortId(), - userId: '1', - type: demo.type || 'system', - title: demo.title || '', - body: demo.body || '', - read: false, - createdAt: new Date(), - }); - } + async findAll(userId: string) { + return this.prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); } - async findAll(): Promise { - return [...this.notifications].sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), - ); + async create(data: { userId: string; type: string; title: string; body: string }) { + return this.prisma.notification.create({ + data: { + userId: data.userId, + type: data.type, + title: data.title, + content: data.body, + }, + }); } - async create(data: Partial): Promise { - const notification: Notification = { - id: data.id || generateShortId(), - userId: data.userId || 'anonymous', - type: data.type || 'system', - title: data.title || '', - body: data.body || '', - read: false, - createdAt: new Date(), - }; - this.notifications.push(notification); - return notification; + async findById(id: string) { + return this.prisma.notification.findUnique({ where: { id } }); } - async findById(id: string): Promise { - return this.notifications.find((n) => n.id === id); - } - - async markRead(id: string): Promise { - const n = this.notifications.find((x) => x.id === id); - if (!n) return undefined; - n.read = true; - return n; + async markRead(id: string) { + return this.prisma.notification.update({ + where: { id }, + data: { readAt: new Date() }, + }); } } diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 8566cbc..06c7321 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -1,36 +1,26 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { NotificationsRepository } from './notifications.repository'; -import { RedisService } from '../../infrastructure/redis/redis.service'; -import { QueueService } from '../../infrastructure/queue/queue.service'; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); - constructor( - private readonly repository: NotificationsRepository, - private readonly redis: RedisService, - private readonly queue: QueueService, - ) {} + constructor(private readonly repository: NotificationsRepository) {} - async list() { - return this.repository.findAll(); + async list(userId: string) { + return this.repository.findAll(userId); } async markRead(id: string) { - const notification = await this.repository.markRead(id); - if (!notification) throw new NotFoundException(`Notification ${id} not found`); - return notification; + try { + return await this.repository.markRead(id); + } catch { + throw new NotFoundException(`Notification ${id} not found`); + } } async send(data: { userId: string; type: string; title: string; body: string }) { const notification = await this.repository.create(data); - this.queue.add('notification', { notificationId: notification.id, ...data }); - this.redis.set( - `session:notifications:${data.userId}:last_sent`, - new Date().toISOString(), - 86400, - ); this.logger.log(`Notification ${notification.id} sent to user ${data.userId}`); return notification; } diff --git a/src/modules/review/review.controller.ts b/src/modules/review/review.controller.ts index b1ab656..3ef61f5 100644 --- a/src/modules/review/review.controller.ts +++ b/src/modules/review/review.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus } from '@nestj import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ReviewService } from './review.service'; import { SubmitReviewDto } from './dto/submit-review.dto'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; @ApiTags('review') @Controller('reviews') @@ -11,15 +13,19 @@ export class ReviewController { @Get('due') @ApiOperation({ summary: '获取到期复习卡片' }) @ApiResponse({ status: 200, description: '到期复习卡片列表' }) - async getDue() { - return this.reviewService.getDueCards(); + async getDue(@CurrentUser() user: UserPayload) { + return this.reviewService.getDueCards(String(user?.id || 'anonymous')); } @Post(':id/submit') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '提交复习结果' }) @ApiResponse({ status: 201, description: '提交成功' }) - async submitReview(@Param('id') id: string, @Body() dto: SubmitReviewDto) { - return this.reviewService.submitReview(id, dto); + async submitReview( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + @Body() dto: SubmitReviewDto, + ) { + return this.reviewService.submitReview(String(user?.id || 'anonymous'), id, dto); } } diff --git a/src/modules/review/review.repository.ts b/src/modules/review/review.repository.ts index 4d44edd..6cd81fc 100644 --- a/src/modules/review/review.repository.ts +++ b/src/modules/review/review.repository.ts @@ -1,80 +1,57 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { generateShortId } from '../../common/utils/id.util'; - -export interface ReviewCard { - id: string; - userId: string; - knowledgeItemId: string; - dueDate: Date; - reviewedAt: Date | null; - createdAt: Date; -} - -export interface ReviewLog { - id: string; - cardId: string; - rating: string; - responseText: string; - reviewedAt: Date; -} +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() -export class ReviewRepository implements OnModuleInit { - private cards: Map = new Map(); - private logs: Map = new Map(); +export class ReviewRepository { + constructor(private readonly prisma: PrismaService) {} - onModuleInit() { - this.seedDemoData(); + async findById(id: string) { + return this.prisma.reviewCard.findUnique({ where: { id } }); } - async findById(id: string): Promise { - return this.cards.get(id); + async findDueCards(userId: string) { + return this.prisma.reviewCard.findMany({ + where: { + userId, + status: 'active', + nextReviewAt: { lte: new Date() }, + deletedAt: null, + }, + orderBy: { nextReviewAt: 'asc' }, + }); } - async findDueCards(): Promise { - const now = new Date(); - return Array.from(this.cards.values()).filter( - (c) => !c.reviewedAt && c.dueDate <= now, - ); + async insertCard(data: { + userId: string; + knowledgeItemId?: string; + frontText: string; + backText?: string; + intervalDays?: number; + }) { + return this.prisma.reviewCard.create({ data }); } - async insertCard(card: Partial): Promise { - const newCard: ReviewCard = { - id: card.id || generateShortId(), - userId: card.userId || '', - knowledgeItemId: card.knowledgeItemId || '', - dueDate: card.dueDate || new Date(), - reviewedAt: card.reviewedAt || null, - createdAt: card.createdAt || new Date(), - }; - this.cards.set(newCard.id, newCard); - return newCard; + async updateCard(id: string, data: { + status?: string; + nextReviewAt?: Date; + intervalDays?: number; + repetitionCount?: number; + lapseCount?: number; + }) { + await this.prisma.reviewCard.update({ where: { id }, data }); } - async updateCard(id: string, update: Partial): Promise { - const card = this.cards.get(id); - if (card) Object.assign(card, update); - } - - async insertLog(log: Partial): Promise { - const newLog: ReviewLog = { - id: log.id || generateShortId(), - cardId: log.cardId || '', - rating: log.rating || '', - responseText: log.responseText || '', - reviewedAt: log.reviewedAt || new Date(), - }; - this.logs.set(newLog.id, newLog); - return newLog; - } - - private seedDemoData(): void { - const demos: Partial[] = [ - { id: 'card_demo_1', userId: '1', knowledgeItemId: 'item_1', dueDate: new Date(Date.now() - 3600000), createdAt: new Date() }, - { id: 'card_demo_2', userId: '1', knowledgeItemId: 'item_2', dueDate: new Date(Date.now() + 3600000), createdAt: new Date() }, - ]; - for (const card of demos) { - this.cards.set(card.id!, card as ReviewCard); - } + async insertLog(data: { + userId: string; + reviewCardId: string; + rating: string; + responseText?: string; + }) { + return this.prisma.reviewLog.create({ + data: { + ...data, + reviewedAt: new Date(), + }, + }); } } diff --git a/src/modules/review/review.service.ts b/src/modules/review/review.service.ts index a87c192..28c1735 100644 --- a/src/modules/review/review.service.ts +++ b/src/modules/review/review.service.ts @@ -1,24 +1,28 @@ -import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; -import { ReviewRepository, ReviewCard, ReviewLog } from './review.repository'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ReviewRepository } from './review.repository'; import { SubmitReviewDto } from './dto/submit-review.dto'; @Injectable() -export class ReviewService implements OnModuleInit { +export class ReviewService { constructor(private readonly reviewRepository: ReviewRepository) {} - onModuleInit() {} - - async getDueCards(): Promise { - return this.reviewRepository.findDueCards(); + async getDueCards(userId: string) { + return this.reviewRepository.findDueCards(userId); } - async submitReview(id: string, dto: SubmitReviewDto): Promise { + async submitReview(userId: string, id: string, dto: SubmitReviewDto) { const card = await this.reviewRepository.findById(id); if (!card) throw new NotFoundException(`Review card ${id} not found`); const log = await this.reviewRepository.insertLog({ - cardId: id, rating: dto.rating, responseText: dto.responseText, + userId, + reviewCardId: id, + rating: dto.rating, + responseText: dto.responseText, + }); + await this.reviewRepository.updateCard(id, { + status: 'reviewed', + nextReviewAt: new Date(Date.now() + 86400000), }); - await this.reviewRepository.updateCard(id, { reviewedAt: new Date() }); return log; } } diff --git a/src/modules/system/system.controller.ts b/src/modules/system/system.controller.ts index ac8c280..fb4128a 100644 --- a/src/modules/system/system.controller.ts +++ b/src/modules/system/system.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get } 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'; +import { Public } from '../../common/decorators/public.decorator'; @ApiTags('health') @Controller() @@ -12,6 +12,7 @@ export class SystemController { private readonly redis: RedisService, ) {} + @Public() @Get() @ApiOperation({ summary: '服务健康检查' }) @ApiResponse({ status: 200, description: 'API 运行正常' }) @@ -23,6 +24,7 @@ export class SystemController { }; } + @Public() @Get('health') @ApiOperation({ summary: '详细健康检查' }) @ApiResponse({ status: 200, description: '服务状态信息' }) @@ -40,7 +42,6 @@ export class SystemController { } @Get('secure-test') - @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: '认证测试端点' }) @ApiResponse({ status: 200, description: '已认证' }) diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index fd03ae2..20ea763 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,13 +1,11 @@ -import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common'; +import { Controller, Get, Patch, Body } 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) {} diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index 0254454..de2ab62 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -7,7 +7,7 @@ export class UsersRepository { async findProfileByUserId(userId: string) { const user = await this.prisma.user.findUnique({ - where: { id: BigInt(userId) }, + where: { id: userId }, select: { id: true, email: true, @@ -25,7 +25,7 @@ export class UsersRepository { } return { - id: String(user.id), + id: user.id, email: user.email, nickname: user.nickname, avatarUrl: user.avatarUrl, @@ -38,7 +38,7 @@ export class UsersRepository { async updateProfile(userId: string, dto: any) { return this.prisma.user.update({ - where: { id: BigInt(userId) }, + where: { id: userId }, data: { nickname: dto.nickname, avatarUrl: dto.avatarUrl, @@ -48,9 +48,9 @@ export class UsersRepository { async updatePreferences(userId: string, dto: any) { return this.prisma.userPreference.upsert({ - where: { userId: BigInt(userId) }, + where: { userId }, create: { - userId: BigInt(userId), + userId, defaultFocusMinutes: dto.defaultFocusMinutes ?? 25, aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal', language: dto.language ?? 'zh-CN', diff --git a/src/modules/waitlist/waitlist.controller.ts b/src/modules/waitlist/waitlist.controller.ts index ba633f4..29b7db4 100644 --- a/src/modules/waitlist/waitlist.controller.ts +++ b/src/modules/waitlist/waitlist.controller.ts @@ -2,12 +2,14 @@ import { Controller, Post, Body, Get, HttpCode, HttpStatus, ValidationPipe } fro import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { WaitlistService } from './waitlist.service'; import { CreateWaitlistDto } from './dto/create-waitlist.dto'; +import { Public } from '../../common/decorators/public.decorator'; @ApiTags('waitlist') @Controller('waitlist') export class WaitlistController { constructor(private readonly waitlistService: WaitlistService) {} + @Public() @Post() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: '加入等待名单' }) diff --git a/src/modules/waitlist/waitlist.repository.ts b/src/modules/waitlist/waitlist.repository.ts index e5cbd17..ed01ed4 100644 --- a/src/modules/waitlist/waitlist.repository.ts +++ b/src/modules/waitlist/waitlist.repository.ts @@ -1,26 +1,31 @@ import { Injectable } from '@nestjs/common'; - -export interface WaitlistEntry { - id: string; - nickname: string; - email: string; - devices: string[]; - interests: string[]; - painpoint: string; - willingBeta: boolean; - createdAt: Date; -} +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class WaitlistRepository { - private entries: WaitlistEntry[] = []; + constructor(private readonly prisma: PrismaService) {} - async findAll(): Promise { - return this.entries; + async findAll() { + return this.prisma.waitlistEntry.findMany({ orderBy: { createdAt: 'desc' } }); } - async create(entry: WaitlistEntry): Promise { - this.entries.push(entry); - return entry; + async create(data: { + nickname: string; + email: string; + devices?: string[]; + interests?: string[]; + painpoint?: string; + willingBeta?: boolean; + }) { + return this.prisma.waitlistEntry.create({ + data: { + nickname: data.nickname, + email: data.email, + devices: data.devices as any, + interests: data.interests as any, + painpoint: data.painpoint ?? '', + willingBeta: data.willingBeta ?? false, + }, + }); } } diff --git a/src/modules/waitlist/waitlist.service.ts b/src/modules/waitlist/waitlist.service.ts index c5490c7..44cef4c 100644 --- a/src/modules/waitlist/waitlist.service.ts +++ b/src/modules/waitlist/waitlist.service.ts @@ -1,35 +1,31 @@ import { Injectable } from '@nestjs/common'; -import { WaitlistRepository, WaitlistEntry } from './waitlist.repository'; +import { WaitlistRepository } from './waitlist.repository'; import { CreateWaitlistDto } from './dto/create-waitlist.dto'; @Injectable() export class WaitlistService { - constructor(private readonly waitlistRepository: WaitlistRepository) {} + constructor(private readonly repository: WaitlistRepository) {} - async create(dto: CreateWaitlistDto): Promise { - const entries = await this.waitlistRepository.findAll(); - const existing = entries.find((e) => e.email === dto.email); - if (existing) throw new Error('该邮箱已报名'); - const entry: WaitlistEntry = { - id: `wl_${Date.now()}`, + async create(dto: CreateWaitlistDto) { + const existing = await this.repository.findAll(); + const duplicate = existing.find((e) => e.email === dto.email); + if (duplicate) throw new Error('该邮箱已报名'); + return this.repository.create({ nickname: dto.nickname || '', email: dto.email, devices: dto.devices || [], interests: dto.interests || [], painpoint: dto.painpoint || '', willingBeta: dto.willingBeta || false, - createdAt: new Date(), - }; - await this.waitlistRepository.create(entry); - return entry; + }); } async findAll() { - return this.waitlistRepository.findAll(); + return this.repository.findAll(); } async getStats() { - const entries = await this.waitlistRepository.findAll(); + const entries = await this.repository.findAll(); return { total: entries.length, betaUsers: entries.filter((e) => e.willingBeta).length,