Compare commits

..

2 Commits

Author SHA1 Message Date
007b56dad5 feat: AI三层架构 + 全局JwtAuthGuard + 12个Repository迁Prisma
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>
2026-05-17 00:39:46 +08:00
fa69749884 refactor(auth): restructure auth system, align with iOS login flow spec
- Split AuthService into AppleAuthService, TokenService, AuthService
- Add dev-login endpoint (dev-only, disabled in production)
- AppleLoginDto: authorizationCode optional, add userIdentifier/email/fullName/nonce
- Login/refresh responses now include user object
- logout: single-token revoke + JwtAuthGuard protection
- users.repository: switch from in-memory Map to Prisma persistence
- JWT payload includes role, guards attach full user info to request
- Dual JWT secret support (JWT_ACCESS_SECRET / JWT_REFRESH_SECRET)
- Replace jwks-rsa+jsonwebtoken with jose library
- Prisma User model: add role field
- Independent DTO files with @Transform for empty string safety
- Add 5 iOS login flow documentation files
2026-05-13 17:31:50 +08:00
86 changed files with 2529 additions and 4597 deletions

View File

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

View File

@ -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 → Workflow15 文件 | ✅ |
| 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=` - 获取复习任务
@ -154,22 +165,44 @@ SWAGGER_PASSWORD=change_me
``` ```
src/ src/
├── main.ts # 入口 ├── main.ts # 入口
├── app.module.ts # 根模块 ├── app.module.ts # 根模块
├── auth/ # 认证模块 ├── config/ # 配置app / jwt / ai / database / redis
├── users/ # 用户模块 ├── common/ # 公共guard / decorator / filter / pipe
├── learning/ # 学习模块 ├── infrastructure/ # 基础设施Prisma / Redis / Storage / Logger / Queue
├── ai/ # AI 模块 │ └── ai/ # ❌ 已删除,迁至 modules/ai/
├── feedback/ # 反馈模块 └── modules/
├── waitlist/ # 等待名单模块 ├── auth/ # 认证模块dev-login / Apple / refresh / logout
├── knowledge/ # 知识库模块 ├── users/ # 用户模块
└── ... ├── knowledge-base/ # 知识库模块
├── knowledge-items/ # 知识点模块
├── active-recall/ # 主动回忆模块(提交答案 → 触发 AI 分析)
├── ai/ # ✅ 三层 AI 架构Provider → Gateway → Workflow
│ ├── gateway/ # AiGatewayService选模型、记日志、JSON容错、超时重试
│ ├── providers/ # DeepSeek / MiniMax / Mock
│ ├── prompts/ # System Prompt + Zod Schema
│ ├── usage/ # UsageLog + CostCalculator
│ └── workflows/ # ActiveRecallAnalysisWorkflow
├── ai-analysis/ # AI 分析结果查询
├── review/ # 复习模块
├── learning-session/ # 学习会话
├── learning-activity/ # 学习活跃
├── focus-items/ # 待巩固项
├── document-import/ # 文档导入
├── notifications/ # 通知
├── feedback/ # 用户反馈
├── waitlist/ # 等待名单
└── system/ # 系统状态
``` ```
## 后续规划 ## 后续规划
- [ ] 接入真实 AI APIOpenAI / Claude - [x] 接入真实 AI APIDeepSeek + MiniMax 三层架构)
- [ ] 添加数据库持久化 - [x] JWT 认证中间件
- [ ] 实现 JWT 认证中间件 - [x] 数据库持久化Prisma + MySQL
- [x] 12 个业务 Repository 从内存 Map 迁到 Prisma
- [x] 全局 JwtAuthGuard
- [ ] 更多 AI Workflow知识导入、费曼分析、复习卡片生成
- [ ] AI 联调 + Prompt 调优
- [ ] 添加 Redis 缓存 - [ ] 添加 Redis 缓存
- [ ] iOS SDK 集成文档 - [ ] iOS 集成

File diff suppressed because it is too large Load Diff

View File

@ -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、外键字段添加合理索引
```

View File

