feat: AI三层架构 + 全局JwtAuthGuard + 12个Repository迁Prisma
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 1m0s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 1m0s
- AI: 新三层架构 Provider→Gateway→Workflow(15文件,DeepSeek+MiniMax) - Auth: 全局JwtAuthGuard + @Public()装饰器白名单路由 - DB: 12个Repository从Map/Array迁到Prisma - Schema: 新增AiUsageLog、WaitlistEntry模型 - API: /api-docs-json加Basic Auth保护 - 清理: 删除infrastructure/ai、docs/旧文档 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fa69749884
commit
007b56dad5
@ -1,7 +1,7 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
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_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
77
README.md
77
README.md
@ -7,14 +7,21 @@ AI 学习产品的后端 API 服务(v0.1),基于 NestJS + TypeScript 构
|
|||||||
| 模块 | 说明 | 状态 |
|
| 模块 | 说明 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| health | 健康检查 | ✅ |
|
| health | 健康检查 | ✅ |
|
||||||
| auth | 用户认证(登录/注册/Token刷新) | ✅ |
|
| auth | 用户认证(dev-login / Apple / 刷新 / 登出) | ✅ |
|
||||||
| users | 用户管理 | ✅ |
|
| users | 用户管理 | ✅ |
|
||||||
| knowledge | 知识库/文章 | ✅ |
|
| knowledge-base | 知识库 CRUD → Prisma | ✅ |
|
||||||
| learning | 学习路径/课程/进度 | ✅ |
|
| knowledge-items | 知识点 CRUD → Prisma | ✅ |
|
||||||
| ai | AI 分析与对话(Mock) | ✅ |
|
| ai | AI 三层架构(Provider → Gateway → Workflow)15 文件 | ✅ |
|
||||||
| review | 间隔重复复习任务 | ✅ |
|
| ai-analysis | AI 分析结果查询 → Prisma | ✅ |
|
||||||
| feedback | 用户反馈 | ✅ |
|
| active-recall | 主动回忆(提交答案触发分析)→ Prisma | ✅ |
|
||||||
| waitlist | 等待名单 | ✅ |
|
| 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` - 更新学习进度
|
- `PATCH /learning/progress` - 更新学习进度
|
||||||
|
|
||||||
### AI
|
### AI
|
||||||
- `POST /ai/analyze` - AI 学习分析(弱点/进度/推荐)
|
- `POST /ai/analyze-recall` - AI 主动回忆分析
|
||||||
- `POST /ai/chat` - AI 对话
|
- `GET /ai/models` - 查看模型分流配置
|
||||||
- `GET /ai/sessions?userId=` - 获取对话历史
|
- `POST /ai-analysis` - 提交 AI 分析
|
||||||
- `GET /ai/sessions/:sessionId` - 获取指定会话
|
- `GET /ai-analysis/:id` - 获取分析结果
|
||||||
|
|
||||||
|
### 主动回忆
|
||||||
|
- `GET /active-recalls` - 获取问题列表
|
||||||
|
- `POST /active-recalls/:id/submit` - 提交回答(自动触发 AI 分析)
|
||||||
|
|
||||||
### 复习
|
### 复习
|
||||||
- `GET /learning/review?userId=` - 获取复习任务
|
- `GET /learning/review?userId=` - 获取复习任务
|
||||||
@ -156,20 +167,42 @@ SWAGGER_PASSWORD=change_me
|
|||||||
src/
|
src/
|
||||||
├── main.ts # 入口
|
├── main.ts # 入口
|
||||||
├── app.module.ts # 根模块
|
├── app.module.ts # 根模块
|
||||||
├── auth/ # 认证模块
|
├── 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/ # 用户模块
|
├── users/ # 用户模块
|
||||||
├── learning/ # 学习模块
|
├── knowledge-base/ # 知识库模块
|
||||||
├── ai/ # AI 模块
|
├── knowledge-items/ # 知识点模块
|
||||||
├── feedback/ # 反馈模块
|
├── active-recall/ # 主动回忆模块(提交答案 → 触发 AI 分析)
|
||||||
├── waitlist/ # 等待名单模块
|
├── ai/ # ✅ 三层 AI 架构(Provider → Gateway → Workflow)
|
||||||
├── knowledge/ # 知识库模块
|
│ ├── 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)
|
- [x] 接入真实 AI API(DeepSeek + MiniMax 三层架构)
|
||||||
- [ ] 添加数据库持久化
|
- [x] JWT 认证中间件
|
||||||
- [ ] 实现 JWT 认证中间件
|
- [x] 数据库持久化(Prisma + MySQL)
|
||||||
|
- [x] 12 个业务 Repository 从内存 Map 迁到 Prisma
|
||||||
|
- [x] 全局 JwtAuthGuard
|
||||||
|
- [ ] 更多 AI Workflow(知识导入、费曼分析、复习卡片生成)
|
||||||
|
- [ ] AI 联调 + Prompt 调优
|
||||||
- [ ] 添加 Redis 缓存
|
- [ ] 添加 Redis 缓存
|
||||||
- [ ] iOS SDK 集成文档
|
- [ ] iOS 集成
|
||||||
1185
docs/BACKEND-PLAN.md
1185
docs/BACKEND-PLAN.md
File diff suppressed because it is too large
Load Diff
@ -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、外键字段添加合理索引
|
|
||||||
```
|
|
||||||
@ -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 管过程。**
|
|
||||||
169
docs/SECURITY.md
169
docs/SECURITY.md
@ -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] 敏感信息不在日志中打印原则已确立
|
|
||||||
@ -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 } │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
@ -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 │ │ 清空数据 │
|
|
||||||
│ 进主页 │ │ 进登录页 │
|
|
||||||
└────────┘ └──────────┘
|
|
||||||
```
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -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` 跑通,知识库和学习闭环就不会卡住。
|
|
||||||
@ -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` 枚举中添加即可。
|
|
||||||
1003
docs/ios登录流程.md
1003
docs/ios登录流程.md
File diff suppressed because it is too large
Load Diff
12
package-lock.json
generated
12
package-lock.json
generated
@ -30,7 +30,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@ -10844,6 +10845,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
592
prisma/migrations/20250516000000_init/migration.sql
Normal file
592
prisma/migrations/20250516000000_init/migration.sql
Normal file
@ -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;
|
||||||
|
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -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"
|
||||||
@ -9,7 +9,7 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
nickname String? @db.VarChar(100)
|
nickname String? @db.VarChar(100)
|
||||||
avatarUrl String? @db.VarChar(500)
|
avatarUrl String? @db.VarChar(500)
|
||||||
@ -45,14 +45,15 @@ model User {
|
|||||||
dailyLearningActivities DailyLearningActivity[]
|
dailyLearningActivities DailyLearningActivity[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
feedbacks Feedback[]
|
feedbacks Feedback[]
|
||||||
|
aiUsageLogs AiUsageLog[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuthAccount {
|
model AuthAccount {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
provider String @db.VarChar(32)
|
provider String @db.VarChar(32)
|
||||||
providerUserId String @db.VarChar(255)
|
providerUserId String @db.VarChar(255)
|
||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
@ -67,8 +68,8 @@ model AuthAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
tokenHash String @db.VarChar(255)
|
tokenHash String @db.VarChar(255)
|
||||||
deviceId String? @db.VarChar(255)
|
deviceId String? @db.VarChar(255)
|
||||||
deviceName String? @db.VarChar(255)
|
deviceName String? @db.VarChar(255)
|
||||||
@ -84,8 +85,8 @@ model RefreshToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserProfile {
|
model UserProfile {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt @unique
|
userId String @unique
|
||||||
learningIdentity String? @db.VarChar(100)
|
learningIdentity String? @db.VarChar(100)
|
||||||
learningDirection String? @db.VarChar(255)
|
learningDirection String? @db.VarChar(255)
|
||||||
bio String? @db.Text
|
bio String? @db.Text
|
||||||
@ -97,8 +98,8 @@ model UserProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserPreference {
|
model UserPreference {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt @unique
|
userId String @unique
|
||||||
preferredMethods Json?
|
preferredMethods Json?
|
||||||
defaultFocusMinutes Int @default(25)
|
defaultFocusMinutes Int @default(25)
|
||||||
aiSuggestionLevel String @default("normal") @db.VarChar(32)
|
aiSuggestionLevel String @default("normal") @db.VarChar(32)
|
||||||
@ -112,8 +113,8 @@ model UserPreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserConsent {
|
model UserConsent {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
consentType String @db.VarChar(32)
|
consentType String @db.VarChar(32)
|
||||||
version String @db.VarChar(50)
|
version String @db.VarChar(50)
|
||||||
acceptedAt DateTime
|
acceptedAt DateTime
|
||||||
@ -128,8 +129,8 @@ model UserConsent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model KnowledgeBase {
|
model KnowledgeBase {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
coverKey String? @db.VarChar(100)
|
coverKey String? @db.VarChar(100)
|
||||||
@ -149,10 +150,10 @@ model KnowledgeBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model KnowledgeItem {
|
model KnowledgeItem {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeBaseId BigInt
|
knowledgeBaseId String
|
||||||
parentId BigInt?
|
parentId String?
|
||||||
itemType String @db.VarChar(32)
|
itemType String @db.VarChar(32)
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
content String? @db.LongText
|
content String? @db.LongText
|
||||||
@ -178,10 +179,10 @@ model KnowledgeItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model KnowledgeItemRelation {
|
model KnowledgeItemRelation {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
sourceItemId BigInt
|
sourceItemId String
|
||||||
targetItemId BigInt
|
targetItemId String
|
||||||
relationType String @db.VarChar(32)
|
relationType String @db.VarChar(32)
|
||||||
confidence Decimal? @db.Decimal(5, 2)
|
confidence Decimal? @db.Decimal(5, 2)
|
||||||
reason String? @db.Text
|
reason String? @db.Text
|
||||||
@ -195,8 +196,8 @@ model KnowledgeItemRelation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
color String? @db.VarChar(32)
|
color String? @db.VarChar(32)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -209,9 +210,9 @@ model Tag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model KnowledgeItemTag {
|
model KnowledgeItemTag {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
knowledgeItemId BigInt
|
knowledgeItemId String
|
||||||
tagId BigInt
|
tagId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id])
|
knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id])
|
||||||
@ -221,8 +222,8 @@ model KnowledgeItemTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UploadedFile {
|
model UploadedFile {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
filename String @db.VarChar(255)
|
filename String @db.VarChar(255)
|
||||||
mimeType String? @db.VarChar(100)
|
mimeType String? @db.VarChar(100)
|
||||||
storagePath String @db.VarChar(500)
|
storagePath String @db.VarChar(500)
|
||||||
@ -236,10 +237,10 @@ model UploadedFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model DocumentImport {
|
model DocumentImport {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeBaseId BigInt?
|
knowledgeBaseId String?
|
||||||
fileId BigInt?
|
fileId String?
|
||||||
sourceType String @db.VarChar(32)
|
sourceType String @db.VarChar(32)
|
||||||
sourceName String? @db.VarChar(255)
|
sourceName String? @db.VarChar(255)
|
||||||
sourceUrl String? @db.VarChar(500)
|
sourceUrl String? @db.VarChar(500)
|
||||||
@ -260,10 +261,10 @@ model DocumentImport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model LearningSession {
|
model LearningSession {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeBaseId BigInt?
|
knowledgeBaseId String?
|
||||||
knowledgeItemId BigInt?
|
knowledgeItemId String?
|
||||||
mode String @db.VarChar(32)
|
mode String @db.VarChar(32)
|
||||||
status String @default("active") @db.VarChar(32)
|
status String @default("active") @db.VarChar(32)
|
||||||
startedAt DateTime
|
startedAt DateTime
|
||||||
@ -282,9 +283,9 @@ model LearningSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model LearningRecord {
|
model LearningRecord {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
sessionId BigInt?
|
sessionId String?
|
||||||
recordType String @db.VarChar(32)
|
recordType String @db.VarChar(32)
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
@ -300,9 +301,9 @@ model LearningRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ActiveRecallQuestion {
|
model ActiveRecallQuestion {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeItemId BigInt?
|
knowledgeItemId String?
|
||||||
questionText String @db.Text
|
questionText String @db.Text
|
||||||
difficulty String? @db.VarChar(32)
|
difficulty String? @db.VarChar(32)
|
||||||
createdBy String @default("ai") @db.VarChar(32)
|
createdBy String @default("ai") @db.VarChar(32)
|
||||||
@ -317,13 +318,13 @@ model ActiveRecallQuestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ActiveRecallAnswer {
|
model ActiveRecallAnswer {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
questionId BigInt?
|
questionId String?
|
||||||
sessionId BigInt?
|
sessionId String?
|
||||||
answerType String @default("text") @db.VarChar(32)
|
answerType String @default("text") @db.VarChar(32)
|
||||||
answerText String? @db.LongText
|
answerText String? @db.LongText
|
||||||
audioFileId BigInt?
|
audioFileId String?
|
||||||
submittedAt DateTime
|
submittedAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@ -336,10 +337,10 @@ model ActiveRecallAnswer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AiAnalysisJob {
|
model AiAnalysisJob {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
sessionId BigInt?
|
sessionId String?
|
||||||
answerId BigInt?
|
answerId String?
|
||||||
jobType String @db.VarChar(32)
|
jobType String @db.VarChar(32)
|
||||||
status String @default("pending") @db.VarChar(32)
|
status String @default("pending") @db.VarChar(32)
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
@ -359,11 +360,11 @@ model AiAnalysisJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AiAnalysisResult {
|
model AiAnalysisResult {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
jobId BigInt
|
jobId String
|
||||||
sessionId BigInt?
|
sessionId String?
|
||||||
answerId BigInt?
|
answerId String?
|
||||||
summary String? @db.Text
|
summary String? @db.Text
|
||||||
masteryScore Int?
|
masteryScore Int?
|
||||||
strengths Json?
|
strengths Json?
|
||||||
@ -383,11 +384,11 @@ model AiAnalysisResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model FocusItem {
|
model FocusItem {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeBaseId BigInt?
|
knowledgeBaseId String?
|
||||||
knowledgeItemId BigInt?
|
knowledgeItemId String?
|
||||||
analysisResultId BigInt?
|
analysisResultId String?
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
reason String? @db.Text
|
reason String? @db.Text
|
||||||
suggestion String? @db.Text
|
suggestion String? @db.Text
|
||||||
@ -409,10 +410,10 @@ model FocusItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ReviewCard {
|
model ReviewCard {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
knowledgeItemId BigInt?
|
knowledgeItemId String?
|
||||||
focusItemId BigInt?
|
focusItemId String?
|
||||||
frontText String @db.Text
|
frontText String @db.Text
|
||||||
backText String? @db.Text
|
backText String? @db.Text
|
||||||
difficulty String? @db.VarChar(32)
|
difficulty String? @db.VarChar(32)
|
||||||
@ -435,10 +436,10 @@ model ReviewCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ReviewLog {
|
model ReviewLog {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
reviewCardId BigInt
|
reviewCardId String
|
||||||
sessionId BigInt?
|
sessionId String?
|
||||||
rating String @db.VarChar(32)
|
rating String @db.VarChar(32)
|
||||||
responseText String? @db.Text
|
responseText String? @db.Text
|
||||||
reviewedAt DateTime
|
reviewedAt DateTime
|
||||||
@ -454,8 +455,8 @@ model ReviewLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ReviewPlan {
|
model ReviewPlan {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
status String @default("active") @db.VarChar(32)
|
status String @default("active") @db.VarChar(32)
|
||||||
scheduledAt DateTime?
|
scheduledAt DateTime?
|
||||||
@ -471,8 +472,8 @@ model ReviewPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model DailyLearningActivity {
|
model DailyLearningActivity {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
activityDate DateTime @db.Date
|
activityDate DateTime @db.Date
|
||||||
durationSeconds Int @default(0)
|
durationSeconds Int @default(0)
|
||||||
sessionsCount Int @default(0)
|
sessionsCount Int @default(0)
|
||||||
@ -491,8 +492,8 @@ model DailyLearningActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt
|
userId String
|
||||||
type String @db.VarChar(32)
|
type String @db.VarChar(32)
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
@ -508,8 +509,8 @@ model Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Feedback {
|
model Feedback {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
userId BigInt?
|
userId String?
|
||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
category String @db.VarChar(64)
|
category String @db.VarChar(64)
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
@ -524,8 +525,45 @@ model Feedback {
|
|||||||
@@index([status])
|
@@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 {
|
model AppChangelog {
|
||||||
id BigInt @id @default(autoincrement())
|
id String @id @default(cuid())
|
||||||
version String @db.VarChar(50)
|
version String @db.VarChar(50)
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
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 { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
import { PrismaModule } from './infrastructure/database/prisma.module';
|
import { PrismaModule } from './infrastructure/database/prisma.module';
|
||||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||||
import { QueueModule } from './infrastructure/queue/queue.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 { StorageModule } from './infrastructure/storage/storage.module';
|
||||||
import { LoggerModule } from './infrastructure/logger/logger.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 { FeedbackModule } from './modules/feedback/feedback.module';
|
||||||
import { WaitlistModule } from './modules/waitlist/waitlist.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 { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||||
|
|
||||||
@ -83,6 +84,7 @@ import appleConfig from './config/apple.config';
|
|||||||
WaitlistModule,
|
WaitlistModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||||
],
|
],
|
||||||
|
|||||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
@ -4,18 +4,27 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const token = this.extractToken(request);
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@ type Delegate = {
|
|||||||
|
|
||||||
export async function findByIdAndUserId<T extends Delegate>(
|
export async function findByIdAndUserId<T extends Delegate>(
|
||||||
delegate: T,
|
delegate: T,
|
||||||
id: number | bigint,
|
id: string,
|
||||||
userId: number | bigint,
|
userId: string,
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
) {
|
) {
|
||||||
const record = await delegate.findUnique({ where: { id } } as any);
|
const record = await delegate.findUnique({ where: { id } } as any);
|
||||||
@ -24,7 +24,7 @@ export async function findByIdAndUserId<T extends Delegate>(
|
|||||||
|
|
||||||
export function ensureOwnership(
|
export function ensureOwnership(
|
||||||
record: any,
|
record: any,
|
||||||
userId: number | bigint,
|
userId: string,
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
) {
|
) {
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|||||||
@ -2,8 +2,20 @@ import { registerAs } from '@nestjs/config';
|
|||||||
|
|
||||||
export default registerAs('ai', () => ({
|
export default registerAs('ai', () => ({
|
||||||
provider: process.env.AI_PROVIDER || 'mock',
|
provider: process.env.AI_PROVIDER || 'mock',
|
||||||
apiKey: process.env.AI_API_KEY || '',
|
defaultTier: process.env.AI_DEFAULT_TIER || 'primary',
|
||||||
baseUrl: process.env.AI_BASE_URL || '',
|
|
||||||
modelName: process.env.AI_MODEL_NAME,
|
deepseek: {
|
||||||
mockEnabled: process.env.AI_MOCK_ENABLED !== 'false',
|
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',
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
export interface AiProvider {
|
|
||||||
generateAnalysis(input: {
|
|
||||||
userInput: string;
|
|
||||||
context: Record<string, any>;
|
|
||||||
}): 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[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
@ -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 {}
|
|
||||||
@ -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<string, any>;
|
|
||||||
}) {
|
|
||||||
return this.provider.generateAnalysis(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
async chat(input: {
|
|
||||||
message: string;
|
|
||||||
history: Array<{ role: string; content: string }>;
|
|
||||||
context?: string;
|
|
||||||
}) {
|
|
||||||
return this.provider.generateChatResponse(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<string, any> }) {
|
|
||||||
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: ['重新解释', '给我一个例子', '检查我的理解'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,13 +13,15 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const url = this.configService.get<string>('redis.url');
|
const url = this.configService.get<string>('redis.url');
|
||||||
if (url) {
|
if (url) {
|
||||||
this.client = new Redis(url);
|
this.client = new Redis(url, { lazyConnect: true, retryStrategy: () => null });
|
||||||
} else {
|
} else {
|
||||||
this.client = new Redis({
|
this.client = new Redis({
|
||||||
host: this.configService.get<string>('redis.host', 'localhost'),
|
host: this.configService.get<string>('redis.host', 'localhost'),
|
||||||
port: this.configService.get<number>('redis.port', 6379),
|
port: this.configService.get<number>('redis.port', 6379),
|
||||||
password: this.configService.get<string>('redis.password'),
|
password: this.configService.get<string>('redis.password'),
|
||||||
db: this.configService.get<number>('redis.db', 0),
|
db: this.configService.get<number>('redis.db', 0),
|
||||||
|
lazyConnect: true,
|
||||||
|
retryStrategy: () => null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
|
|||||||
@ -53,7 +53,7 @@ async function bootstrap() {
|
|||||||
const swaggerUser = configService.get('app.swaggerUser', 'admin');
|
const swaggerUser = configService.get('app.swaggerUser', 'admin');
|
||||||
const swaggerPassword = configService.get('app.swaggerPassword');
|
const swaggerPassword = configService.get('app.swaggerPassword');
|
||||||
if (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;
|
const auth = req.headers.authorization;
|
||||||
if (!auth?.startsWith('Basic ')) {
|
if (!auth?.startsWith('Basic ')) {
|
||||||
res.setHeader('WWW-Authenticate', 'Basic');
|
res.setHeader('WWW-Authenticate', 'Basic');
|
||||||
@ -66,7 +66,10 @@ async function bootstrap() {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
return res.status(401).send('Invalid credentials');
|
return res.status(401).send('Invalid credentials');
|
||||||
});
|
};
|
||||||
|
|
||||||
|
app.use('/api-docs', basicAuthMiddleware);
|
||||||
|
app.use('/api-docs-json', basicAuthMiddleware);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,13 @@ export class ActiveRecallController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取主动回忆问题列表' })
|
@ApiOperation({ summary: '获取主动回忆问题列表' })
|
||||||
async findAll(@CurrentUser() user: UserPayload | undefined) {
|
async findAll(@CurrentUser() user: UserPayload) {
|
||||||
return this.service.findByUserId(String(user?.id || 'anonymous'));
|
return this.service.findByUserId(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@ApiOperation({ summary: '提交主动回忆回答' })
|
@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);
|
return this.service.submit(String(user?.id || 'anonymous'), id, body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AiModule } from '../ai/ai.module';
|
||||||
import { ActiveRecallController } from './active-recall.controller';
|
import { ActiveRecallController } from './active-recall.controller';
|
||||||
import { ActiveRecallService } from './active-recall.service';
|
import { ActiveRecallService } from './active-recall.service';
|
||||||
import { ActiveRecallRepository } from './active-recall.repository';
|
import { ActiveRecallRepository } from './active-recall.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AiModule],
|
||||||
controllers: [ActiveRecallController],
|
controllers: [ActiveRecallController],
|
||||||
providers: [ActiveRecallService, ActiveRecallRepository],
|
providers: [ActiveRecallService, ActiveRecallRepository],
|
||||||
exports: [ActiveRecallService],
|
exports: [ActiveRecallService],
|
||||||
|
|||||||
@ -1,56 +1,47 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActiveRecallRepository {
|
export class ActiveRecallRepository {
|
||||||
private questions: Map<string, RecallQuestion> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private answers: RecallAnswer[] = [];
|
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<RecallQuestion[]> {
|
async findByUserId(userId: string) {
|
||||||
return Array.from(this.questions.values()).filter((q) => q.userId === userId);
|
return this.prisma.activeRecallQuestion.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<RecallQuestion | undefined> {
|
async findById(id: string) {
|
||||||
return this.questions.get(id);
|
return this.prisma.activeRecallQuestion.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createQuestion(data: Partial<RecallQuestion>): Promise<RecallQuestion> {
|
async createQuestion(data: {
|
||||||
const q: RecallQuestion = {
|
userId: string;
|
||||||
id: data.id || generateShortId(),
|
knowledgeItemId?: string;
|
||||||
userId: data.userId || '',
|
questionText: string;
|
||||||
knowledgeItemId: data.knowledgeItemId || '',
|
difficulty?: string;
|
||||||
questionText: data.questionText || '',
|
createdBy?: string;
|
||||||
difficulty: data.difficulty || 'normal',
|
}) {
|
||||||
};
|
return this.prisma.activeRecallQuestion.create({
|
||||||
this.questions.set(q.id, q);
|
data: {
|
||||||
return q;
|
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<RecallAnswer> {
|
async createAnswer(userId: string, questionId: string, body: { answerText: string }) {
|
||||||
const answer: RecallAnswer = {
|
return this.prisma.activeRecallAnswer.create({
|
||||||
id: generateShortId(),
|
data: {
|
||||||
userId,
|
userId,
|
||||||
questionId,
|
questionId,
|
||||||
answerText: body.answerText || '',
|
answerText: body.answerText,
|
||||||
submittedAt: new Date(),
|
submittedAt: new Date(),
|
||||||
};
|
},
|
||||||
this.answers.push(answer);
|
});
|
||||||
return answer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,37 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { ActiveRecallRepository } from './active-recall.repository';
|
import { ActiveRecallRepository } from './active-recall.repository';
|
||||||
|
import { ActiveRecallAnalysisWorkflow } from '../ai/workflows/active-recall-analysis.workflow';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActiveRecallService {
|
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) {
|
async findByUserId(userId: string) {
|
||||||
return this.repository.findByUserId(userId);
|
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);
|
const question = await this.repository.findById(questionId);
|
||||||
if (!question) throw new NotFoundException('问题不存在');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { AiAnalysisService } from './ai-analysis.service';
|
import { AiAnalysisService } from './ai-analysis.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
@ -10,20 +10,17 @@ export class AiAnalysisController {
|
|||||||
constructor(private readonly service: AiAnalysisService) {}
|
constructor(private readonly service: AiAnalysisService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '提交 AI 分析任务' })
|
@ApiOperation({ summary: '提交主动回忆分析' })
|
||||||
async create(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
|
async analyze(
|
||||||
return this.service.createJob(String(user?.id || 'anonymous'), body);
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Body() body: { questionText: string; knowledgeItemContent: string; userAnswer: string },
|
||||||
|
) {
|
||||||
|
return this.service.analyze(String(user?.id || 'anonymous'), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: '获取 AI 分析结果' })
|
@ApiOperation({ summary: '获取分析结果' })
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string) {
|
||||||
return this.service.getResult(id);
|
return this.service.getResult(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('jobs/:jobId/status')
|
|
||||||
@ApiOperation({ summary: '查询任务状态' })
|
|
||||||
async getJobStatus(@Param('jobId') jobId: string) {
|
|
||||||
return this.service.getJobStatus(jobId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AiModule } from '../ai/ai.module';
|
||||||
import { AiAnalysisController } from './ai-analysis.controller';
|
import { AiAnalysisController } from './ai-analysis.controller';
|
||||||
import { AiAnalysisService } from './ai-analysis.service';
|
import { AiAnalysisService } from './ai-analysis.service';
|
||||||
import { AiAnalysisRepository } from './ai-analysis.repository';
|
import { AiAnalysisRepository } from './ai-analysis.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AiModule],
|
||||||
controllers: [AiAnalysisController],
|
controllers: [AiAnalysisController],
|
||||||
providers: [AiAnalysisService, AiAnalysisRepository],
|
providers: [AiAnalysisService, AiAnalysisRepository],
|
||||||
exports: [AiAnalysisService],
|
exports: [AiAnalysisService],
|
||||||
|
|||||||
@ -1,71 +1,37 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiAnalysisRepository {
|
export class AiAnalysisRepository {
|
||||||
private jobs: Map<string, AnalysisJob> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private results: Map<string, AnalysisResult> = new Map();
|
|
||||||
|
|
||||||
async createJob(userId: string, data: any): Promise<AnalysisJob> {
|
async createResult(userId: string, aiResult: {
|
||||||
const job: AnalysisJob = {
|
score: number;
|
||||||
id: generateShortId(),
|
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,
|
userId,
|
||||||
sessionId: data.sessionId || '',
|
jobId: '', // no job for sync analysis
|
||||||
inputText: data.userInput || '',
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
this.jobs.set(job.id, job);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findJobById(id: string): Promise<AnalysisJob | undefined> {
|
|
||||||
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<AnalysisResult> {
|
|
||||||
const result: AnalysisResult = {
|
|
||||||
id: generateShortId(),
|
|
||||||
jobId,
|
|
||||||
userId,
|
|
||||||
masteryScore: aiResult.masteryScore,
|
|
||||||
summary: aiResult.summary,
|
summary: aiResult.summary,
|
||||||
strengths: aiResult.strengths,
|
masteryScore: aiResult.score,
|
||||||
weakPoints: aiResult.weakPoints,
|
strengths: aiResult.strengths as any,
|
||||||
suggestions: aiResult.suggestions,
|
weaknesses: aiResult.weaknesses as any,
|
||||||
createdAt: new Date(),
|
suggestions: aiResult.focusItems as any,
|
||||||
};
|
nextActions: aiResult.reviewSuggestion as any,
|
||||||
this.results.set(result.id, result);
|
rawResult: aiResult as any,
|
||||||
return result;
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findResultById(id: string): Promise<AnalysisResult | undefined> {
|
async findResultById(id: string) {
|
||||||
return this.results.get(id);
|
return this.prisma.aiAnalysisResult.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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()
|
@Injectable()
|
||||||
export class AiAnalysisService {
|
export class AiAnalysisService {
|
||||||
private readonly logger = new Logger(AiAnalysisService.name);
|
private readonly logger = new Logger(AiAnalysisService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly workflow: ActiveRecallAnalysisWorkflow,
|
||||||
private readonly repository: AiAnalysisRepository,
|
private readonly repository: AiAnalysisRepository,
|
||||||
private readonly aiService: AiService,
|
|
||||||
private readonly redis: RedisService,
|
|
||||||
private readonly queue: QueueService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createJob(userId: string, body: any) {
|
async analyze(userId: string, input: {
|
||||||
await this.checkRateLimit(userId);
|
questionText: string;
|
||||||
|
knowledgeItemContent: string;
|
||||||
const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`;
|
userAnswer: string;
|
||||||
const lockToken = await this.redis.lock(lockKey, 300);
|
}) {
|
||||||
if (!lockToken) {
|
const result = await this.workflow.execute({
|
||||||
throw new HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT);
|
userId,
|
||||||
}
|
questionText: input.questionText,
|
||||||
|
knowledgeItemContent: input.knowledgeItemContent,
|
||||||
const job = await this.repository.createJob(userId, body);
|
userAnswer: input.userAnswer,
|
||||||
|
|
||||||
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}`);
|
const saved = await this.repository.createResult(userId, result);
|
||||||
this.redis.unlock(lockKey, lockToken);
|
return { resultId: saved.id, ...result };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getResult(id: string) {
|
async getResult(id: string) {
|
||||||
return this.repository.findResultById(id);
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/modules/ai/ai.controller.ts
Normal file
45
src/modules/ai/ai.controller.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/modules/ai/ai.module.ts
Normal file
70
src/modules/ai/ai.module.ts
Normal file
@ -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<string, AiProvider> => {
|
||||||
|
const map = new Map<string, AiProvider>();
|
||||||
|
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<string, AiProvider>,
|
||||||
|
) => {
|
||||||
|
return new AiGatewayService(
|
||||||
|
modelRouter,
|
||||||
|
promptTemplate,
|
||||||
|
costCalculator,
|
||||||
|
usageLog,
|
||||||
|
providers,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
inject: [
|
||||||
|
ModelRouter,
|
||||||
|
PromptTemplateService,
|
||||||
|
AiCostCalculatorService,
|
||||||
|
AiUsageLogService,
|
||||||
|
'AI_PROVIDERS',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ActiveRecallAnalysisWorkflow,
|
||||||
|
],
|
||||||
|
exports: [AiGatewayService, ActiveRecallAnalysisWorkflow],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
155
src/modules/ai/gateway/ai-gateway.service.ts
Normal file
155
src/modules/ai/gateway/ai-gateway.service.ts
Normal file
@ -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<string, AiProvider>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(request: GatewayRequest, timeoutMs = this.DEFAULT_TIMEOUT_MS): Promise<GatewayResponse> {
|
||||||
|
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<string, any> {
|
||||||
|
if (!schema) return {};
|
||||||
|
|
||||||
|
// Layer 1: direct parse
|
||||||
|
try {
|
||||||
|
return schema.parse(JSON.parse(raw)) as Record<string, any>;
|
||||||
|
} 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<string, any>;
|
||||||
|
} 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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/modules/ai/gateway/ai-gateway.types.ts
Normal file
26
src/modules/ai/gateway/ai-gateway.types.ts
Normal file
@ -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<string, any>;
|
||||||
|
usage: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
estimatedCost: number;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/modules/ai/model-router.ts
Normal file
65
src/modules/ai/model-router.ts
Normal file
@ -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<ModelTier, TierConfig>;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.tiers = {
|
||||||
|
cheap: {
|
||||||
|
tier: 'cheap',
|
||||||
|
preferred: {
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: this.config.get<string>('ai.deepseek.cheapModel', 'deepseek-v4-flash'),
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: this.config.get<string>('ai.deepseek.cheapModel', 'deepseek-v4-flash'),
|
||||||
|
},
|
||||||
|
maxRetries: 2,
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
tier: 'primary',
|
||||||
|
preferred: {
|
||||||
|
provider: 'minimax',
|
||||||
|
model: this.config.get<string>('ai.minimax.primaryModel', 'minimax-m2.7'),
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: this.config.get<string>('ai.deepseek.strongModel', 'deepseek-v4-pro'),
|
||||||
|
},
|
||||||
|
maxRetries: 3,
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
tier: 'strong',
|
||||||
|
preferred: {
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: this.config.get<string>('ai.deepseek.strongModel', 'deepseek-v4-pro'),
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: this.config.get<string>('ai.deepseek.strongModel', 'deepseek-v4-pro'),
|
||||||
|
},
|
||||||
|
maxRetries: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(tier: ModelTier): TierConfig {
|
||||||
|
return this.tiers[tier];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/modules/ai/prompts/active-recall-analysis.prompt.ts
Normal file
27
src/modules/ai/prompts/active-recall-analysis.prompt.ts
Normal file
@ -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 是参考答案`;
|
||||||
39
src/modules/ai/prompts/prompt-template.service.ts
Normal file
39
src/modules/ai/prompts/prompt-template.service.ts
Normal file
@ -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<string, PromptTemplate> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<typeof ActiveRecallAnalysisResultSchema>;
|
||||||
|
|
||||||
|
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失效。"
|
||||||
|
}
|
||||||
|
}`;
|
||||||
23
src/modules/ai/providers/ai-provider.interface.ts
Normal file
23
src/modules/ai/providers/ai-provider.interface.ts
Normal file
@ -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<AiGenerateOutput>;
|
||||||
|
}
|
||||||
67
src/modules/ai/providers/deepseek.provider.ts
Normal file
67
src/modules/ai/providers/deepseek.provider.ts
Normal file
@ -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<string>('ai.deepseek.apiKey', '');
|
||||||
|
this.baseUrl = this.config.get<string>('ai.deepseek.baseUrl', 'https://api.deepseek.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(input: AiGenerateInput): Promise<AiGenerateOutput> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('DeepSeek API key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/modules/ai/providers/minimax.provider.ts
Normal file
67
src/modules/ai/providers/minimax.provider.ts
Normal file
@ -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<string>('ai.minimax.apiKey', '');
|
||||||
|
this.baseUrl = this.config.get<string>('ai.minimax.baseUrl', 'https://api.minimaxi.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(input: AiGenerateInput): Promise<AiGenerateOutput> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('MiniMax API key not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/modules/ai/providers/mock-ai.provider.ts
Normal file
47
src/modules/ai/providers/mock-ai.provider.ts
Normal file
@ -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<AiGenerateOutput> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/modules/ai/usage/ai-cost-calculator.service.ts
Normal file
26
src/modules/ai/usage/ai-cost-calculator.service.ts
Normal file
@ -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<string, PricingTier> = {
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/modules/ai/usage/ai-usage-log.service.ts
Normal file
33
src/modules/ai/usage/ai-usage-log.service.ts
Normal file
@ -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<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.aiUsageLog.create({ data: entry });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to write AI usage log: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/modules/ai/workflows/active-recall-analysis.workflow.ts
Normal file
42
src/modules/ai/workflows/active-recall-analysis.workflow.ts
Normal file
@ -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<ActiveRecallAnalysisResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,8 @@
|
|||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import {
|
import { Controller, Post, Body, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Req,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
|
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';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@ -18,6 +10,7 @@ import type { Request } from 'express';
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('dev-login')
|
@Post('dev-login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
|
||||||
@ -27,6 +20,7 @@ export class AuthController {
|
|||||||
return this.authService.devLogin(dto);
|
return this.authService.devLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('apple')
|
@Post('apple')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Apple 登录' })
|
@ApiOperation({ summary: 'Apple 登录' })
|
||||||
@ -36,6 +30,7 @@ export class AuthController {
|
|||||||
return this.authService.appleLogin(dto);
|
return this.authService.appleLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '刷新令牌' })
|
@ApiOperation({ summary: '刷新令牌' })
|
||||||
@ -45,7 +40,6 @@ export class AuthController {
|
|||||||
return this.authService.refresh(dto.refreshToken);
|
return this.authService.refresh(dto.refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '退出登录' })
|
@ApiOperation({ summary: '退出登录' })
|
||||||
|
|||||||
@ -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 hash = this.tokenService.hashToken(refreshToken);
|
||||||
const stored = await this.prisma.refreshToken.findFirst({
|
const stored = await this.prisma.refreshToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
tokenHash: hash,
|
tokenHash: hash,
|
||||||
userId: BigInt(userId),
|
userId,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -152,7 +152,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildLoginResponse(user: {
|
private async buildLoginResponse(user: {
|
||||||
id: bigint;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
@ -181,7 +181,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private serializeUser(user: {
|
private serializeUser(user: {
|
||||||
id: bigint;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
@ -190,7 +190,7 @@ export class AuthService {
|
|||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: String(user.id),
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
export class TokenService {
|
export class TokenService {
|
||||||
constructor(private readonly jwtService: JwtService) {}
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
generateAccessToken(user: { id: bigint; email?: string | null; role?: string | null }): Promise<string> {
|
generateAccessToken(user: { id: string; email?: string | null; role?: string | null }): Promise<string> {
|
||||||
return this.jwtService.signAsync({
|
return this.jwtService.signAsync({
|
||||||
sub: String(user.id),
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,39 +1,29 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
export interface ImportJob {
|
|
||||||
id: string;
|
|
||||||
fileName: string;
|
|
||||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocumentImportRepository {
|
export class DocumentImportRepository {
|
||||||
private jobs: Map<string, ImportJob> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(data: any): Promise<ImportJob> {
|
async create(data: { userId?: string; fileName?: string; sourceType?: string }) {
|
||||||
const job: ImportJob = {
|
return this.prisma.documentImport.create({
|
||||||
id: generateShortId(),
|
data: {
|
||||||
fileName: data.fileName || 'unknown',
|
userId: data.userId ?? '',
|
||||||
|
sourceType: data.sourceType ?? 'upload',
|
||||||
|
sourceName: data.fileName ?? 'unknown',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: new Date(),
|
},
|
||||||
updatedAt: new Date(),
|
});
|
||||||
};
|
|
||||||
this.jobs.set(job.id, job);
|
|
||||||
return job;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<ImportJob | undefined> {
|
async findById(id: string) {
|
||||||
return this.jobs.get(id);
|
return this.prisma.documentImport.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: ImportJob['status']): Promise<void> {
|
async updateStatus(id: string, status: string) {
|
||||||
const job = this.jobs.get(id);
|
await this.prisma.documentImport.update({
|
||||||
if (job) {
|
where: { id },
|
||||||
job.status = status;
|
data: { status },
|
||||||
job.updatedAt = new Date();
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export class DocumentImportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
fileName: dbJob?.fileName,
|
fileName: dbJob?.sourceName,
|
||||||
status: redisStatus || dbJob?.status || 'unknown',
|
status: redisStatus || dbJob?.status || 'unknown',
|
||||||
progress: redisProgress ? parseInt(redisProgress, 10) : 0,
|
progress: redisProgress ? parseInt(redisProgress, 10) : 0,
|
||||||
message: redisMessage || null,
|
message: redisMessage || null,
|
||||||
|
|||||||
@ -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 { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { FeedbackService } from './feedback.service';
|
import { FeedbackService } from './feedback.service';
|
||||||
import { CreateFeedbackDto } from './dto/create-feedback.dto';
|
import { CreateFeedbackDto } from './dto/create-feedback.dto';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
|
||||||
@ApiTags('feedback')
|
@ApiTags('feedback')
|
||||||
@Controller('feedback')
|
@Controller('feedback')
|
||||||
export class FeedbackController {
|
export class FeedbackController {
|
||||||
constructor(private readonly feedbackService: FeedbackService) {}
|
constructor(private readonly feedbackService: FeedbackService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '提交反馈' })
|
@ApiOperation({ summary: '提交反馈' })
|
||||||
@ApiResponse({ status: 201, description: '反馈提交成功' })
|
@ApiResponse({ status: 201, description: '反馈提交成功' })
|
||||||
|
|||||||
@ -1,58 +1,64 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
export interface FeedbackEntry {
|
|
||||||
id: string;
|
|
||||||
userId?: string;
|
|
||||||
type: 'bug' | 'feature' | 'general';
|
|
||||||
content: string;
|
|
||||||
contact?: string;
|
|
||||||
status: 'pending' | 'reviewed' | 'resolved';
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeedbackRepository {
|
export class FeedbackRepository {
|
||||||
private feedbacks: FeedbackEntry[] = [];
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(data: Partial<FeedbackEntry>): Promise<FeedbackEntry> {
|
async create(data: {
|
||||||
const entry: FeedbackEntry = {
|
userId?: string;
|
||||||
id: `fb_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
email?: string;
|
||||||
userId: data.userId,
|
type?: string;
|
||||||
type: data.type || 'general',
|
content?: string;
|
||||||
content: data.content || '',
|
contact?: string;
|
||||||
contact: data.contact,
|
}) {
|
||||||
status: 'pending',
|
return this.prisma.feedback.create({
|
||||||
createdAt: new Date(),
|
data: {
|
||||||
updatedAt: new Date(),
|
userId: data.userId ?? null,
|
||||||
};
|
email: data.email ?? data.contact ?? null,
|
||||||
this.feedbacks.push(entry);
|
category: data.type ?? 'general',
|
||||||
return entry;
|
content: data.content ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<FeedbackEntry[]> {
|
async findAll() {
|
||||||
return this.feedbacks;
|
return this.prisma.feedback.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<FeedbackEntry[]> {
|
async findByUserId(userId: string) {
|
||||||
return this.feedbacks.filter((f) => f.userId === userId);
|
return this.prisma.feedback.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: string): Promise<FeedbackEntry | undefined> {
|
async updateStatus(id: string, status: string) {
|
||||||
const feedback = this.feedbacks.find((f) => f.id === id);
|
return this.prisma.feedback.update({
|
||||||
if (!feedback) return undefined;
|
where: { id },
|
||||||
feedback.status = status as any;
|
data: { status },
|
||||||
feedback.updatedAt = new Date();
|
});
|
||||||
return feedback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStats() {
|
async getStats() {
|
||||||
const byType: Record<string, number> = {};
|
const byCategory = await this.prisma.feedback.groupBy({
|
||||||
const byStatus: Record<string, number> = {};
|
by: ['category'],
|
||||||
this.feedbacks.forEach((f) => {
|
_count: true,
|
||||||
byType[f.type] = (byType[f.type] || 0) + 1;
|
|
||||||
byStatus[f.status] = (byStatus[f.status] || 0) + 1;
|
|
||||||
});
|
});
|
||||||
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<string, number> = {};
|
||||||
|
for (const g of byCategory) {
|
||||||
|
byType[g.category] = g._count;
|
||||||
|
}
|
||||||
|
const byStatusObj: Record<string, number> = {};
|
||||||
|
for (const g of byStatus) {
|
||||||
|
byStatusObj[g.status] = g._count;
|
||||||
|
}
|
||||||
|
return { total, byType, byStatus: byStatusObj };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { FocusItemsService } from './focus-items.service';
|
import { FocusItemsService } from './focus-items.service';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('focus-items')
|
@ApiTags('focus-items')
|
||||||
@Controller('focus-items')
|
@Controller('focus-items')
|
||||||
@ -9,14 +11,14 @@ export class FocusItemsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取待巩固项列表' })
|
@ApiOperation({ summary: '获取待巩固项列表' })
|
||||||
async findAll() {
|
async findAll(@CurrentUser() user: UserPayload) {
|
||||||
return this.focusItemsService.findAll();
|
return this.focusItemsService.findAll(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '创建待巩固项' })
|
@ApiOperation({ summary: '创建待巩固项' })
|
||||||
async create(@Body() dto: any) {
|
async create(@CurrentUser() user: UserPayload, @Body() dto: any) {
|
||||||
return this.focusItemsService.create(dto);
|
return this.focusItemsService.create(String(user?.id || 'anonymous'), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
|||||||
@ -1,39 +1,45 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { FocusItem } from './types/focus-item.types';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FocusItemsRepository {
|
export class FocusItemsRepository {
|
||||||
private items: Map<string, FocusItem> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(): Promise<FocusItem[]> {
|
async findAll(userId: string) {
|
||||||
return Array.from(this.items.values());
|
return this.prisma.focusItem.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<FocusItem | undefined> {
|
async findById(id: string) {
|
||||||
return this.items.get(id);
|
return this.prisma.focusItem.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: Partial<FocusItem>): Promise<FocusItem> {
|
async create(data: {
|
||||||
const now = new Date();
|
userId: string;
|
||||||
const item: FocusItem = {
|
title: string;
|
||||||
id: data.id || generateShortId(),
|
reason?: string;
|
||||||
title: data.title || '',
|
suggestion?: string;
|
||||||
description: data.description || '',
|
priority?: string;
|
||||||
priority: data.priority || 'normal',
|
knowledgeBaseId?: string;
|
||||||
status: data.status || 'open',
|
knowledgeItemId?: string;
|
||||||
createdAt: data.createdAt || now,
|
}) {
|
||||||
updatedAt: now,
|
return this.prisma.focusItem.create({
|
||||||
completedAt: data.completedAt || null,
|
data: {
|
||||||
};
|
userId: data.userId,
|
||||||
this.items.set(item.id, item);
|
title: data.title,
|
||||||
return item;
|
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<FocusItem>): Promise<FocusItem | undefined> {
|
async update(id: string, data: Record<string, any>) {
|
||||||
const item = this.items.get(id);
|
return this.prisma.focusItem.update({ where: { id }, data });
|
||||||
if (!item) return undefined;
|
|
||||||
Object.assign(item, { ...data, updatedAt: new Date() });
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,26 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { FocusItemsRepository } from './focus-items.repository';
|
import { FocusItemsRepository } from './focus-items.repository';
|
||||||
import { FocusItem } from './types/focus-item.types';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FocusItemsService {
|
export class FocusItemsService {
|
||||||
constructor(private readonly repository: FocusItemsRepository) {}
|
constructor(private readonly repository: FocusItemsRepository) {}
|
||||||
|
|
||||||
async findAll(): Promise<FocusItem[]> {
|
async findAll(userId: string) {
|
||||||
return this.repository.findAll();
|
return this.repository.findAll(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: any): Promise<FocusItem> {
|
async create(userId: string, dto: any) {
|
||||||
return this.repository.create(dto);
|
return this.repository.create({ userId, ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any): Promise<FocusItem> {
|
async update(id: string, dto: any) {
|
||||||
const item = await this.repository.update(id, dto);
|
return this.repository.update(id, dto);
|
||||||
if (!item) throw new NotFoundException(`Focus item ${id} not found`);
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(id: string): Promise<FocusItem> {
|
async complete(id: string) {
|
||||||
const item = await this.repository.update(id, {
|
return this.repository.update(id, {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
});
|
});
|
||||||
if (!item) throw new NotFoundException(`Focus item ${id} not found`);
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,31 +11,31 @@ export class KnowledgeBaseController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '创建知识库' })
|
@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);
|
return this.service.create(String(user?.id || 'anonymous'), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取知识库列表' })
|
@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);
|
return this.service.findAll(String(user?.id || 'anonymous'), query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: '获取知识库详情' })
|
@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);
|
return this.service.findOne(String(user?.id || 'anonymous'), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: '更新知识库' })
|
@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);
|
return this.service.update(String(user?.id || 'anonymous'), id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: '删除知识库' })
|
@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);
|
return this.service.remove(String(user?.id || 'anonymous'), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,68 +1,51 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeBaseRepository {
|
export class KnowledgeBaseRepository {
|
||||||
private items: Map<string, KnowledgeBase> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(userId: string, dto: any): Promise<KnowledgeBase> {
|
async create(userId: string, dto: { title: string; description?: string }) {
|
||||||
const kb: KnowledgeBase = {
|
return this.prisma.knowledgeBase.create({
|
||||||
id: generateShortId(),
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description || '',
|
description: dto.description ?? '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
lastStudiedAt: null,
|
},
|
||||||
createdAt: new Date(),
|
});
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
this.items.set(kb.id, kb);
|
|
||||||
return kb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<KnowledgeBase | undefined> {
|
async findById(id: string) {
|
||||||
return this.items.get(id);
|
return this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllByUserId(userId: string): Promise<KnowledgeBase[]> {
|
async findAllByUserId(userId: string) {
|
||||||
return Array.from(this.items.values()).filter(
|
return this.prisma.knowledgeBase.findMany({
|
||||||
(kb) => kb.userId === userId && kb.status !== 'deleted',
|
where: { userId, deletedAt: null },
|
||||||
);
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async countByUserId(userId: string): Promise<number> {
|
async countByUserId(userId: string) {
|
||||||
return Array.from(this.items.values()).filter(
|
return this.prisma.knowledgeBase.count({
|
||||||
(kb) => kb.userId === userId && kb.status !== 'deleted',
|
where: { userId, deletedAt: null },
|
||||||
).length;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any): Promise<KnowledgeBase | undefined> {
|
async update(id: string, dto: { title?: string; description?: string }) {
|
||||||
const kb = this.items.get(id);
|
return this.prisma.knowledgeBase.update({
|
||||||
if (!kb) return undefined;
|
where: { id },
|
||||||
Object.assign(kb, { ...dto, updatedAt: new Date() });
|
data: dto,
|
||||||
this.items.set(id, kb);
|
});
|
||||||
return kb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDelete(id: string): Promise<boolean> {
|
async softDelete(id: string) {
|
||||||
const kb = this.items.get(id);
|
await this.prisma.knowledgeBase.update({
|
||||||
if (!kb) return false;
|
where: { id },
|
||||||
kb.status = 'deleted';
|
data: { deletedAt: new Date() },
|
||||||
kb.updatedAt = new Date();
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export class KnowledgeItemsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '创建知识点' })
|
@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);
|
return this.service.create(String(user?.id || 'anonymous'), body.knowledgeBaseId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +1,45 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgeItemsRepository {
|
export class KnowledgeItemsRepository {
|
||||||
private items: Map<string, KnowledgeItem> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(userId: string, knowledgeBaseId: string, dto: any): Promise<KnowledgeItem> {
|
async create(userId: string, knowledgeBaseId: string, dto: {
|
||||||
const item: KnowledgeItem = {
|
title?: string;
|
||||||
id: generateShortId(),
|
content?: string;
|
||||||
|
parentId?: string;
|
||||||
|
itemType?: string;
|
||||||
|
orderIndex?: number;
|
||||||
|
}) {
|
||||||
|
return this.prisma.knowledgeItem.create({
|
||||||
|
data: {
|
||||||
userId,
|
userId,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
parentId: dto.parentId || null,
|
title: dto.title ?? '',
|
||||||
itemType: dto.itemType || 'lesson',
|
content: dto.content ?? '',
|
||||||
title: dto.title || '',
|
parentId: dto.parentId ?? null,
|
||||||
content: dto.content || '',
|
itemType: dto.itemType ?? 'lesson',
|
||||||
orderIndex: dto.orderIndex || 0,
|
orderIndex: dto.orderIndex ?? 0,
|
||||||
createdAt: new Date(),
|
},
|
||||||
updatedAt: new Date(),
|
});
|
||||||
};
|
|
||||||
this.items.set(item.id, item);
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<KnowledgeItem | undefined> {
|
async findById(id: string) {
|
||||||
return this.items.get(id);
|
return this.prisma.knowledgeItem.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByKnowledgeBaseId(knowledgeBaseId: string): Promise<KnowledgeItem[]> {
|
async findByKnowledgeBaseId(knowledgeBaseId: string) {
|
||||||
return Array.from(this.items.values()).filter((i) => i.knowledgeBaseId === knowledgeBaseId);
|
return this.prisma.knowledgeItem.findMany({
|
||||||
|
where: { knowledgeBaseId, deletedAt: null },
|
||||||
|
orderBy: { orderIndex: 'asc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any): Promise<KnowledgeItem | undefined> {
|
async update(id: string, dto: Record<string, any>) {
|
||||||
const item = this.items.get(id);
|
return this.prisma.knowledgeItem.update({
|
||||||
if (!item) return undefined;
|
where: { id },
|
||||||
Object.assign(item, { ...dto, updatedAt: new Date() });
|
data: dto,
|
||||||
return item;
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { LearningActivityService } from './learning-activity.service';
|
import { LearningActivityService } from './learning-activity.service';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('learning-activity')
|
@ApiTags('learning-activity')
|
||||||
@Controller('activity')
|
@Controller('activity')
|
||||||
@ -9,13 +11,13 @@ export class LearningActivityController {
|
|||||||
|
|
||||||
@Get('heatmap')
|
@Get('heatmap')
|
||||||
@ApiOperation({ summary: '获取学习热力图数据' })
|
@ApiOperation({ summary: '获取学习热力图数据' })
|
||||||
async getHeatmap() {
|
async getHeatmap(@CurrentUser() user: UserPayload) {
|
||||||
return this.activityService.getHeatmap();
|
return this.activityService.getHeatmap(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ApiOperation({ summary: '获取学习统计概览' })
|
@ApiOperation({ summary: '获取学习统计概览' })
|
||||||
async getSummary() {
|
async getSummary(@CurrentUser() user: UserPayload) {
|
||||||
return this.activityService.getSummary();
|
return this.activityService.getSummary(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,14 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
export interface DailyActivity {
|
|
||||||
date: string;
|
|
||||||
minutes: number;
|
|
||||||
cardsReviewed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LearningActivityRepository implements OnModuleInit {
|
export class LearningActivityRepository {
|
||||||
private activities: Map<string, DailyActivity> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
onModuleInit() {
|
async findAll(userId: string) {
|
||||||
const today = new Date();
|
return this.prisma.dailyLearningActivity.findMany({
|
||||||
for (let i = 6; i >= 0; i--) {
|
where: { userId },
|
||||||
const d = new Date(today);
|
orderBy: { activityDate: 'asc' },
|
||||||
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<DailyActivity[]> {
|
|
||||||
return Array.from(this.activities.values()).sort(
|
|
||||||
(a, b) => a.date.localeCompare(b.date),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,20 +5,25 @@ import { LearningActivityRepository } from './learning-activity.repository';
|
|||||||
export class LearningActivityService {
|
export class LearningActivityService {
|
||||||
constructor(private readonly repository: LearningActivityRepository) {}
|
constructor(private readonly repository: LearningActivityRepository) {}
|
||||||
|
|
||||||
async getHeatmap(): Promise<Record<string, number>> {
|
async getHeatmap(userId: string) {
|
||||||
const activities = await this.repository.findAll();
|
const activities = await this.repository.findAll(userId);
|
||||||
const heatmap: Record<string, number> = {};
|
const heatmap: Record<string, number> = {};
|
||||||
for (const a of activities) {
|
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;
|
return heatmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSummary() {
|
async getSummary(userId: string) {
|
||||||
const activities = await this.repository.findAll();
|
const activities = await this.repository.findAll(userId);
|
||||||
const totalMinutes = activities.reduce((s, a) => s + a.minutes, 0);
|
const totalMinutes = Math.round(
|
||||||
const totalCards = activities.reduce((s, a) => s + a.cardsReviewed, 0);
|
activities.reduce((s, a) => s + a.durationSeconds, 0) / 60,
|
||||||
const activeDays = activities.filter((a) => a.minutes > 0).length;
|
);
|
||||||
|
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;
|
const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0;
|
||||||
return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage };
|
return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export class LearningSessionController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '开始学习会话' })
|
@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);
|
return this.service.start(String(user?.id || 'anonymous'), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ export class LearningSessionController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取学习会话列表' })
|
@ApiOperation({ summary: '获取学习会话列表' })
|
||||||
async findAll(@CurrentUser() user: UserPayload | undefined) {
|
async findAll(@CurrentUser() user: UserPayload) {
|
||||||
return this.service.findByUserId(String(user?.id || 'anonymous'));
|
return this.service.findByUserId(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,47 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
export interface LearningSession {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
knowledgeItemId: string;
|
|
||||||
mode: string;
|
|
||||||
status: 'active' | 'completed';
|
|
||||||
startedAt: Date;
|
|
||||||
endedAt: Date | null;
|
|
||||||
durationSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LearningSessionRepository {
|
export class LearningSessionRepository {
|
||||||
private sessions: Map<string, LearningSession> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(userId: string, dto: any): Promise<LearningSession> {
|
async create(userId: string, dto: {
|
||||||
const session: LearningSession = {
|
knowledgeItemId?: string;
|
||||||
id: generateShortId(),
|
knowledgeBaseId?: string;
|
||||||
|
mode?: string;
|
||||||
|
}) {
|
||||||
|
return this.prisma.learningSession.create({
|
||||||
|
data: {
|
||||||
userId,
|
userId,
|
||||||
knowledgeItemId: dto.knowledgeItemId || '',
|
knowledgeItemId: dto.knowledgeItemId ?? null,
|
||||||
mode: dto.mode || 'reading',
|
knowledgeBaseId: dto.knowledgeBaseId ?? null,
|
||||||
|
mode: dto.mode ?? 'reading',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
endedAt: null,
|
},
|
||||||
durationSeconds: 0,
|
});
|
||||||
};
|
|
||||||
this.sessions.set(session.id, session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async end(id: string): Promise<LearningSession | undefined> {
|
async end(id: string) {
|
||||||
const session = this.sessions.get(id);
|
const session = await this.prisma.learningSession.findUnique({ where: { id } });
|
||||||
if (!session) return undefined;
|
if (!session) return undefined;
|
||||||
session.status = 'completed';
|
|
||||||
session.endedAt = new Date();
|
return this.prisma.learningSession.update({
|
||||||
session.durationSeconds = Math.floor(
|
where: { id },
|
||||||
(session.endedAt.getTime() - session.startedAt.getTime()) / 1000,
|
data: {
|
||||||
);
|
status: 'completed',
|
||||||
return session;
|
endedAt: new Date(),
|
||||||
|
durationSeconds: Math.floor(
|
||||||
|
(Date.now() - session.startedAt.getTime()) / 1000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<LearningSession[]> {
|
async findByUserId(userId: string) {
|
||||||
return Array.from(this.sessions.values()).filter((s) => s.userId === userId);
|
return this.prisma.learningSession.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get, Post, Param, HttpCode, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, Post, Param, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('notifications')
|
@ApiTags('notifications')
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
@ -9,8 +11,8 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取通知列表' })
|
@ApiOperation({ summary: '获取通知列表' })
|
||||||
async list() {
|
async list(@CurrentUser() user: UserPayload) {
|
||||||
return this.service.list();
|
return this.service.list(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/read')
|
@Post(':id/read')
|
||||||
|
|||||||
@ -1,66 +1,36 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
export interface Notification {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsRepository implements OnModuleInit {
|
export class NotificationsRepository {
|
||||||
private notifications: Notification[] = [];
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
onModuleInit() {
|
async findAll(userId: string) {
|
||||||
const demos: Partial<Notification>[] = [
|
return this.prisma.notification.findMany({
|
||||||
{ title: '欢迎使用', body: '欢迎来到知习,开始你的学习之旅!', type: 'system' },
|
where: { userId },
|
||||||
{ title: '复习提醒', body: '你有 5 张卡片需要复习', type: 'review_due' },
|
orderBy: { createdAt: 'desc' },
|
||||||
];
|
});
|
||||||
for (const demo of demos) {
|
}
|
||||||
this.notifications.push({
|
|
||||||
id: generateShortId(),
|
async create(data: { userId: string; type: string; title: string; body: string }) {
|
||||||
userId: '1',
|
return this.prisma.notification.create({
|
||||||
type: demo.type || 'system',
|
data: {
|
||||||
title: demo.title || '',
|
userId: data.userId,
|
||||||
body: demo.body || '',
|
type: data.type,
|
||||||
read: false,
|
title: data.title,
|
||||||
createdAt: new Date(),
|
content: data.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string) {
|
||||||
|
return this.prisma.notification.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(id: string) {
|
||||||
|
return this.prisma.notification.update({
|
||||||
|
where: { id },
|
||||||
|
data: { readAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Notification[]> {
|
|
||||||
return [...this.notifications].sort(
|
|
||||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: Partial<Notification>): Promise<Notification> {
|
|
||||||
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): Promise<Notification | undefined> {
|
|
||||||
return this.notifications.find((n) => n.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markRead(id: string): Promise<Notification | undefined> {
|
|
||||||
const n = this.notifications.find((x) => x.id === id);
|
|
||||||
if (!n) return undefined;
|
|
||||||
n.read = true;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,36 +1,26 @@
|
|||||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { NotificationsRepository } from './notifications.repository';
|
import { NotificationsRepository } from './notifications.repository';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
|
||||||
import { QueueService } from '../../infrastructure/queue/queue.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly repository: NotificationsRepository) {}
|
||||||
private readonly repository: NotificationsRepository,
|
|
||||||
private readonly redis: RedisService,
|
|
||||||
private readonly queue: QueueService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list() {
|
async list(userId: string) {
|
||||||
return this.repository.findAll();
|
return this.repository.findAll(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markRead(id: string) {
|
async markRead(id: string) {
|
||||||
const notification = await this.repository.markRead(id);
|
try {
|
||||||
if (!notification) throw new NotFoundException(`Notification ${id} not found`);
|
return await this.repository.markRead(id);
|
||||||
return notification;
|
} catch {
|
||||||
|
throw new NotFoundException(`Notification ${id} not found`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(data: { userId: string; type: string; title: string; body: string }) {
|
async send(data: { userId: string; type: string; title: string; body: string }) {
|
||||||
const notification = await this.repository.create(data);
|
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}`);
|
this.logger.log(`Notification ${notification.id} sent to user ${data.userId}`);
|
||||||
return notification;
|
return notification;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus } from '@nestj
|
|||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { ReviewService } from './review.service';
|
import { ReviewService } from './review.service';
|
||||||
import { SubmitReviewDto } from './dto/submit-review.dto';
|
import { SubmitReviewDto } from './dto/submit-review.dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ApiTags('review')
|
@ApiTags('review')
|
||||||
@Controller('reviews')
|
@Controller('reviews')
|
||||||
@ -11,15 +13,19 @@ export class ReviewController {
|
|||||||
@Get('due')
|
@Get('due')
|
||||||
@ApiOperation({ summary: '获取到期复习卡片' })
|
@ApiOperation({ summary: '获取到期复习卡片' })
|
||||||
@ApiResponse({ status: 200, description: '到期复习卡片列表' })
|
@ApiResponse({ status: 200, description: '到期复习卡片列表' })
|
||||||
async getDue() {
|
async getDue(@CurrentUser() user: UserPayload) {
|
||||||
return this.reviewService.getDueCards();
|
return this.reviewService.getDueCards(String(user?.id || 'anonymous'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: '提交复习结果' })
|
@ApiOperation({ summary: '提交复习结果' })
|
||||||
@ApiResponse({ status: 201, description: '提交成功' })
|
@ApiResponse({ status: 201, description: '提交成功' })
|
||||||
async submitReview(@Param('id') id: string, @Body() dto: SubmitReviewDto) {
|
async submitReview(
|
||||||
return this.reviewService.submitReview(id, dto);
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: SubmitReviewDto,
|
||||||
|
) {
|
||||||
|
return this.reviewService.submitReview(String(user?.id || 'anonymous'), id, dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +1,57 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { generateShortId } from '../../common/utils/id.util';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReviewRepository implements OnModuleInit {
|
export class ReviewRepository {
|
||||||
private cards: Map<string, ReviewCard> = new Map();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private logs: Map<string, ReviewLog> = new Map();
|
|
||||||
|
|
||||||
onModuleInit() {
|
async findById(id: string) {
|
||||||
this.seedDemoData();
|
return this.prisma.reviewCard.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<ReviewCard | undefined> {
|
async findDueCards(userId: string) {
|
||||||
return this.cards.get(id);
|
return this.prisma.reviewCard.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: 'active',
|
||||||
|
nextReviewAt: { lte: new Date() },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { nextReviewAt: 'asc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDueCards(): Promise<ReviewCard[]> {
|
async insertCard(data: {
|
||||||
const now = new Date();
|
userId: string;
|
||||||
return Array.from(this.cards.values()).filter(
|
knowledgeItemId?: string;
|
||||||
(c) => !c.reviewedAt && c.dueDate <= now,
|
frontText: string;
|
||||||
);
|
backText?: string;
|
||||||
|
intervalDays?: number;
|
||||||
|
}) {
|
||||||
|
return this.prisma.reviewCard.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertCard(card: Partial<ReviewCard>): Promise<ReviewCard> {
|
async updateCard(id: string, data: {
|
||||||
const newCard: ReviewCard = {
|
status?: string;
|
||||||
id: card.id || generateShortId(),
|
nextReviewAt?: Date;
|
||||||
userId: card.userId || '',
|
intervalDays?: number;
|
||||||
knowledgeItemId: card.knowledgeItemId || '',
|
repetitionCount?: number;
|
||||||
dueDate: card.dueDate || new Date(),
|
lapseCount?: number;
|
||||||
reviewedAt: card.reviewedAt || null,
|
}) {
|
||||||
createdAt: card.createdAt || new Date(),
|
await this.prisma.reviewCard.update({ where: { id }, data });
|
||||||
};
|
|
||||||
this.cards.set(newCard.id, newCard);
|
|
||||||
return newCard;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCard(id: string, update: Partial<ReviewCard>): Promise<void> {
|
async insertLog(data: {
|
||||||
const card = this.cards.get(id);
|
userId: string;
|
||||||
if (card) Object.assign(card, update);
|
reviewCardId: string;
|
||||||
}
|
rating: string;
|
||||||
|
responseText?: string;
|
||||||
async insertLog(log: Partial<ReviewLog>): Promise<ReviewLog> {
|
}) {
|
||||||
const newLog: ReviewLog = {
|
return this.prisma.reviewLog.create({
|
||||||
id: log.id || generateShortId(),
|
data: {
|
||||||
cardId: log.cardId || '',
|
...data,
|
||||||
rating: log.rating || '',
|
reviewedAt: new Date(),
|
||||||
responseText: log.responseText || '',
|
},
|
||||||
reviewedAt: log.reviewedAt || new Date(),
|
});
|
||||||
};
|
|
||||||
this.logs.set(newLog.id, newLog);
|
|
||||||
return newLog;
|
|
||||||
}
|
|
||||||
|
|
||||||
private seedDemoData(): void {
|
|
||||||
const demos: Partial<ReviewCard>[] = [
|
|
||||||
{ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,28 @@
|
|||||||
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ReviewRepository, ReviewCard, ReviewLog } from './review.repository';
|
import { ReviewRepository } from './review.repository';
|
||||||
import { SubmitReviewDto } from './dto/submit-review.dto';
|
import { SubmitReviewDto } from './dto/submit-review.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReviewService implements OnModuleInit {
|
export class ReviewService {
|
||||||
constructor(private readonly reviewRepository: ReviewRepository) {}
|
constructor(private readonly reviewRepository: ReviewRepository) {}
|
||||||
|
|
||||||
onModuleInit() {}
|
async getDueCards(userId: string) {
|
||||||
|
return this.reviewRepository.findDueCards(userId);
|
||||||
async getDueCards(): Promise<ReviewCard[]> {
|
|
||||||
return this.reviewRepository.findDueCards();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitReview(id: string, dto: SubmitReviewDto): Promise<ReviewLog> {
|
async submitReview(userId: string, id: string, dto: SubmitReviewDto) {
|
||||||
const card = await this.reviewRepository.findById(id);
|
const card = await this.reviewRepository.findById(id);
|
||||||
if (!card) throw new NotFoundException(`Review card ${id} not found`);
|
if (!card) throw new NotFoundException(`Review card ${id} not found`);
|
||||||
const log = await this.reviewRepository.insertLog({
|
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;
|
return log;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { RedisService } from '../../infrastructure/redis/redis.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')
|
@ApiTags('health')
|
||||||
@Controller()
|
@Controller()
|
||||||
@ -12,6 +12,7 @@ export class SystemController {
|
|||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '服务健康检查' })
|
@ApiOperation({ summary: '服务健康检查' })
|
||||||
@ApiResponse({ status: 200, description: 'API 运行正常' })
|
@ApiResponse({ status: 200, description: 'API 运行正常' })
|
||||||
@ -23,6 +24,7 @@ export class SystemController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get('health')
|
@Get('health')
|
||||||
@ApiOperation({ summary: '详细健康检查' })
|
@ApiOperation({ summary: '详细健康检查' })
|
||||||
@ApiResponse({ status: 200, description: '服务状态信息' })
|
@ApiResponse({ status: 200, description: '服务状态信息' })
|
||||||
@ -40,7 +42,6 @@ export class SystemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('secure-test')
|
@Get('secure-test')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '认证测试端点' })
|
@ApiOperation({ summary: '认证测试端点' })
|
||||||
@ApiResponse({ status: 200, description: '已认证' })
|
@ApiResponse({ status: 200, description: '已认证' })
|
||||||
|
|||||||
@ -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 { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import type { UserPayload } from '../../common/types';
|
import type { UserPayload } from '../../common/types';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@ApiTags('users')
|
@ApiTags('users')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export class UsersRepository {
|
|||||||
|
|
||||||
async findProfileByUserId(userId: string) {
|
async findProfileByUserId(userId: string) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: BigInt(userId) },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
@ -25,7 +25,7 @@ export class UsersRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(user.id),
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
@ -38,7 +38,7 @@ export class UsersRepository {
|
|||||||
|
|
||||||
async updateProfile(userId: string, dto: any) {
|
async updateProfile(userId: string, dto: any) {
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id: BigInt(userId) },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
nickname: dto.nickname,
|
nickname: dto.nickname,
|
||||||
avatarUrl: dto.avatarUrl,
|
avatarUrl: dto.avatarUrl,
|
||||||
@ -48,9 +48,9 @@ export class UsersRepository {
|
|||||||
|
|
||||||
async updatePreferences(userId: string, dto: any) {
|
async updatePreferences(userId: string, dto: any) {
|
||||||
return this.prisma.userPreference.upsert({
|
return this.prisma.userPreference.upsert({
|
||||||
where: { userId: BigInt(userId) },
|
where: { userId },
|
||||||
create: {
|
create: {
|
||||||
userId: BigInt(userId),
|
userId,
|
||||||
defaultFocusMinutes: dto.defaultFocusMinutes ?? 25,
|
defaultFocusMinutes: dto.defaultFocusMinutes ?? 25,
|
||||||
aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal',
|
aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal',
|
||||||
language: dto.language ?? 'zh-CN',
|
language: dto.language ?? 'zh-CN',
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { Controller, Post, Body, Get, HttpCode, HttpStatus, ValidationPipe } fro
|
|||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { WaitlistService } from './waitlist.service';
|
import { WaitlistService } from './waitlist.service';
|
||||||
import { CreateWaitlistDto } from './dto/create-waitlist.dto';
|
import { CreateWaitlistDto } from './dto/create-waitlist.dto';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
|
||||||
@ApiTags('waitlist')
|
@ApiTags('waitlist')
|
||||||
@Controller('waitlist')
|
@Controller('waitlist')
|
||||||
export class WaitlistController {
|
export class WaitlistController {
|
||||||
constructor(private readonly waitlistService: WaitlistService) {}
|
constructor(private readonly waitlistService: WaitlistService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: '加入等待名单' })
|
@ApiOperation({ summary: '加入等待名单' })
|
||||||
|
|||||||
@ -1,26 +1,31 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
export interface WaitlistEntry {
|
|
||||||
id: string;
|
|
||||||
nickname: string;
|
|
||||||
email: string;
|
|
||||||
devices: string[];
|
|
||||||
interests: string[];
|
|
||||||
painpoint: string;
|
|
||||||
willingBeta: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WaitlistRepository {
|
export class WaitlistRepository {
|
||||||
private entries: WaitlistEntry[] = [];
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(): Promise<WaitlistEntry[]> {
|
async findAll() {
|
||||||
return this.entries;
|
return this.prisma.waitlistEntry.findMany({ orderBy: { createdAt: 'desc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(entry: WaitlistEntry): Promise<WaitlistEntry> {
|
async create(data: {
|
||||||
this.entries.push(entry);
|
nickname: string;
|
||||||
return entry;
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,31 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { WaitlistRepository, WaitlistEntry } from './waitlist.repository';
|
import { WaitlistRepository } from './waitlist.repository';
|
||||||
import { CreateWaitlistDto } from './dto/create-waitlist.dto';
|
import { CreateWaitlistDto } from './dto/create-waitlist.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WaitlistService {
|
export class WaitlistService {
|
||||||
constructor(private readonly waitlistRepository: WaitlistRepository) {}
|
constructor(private readonly repository: WaitlistRepository) {}
|
||||||
|
|
||||||
async create(dto: CreateWaitlistDto): Promise<WaitlistEntry> {
|
async create(dto: CreateWaitlistDto) {
|
||||||
const entries = await this.waitlistRepository.findAll();
|
const existing = await this.repository.findAll();
|
||||||
const existing = entries.find((e) => e.email === dto.email);
|
const duplicate = existing.find((e) => e.email === dto.email);
|
||||||
if (existing) throw new Error('该邮箱已报名');
|
if (duplicate) throw new Error('该邮箱已报名');
|
||||||
const entry: WaitlistEntry = {
|
return this.repository.create({
|
||||||
id: `wl_${Date.now()}`,
|
|
||||||
nickname: dto.nickname || '',
|
nickname: dto.nickname || '',
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
devices: dto.devices || [],
|
devices: dto.devices || [],
|
||||||
interests: dto.interests || [],
|
interests: dto.interests || [],
|
||||||
painpoint: dto.painpoint || '',
|
painpoint: dto.painpoint || '',
|
||||||
willingBeta: dto.willingBeta || false,
|
willingBeta: dto.willingBeta || false,
|
||||||
createdAt: new Date(),
|
});
|
||||||
};
|
|
||||||
await this.waitlistRepository.create(entry);
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.waitlistRepository.findAll();
|
return this.repository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStats() {
|
async getStats() {
|
||||||
const entries = await this.waitlistRepository.findAll();
|
const entries = await this.repository.findAll();
|
||||||
return {
|
return {
|
||||||
total: entries.length,
|
total: entries.length,
|
||||||
betaUsers: entries.filter((e) => e.willingBeta).length,
|
betaUsers: entries.filter((e) => e.willingBeta).length,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user