@ -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
以下全部必须写 MySQLRedis 只能做缓存,不是唯一来源:
```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 + RedisServiceget/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 管过程。**

View File

@ -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`: JWT1 小时过期
- `refreshToken`: 128 位随机 hex入库只存 SHA-256 hash
- logout 时 `revokedAt = now()` 撤销所有 refresh token
- `/users/me` 及其所有子路由强制 `@UseGuards(JwtAuthGuard)`
```
POST /auth/apple → 返回 accessToken + refreshToken
POST /auth/refresh → 消耗旧 refreshToken发放新 token pairrotation
POST /auth/logout → 撤销该用户所有 refresh token
```
### 存储安全
```
refresh_tokens.tokenHash = SHA-256(实际 token)
数据库中永远不存明文 refreshToken
```
---
## 3. 权限与越权防护
### 资源归属校验
所有用户资源操作必须校验 `userId` 归属:
```ts
// src/common/utils/security.util.ts
export async function findByIdAndUserId(delegate, id, userId, resourceName)
export function ensureOwnership(record, userId, resourceName)
```
### 需校验的资源
| 资源 | 校验字段 |
|------|---------|
| KnowledgeBase | `userId` |
| KnowledgeItem | `userId` |
| LearningSession | `userId` |
| ActiveRecallAnswer | `userId` |
| AiAnalysisJob | `userId` |
| AiAnalysisResult | `userId` |
| FocusItem | `userId` |
| ReviewCard | `userId` |
| ReviewLog | `userId` |
| DocumentImport | `userId` |
---
## 4. 参数校验
- 全局 `StrictValidationPipe`:
- `whitelist: true` — 自动剥离未声明字段
- `forbidNonWhitelisted: true` — 未知字段返回 400
- 字符串字段最大长度 5000 字符
- 分页 DTO: page≥1, limit 1-100
---
## 5. 限流Redis
| 场景 | Key | 限制 |
|------|-----|------|
| 登录 | `rate:ip:{ip}:login:{date}` | 20次/IP/天 |
| 反馈 | `rate:ip:{ip}:feedback:hourly` | 5次/IP/时 |
| AI 分析 | `rate:user:{userId}:ai:daily:{date}` | 50次/用户/天 |
| 文件上传 | `rate:user:{userId}:upload:hourly` | 10次/用户/时 |
实现: `src/common/utils/rate-limit.service.ts`
---
## 6. 文件上传安全
| 措施 | 说明 |
|------|------|
| 类型白名单 | PDF, Word, Excel, 纯文本, Markdown, CSV, PNG, JPEG, WebP |
| 大小限制 | 最大 20MB |
| 随机文件名 | `sanitizeFilename()` 生成随机 key不信任用户原始文件名 |
| 默认私有 | 所有文件默认私有访问 |
| 路径隔离 | `users/{userId}/...` |
---
## 7. Redis 安全使用
- 不存核心业务结果(用户资料/知识点/AI分析结果等必须在 MySQL
- 队列任务只存 `jobId`/`userId` 等引用 ID
- 所有临时 key 必须设置 TTL
- 防重复提交锁必须有 TTL解锁校验 token
- 不在 Redis 中存 token 明文
---
## 8. COS 安全使用
- Bucket 默认私有读写
- 后端不向前端暴露 SecretId/SecretKey
- 下载私有文件通过签名 URL
- 上传路径按 `users/{userId}/{randomKey}` 组织
- 预留临时上传 URLSTS机制
---
## 9. Swagger 安全
- 开发环境默认开启
- 生产环境默认关闭
- 生产环境如需开启,必须配置 Basic Auth`SWAGGER_USER`/`SWAGGER_PASSWORD`
- 生产环境手动设置 `ENABLE_SWAGGER=true`
---
## 10. 数据库安全
- 不使用 root 连接业务
- 业务账号 `zhixi_user` 仅需 SELECT/INSERT/UPDATE/DELETE
- 迁移账号和业务账号分离(`prisma db push` 与运行时连接帐号可不同)
- 数据库自动备份建议: `mysqldump zhixi | gzip > backup-$(date +%Y%m%d).sql.gz`
### 日志中禁止打印
```
DATABASE_URL含密码
JWT_SECRET
AI_API_KEY
COS SecretKey
用户完整 refreshToken
用户上传文件的完整内容
Authorization header
```
---
## 11. 安全检查清单
- [x] helmet 已启用
- [x] CORS 仅允许白名单域名
- [x] JWT + refresh token rotation + hash 存储
- [x] logout 撤销 refresh token
- [x] 所有用户数据接口需要认证
- [x] 资源所有权校验工具已就绪
- [x] StrictValidationPipe 全局启用whitelist + forbidNonWhitelisted
- [x] Redis 限流已实现
- [x] 文件类型/大小白名单
- [x] 全局异常过滤器生产环境不暴露 stack trace
- [x] Swagger 生产环境默认关闭
- [x] 敏感信息不在日志中打印原则已确立

File diff suppressed because it is too large Load Diff

60
package-lock.json generated
View File

@ -25,12 +25,13 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1", "jose": "^6.2.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"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",
@ -7733,22 +7734,6 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/jwks-rsa": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/jwks-rsa/-/jwks-rsa-4.0.1.tgz",
"integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^6.1.3",
"limiter": "^1.1.5",
"lru-memoizer": "^3.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
}
},
"node_modules/jws": { "node_modules/jws": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz",
@ -7799,11 +7784,6 @@
"integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -7866,12 +7846,6 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -7967,25 +7941,6 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru-memoizer": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^11.0.1"
}
},
"node_modules/lru-memoizer/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
@ -10890,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"
}
} }
} }
} }

View File

@ -36,12 +36,13 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1", "jose": "^6.2.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"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",

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

View 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"

View File

@ -9,10 +9,11 @@ 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)
role String @default("USER") @db.VarChar(32)
status String @default("active") @db.VarChar(32) status String @default("active") @db.VarChar(32)
onboardingCompleted Boolean @default(false) onboardingCompleted Boolean @default(false)
lastLoginAt DateTime? lastLoginAt DateTime?
@ -44,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)
@ -66,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)
@ -83,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
@ -96,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)
@ -111,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
@ -127,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)
@ -148,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
@ -177,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
@ -194,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())
@ -208,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])
@ -220,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)
@ -235,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)
@ -259,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
@ -281,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
@ -299,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)
@ -316,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())
@ -335,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)
@ -358,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?
@ -382,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
@ -408,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)
@ -434,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
@ -453,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?
@ -470,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)
@ -490,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
@ -507,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
@ -523,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

View File

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

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -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);
@ -27,7 +36,7 @@ export class JwtAuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('jwt.secret'),
}); });
request.user = { id: String(payload.sub), email: payload.email }; request.user = { id: String(payload.sub), email: payload.email, role: payload.role };
return true; return true;
} catch { } catch {
throw new UnauthorizedException('登录已过期,请重新登录'); throw new UnauthorizedException('登录已过期,请重新登录');

View File

@ -20,7 +20,7 @@ export class OptionalAuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('jwt.secret'),
}); });
request.user = { id: String(payload.sub), email: payload.email }; request.user = { id: String(payload.sub), email: payload.email, role: payload.role };
} catch {} } catch {}
return true; return true;
} }

View File

@ -2,6 +2,8 @@ export interface UserPayload {
id: string; id: string;
email?: string; email?: string;
nickname?: string; nickname?: string;
role?: string;
status?: string;
} }
export interface PaginationMeta { export interface PaginationMeta {

View File

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

View File

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

View File

@ -1,20 +1,28 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => { export default registerAs('jwt', () => {
const secret = process.env.JWT_SECRET; const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
if (!secret || secret === 'change_me_in_production') { const refreshSecret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
if (
!accessSecret ||
accessSecret === 'change_me_in_production'
) {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
throw new Error( throw new Error(
'生产环境必须设置环境变量 JWT_SECRET不能使用默认值', '生产环境必须设置环境变量 JWT_ACCESS_SECRET 或 JWT_SECRET不能使用默认值',
); );
} }
console.warn( console.warn(
'\n⚠ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' + '\n⚠ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' +
' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n', ' 部署到生产环境前请务必设置环境变量 JWT_ACCESS_SECRET\n',
); );
} }
return { return {
secret: secret || 'change_me_in_production', secret: accessSecret || 'change_me_in_production',
accessSecret: accessSecret || 'change_me_in_production',
refreshSecret: refreshSecret || 'change_me_in_production',
expiresIn: process.env.JWT_EXPIRES_IN || '1h', expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}; };

View File

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

View File

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

View File

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

View File

@ -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: ['重新解释', '给我一个例子', '检查我的理解'],
};
}
}

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
userId, summary: string;
sessionId: data.sessionId || '', strengths: string[];
inputText: data.userInput || '', weaknesses: string[];
status: 'pending', missingKeyPoints: string[];
createdAt: new Date(), misconceptions: string[];
}; focusItems: Array<{ title: string; reason: string; suggestion?: string; priority: string }>;
this.jobs.set(job.id, job); reviewSuggestion: { shouldReview: boolean; intervalDays: number; cardFront?: string; cardBack?: string };
return job; }) {
return this.prisma.aiAnalysisResult.create({
data: {
userId,
jobId: '', // no job for sync analysis
summary: aiResult.summary,
masteryScore: aiResult.score,
strengths: aiResult.strengths as any,
weaknesses: aiResult.weaknesses as any,
suggestions: aiResult.focusItems as any,
nextActions: aiResult.reviewSuggestion as any,
rawResult: aiResult as any,
},
});
} }
async findJobById(id: string): Promise<AnalysisJob | undefined> { async findResultById(id: string) {
return this.jobs.get(id); return this.prisma.aiAnalysisResult.findUnique({ where: { 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,
strengths: aiResult.strengths,
weakPoints: aiResult.weakPoints,
suggestions: aiResult.suggestions,
createdAt: new Date(),
};
this.results.set(result.id, result);
return result;
}
async findResultById(id: string): Promise<AnalysisResult | undefined> {
return this.results.get(id);
} }
} }

View File

@ -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;
userAnswer: string;
}) {
const result = await this.workflow.execute({
userId,
questionText: input.questionText,
knowledgeItemContent: input.knowledgeItemContent,
userAnswer: input.userAnswer,
});
const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`; const saved = await this.repository.createResult(userId, result);
const lockToken = await this.redis.lock(lockKey, 300); return { resultId: saved.id, ...result };
if (!lockToken) {
throw new HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT);
}
const job = await this.repository.createJob(userId, body);
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'pending', 86400);
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '0', 86400);
this.queue.add('ai-analysis', { jobId: job.id, userId, sessionId: body.sessionId });
this.processJob(job, lockKey, lockToken);
return { jobId: job.id, status: job.status };
}
private async checkRateLimit(userId: string) {
const today = new Date().toISOString().split('T')[0];
const rateKey = `rate:user:${userId}:ai:daily:${today}`;
const count = await this.redis.incr(rateKey);
if (count === 1) {
await this.redis.expire(rateKey, 86400);
}
if (count > DAILY_AI_LIMIT) {
throw new HttpException(
`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
private processJob(job: any, lockKey: string, lockToken: string) {
try {
this.repository.updateJobStatus(job.id, 'processing');
this.redis.set(`job:ai-analysis:${job.id}:status`, 'processing', 86400);
this.redis.set(`job:ai-analysis:${job.id}:progress`, '30', 86400);
this.aiService.analyze({
userInput: job.inputText,
context: { lessonTitle: '', objectives: [], keyPoints: [] },
}).then(async (result) => {
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '80', 86400);
await this.repository.createResult(job.id, job.userId, result);
this.repository.updateJobStatus(job.id, 'success');
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'completed', 86400);
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '100', 86400);
await this.redis.unlock(lockKey, lockToken);
this.logger.log(`Job ${job.id} completed`);
}).catch(async (err) => {
this.logger.error(`Job ${job.id} failed: ${err.message}`);
this.repository.updateJobStatus(job.id, 'failed');
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'failed', 86400);
await this.redis.set(`job:ai-analysis:${job.id}:error`, err.message, 86400);
await this.redis.unlock(lockKey, lockToken);
});
} catch (err) {
this.logger.error(`Job ${job.id} sync error: ${err}`);
this.redis.unlock(lockKey, lockToken);
}
} }
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,
};
}
} }

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

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

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

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

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

View File

@ -0,0 +1,27 @@
export const ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT = `你是一位专业的学习分析导师,擅长评估学习者对知识点的理解程度。
1.
2.
3.
4.
- score0-100
- masteryLevelexcellent(90+) / good(70-89) / partial(50-69) / weak(30-49) / none(<30)
- summary1-3
- strengths
- weaknesses
- missingKeyPoints
- misconceptions
- weaknessTypesmissing_detail / missing_application / misconception / vague_expression / incomplete_structure / wrong_emphasis
- focusItems5 title/reason/suggestion/priority
- reviewSuggestion
-
- 使
- "不完整"
- reviewSuggestion.cardFront cardBack `;

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

View File

@ -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失效。"
}
}`;

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

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

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

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

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

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

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

View File

@ -0,0 +1,79 @@
import * as crypto from 'crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose';
@Injectable()
export class AppleAuthService {
private readonly appleIssuer: string;
private readonly appleBundleId: string;
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
constructor(private readonly configService: ConfigService) {
this.appleIssuer = this.configService.get<string>(
'apple.issuer',
'https://appleid.apple.com',
);
this.appleBundleId = this.configService.get<string>('apple.bundleId', '');
this.jwks = createRemoteJWKSet(
new URL('https://appleid.apple.com/auth/keys'),
);
}
async verifyIdentityToken(identityToken: string): Promise<{
appleUserId: string;
email?: string;
emailVerified?: boolean;
}> {
if (!this.appleBundleId) {
return this.verifyMock(identityToken);
}
return this.verifyReal(identityToken);
}
private verifyMock(identityToken: string): {
appleUserId: string;
} {
if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效');
}
return {
appleUserId: crypto
.createHash('sha256')
.update(`apple-mock:${identityToken}`)
.digest('hex')
.slice(0, 64),
};
}
private async verifyReal(identityToken: string): Promise<{
appleUserId: string;
email?: string;
emailVerified?: boolean;
}> {
try {
const { payload } = await jwtVerify(identityToken, this.jwks, {
issuer: this.appleIssuer,
audience: this.appleBundleId,
});
return {
appleUserId: payload.sub!,
email:
typeof payload.email === 'string' ? payload.email : undefined,
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
};
} catch (err: any) {
const msg: string = err?.message ?? '';
if (msg.includes('audience')) {
throw new UnauthorizedException(
`identityToken audience 不匹配,期望 ${this.appleBundleId}`,
);
}
if (msg.includes('issuer')) {
throw new UnauthorizedException('identityToken issuer 无效');
}
throw new UnauthorizedException('identityToken 验证失败');
}
}
}

View File

@ -1,69 +1,53 @@
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,
BadRequestException,
} from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto';
import { Public } from '../../common/decorators/public.decorator';
import type { Request } from 'express'; import type { Request } from 'express';
import { IsString, Allow, IsOptional } from 'class-validator';
class AppleLoginDto {
@IsString()
identityToken: string;
@IsString()
authorizationCode: string;
@Allow()
@IsOptional()
user?: any;
}
class RefreshDto {
@IsString()
refreshToken: string;
}
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Public()
@Post('dev-login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '开发登录(仅非生产环境)' })
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 403, description: '生产环境禁用' })
async devLogin(@Body() dto: DevLoginDto) {
return this.authService.devLogin(dto);
}
@Public()
@Post('apple') @Post('apple')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apple 登录' }) @ApiOperation({ summary: 'Apple 登录' })
@ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '身份验证失败' }) @ApiResponse({ status: 401, description: '身份验证失败' })
async appleLogin(@Body() body: AppleLoginDto) { async appleLogin(@Body() dto: AppleLoginDto) {
return this.authService.appleLogin(body); return this.authService.appleLogin(dto);
} }
@Public()
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌' }) @ApiOperation({ summary: '刷新令牌' })
@ApiResponse({ status: 200, description: '刷新成功' }) @ApiResponse({ status: 200, description: '刷新成功' })
@ApiResponse({ status: 401, description: '刷新令牌无效' }) @ApiResponse({ status: 401, description: '刷新令牌无效' })
async refresh(@Body() body: RefreshDto) { async refresh(@Body() dto: RefreshDto) {
if (!body.refreshToken) { return this.authService.refresh(dto.refreshToken);
throw new BadRequestException('缺少 refreshToken');
}
return this.authService.refresh(body.refreshToken);
} }
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出登录' }) @ApiOperation({ summary: '退出登录' })
@ApiResponse({ status: 200, description: '退出成功' }) @ApiResponse({ status: 200, description: '退出成功' })
async logout(@Req() req: Request) { @ApiResponse({ status: 401, description: '未登录' })
async logout(@Req() req: Request, @Body() dto: RefreshDto) {
const user = (req as any).user; const user = (req as any).user;
if (user?.id) { await this.authService.logout(user.id, dto.refreshToken);
await this.authService.logout(user.id);
}
return { success: true, message: '已退出登录' }; return { success: true, message: '已退出登录' };
} }
} }

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AppleAuthService } from './apple-auth.service';
import { TokenService } from './token.service';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService], providers: [AuthService, AppleAuthService, TokenService],
exports: [AuthService], exports: [AuthService, TokenService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,60 +1,88 @@
import * as crypto from 'crypto'; import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import jwksClient from 'jwks-rsa'; import { AppleAuthService } from './apple-auth.service';
import jwt from 'jsonwebtoken'; import { TokenService } from './token.service';
import type { AppleLoginDto, DevLoginDto } from './dto';
interface AppleIdTokenPayload {
sub: string;
email?: string;
email_verified?: string | boolean;
is_private_email?: string | boolean;
aud: string;
iss: string;
exp: number;
iat: number;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private jwks: jwksClient.JwksClient;
constructor( constructor(
private readonly nativeJwtService: JwtService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly configService: ConfigService, private readonly appleAuthService: AppleAuthService,
private readonly tokenService: TokenService,
) {} ) {}
async appleLogin(params: { async devLogin(dto: DevLoginDto) {
identityToken: string; if (process.env.NODE_ENV === 'production') {
authorizationCode: string; throw new ForbiddenException('dev-login is disabled in production');
user?: { name?: { firstName?: string; lastName?: string }; email?: string }; }
}) {
const appleUserId = await this.verifyAppleIdentity( const devSecret = process.env.DEV_SECRET;
params.identityToken, if (!devSecret || dto.devSecret !== devSecret) {
params.authorizationCode, throw new UnauthorizedException('devSecret 无效');
); }
const providerUserId = dto.email;
let account = await this.prisma.authAccount.findUnique({ let account = await this.prisma.authAccount.findUnique({
where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } }, where: {
provider_providerUserId: {
provider: 'DEV',
providerUserId,
},
},
include: { user: true },
});
if (!account) {
account = await this.prisma.authAccount.create({
data: {
provider: 'DEV',
providerUserId,
email: dto.email,
user: {
create: {
email: dto.email,
nickname: dto.nickname || '测试用户',
status: 'active',
},
},
},
include: { user: true },
});
}
return this.buildLoginResponse(account.user);
}
async appleLogin(dto: AppleLoginDto) {
const { appleUserId, email: appleEmail } =
await this.appleAuthService.verifyIdentityToken(dto.identityToken);
let account = await this.prisma.authAccount.findUnique({
where: {
provider_providerUserId: {
provider: 'APPLE',
providerUserId: appleUserId,
},
},
include: { user: true }, include: { user: true },
}); });
if (!account) { if (!account) {
const displayName = const displayName =
params.user?.name dto.fullName?.givenName
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` ? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
: undefined; : undefined;
account = await this.prisma.authAccount.create({ account = await this.prisma.authAccount.create({
data: { data: {
provider: 'apple', provider: 'APPLE',
providerUserId: appleUserId, providerUserId: appleUserId,
email: params.user?.email, email: appleEmail || dto.email || null,
user: { user: {
create: { create: {
email: params.user?.email, email: appleEmail || dto.email || null,
nickname: displayName || undefined, nickname: displayName || undefined,
status: 'active', status: 'active',
}, },
@ -64,30 +92,11 @@ export class AuthService {
}); });
} }
const accessToken = await this.nativeJwtService.signAsync({ return this.buildLoginResponse(account.user);
sub: String(account.user.id),
email: account.user.email,
});
const refreshToken = crypto.randomBytes(48).toString('hex');
const refreshTokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
await this.prisma.refreshToken.create({
data: {
userId: account.user.id,
tokenHash: refreshTokenHash,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
});
return { accessToken, refreshToken, expiresIn: 3600 };
} }
async refresh(refreshToken: string) { async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); const hash = this.tokenService.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({ const stored = await this.prisma.refreshToken.findFirst({
where: { tokenHash: hash, revokedAt: null }, where: { tokenHash: hash, revokedAt: null },
include: { user: true }, include: { user: true },
@ -102,11 +111,8 @@ export class AuthService {
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
const newRefreshToken = crypto.randomBytes(48).toString('hex'); const { token: newRefreshToken, hash: newHash } =
const newHash = crypto this.tokenService.generateRefreshToken();
.createHash('sha256')
.update(newRefreshToken)
.digest('hex');
await this.prisma.refreshToken.create({ await this.prisma.refreshToken.create({
data: { data: {
@ -116,101 +122,81 @@ export class AuthService {
}, },
}); });
const accessToken = await this.nativeJwtService.signAsync({ const accessToken = await this.tokenService.generateAccessToken(
sub: String(stored.user.id), stored.user,
email: stored.user.email, );
return {
accessToken,
refreshToken: newRefreshToken,
user: this.serializeUser(stored.user),
};
}
async logout(userId: string, refreshToken: string) {
const hash = this.tokenService.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: {
tokenHash: hash,
userId,
revokedAt: null,
},
}); });
return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 }; if (stored) {
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
}
} }
async logout(userId: number) { private async buildLoginResponse(user: {
await this.prisma.refreshToken.updateMany({ id: string;
where: { userId, revokedAt: null }, email: string | null;
data: { revokedAt: new Date() }, nickname: string | null;
avatarUrl: string | null;
role: string;
status: string;
onboardingCompleted: boolean;
}) {
const accessToken = await this.tokenService.generateAccessToken(user);
const { token: refreshToken, hash } =
this.tokenService.generateRefreshToken();
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 7 * 86400000),
},
}); });
return {
accessToken,
refreshToken,
user: this.serializeUser(user),
};
} }
private async verifyAppleIdentity( private serializeUser(user: {
identityToken: string, id: string;
authorizationCode: string, email: string | null;
): Promise<string> { nickname: string | null;
if (this.isMockMode()) { avatarUrl: string | null;
return this.verifyMockApple(identityToken); role: string;
} status: string;
return this.verifyRealApple(identityToken); onboardingCompleted: boolean;
} }) {
return {
private isMockMode(): boolean { id: user.id,
const bundleId = this.configService.get<string>('apple.bundleId'); email: user.email,
return !bundleId; nickname: user.nickname,
} avatarUrl: user.avatarUrl,
role: user.role,
private verifyMockApple(identityToken: string): string { status: user.status,
if (!identityToken || identityToken.trim().length < 4) { onboardingCompleted: user.onboardingCompleted,
throw new UnauthorizedException('identityToken 无效'); };
}
return crypto
.createHash('sha256')
.update(`apple-mock:${identityToken}`)
.digest('hex')
.slice(0, 64);
}
private getJwksClient(): jwksClient.JwksClient {
if (!this.jwks) {
const jwksUrl = this.configService.get<string>(
'apple.jwksUrl',
'https://appleid.apple.com/auth/keys',
);
this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true });
}
return this.jwks!;
}
private async verifyRealApple(identityToken: string): Promise<string> {
const bundleId = this.configService.get<string>('apple.bundleId');
const issuer = this.configService.get<string>('apple.issuer', 'https://appleid.apple.com');
const decodedHeader = jwt.decode(identityToken, { complete: true });
if (!decodedHeader || typeof decodedHeader === 'string') {
throw new UnauthorizedException('无法解析 identityToken');
}
const kid = decodedHeader.header.kid;
if (!kid) {
throw new UnauthorizedException('identityToken 缺少 kid');
}
let publicKey: string;
try {
const client = this.getJwksClient();
const key = await client.getSigningKey(kid);
publicKey = key.getPublicKey();
} catch {
throw new UnauthorizedException('无法获取 Apple 公钥,请稍后重试');
}
let payload: AppleIdTokenPayload;
try {
payload = jwt.verify(identityToken, publicKey, {
algorithms: ['RS256'],
issuer,
audience: bundleId,
}) as AppleIdTokenPayload;
} catch (err: any) {
const msg = err.message || '';
if (msg.includes('audience')) {
throw new UnauthorizedException(
`identityToken audience 不匹配,期望 ${bundleId}`,
);
}
if (msg.includes('issuer')) {
throw new UnauthorizedException('identityToken issuer 无效');
}
throw new UnauthorizedException('identityToken 验证失败');
}
return payload.sub;
} }
} }

View File

@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AppleLoginDto {
@ApiProperty({ description: 'Apple identityToken (JWT)' })
@IsString()
@IsNotEmpty()
identityToken: string;
@ApiPropertyOptional({ description: 'Apple authorizationCode' })
@IsOptional()
@IsString()
authorizationCode?: string;
@ApiPropertyOptional({ description: 'Apple userIdentifier' })
@IsOptional()
@IsString()
userIdentifier?: string;
@ApiPropertyOptional({ description: 'Apple 返回的邮箱(仅首次返回)' })
@Transform(({ value }) => (value === '' ? undefined : value))
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ description: 'Apple 返回的姓名(仅首次返回)' })
@IsOptional()
fullName?: {
givenName?: string;
familyName?: string;
};
@ApiPropertyOptional({ description: 'Apple nonce安全校验用' })
@Transform(({ value }) => (value === '' ? undefined : value))
@IsOptional()
@IsString()
nonce?: string;
}

View File

@ -0,0 +1,17 @@
import { IsString, IsEmail, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class DevLoginDto {
@ApiProperty({ description: '开发测试邮箱' })
@IsEmail()
email: string;
@ApiPropertyOptional({ description: '昵称' })
@IsOptional()
@IsString()
nickname?: string;
@ApiProperty({ description: '开发密钥' })
@IsString()
devSecret: string;
}

View File

@ -0,0 +1,3 @@
export { AppleLoginDto } from './apple-login.dto';
export { DevLoginDto } from './dev-login.dto';
export { RefreshDto } from './refresh-token.dto';

View File

@ -0,0 +1,9 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshDto {
@ApiProperty({ description: 'refreshToken' })
@IsString()
@IsNotEmpty()
refreshToken: string;
}

View File

@ -0,0 +1,26 @@
import * as crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class TokenService {
constructor(private readonly jwtService: JwtService) {}
generateAccessToken(user: { id: string; email?: string | null; role?: string | null }): Promise<string> {
return this.jwtService.signAsync({
sub: user.id,
email: user.email,
role: user.role,
});
}
generateRefreshToken(): { token: string; hash: string } {
const token = crypto.randomBytes(48).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
return { token, hash };
}
hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}

View File

@ -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 ?? '',
status: 'pending', sourceType: data.sourceType ?? 'upload',
createdAt: new Date(), sourceName: data.fileName ?? 'unknown',
updatedAt: new Date(), status: 'pending',
}; },
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(); });
}
} }
} }

View File

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

View File

@ -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: '反馈提交成功' })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
userId, parentId?: string;
knowledgeBaseId, itemType?: string;
parentId: dto.parentId || null, orderIndex?: number;
itemType: dto.itemType || 'lesson', }) {
title: dto.title || '', return this.prisma.knowledgeItem.create({
content: dto.content || '', data: {
orderIndex: dto.orderIndex || 0, userId,
createdAt: new Date(), knowledgeBaseId,
updatedAt: new Date(), title: dto.title ?? '',
}; content: dto.content ?? '',
this.items.set(item.id, item); parentId: dto.parentId ?? null,
return item; itemType: dto.itemType ?? 'lesson',
orderIndex: dto.orderIndex ?? 0,
},
});
} }
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; });
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
userId, mode?: string;
knowledgeItemId: dto.knowledgeItemId || '', }) {
mode: dto.mode || 'reading', return this.prisma.learningSession.create({
status: 'active', data: {
startedAt: new Date(), userId,
endedAt: null, knowledgeItemId: dto.knowledgeItemId ?? null,
durationSeconds: 0, knowledgeBaseId: dto.knowledgeBaseId ?? null,
}; mode: dto.mode ?? 'reading',
this.sessions.set(session.id, session); status: 'active',
return session; startedAt: new Date(),
},
});
} }
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' },
});
} }
} }

View File

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

View File

@ -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(),
userId: '1',
type: demo.type || 'system',
title: demo.title || '',
body: demo.body || '',
read: false,
createdAt: new Date(),
});
}
} }
async findAll(): Promise<Notification[]> { async create(data: { userId: string; type: string; title: string; body: string }) {
return [...this.notifications].sort( return this.prisma.notification.create({
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(), data: {
); userId: data.userId,
type: data.type,
title: data.title,
content: data.body,
},
});
} }
async create(data: Partial<Notification>): Promise<Notification> { async findById(id: string) {
const notification: Notification = { return this.prisma.notification.findUnique({ where: { id } });
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> { async markRead(id: string) {
return this.notifications.find((n) => n.id === id); return this.prisma.notification.update({
} where: { id },
data: { readAt: new Date() },
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;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '已认证' })

View File

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

View File

@ -1,37 +1,63 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable() @Injectable()
export class UsersRepository { export class UsersRepository {
private profiles: Map<string, any> = new Map(); constructor(private readonly prisma: PrismaService) {}
private preferences: Map<string, any> = new Map();
async findProfileByUserId(userId: string) { async findProfileByUserId(userId: string) {
return this.profiles.get(userId) || { const user = await this.prisma.user.findUnique({
userId, where: { id: userId },
nickname: '学习者', select: {
learningDirection: '', id: true,
bio: '', email: true,
nickname: true,
avatarUrl: true,
role: true,
status: true,
onboardingCompleted: true,
createdAt: true,
},
});
if (!user) {
throw new NotFoundException('用户不存在');
}
return {
id: user.id,
email: user.email,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role,
status: user.status,
onboardingCompleted: user.onboardingCompleted,
createdAt: user.createdAt,
}; };
} }
async updateProfile(userId: string, dto: any) { async updateProfile(userId: string, dto: any) {
const existing = (await this.findProfileByUserId(userId)) || {}; return this.prisma.user.update({
const updated = { ...existing, ...dto }; where: { id: userId },
this.profiles.set(userId, updated); data: {
return updated; nickname: dto.nickname,
avatarUrl: dto.avatarUrl,
},
});
} }
async updatePreferences(userId: string, dto: any) { async updatePreferences(userId: string, dto: any) {
const existing = this.preferences.get(userId) || { return this.prisma.userPreference.upsert({
userId, where: { userId },
defaultFocusMinutes: 25, create: {
aiSuggestionLevel: 'normal', userId,
language: 'zh-CN', defaultFocusMinutes: dto.defaultFocusMinutes ?? 25,
appearance: 'system', aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal',
notificationEnabled: true, language: dto.language ?? 'zh-CN',
}; appearance: dto.appearance ?? 'system',
const updated = { ...existing, ...dto }; notificationEnabled: dto.notificationEnabled ?? true,
this.preferences.set(userId, updated); },
return updated; update: dto,
});
} }
} }

View File

@ -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: '加入等待名单' })

View File

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

View File

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