feat: 重构 api-server 为模块化单体架构,接入 MySQL + Redis

- 按 BACKEND-PLAN.md 将项目重构为 4 层架构:
  config/ -> common/ -> infrastructure/ -> modules/
- 15 个业务模块,遵循 Controller → Service → Repository 分层
- infrastructure: PrismaService / RedisService / QueueService / AiService / StorageService
- common: guards / interceptors / filters / pipes / decorators / dto / types / utils
- Prisma schema 含 27 张表,MySQL 8.0 服务器 db push 成功
- Redis 7 接入: 限流/任务状态/分布式锁/队列预留
- ai-analysis 模块: 每日 50 次限流 + 重复提交锁 + 异步任务状态追踪
- document-import 模块: 异步导入流程 + 进度追踪
- notifications 模块: BullMQ notification 队列预留
- /health 端点实时返回 database + redis 连接状态
- Swagger 注册 15 个 tag,67 个路由全部映射
This commit is contained in:
WangDL 2026-05-09 18:25:04 +08:00
parent bd44b7e138
commit 35de65e99b
133 changed files with 6571 additions and 1935 deletions

View File

@ -1,9 +1,11 @@
PORT=3000
DATABASE_URL="mysql://ai_study_user:ai_study_password@localhost:3306/ai_study"
DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@81.70.187.179:3306/zhixi"
REDIS_HOST=localhost
REDIS_HOST=81.70.187.179
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
AI_PROVIDER=mock
AI_API_KEY=

1185
BACKEND-PLAN.md Normal file

File diff suppressed because it is too large Load Diff

814
DATABASE-DESIGN.md Normal file
View File

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

262
REDIS-DESIGN.md Normal file
View File

@ -0,0 +1,262 @@
---
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 管过程。**

1496
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,12 +20,17 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@bull-board/nestjs": "^7.0.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"ioredis": "^5.10.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
@ -36,16 +41,19 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@prisma/client": "^5.22.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/node": "^24.12.3",
"@types/supertest": "^7.0.0",
"bullmq": "^5.76.6",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

534
prisma/schema.prisma Normal file
View File

@ -0,0 +1,534 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id BigInt @id @default(autoincrement())
email String? @db.VarChar(255)
nickname String? @db.VarChar(100)
avatarUrl String? @db.VarChar(500)
status String @default("active") @db.VarChar(32)
onboardingCompleted Boolean @default(false)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
authAccounts AuthAccount[]
refreshTokens RefreshToken[]
profile UserProfile?
preferences UserPreference?
consents UserConsent[]
knowledgeBases KnowledgeBase[]
knowledgeItems KnowledgeItem[]
knowledgeItemRelations KnowledgeItemRelation[]
tags Tag[]
uploadedFiles UploadedFile[]
documentImports DocumentImport[]
learningSessions LearningSession[]
learningRecords LearningRecord[]
activeRecallQuestions ActiveRecallQuestion[]
activeRecallAnswers ActiveRecallAnswer[]
aiAnalysisJobs AiAnalysisJob[]
aiAnalysisResults AiAnalysisResult[]
focusItems FocusItem[]
reviewCards ReviewCard[]
reviewLogs ReviewLog[]
reviewPlans ReviewPlan[]
dailyLearningActivities DailyLearningActivity[]
notifications Notification[]
feedbacks Feedback[]
@@index([email])
@@index([status])
}
model AuthAccount {
id BigInt @id @default(autoincrement())
userId BigInt
provider String @db.VarChar(32)
providerUserId String @db.VarChar(255)
email String? @db.VarChar(255)
rawProfileJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([provider, providerUserId])
@@index([userId])
}
model RefreshToken {
id BigInt @id @default(autoincrement())
userId BigInt
tokenHash String @db.VarChar(255)
deviceId String? @db.VarChar(255)
deviceName String? @db.VarChar(255)
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([tokenHash])
}
model UserProfile {
id BigInt @id @default(autoincrement())
userId BigInt @unique
learningIdentity String? @db.VarChar(100)
learningDirection String? @db.VarChar(255)
bio String? @db.Text
currentGoal String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model UserPreference {
id BigInt @id @default(autoincrement())
userId BigInt @unique
preferredMethods Json?
defaultFocusMinutes Int @default(25)
aiSuggestionLevel String @default("normal") @db.VarChar(32)
language String @default("zh-CN") @db.VarChar(32)
appearance String @default("system") @db.VarChar(32)
notificationEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model UserConsent {
id BigInt @id @default(autoincrement())
userId BigInt
consentType String @db.VarChar(32)
version String @db.VarChar(50)
acceptedAt DateTime
ipAddress String? @db.VarChar(100)
userAgent String? @db.VarChar(500)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([consentType])
}
model KnowledgeBase {
id BigInt @id @default(autoincrement())
userId BigInt
title String @db.VarChar(255)
description String? @db.Text
coverKey String? @db.VarChar(100)
status String @default("active") @db.VarChar(32)
itemCount Int @default(0)
lastStudiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id])
items KnowledgeItem[]
focusItems FocusItem[]
@@index([userId])
@@index([status])
}
model KnowledgeItem {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeBaseId BigInt
parentId BigInt?
itemType String @db.VarChar(32)
title String @db.VarChar(255)
content String? @db.LongText
summary String? @db.Text
sourceType String? @db.VarChar(32)
sourceRef String? @db.VarChar(500)
orderIndex Int @default(0)
status String @default("active") @db.VarChar(32)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id])
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
parent KnowledgeItem? @relation("KnowledgeItemRelations", fields: [parentId], references: [id])
children KnowledgeItem[] @relation("KnowledgeItemRelations")
tags KnowledgeItemTag[]
@@index([userId])
@@index([knowledgeBaseId])
@@index([parentId])
@@index([itemType])
}
model KnowledgeItemRelation {
id BigInt @id @default(autoincrement())
userId BigInt
sourceItemId BigInt
targetItemId BigInt
relationType String @db.VarChar(32)
confidence Decimal? @db.Decimal(5, 2)
reason String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([sourceItemId])
@@index([targetItemId])
}
model Tag {
id BigInt @id @default(autoincrement())
userId BigInt
name String @db.VarChar(100)
color String? @db.VarChar(32)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
items KnowledgeItemTag[]
@@unique([userId, name])
}
model KnowledgeItemTag {
id BigInt @id @default(autoincrement())
knowledgeItemId BigInt
tagId BigInt
createdAt DateTime @default(now())
knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@unique([knowledgeItemId, tagId])
}
model UploadedFile {
id BigInt @id @default(autoincrement())
userId BigInt
filename String @db.VarChar(255)
mimeType String? @db.VarChar(100)
storagePath String @db.VarChar(500)
sizeBytes BigInt @default(0)
checksum String? @db.VarChar(255)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
}
model DocumentImport {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeBaseId BigInt?
fileId BigInt?
sourceType String @db.VarChar(32)
sourceName String? @db.VarChar(255)
sourceUrl String? @db.VarChar(500)
rawText String? @db.LongText
status String @default("pending") @db.VarChar(32)
progress Int @default(0)
errorMessage String? @db.Text
resultJson Json?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([status])
}
model LearningSession {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeBaseId BigInt?
knowledgeItemId BigInt?
mode String @db.VarChar(32)
status String @default("active") @db.VarChar(32)
startedAt DateTime
endedAt DateTime?
durationSeconds Int @default(0)
focusMinutes Int?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([knowledgeItemId])
@@index([startedAt])
}
model LearningRecord {
id BigInt @id @default(autoincrement())
userId BigInt
sessionId BigInt?
recordType String @db.VarChar(32)
title String @db.VarChar(255)
description String? @db.Text
durationSeconds Int @default(0)
occurredAt DateTime
metadata Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([occurredAt])
}
model ActiveRecallQuestion {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeItemId BigInt?
questionText String @db.Text
difficulty String? @db.VarChar(32)
createdBy String @default("ai") @db.VarChar(32)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
answers ActiveRecallAnswer[]
@@index([userId])
@@index([knowledgeItemId])
}
model ActiveRecallAnswer {
id BigInt @id @default(autoincrement())
userId BigInt
questionId BigInt?
sessionId BigInt?
answerType String @default("text") @db.VarChar(32)
answerText String? @db.LongText
audioFileId BigInt?
submittedAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
question ActiveRecallQuestion? @relation(fields: [questionId], references: [id])
@@index([userId])
@@index([questionId])
@@index([sessionId])
}
model AiAnalysisJob {
id BigInt @id @default(autoincrement())
userId BigInt
sessionId BigInt?
answerId BigInt?
jobType String @db.VarChar(32)
status String @default("pending") @db.VarChar(32)
progress Int @default(0)
errorMessage String? @db.Text
queuedAt DateTime?
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
results AiAnalysisResult[]
@@index([userId])
@@index([status])
@@index([sessionId])
}
model AiAnalysisResult {
id BigInt @id @default(autoincrement())
userId BigInt
jobId BigInt
sessionId BigInt?
answerId BigInt?
summary String? @db.Text
masteryScore Int?
strengths Json?
weaknesses Json?
suggestions Json?
nextActions Json?
rawResult Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
job AiAnalysisJob @relation(fields: [jobId], references: [id])
@@index([userId])
@@index([jobId])
@@index([sessionId])
}
model FocusItem {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeBaseId BigInt?
knowledgeItemId BigInt?
analysisResultId BigInt?
title String @db.VarChar(255)
reason String? @db.Text
suggestion String? @db.Text
priority String @default("normal") @db.VarChar(32)
status String @default("open") @db.VarChar(32)
masteryScore Int?
dueAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id])
knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id])
@@index([userId])
@@index([status])
@@index([dueAt])
}
model ReviewCard {
id BigInt @id @default(autoincrement())
userId BigInt
knowledgeItemId BigInt?
focusItemId BigInt?
frontText String @db.Text
backText String? @db.Text
difficulty String? @db.VarChar(32)
status String @default("active") @db.VarChar(32)
nextReviewAt DateTime?
intervalDays Int @default(1)
easeFactor Decimal @default(2.50) @db.Decimal(4, 2)
repetitionCount Int @default(0)
lapseCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id])
logs ReviewLog[]
@@index([userId])
@@index([nextReviewAt])
@@index([focusItemId])
}
model ReviewLog {
id BigInt @id @default(autoincrement())
userId BigInt
reviewCardId BigInt
sessionId BigInt?
rating String @db.VarChar(32)
responseText String? @db.Text
reviewedAt DateTime
nextReviewAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
reviewCard ReviewCard @relation(fields: [reviewCardId], references: [id])
@@index([userId])
@@index([reviewCardId])
@@index([reviewedAt])
}
model ReviewPlan {
id BigInt @id @default(autoincrement())
userId BigInt
title String @db.VarChar(255)
status String @default("active") @db.VarChar(32)
scheduledAt DateTime?
completedAt DateTime?
cardCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([scheduledAt])
}
model DailyLearningActivity {
id BigInt @id @default(autoincrement())
userId BigInt
activityDate DateTime @db.Date
durationSeconds Int @default(0)
sessionsCount Int @default(0)
activeRecallCount Int @default(0)
reviewCount Int @default(0)
aiAnalysisCount Int @default(0)
completedLoopCount Int @default(0)
activityLevel Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, activityDate])
@@index([userId])
}
model Notification {
id BigInt @id @default(autoincrement())
userId BigInt
type String @db.VarChar(32)
title String @db.VarChar(255)
content String? @db.Text
data Json?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([readAt])
@@index([type])
}
model Feedback {
id BigInt @id @default(autoincrement())
userId BigInt?
email String? @db.VarChar(255)
category String @db.VarChar(64)
content String @db.Text
deviceInfo Json?
status String @default("open") @db.VarChar(32)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([status])
}
model AppChangelog {
id BigInt @id @default(autoincrement())
version String @db.VarChar(50)
title String @db.VarChar(255)
content String @db.Text
platform String @default("ios") @db.VarChar(32)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,50 +0,0 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { AiService } from './ai.service';
@ApiTags('ai')
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('analyze')
@ApiOperation({ summary: 'AI 学习分析', description: '分析用户学习数据,提供学习洞察' })
@ApiResponse({ status: 200, description: '返回分析结果' })
@ApiResponse({ status: 400, description: '请求参数无效' })
async analyze(@Body() body: {
userId: string;
type: 'weakness' | 'progress' | 'recommendation';
context?: any;
}) {
return this.aiService.analyze(body);
}
@Post('chat')
@ApiOperation({ summary: 'AI 对话', description: '向 AI 学习助手发送消息' })
@ApiResponse({ status: 200, description: '返回 AI 回复' })
@ApiResponse({ status: 400, description: '消息格式无效' })
async chat(@Body() body: { userId: string; message: string; context?: string }) {
return this.aiService.chat(body);
}
@Get('sessions')
@ApiOperation({ summary: '获取对话历史', description: '获取用户的所有对话会话' })
@ApiQuery({ name: 'userId', description: '用户 ID', example: 'user_123456' })
@ApiResponse({ status: 200, description: '对话会话列表' })
async getSessions(@Query('userId') userId: string) {
return this.aiService.getSessions(userId);
}
@Get('sessions/:sessionId')
@ApiOperation({ summary: '获取指定会话', description: '获取特定的对话会话详情' })
@ApiParam({ name: 'sessionId', description: '会话 ID', example: 'session_123' })
@ApiQuery({ name: 'userId', description: '用户 ID', example: 'user_123456' })
@ApiResponse({ status: 200, description: '找到对话会话' })
@ApiResponse({ status: 404, description: '会话不存在' })
async getSession(
@Query('userId') userId: string,
@Param('sessionId') sessionId: string
) {
return this.aiService.getSession(userId, sessionId);
}
}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View File

@ -1,156 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AnalysisRequest, AnalysisResult, ChatSession, ChatMessage, ChatRequest, ChatResponse } from './entities/ai.entity';
@Injectable()
export class AiService {
private sessions: Map<string, ChatSession[]> = new Map();
async analyze(request: AnalysisRequest): Promise<AnalysisResult> {
const { type, context } = request;
if (type === 'weakness') {
return this.analyzeWeakness(context);
} else if (type === 'progress') {
return this.analyzeProgress(context);
} else {
return this.generateRecommendation(context);
}
}
private analyzeWeakness(context: any): AnalysisResult {
return {
type: 'weakness_analysis',
score: 0.7,
insights: [
'在归纳概括类题目中失分较多',
'对策建议的实操性有待提升',
'文章结构逻辑偶有跳跃',
],
recommendations: [
'每天练习一道归纳概括题',
'多参考政府官方文件中的对策表达',
'使用 AI 辅助检查文章逻辑连贯性',
],
generatedAt: new Date(),
};
}
private analyzeProgress(context: any): AnalysisResult {
return {
type: 'progress_analysis',
score: 0.85,
insights: [
'本周学习时长较上周增长 20%',
'练习正确率从 65% 提升到 78%',
'薄弱知识点数量减少 3 个',
],
recommendations: [
'保持当前学习节奏',
'可适当增加实战演练',
],
generatedAt: new Date(),
};
}
private generateRecommendation(context: any): AnalysisResult {
return {
type: 'recommendation',
score: 0.9,
insights: [
'根据你的学习数据,推荐以下学习路径',
],
recommendations: [
'继续完成申论基础认知课程',
'开始练习材料分析技巧',
'每天预留 30 分钟复习错题',
],
generatedAt: new Date(),
};
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const { userId, message, context } = request;
const sessions = this.sessions.get(userId) || [];
const currentSession = sessions[sessions.length - 1] || this.createSession(userId, context);
const userMessage: ChatMessage = {
id: `msg_${Date.now()}_user`,
role: 'user',
content: message,
timestamp: new Date(),
};
currentSession.messages.push(userMessage);
const aiResponse = this.generateMockResponse(message, currentSession.messages);
const assistantMessage: ChatMessage = {
id: `msg_${Date.now()}_ai`,
role: 'assistant',
content: aiResponse.content,
timestamp: new Date(),
};
currentSession.messages.push(assistantMessage);
if (!sessions.length) {
sessions.push(currentSession);
}
this.sessions.set(userId, sessions);
return {
sessionId: currentSession.id,
message: assistantMessage,
suggestions: aiResponse.suggestions,
};
}
private createSession(userId: string, context?: string): ChatSession {
return {
id: `session_${Date.now()}`,
userId,
messages: [],
context: context || '你是龙de的 AI 学习助手,专注于帮助用户学习。',
createdAt: new Date(),
};
}
private generateMockResponse(message: string, history: ChatMessage[]): { content: string; suggestions?: string[] } {
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes('申论') || lowerMessage.includes('怎么写')) {
return {
content: '申论写作的关键在于1) 准确理解材料主题 2) 逻辑清晰地展开论述 3) 提出的对策要具有可操作性。建议你先确定文章的结构框架,再逐步填充内容。需要我帮你分析具体的题目吗?',
suggestions: ['帮我分析这道题', '给我一个写作框架', '如何提升对策质量'],
};
}
if (lowerMessage.includes('复习') || lowerMessage.includes('忘记')) {
return {
content: '根据间隔重复原理,我建议你在学习新知识后的 1 天、3 天、7 天、14 天分别复习。复习时先回忆要点,如果想不起来再看原内容,这样记忆效果会更好。要我帮你制定复习计划吗?',
suggestions: ['制定复习计划', '查看复习任务', '调整复习间隔'],
};
}
if (lowerMessage.includes('算法') || lowerMessage.includes('面试')) {
return {
content: '面试算法准备建议分三步1) 掌握基础数据结构数组、链表、树、图2) 熟悉常见算法思想递归、动态规划、二分3) 多做真题模拟。建议每天至少刷 2 道题,保持手感。',
suggestions: ['推荐刷题路线', '讲解动态规划', '模拟面试场景'],
};
}
return {
content: '好的,我理解你的问题。作为你的学习助手,我可以帮助你:\n\n• 解答学习中的具体问题\n• 分析你的学习薄弱点\n• 提供学习计划建议\n• 陪你练习题目\n\n请告诉我你具体想学习什么内容',
suggestions: ['公考申论指导', 'AI工具使用', '编程面试准备'],
};
}
async getSession(userId: string, sessionId: string): Promise<ChatSession | undefined> {
const sessions = this.sessions.get(userId) || [];
return sessions.find(s => s.id === sessionId);
}
async getSessions(userId: string): Promise<ChatSession[]> {
return this.sessions.get(userId) || [];
}
}

View File

@ -1,86 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AnalysisRequest {
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '分析类型', example: 'weakness', enum: ['weakness', 'progress', 'recommendation'] })
type: 'weakness' | 'progress' | 'recommendation';
@ApiPropertyOptional({ description: '分析上下文数据' })
context?: {
completedLessons?: string[];
accuracy?: number;
timeSpent?: number;
};
}
export class AnalysisResult {
@ApiProperty({ description: '分析类型', example: 'weakness_analysis' })
type: string;
@ApiProperty({ description: '评分 0-1', example: 0.7 })
score: number;
@ApiProperty({ description: '关键洞察', example: ['在归纳概括类题目中失分较多'] })
insights: string[];
@ApiProperty({ description: '建议', example: ['每天练习一道归纳概括题'] })
recommendations: string[];
@ApiProperty({ description: '分析时间' })
generatedAt: Date;
}
export class ChatMessage {
@ApiProperty({ description: '消息 ID', example: 'msg_123' })
id: string;
@ApiProperty({ description: '消息角色', example: 'user', enum: ['user', 'assistant', 'system'] })
role: 'user' | 'assistant' | 'system';
@ApiProperty({ description: '消息内容', example: '我想学习申论怎么写' })
content: string;
@ApiProperty({ description: '时间戳' })
timestamp: Date;
}
export class ChatSession {
@ApiProperty({ description: '会话 ID', example: 'session_123' })
id: string;
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '聊天历史', type: [ChatMessage] })
messages: ChatMessage[];
@ApiProperty({ description: '系统上下文', example: '你是龙de的 AI 学习助手' })
context: string;
@ApiProperty({ description: '会话创建时间' })
createdAt: Date;
}
export class ChatRequest {
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '用户消息', example: '我想学习申论怎么写' })
message: string;
@ApiPropertyOptional({ description: '对话上下文(可选)' })
context?: string;
}
export class ChatResponse {
@ApiProperty({ description: '会话 ID', example: 'session_123' })
sessionId: string;
@ApiProperty({ description: 'AI 回复消息' })
message: ChatMessage;
@ApiPropertyOptional({ description: '建议的跟进操作', example: ['帮我分析这道题'] })
suggestions?: string[];
}

View File

@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -1,16 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AppService } from './app.service';
@ApiTags('health')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ApiOperation({ summary: '服务健康检查', description: '检查 API 服务器是否正常运行' })
@ApiResponse({ status: 200, description: 'API 运行正常', schema: { example: 'Hello World!' } })
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,25 +1,68 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WaitlistModule } from './waitlist/waitlist.module';
import { UsersModule } from './users/users.module';
import { LearningModule } from './learning/learning.module';
import { AiModule } from './ai/ai.module';
import { FeedbackModule } from './feedback/feedback.module';
import { AuthModule } from './auth/auth.module';
import { KnowledgeModule } from './knowledge/knowledge.module';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './infrastructure/database/prisma.module';
import { RedisModule } from './infrastructure/redis/redis.module';
import { QueueModule } from './infrastructure/queue/queue.module';
import { AiModule } from './infrastructure/ai/ai.module';
import { StorageModule } from './infrastructure/storage/storage.module';
import { LoggerModule } from './infrastructure/logger/logger.module';
import { SystemModule } from './modules/system/system.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module';
import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module';
import { DocumentImportModule } from './modules/document-import/document-import.module';
import { LearningSessionModule } from './modules/learning-session/learning-session.module';
import { ActiveRecallModule } from './modules/active-recall/active-recall.module';
import { AiAnalysisModule } from './modules/ai-analysis/ai-analysis.module';
import { ReviewModule } from './modules/review/review.module';
import { FocusItemsModule } from './modules/focus-items/focus-items.module';
import { LearningActivityModule } from './modules/learning-activity/learning-activity.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { FeedbackModule } from './modules/feedback/feedback.module';
import { WaitlistModule } from './modules/waitlist/waitlist.module';
import appConfig from './config/app.config';
import databaseConfig from './config/database.config';
import redisConfig from './config/redis.config';
import jwtConfig from './config/jwt.config';
import aiConfig from './config/ai.config';
import storageConfig from './config/storage.config';
@Module({
imports: [
WaitlistModule,
UsersModule,
LearningModule,
ConfigModule.forRoot({
isGlobal: true,
load: [
appConfig,
databaseConfig,
redisConfig,
jwtConfig,
aiConfig,
storageConfig,
],
}),
PrismaModule,
RedisModule,
QueueModule,
AiModule,
FeedbackModule,
StorageModule,
LoggerModule,
SystemModule,
AuthModule,
KnowledgeModule,
UsersModule,
KnowledgeBaseModule,
KnowledgeItemsModule,
DocumentImportModule,
LearningSessionModule,
ActiveRecallModule,
AiAnalysisModule,
ReviewModule,
FocusItemsModule,
LearningActivityModule,
NotificationsModule,
FeedbackModule,
WaitlistModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -1,60 +0,0 @@
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户登录', description: '使用邮箱和密码登录系统,返回访问令牌' })
@ApiResponse({ status: 200, description: '登录成功,返回访问令牌' })
@ApiResponse({ status: 401, description: '用户名或密码错误' })
async login(@Body() body: { email: string; password: string }) {
return {
success: true,
data: {
accessToken: 'mock_token_' + Date.now(),
refreshToken: 'mock_refresh_' + Date.now(),
expiresIn: 3600,
},
};
}
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '用户注册', description: '注册一个新的用户账号' })
@ApiResponse({ status: 201, description: '注册成功' })
@ApiResponse({ status: 400, description: '输入无效或邮箱已被注册' })
async register(@Body() body: { email: string; password: string; nickname?: string }) {
return {
success: true,
data: {
userId: 'user_' + Date.now(),
email: body.email,
},
};
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌', description: '使用刷新令牌获取新的访问令牌' })
@ApiResponse({ status: 200, description: '令牌刷新成功' })
@ApiResponse({ status: 401, description: '刷新令牌无效或已过期' })
async refresh(@Body() body: { refreshToken: string }) {
return {
success: true,
data: {
accessToken: 'mock_token_' + Date.now(),
expiresIn: 3600,
},
};
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户退出', description: '使当前会话失效' })
@ApiResponse({ status: 200, description: '退出成功' })
async logout() {
return { success: true, message: '已退出登录' };
}
}

View File

@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
@Module({
controllers: [AuthController],
})
export class AuthModule {}

View File

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}

View File

@ -0,0 +1,32 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
response.status(status).json({
success: false,
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1,9 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return !!request.user;
}
}

View File

@ -0,0 +1,8 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class OptionalAuthGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean {
return true;
}
}

View File

@ -0,0 +1,21 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@ -0,0 +1,31 @@
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object);
if (errors.length > 0) {
const messages = errors.map((err) =>
Object.values(err.constraints || {}).join(', '),
);
throw new BadRequestException(messages);
}
return value;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}

17
src/common/types/index.ts Normal file
View File

@ -0,0 +1,17 @@
export interface UserPayload {
id: number;
email?: string;
nickname?: string;
}
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
}

View File

@ -0,0 +1,9 @@
import { randomUUID } from 'crypto';
export function generateUuid(): string {
return randomUUID();
}
export function generateShortId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
}

9
src/config/ai.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ai', () => ({
provider: process.env.AI_PROVIDER || 'mock',
apiKey: process.env.AI_API_KEY || '',
baseUrl: process.env.AI_BASE_URL || '',
modelName: process.env.AI_MODEL_NAME,
mockEnabled: process.env.AI_MOCK_ENABLED !== 'false',
}));

9
src/config/app.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
enableSwagger: process.env.ENABLE_SWAGGER !== 'false',
swaggerUser: process.env.SWAGGER_USER || 'admin',
swaggerPassword: process.env.SWAGGER_PASSWORD || 'change_me',
}));

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
url:
process.env.DATABASE_URL ||
'mysql://ai_study_user:ai_study_password@localhost:3306/ai_study',
}));

7
src/config/jwt.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'change_me_in_production',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}));

View File

@ -0,0 +1,9 @@
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0', 10),
url: process.env.REDIS_URL,
}));

View File

@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('storage', () => ({
driver: process.env.STORAGE_DRIVER || 'local',
localPath: process.env.STORAGE_LOCAL_PATH || './uploads',
s3: {
bucket: process.env.STORAGE_S3_BUCKET,
region: process.env.STORAGE_S3_REGION,
endpoint: process.env.STORAGE_S3_ENDPOINT,
},
}));

View File

@ -1,26 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFeedbackDto {
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '反馈类型', example: 'feature', enum: ['bug', 'feature', 'general'] })
type: 'bug' | 'feature' | 'general';
@ApiProperty({ description: '反馈内容', example: '希望能添加离线下载功能' })
content: string;
@ApiPropertyOptional({ description: '联系方式(便于后续跟进)', example: 'user@email.com' })
contact?: string;
}
export class FeedbackResponse {
@ApiProperty({ description: '反馈 ID', example: 'fb_123_abc' })
id: string;
@ApiProperty({ description: '反馈内容' })
content: string;
@ApiProperty({ description: '提交时间' })
createdAt: Date;
}

View File

@ -1,27 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class Feedback {
@ApiProperty({ description: '反馈 ID', example: 'fb_123_abc' })
id: string;
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '反馈类型', example: 'feature', enum: ['bug', 'feature', 'general'] })
type: 'bug' | 'feature' | 'general';
@ApiProperty({ description: '反馈内容', example: '希望能添加离线下载功能' })
content: string;
@ApiPropertyOptional({ description: '联系方式' })
contact?: string;
@ApiProperty({ description: '处理状态', example: 'pending', enum: ['pending', 'reviewed', 'resolved'] })
status: 'pending' | 'reviewed' | 'resolved';
@ApiProperty({ description: '提交时间' })
createdAt: Date;
@ApiProperty({ description: '最后更新时间' })
updatedAt: Date;
}

View File

@ -1,54 +0,0 @@
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { FeedbackService } from './feedback.service';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
@ApiTags('feedback')
@Controller('feedback')
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Post()
@ApiOperation({ summary: '提交反馈', description: '提交用户反馈或建议' })
@ApiResponse({ status: 201, description: '反馈提交成功' })
@ApiResponse({ status: 400, description: '反馈数据无效' })
async create(@Body() createFeedbackDto: CreateFeedbackDto) {
const feedback = await this.feedbackService.create(createFeedbackDto);
return {
success: true,
message: '反馈已提交,感谢你的支持',
data: { id: feedback.id, createdAt: feedback.createdAt },
};
}
@Get()
@ApiOperation({ summary: '获取反馈列表', description: '获取所有用户反馈提交记录' })
@ApiQuery({ name: 'userId', required: false, description: '按用户 ID 筛选', example: 'user_123456' })
@ApiResponse({ status: 200, description: '反馈列表' })
async findAll(@Query('userId') userId?: string) {
if (userId) {
return this.feedbackService.findByUserId(userId);
}
return this.feedbackService.findAll();
}
@Get('stats')
@ApiOperation({ summary: '获取反馈统计', description: '获取反馈的聚合统计数据' })
@ApiResponse({ status: 200, description: '反馈统计数据' })
async getStats() {
return this.feedbackService.getStats();
}
@Patch(':id/status')
@ApiOperation({ summary: '更新反馈状态', description: '更新反馈条目的处理状态' })
@ApiParam({ name: 'id', description: '反馈 ID', example: 'fb_123_abc' })
@ApiResponse({ status: 200, description: '状态已更新' })
@ApiResponse({ status: 404, description: '反馈不存在' })
async updateStatus(
@Param('id') id: string,
@Body('status') status: 'pending' | 'reviewed' | 'resolved'
) {
const feedback = await this.feedbackService.updateStatus(id, status);
return { success: true, data: feedback };
}
}

View File

@ -1,56 +0,0 @@
import { Injectable } from '@nestjs/common';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
import { Feedback } from './entities/feedback.entity';
@Injectable()
export class FeedbackService {
private feedbacks: Feedback[] = [];
async create(createFeedbackDto: CreateFeedbackDto): Promise<Feedback> {
const feedback: Feedback = {
id: `fb_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
userId: createFeedbackDto.userId,
type: createFeedbackDto.type,
content: createFeedbackDto.content,
contact: createFeedbackDto.contact,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
this.feedbacks.push(feedback);
console.log('[Feedback] New feedback received:', feedback.type);
return feedback;
}
async findAll(): Promise<Feedback[]> {
return this.feedbacks;
}
async findByUserId(userId: string): Promise<Feedback[]> {
return this.feedbacks.filter(f => f.userId === userId);
}
async updateStatus(id: string, status: 'pending' | 'reviewed' | 'resolved'): Promise<Feedback | undefined> {
const feedback = this.feedbacks.find(f => f.id === id);
if (!feedback) return undefined;
feedback.status = status;
feedback.updatedAt = new Date();
return feedback;
}
async getStats() {
return {
total: this.feedbacks.length,
byType: this.feedbacks.reduce((acc, f) => {
acc[f.type] = (acc[f.type] || 0) + 1;
return acc;
}, {} as Record<string, number>),
byStatus: this.feedbacks.reduce((acc, f) => {
acc[f.status] = (acc[f.status] || 0) + 1;
return acc;
}, {} as Record<string, number>),
};
}
}

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class AppLoggerService extends Logger {}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AppLoggerService } from './app-logger.service';
@Global()
@Module({
providers: [AppLoggerService],
exports: [AppLoggerService],
})
export class LoggerModule {}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { QueueService } from './queue.service';
@Global()
@Module({
providers: [QueueService],
exports: [QueueService],
})
export class QueueModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class QueueService {
private queues: Map<string, any[]> = new Map();
add(queueName: string, data: any) {
if (!this.queues.has(queueName)) {
this.queues.set(queueName, []);
}
this.queues.get(queueName)!.push(data);
}
async processNext(queueName: string): Promise<any | null> {
const queue = this.queues.get(queueName);
if (!queue || queue.length === 0) return null;
return queue.shift();
}
getQueueNames(): string[] {
return Array.from(this.queues.keys());
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,96 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client: Redis;
private _connected = false;
constructor(private configService: ConfigService) {}
async onModuleInit() {
const url = this.configService.get<string>('redis.url');
if (url) {
this.client = new Redis(url);
} else {
this.client = new Redis({
host: this.configService.get<string>('redis.host', 'localhost'),
port: this.configService.get<number>('redis.port', 6379),
password: this.configService.get<string>('redis.password'),
db: this.configService.get<number>('redis.db', 0),
});
}
this.client.on('connect', () => {
this._connected = true;
this.logger.log('Redis connected');
});
this.client.on('error', (err) => {
this._connected = false;
this.logger.warn(`Redis error: ${err.message}`);
});
}
async onModuleDestroy() {
await this.client?.quit();
}
isHealthy(): boolean {
return this._connected && this.client?.status === 'ready';
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttl?: number): Promise<void> {
if (ttl) {
await this.client.set(key, value, 'EX', ttl);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
return (await this.client.exists(key)) === 1;
}
async expire(key: string, ttl: number): Promise<void> {
await this.client.expire(key, ttl);
}
async ttl(key: string): Promise<number> {
return this.client.ttl(key);
}
async incr(key: string): Promise<number> {
return this.client.incr(key);
}
async setNx(key: string, value: string): Promise<boolean> {
return (await this.client.setnx(key, value)) === 1;
}
async lock(key: string, ttlSeconds: number): Promise<string | null> {
const token = Math.random().toString(36).substring(2);
const result = await this.client.set(key, token, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? token : null;
}
async unlock(key: string, token: string): Promise<boolean> {
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
const result = await this.client.eval(script, 1, key, token);
return result === 1;
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class StorageService {
constructor(private configService: ConfigService) {}
getUploadPath(filename: string): string {
const basePath = this.configService.get<string>(
'storage.localPath',
'./uploads',
);
return `${basePath}/${filename}`;
}
async healthCheck(): Promise<boolean> {
return true;
}
}

View File

@ -1,42 +0,0 @@
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Get, Param } from '@nestjs/common';
@ApiTags('knowledge')
@Controller('knowledge')
export class KnowledgeController {
@Get('categories')
@ApiOperation({ summary: '获取知识分类', description: '获取所有知识库分类' })
@ApiResponse({ status: 200, description: '分类列表' })
async getCategories() {
return [
{ id: 'cat_1', name: '公考申论', icon: '📝', count: 48 },
{ id: 'cat_2', name: 'AI工具', icon: '🤖', count: 24 },
{ id: 'cat_3', name: '编程面试', icon: '💻', count: 72 },
];
}
@Get('articles')
@ApiOperation({ summary: '获取文章列表', description: '获取知识库文章列表' })
@ApiResponse({ status: 200, description: '文章列表' })
async getArticles() {
return [
{ id: 'art_1', title: '申论写作基础', category: 'cat_1', excerpt: '了解申论的基本结构和写作要点...' },
{ id: 'art_2', title: 'ChatGPT 入门指南', category: 'cat_2', excerpt: '快速上手 ChatGPT提升工作效率...' },
];
}
@Get('articles/:id')
@ApiOperation({ summary: '获取文章详情', description: '获取文章详细内容' })
@ApiResponse({ status: 200, description: '文章详情' })
@ApiResponse({ status: 404, description: '文章不存在' })
async getArticle(@Param('id') id: string) {
return {
id,
title: '申论写作基础',
content: '申论是公务员考试的核心科目...',
category: '公考申论',
tags: ['申论', '写作', '备考'],
createdAt: new Date(),
};
}
}

View File

@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { KnowledgeController } from './knowledge.controller';
@Module({
controllers: [KnowledgeController],
})
export class KnowledgeModule {}

View File

@ -1,131 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LearningPath {
@ApiProperty({ description: '学习路径 ID', example: 'path_gongkao' })
id: string;
@ApiProperty({ description: '学习路径标题', example: '公考申论备考' })
title: string;
@ApiProperty({ description: '学习路径描述', example: '系统学习申论写作技巧,提升公文写作能力' })
description: string;
@ApiProperty({ description: '分类', example: 'exam', enum: ['exam', 'skill', 'interview'] })
category: 'exam' | 'skill' | 'interview';
@ApiProperty({ description: '难度级别', example: 'intermediate', enum: ['beginner', 'intermediate', 'advanced'] })
difficulty: 'beginner' | 'intermediate' | 'advanced';
@ApiProperty({ description: '预计学习时长(分钟)', example: 2400 })
estimatedMinutes: number;
@ApiPropertyOptional({ description: '封面图片 URL' })
coverImage?: string;
@ApiProperty({ description: '总课时数', example: 48 })
totalLessons: number;
@ApiProperty({ description: '已完成课时数', example: 0 })
completedLessons: number;
@ApiProperty({ description: '创建时间' })
createdAt: Date;
}
export class Course {
@ApiProperty({ description: '课程 ID', example: 'course_gk_1' })
id: string;
@ApiProperty({ description: '所属学习路径 ID', example: 'path_gongkao' })
pathId: string;
@ApiProperty({ description: '课程标题', example: '申论基础认知' })
title: string;
@ApiProperty({ description: '课程描述', example: '了解申论考试的性质与要求' })
description: string;
@ApiProperty({ description: '显示顺序', example: 1 })
order: number;
@ApiProperty({ description: '课时列表', type: () => Lesson })
lessons: Lesson[];
}
export class Lesson {
@ApiProperty({ description: '课时 ID', example: 'lesson_gk_1_1' })
id: string;
@ApiProperty({ description: '所属课程 ID', example: 'course_gk_1' })
courseId: string;
@ApiProperty({ description: '课时标题', example: '申论是什么' })
title: string;
@ApiProperty({ description: '课时内容', example: '申论是公务员考试的核心科目...' })
content: string;
@ApiProperty({ description: '课时类型', example: 'reading', enum: ['reading', 'practice', 'quiz'] })
type: 'reading' | 'practice' | 'quiz';
@ApiProperty({ description: '预计时长(分钟)', example: 15 })
duration: number;
@ApiProperty({ description: '显示顺序', example: 1 })
order: number;
}
export class LearningRecord {
@ApiProperty({ description: '记录 ID', example: 'rec_123' })
id: string;
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '学习路径 ID', example: 'path_gongkao' })
pathId: string;
@ApiProperty({ description: '课程 ID', example: 'course_gk_1' })
courseId: string;
@ApiProperty({ description: '课时 ID', example: 'lesson_gk_1_1' })
lessonId: string;
@ApiProperty({ description: '学习状态', example: 'in_progress', enum: ['not_started', 'in_progress', 'completed'] })
status: 'not_started' | 'in_progress' | 'completed';
@ApiProperty({ description: '进度百分比', example: 50 })
progress: number;
@ApiProperty({ description: '开始时间' })
startedAt: Date;
@ApiPropertyOptional({ description: '完成时间' })
completedAt?: Date;
}
export class ReviewTask {
@ApiProperty({ description: '复习任务 ID', example: 'review_123' })
id: string;
@ApiProperty({ description: '用户 ID', example: 'user_123456' })
userId: string;
@ApiProperty({ description: '课时 ID', example: 'lesson_gk_1_1' })
lessonId: string;
@ApiProperty({ description: '课时标题', example: '申论是什么' })
lessonTitle: string;
@ApiProperty({ description: '复习截止日期' })
dueDate: Date;
@ApiProperty({ description: '复习间隔(天)', example: 1 })
interval: number;
@ApiProperty({ description: '间隔重复难易因子', example: 2.5 })
easeFactor: number;
@ApiProperty({ description: '下次复习日期' })
nextReviewDate: Date;
}

View File

@ -1,85 +0,0 @@
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { LearningService } from './learning.service';
@ApiTags('learning')
@Controller('learning')
export class LearningController {
constructor(private readonly learningService: LearningService) {}
@Get('paths')
@ApiOperation({ summary: '获取学习路径列表', description: '获取所有可用的学习路径' })
@ApiResponse({ status: 200, description: '学习路径列表' })
async getPaths() {
return this.learningService.getAllPaths();
}
@Get('paths/:id')
@ApiOperation({ summary: '获取学习路径详情', description: '获取指定学习路径的详细信息' })
@ApiParam({ name: 'id', description: '学习路径 ID', example: 'path_gongkao' })
@ApiResponse({ status: 200, description: '找到学习路径' })
@ApiResponse({ status: 404, description: '学习路径不存在' })
async getPath(@Param('id') id: string) {
return this.learningService.getPathById(id);
}
@Get('paths/:id/courses')
@ApiOperation({ summary: '获取路径下的课程', description: '获取指定学习路径的所有课程' })
@ApiParam({ name: 'id', description: '学习路径 ID', example: 'path_gongkao' })
@ApiResponse({ status: 200, description: '课程列表' })
async getCourses(@Param('id') pathId: string) {
return this.learningService.getCoursesByPath(pathId);
}
@Get('courses/:id')
@ApiOperation({ summary: '获取课程详情', description: '获取指定课程的详细信息,包含课时' })
@ApiParam({ name: 'id', description: '课程 ID', example: 'course_gk_1' })
@ApiResponse({ status: 200, description: '找到课程' })
@ApiResponse({ status: 404, description: '课程不存在' })
async getCourse(@Param('id') id: string) {
return this.learningService.getCourseById(id);
}
@Get('lessons/:id')
@ApiOperation({ summary: '获取课时详情', description: '获取指定课时的详细信息' })
@ApiParam({ name: 'id', description: '课时 ID', example: 'lesson_gk_1_1' })
@ApiResponse({ status: 200, description: '找到课时' })
@ApiResponse({ status: 404, description: '课时不存在' })
async getLesson(@Param('id') id: string) {
return this.learningService.getLessonById(id);
}
@Get('records')
@ApiOperation({ summary: '获取学习记录', description: '获取用户的学习进度记录' })
@ApiQuery({ name: 'userId', description: '用户 ID', example: 'user_123456' })
@ApiResponse({ status: 200, description: '学习记录列表' })
async getRecords(@Query('userId') userId: string) {
return this.learningService.getUserRecords(userId);
}
@Patch('progress')
@ApiOperation({ summary: '更新学习进度', description: '更新指定课时的学习进度' })
@ApiResponse({ status: 200, description: '进度已更新' })
@ApiResponse({ status: 404, description: '课时不存在' })
async updateProgress(@Body() body: { userId: string; lessonId: string; progress: number }) {
const record = await this.learningService.updateProgress(body.userId, body.lessonId, body.progress);
return { success: true, data: record };
}
@Get('review')
@ApiOperation({ summary: '获取复习任务', description: '获取用户的间隔重复复习任务' })
@ApiQuery({ name: 'userId', description: '用户 ID', example: 'user_123456' })
@ApiResponse({ status: 200, description: '复习任务列表' })
async getReviewTasks(@Query('userId') userId: string) {
return this.learningService.getReviewTasks(userId);
}
@Post('review')
@ApiOperation({ summary: '创建复习任务', description: '创建一个新的间隔重复复习任务' })
@ApiResponse({ status: 201, description: '复习任务已创建' })
@ApiResponse({ status: 404, description: '课时不存在' })
async createReviewTask(@Body() body: { userId: string; lessonId: string }) {
const task = await this.learningService.createReviewTask(body.userId, body.lessonId);
return { success: true, data: task };
}
}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { LearningController } from './learning.controller';
import { LearningService } from './learning.service';
@Module({
controllers: [LearningController],
providers: [LearningService],
exports: [LearningService],
})
export class LearningModule {}

View File

@ -1,172 +0,0 @@
import { Injectable } from '@nestjs/common';
import { LearningPath, Course, LearningRecord, ReviewTask } from './entities/learning.entity';
@Injectable()
export class LearningService {
private paths: LearningPath[] = [
{
id: 'path_gongkao',
title: '公考申论备考',
description: '系统学习申论写作技巧,提升公文写作能力',
category: 'exam',
difficulty: 'intermediate',
estimatedMinutes: 2400,
totalLessons: 48,
completedLessons: 0,
createdAt: new Date(),
},
{
id: 'path_ai_tools',
title: 'AI 工具实战',
description: '掌握主流 AI 工具使用方法,提升工作效率',
category: 'skill',
difficulty: 'beginner',
estimatedMinutes: 1200,
totalLessons: 24,
completedLessons: 0,
createdAt: new Date(),
},
{
id: 'path_interview',
title: '程序员面试冲刺',
description: '算法与系统设计面试题精讲',
category: 'interview',
difficulty: 'advanced',
estimatedMinutes: 3600,
totalLessons: 72,
completedLessons: 0,
createdAt: new Date(),
},
];
private courses: Course[] = [
{
id: 'course_gk_1',
pathId: 'path_gongkao',
title: '申论基础认知',
description: '了解申论考试的性质与要求',
order: 1,
lessons: [
{ id: 'lesson_gk_1_1', courseId: 'course_gk_1', title: '申论是什么', content: '申论是公务员考试的核心科目...', type: 'reading', duration: 15, order: 1 },
{ id: 'lesson_gk_1_2', courseId: 'course_gk_1', title: '考试形式解析', content: '申论考试通常包含...', type: 'reading', duration: 20, order: 2 },
{ id: 'lesson_gk_1_3', courseId: 'course_gk_1', title: '评分标准', content: '阅卷老师关注的重点...', type: 'reading', duration: 15, order: 3 },
],
},
{
id: 'course_ai_1',
pathId: 'path_ai_tools',
title: 'ChatGPT 入门',
description: '学会使用 ChatGPT 提升日常效率',
order: 1,
lessons: [
{ id: 'lesson_ai_1_1', courseId: 'course_ai_1', title: '注册与基础操作', content: '如何创建账号并开始对话...', type: 'reading', duration: 10, order: 1 },
{ id: 'lesson_ai_1_2', courseId: 'course_ai_1', title: '有效提问技巧', content: '如何写出好的 Prompt...', type: 'practice', duration: 20, order: 2 },
{ id: 'lesson_ai_1_3', courseId: 'course_ai_1', title: '实战案例练习', content: '用 ChatGPT 写一封邮件...', type: 'practice', duration: 25, order: 3 },
],
},
{
id: 'course_int_1',
pathId: 'path_interview',
title: '算法基础',
description: '面试必备算法知识点回顾',
order: 1,
lessons: [
{ id: 'lesson_int_1_1', courseId: 'course_int_1', title: '时间空间复杂度', content: '如何分析算法效率...', type: 'reading', duration: 20, order: 1 },
{ id: 'lesson_int_1_2', courseId: 'course_int_1', title: '数组与链表', content: '基础数据结构回顾...', type: 'reading', duration: 30, order: 2 },
{ id: 'lesson_int_1_3', courseId: 'course_int_1', title: '栈与队列', content: 'LIFO 与 FIFO 的应用...', type: 'reading', duration: 25, order: 3 },
],
},
];
private records: Map<string, LearningRecord[]> = new Map();
private reviewTasks: Map<string, ReviewTask[]> = new Map();
async getAllPaths(): Promise<LearningPath[]> {
return this.paths;
}
async getPathById(id: string): Promise<LearningPath | undefined> {
return this.paths.find(p => p.id === id);
}
async getCoursesByPath(pathId: string): Promise<Course[]> {
return this.courses.filter(c => c.pathId === pathId);
}
async getCourseById(id: string): Promise<Course | undefined> {
return this.courses.find(c => c.id === id);
}
async getLessonById(id: string): Promise<{ course: Course; lesson: any } | undefined> {
for (const course of this.courses) {
const lesson = course.lessons.find(l => l.id === id);
if (lesson) {
return { course, lesson };
}
}
return undefined;
}
async getUserRecords(userId: string): Promise<LearningRecord[]> {
return this.records.get(userId) || [];
}
async updateProgress(userId: string, lessonId: string, progress: number): Promise<LearningRecord> {
const { lesson } = await this.getLessonById(lessonId) || {};
if (!lesson) throw new Error('Lesson not found');
const records = this.records.get(userId) || [];
const existing = records.find(r => r.lessonId === lessonId);
if (existing) {
existing.progress = progress;
existing.status = progress >= 100 ? 'completed' : 'in_progress';
if (progress >= 100) existing.completedAt = new Date();
return existing;
}
const record: LearningRecord = {
id: `rec_${Date.now()}`,
userId,
pathId: lesson.courseId.split('_')[0] + '_' + lesson.courseId.split('_')[1],
courseId: lesson.courseId,
lessonId,
status: progress >= 100 ? 'completed' : 'in_progress',
progress,
startedAt: new Date(),
completedAt: progress >= 100 ? new Date() : undefined,
};
records.push(record);
this.records.set(userId, records);
return record;
}
async getReviewTasks(userId: string): Promise<ReviewTask[]> {
return this.reviewTasks.get(userId) || [];
}
async createReviewTask(userId: string, lessonId: string): Promise<ReviewTask> {
const { lesson } = await this.getLessonById(lessonId) || {};
if (!lesson) throw new Error('Lesson not found');
const tasks = this.reviewTasks.get(userId) || [];
const existing = tasks.find(t => t.lessonId === lessonId);
if (existing) return existing;
const task: ReviewTask = {
id: `review_${Date.now()}`,
userId,
lessonId,
lessonTitle: lesson.title,
dueDate: new Date(),
interval: 1,
easeFactor: 2.5,
nextReviewDate: new Date(Date.now() + 86400000),
};
tasks.push(task);
this.reviewTasks.set(userId, tasks);
return task;
}
}

View File

@ -2,49 +2,6 @@ import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
const SWAGGER_BASIC_AUTH_USER = process.env.SWAGGER_USER || 'admin';
const SWAGGER_BASIC_AUTH_PASSWORD = process.env.SWAGGER_PASSWORD || 'change_me';
function isLocalhost(url: string): boolean {
return url.includes('localhost') || url.includes('127.0.0.1');
}
function isSwaggerEnabled(): boolean {
if (process.env.NODE_ENV === 'production') {
return process.env.ENABLE_SWAGGER === 'true';
}
return process.env.ENABLE_SWAGGER !== 'false';
}
function needsBasicAuth(): boolean {
if (process.env.NODE_ENV === 'production') {
return process.env.ENABLE_SWAGGER === 'true';
}
return false;
}
function createBasicAuthMiddleware() {
return (req: any, res: any, next: any) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="Swagger API Docs"');
res.status(401).send('Authentication required');
return;
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
if (username === SWAGGER_BASIC_AUTH_USER && password === SWAGGER_BASIC_AUTH_PASSWORD) {
next();
} else {
res.status(401).send('Invalid credentials');
}
};
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -54,51 +11,41 @@ async function bootstrap() {
credentials: true,
});
if (isSwaggerEnabled()) {
const config = new DocumentBuilder()
.setTitle('龙de AI 学习产品 API')
.setDescription('AI 学习产品后端 API 文档v0.1包含用户管理、学习路径、AI 对话、反馈等功能。')
.setVersion('0.1.0')
.addTag('health', '服务健康检查')
.addTag('auth', '用户认证')
.addTag('users', '用户管理')
.addTag('knowledge', '知识库')
.addTag('learning', '学习路径与进度')
.addTag('ai', 'AI 分析与对话')
.addTag('review', '复习任务')
.addTag('feedback', '用户反馈')
.addTag('waitlist', '等待名单')
.build();
const config = new DocumentBuilder()
.setTitle('知习 API')
.setDescription('知习 AI-first 系统化学习产品后端 APIv0.1')
.setVersion('0.1.0')
.addTag('health', '服务健康检查')
.addTag('auth', '用户认证')
.addTag('users', '用户管理')
.addTag('knowledge-base', '知识库')
.addTag('knowledge-items', '知识点')
.addTag('document-import', '资料导入')
.addTag('learning-session', '学习会话')
.addTag('active-recall', '主动回忆')
.addTag('ai-analysis', 'AI 分析')
.addTag('review', '复习管理')
.addTag('focus-items', '待巩固项')
.addTag('learning-activity', '学习活跃')
.addTag('notifications', '消息通知')
.addTag('feedback', '用户反馈')
.addTag('waitlist', '等待名单')
.build();
const document = SwaggerModule.createDocument(app, config);
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document, {
swaggerOptions: { persistAuthorization: true },
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: '知习 API 文档',
});
if (needsBasicAuth()) {
app.use('/api-docs', createBasicAuthMiddleware() as any);
app.use('/api-docs-json', createBasicAuthMiddleware() as any);
}
SwaggerModule.setup('api-docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: '龙de API 文档',
});
app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => {
res.json(document);
});
console.log('[Swagger] API 文档已启用');
if (needsBasicAuth()) {
console.log(`[Swagger] Basic Auth 已启用 (${SWAGGER_BASIC_AUTH_USER})`);
}
} else {
console.log('[Swagger] API 文档已禁用');
}
app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => {
res.json(document);
});
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`[API] Server running on http://localhost:${port}`);
console.log(`[API] Swagger docs at http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@ -0,0 +1,23 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ActiveRecallService } from './active-recall.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types';
@ApiTags('active-recall')
@Controller('active-recalls')
export class ActiveRecallController {
constructor(private readonly service: ActiveRecallService) {}
@Get()
@ApiOperation({ summary: '获取主动回忆问题列表' })
async findAll(@CurrentUser() user: UserPayload | undefined) {
return this.service.findByUserId(String(user?.id || 'anonymous'));
}
@Post(':id/submit')
@ApiOperation({ summary: '提交主动回忆回答' })
async submit(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string, @Body() body: any) {
return this.service.submit(String(user?.id || 'anonymous'), id, body);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ActiveRecallController } from './active-recall.controller';
import { ActiveRecallService } from './active-recall.service';
import { ActiveRecallRepository } from './active-recall.repository';
@Module({
controllers: [ActiveRecallController],
providers: [ActiveRecallService, ActiveRecallRepository],
exports: [ActiveRecallService],
})
export class ActiveRecallModule {}

View File

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
export interface RecallQuestion {
id: string;
userId: string;
knowledgeItemId: string;
questionText: string;
difficulty: string;
}
export interface RecallAnswer {
id: string;
userId: string;
questionId: string;
answerText: string;
submittedAt: Date;
}
@Injectable()
export class ActiveRecallRepository {
private questions: Map<string, RecallQuestion> = new Map();
private answers: RecallAnswer[] = [];
async findByUserId(userId: string): Promise<RecallQuestion[]> {
return Array.from(this.questions.values()).filter((q) => q.userId === userId);
}
async findById(id: string): Promise<RecallQuestion | undefined> {
return this.questions.get(id);
}
async createQuestion(data: Partial<RecallQuestion>): Promise<RecallQuestion> {
const q: RecallQuestion = {
id: data.id || generateShortId(),
userId: data.userId || '',
knowledgeItemId: data.knowledgeItemId || '',
questionText: data.questionText || '',
difficulty: data.difficulty || 'normal',
};
this.questions.set(q.id, q);
return q;
}
async createAnswer(userId: string, questionId: string, body: any): Promise<RecallAnswer> {
const answer: RecallAnswer = {
id: generateShortId(),
userId,
questionId,
answerText: body.answerText || '',
submittedAt: new Date(),
};
this.answers.push(answer);
return answer;
}
}

View File

@ -0,0 +1,17 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ActiveRecallRepository } from './active-recall.repository';
@Injectable()
export class ActiveRecallService {
constructor(private readonly repository: ActiveRecallRepository) {}
async findByUserId(userId: string) {
return this.repository.findByUserId(userId);
}
async submit(userId: string, questionId: string, body: any) {
const question = await this.repository.findById(questionId);
if (!question) throw new NotFoundException('问题不存在');
return this.repository.createAnswer(userId, questionId, body);
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AiAnalysisService } from './ai-analysis.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types';
@ApiTags('ai-analysis')
@Controller('ai-analysis')
export class AiAnalysisController {
constructor(private readonly service: AiAnalysisService) {}
@Post()
@ApiOperation({ summary: '提交 AI 分析任务' })
async create(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
return this.service.createJob(String(user?.id || 'anonymous'), body);
}
@Get(':id')
@ApiOperation({ summary: '获取 AI 分析结果' })
async findOne(@Param('id') id: string) {
return this.service.getResult(id);
}
@Get('jobs/:jobId/status')
@ApiOperation({ summary: '查询任务状态' })
async getJobStatus(@Param('jobId') jobId: string) {
return this.service.getJobStatus(jobId);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AiAnalysisController } from './ai-analysis.controller';
import { AiAnalysisService } from './ai-analysis.service';
import { AiAnalysisRepository } from './ai-analysis.repository';
@Module({
controllers: [AiAnalysisController],
providers: [AiAnalysisService, AiAnalysisRepository],
exports: [AiAnalysisService],
})
export class AiAnalysisModule {}

View File

@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
export interface AnalysisJob {
id: string;
userId: string;
sessionId: string;
inputText: string;
status: 'pending' | 'processing' | 'success' | 'failed';
createdAt: Date;
}
export interface AnalysisResult {
id: string;
jobId: string;
userId: string;
masteryScore: number;
summary: string;
strengths: string[];
weakPoints: string[];
suggestions: string[];
createdAt: Date;
}
@Injectable()
export class AiAnalysisRepository {
private jobs: Map<string, AnalysisJob> = new Map();
private results: Map<string, AnalysisResult> = new Map();
async createJob(userId: string, data: any): Promise<AnalysisJob> {
const job: AnalysisJob = {
id: generateShortId(),
userId,
sessionId: data.sessionId || '',
inputText: data.userInput || '',
status: 'pending',
createdAt: new Date(),
};
this.jobs.set(job.id, job);
return job;
}
async findJobById(id: string): Promise<AnalysisJob | undefined> {
return this.jobs.get(id);
}
async updateJobStatus(id: string, status: AnalysisJob['status']) {
const job = this.jobs.get(id);
if (job) job.status = status;
}
async createResult(jobId: string, userId: string, aiResult: any): Promise<AnalysisResult> {
const result: AnalysisResult = {
id: generateShortId(),
jobId,
userId,
masteryScore: aiResult.masteryScore,
summary: aiResult.summary,
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

@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { AiAnalysisRepository } from './ai-analysis.repository';
import { AiService } from '../../infrastructure/ai/ai.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { QueueService } from '../../infrastructure/queue/queue.service';
const DAILY_AI_LIMIT = 50;
@Injectable()
export class AiAnalysisService {
private readonly logger = new Logger(AiAnalysisService.name);
constructor(
private readonly repository: AiAnalysisRepository,
private readonly aiService: AiService,
private readonly redis: RedisService,
private readonly queue: QueueService,
) {}
async createJob(userId: string, body: any) {
await this.checkRateLimit(userId);
const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`;
const lockToken = await this.redis.lock(lockKey, 300);
if (!lockToken) {
throw new Error('同一学习会话的 AI 分析正在处理中,请稍候');
}
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 Error(`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`);
}
}
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) {
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,39 @@
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
class AppleLoginDto {
identityToken: string;
authorizationCode: string;
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('apple')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apple 登录', description: '使用 Sign in with Apple 登录,返回访问令牌' })
@ApiResponse({ status: 200, description: '登录成功' })
async appleLogin(@Body() body: AppleLoginDto) {
return this.authService.appleLogin(body);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '刷新令牌', description: '使用刷新令牌获取新的访问令牌' })
@ApiResponse({ status: 200, description: '令牌刷新成功' })
async refresh(@Body('refreshToken') refreshToken: string) {
return this.authService.refresh(refreshToken);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '用户退出', description: '使当前会话失效' })
@ApiResponse({ status: 200, description: '退出成功' })
async logout() {
return this.authService.logout();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthRepository } from './auth.repository';
@Module({
controllers: [AuthController],
providers: [AuthService, AuthRepository],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
export interface AuthUser {
id: number;
appleUserId: string;
email?: string;
displayName?: string;
}
@Injectable()
export class AuthRepository {
private users: AuthUser[] = [];
private nextId = 1;
async findByAppleUserId(appleUserId: string): Promise<AuthUser | undefined> {
return this.users.find((u) => u.appleUserId === appleUserId);
}
async createUser(data: Partial<AuthUser>): Promise<AuthUser> {
const user: AuthUser = {
id: data.id || this.nextId++,
appleUserId: data.appleUserId || '',
email: data.email,
displayName: data.displayName,
};
this.users.push(user);
return user;
}
}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { AuthRepository } from './auth.repository';
@Injectable()
export class AuthService {
constructor(private readonly authRepository: AuthRepository) {}
async appleLogin(params: {
identityToken: string;
authorizationCode: string;
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
}) {
const appleUserId = `apple_${params.identityToken.substring(0, 20)}`;
let user = await this.authRepository.findByAppleUserId(appleUserId);
if (!user) {
const displayName =
params.user?.name
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}`
: undefined;
user = await this.authRepository.createUser({
appleUserId,
email: params.user?.email,
displayName,
});
}
const accessToken = `mock_token_${Date.now()}`;
const refreshToken = `mock_refresh_${Date.now()}`;
return { accessToken, refreshToken, expiresIn: 3600 };
}
async refresh(refreshToken: string) {
const accessToken = `mock_token_${Date.now()}`;
return { accessToken, expiresIn: 3600 };
}
async logout() {
return { success: true, message: '已退出登录' };
}
}

View File

@ -0,0 +1,22 @@
import { Controller, Get, Post, Param, HttpCode, HttpStatus, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { DocumentImportService } from './document-import.service';
@ApiTags('document-import')
@Controller('imports')
export class DocumentImportController {
constructor(private readonly service: DocumentImportService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建导入任务' })
async createImport(@Body() body: any) {
return this.service.createImport(body);
}
@Get(':id/status')
@ApiOperation({ summary: '查询导入状态' })
async getStatus(@Param('id') id: string) {
return this.service.getStatus(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DocumentImportController } from './document-import.controller';
import { DocumentImportService } from './document-import.service';
import { DocumentImportRepository } from './document-import.repository';
@Module({
controllers: [DocumentImportController],
providers: [DocumentImportService, DocumentImportRepository],
exports: [DocumentImportService],
})
export class DocumentImportModule {}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
export interface ImportJob {
id: string;
fileName: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class DocumentImportRepository {
private jobs: Map<string, ImportJob> = new Map();
async create(data: any): Promise<ImportJob> {
const job: ImportJob = {
id: generateShortId(),
fileName: data.fileName || 'unknown',
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
this.jobs.set(job.id, job);
return job;
}
async findById(id: string): Promise<ImportJob | undefined> {
return this.jobs.get(id);
}
async updateStatus(id: string, status: ImportJob['status']): Promise<void> {
const job = this.jobs.get(id);
if (job) {
job.status = status;
job.updatedAt = new Date();
}
}
}

View File

@ -0,0 +1,77 @@
import { Injectable, Logger } from '@nestjs/common';
import { DocumentImportRepository } from './document-import.repository';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { QueueService } from '../../infrastructure/queue/queue.service';
@Injectable()
export class DocumentImportService {
private readonly logger = new Logger(DocumentImportService.name);
constructor(
private readonly repository: DocumentImportRepository,
private readonly redis: RedisService,
private readonly queue: QueueService,
) {}
async createImport(dto: any) {
const lockKey = `lock:document-import:${dto.fileName || Date.now()}`;
const lockToken = await this.redis.lock(lockKey, 1800);
if (!lockToken) {
throw new Error('相同文件正在导入中,请稍候');
}
const job = await this.repository.create(dto);
await this.redis.set(`job:document-import:${job.id}:status`, 'pending', 86400);
await this.redis.set(`job:document-import:${job.id}:progress`, '0', 86400);
await this.redis.set(`job:document-import:${job.id}:message`, '任务已加入队列', 86400);
this.queue.add('document-import', { importId: job.id, userId: dto.userId || 'anonymous' });
this.processImport(job, lockKey, lockToken);
return job;
}
private processImport(job: any, lockKey: string, lockToken: string) {
this.repository.updateStatus(job.id, 'processing');
this.redis.set(`job:document-import:${job.id}:status`, 'parsing', 86400);
this.redis.set(`job:document-import:${job.id}:message`, '正在解析文件', 86400);
this.redis.set(`job:document-import:${job.id}:progress`, '25', 86400);
setTimeout(async () => {
await this.redis.set(`job:document-import:${job.id}:status`, 'chunking', 86400);
await this.redis.set(`job:document-import:${job.id}:message`, '正在分段提取', 86400);
await this.redis.set(`job:document-import:${job.id}:progress`, '50', 86400);
setTimeout(async () => {
await this.redis.set(`job:document-import:${job.id}:status`, 'generating', 86400);
await this.redis.set(`job:document-import:${job.id}:message`, '正在生成知识点', 86400);
await this.redis.set(`job:document-import:${job.id}:progress`, '75', 86400);
setTimeout(async () => {
this.repository.updateStatus(job.id, 'completed');
await this.redis.set(`job:document-import:${job.id}:status`, 'completed', 86400);
await this.redis.set(`job:document-import:${job.id}:progress`, '100', 86400);
await this.redis.unlock(lockKey, lockToken);
this.logger.log(`Import ${job.id} completed`);
}, 1000);
}, 1000);
}, 1000);
}
async getStatus(id: string) {
const redisStatus = await this.redis.get(`job:document-import:${id}:status`);
const redisProgress = await this.redis.get(`job:document-import:${id}:progress`);
const redisMessage = await this.redis.get(`job:document-import:${id}:message`);
const dbJob = await this.repository.findById(id);
return {
id,
fileName: dbJob?.fileName,
status: redisStatus || dbJob?.status || 'unknown',
progress: redisProgress ? parseInt(redisProgress, 10) : 0,
message: redisMessage || null,
};
}
}

View File

@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFeedbackDto {
@ApiPropertyOptional({ description: '用户 ID' })
userId?: string;
@ApiProperty({ description: '反馈类型', enum: ['bug', 'feature', 'general'] })
type: 'bug' | 'feature' | 'general';
@ApiProperty({ description: '反馈内容' })
content: string;
@ApiPropertyOptional({ description: '联系方式' })
contact?: string;
}

View File

@ -0,0 +1,38 @@
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { FeedbackService } from './feedback.service';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
@ApiTags('feedback')
@Controller('feedback')
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Post()
@ApiOperation({ summary: '提交反馈' })
@ApiResponse({ status: 201, description: '反馈提交成功' })
async create(@Body() dto: CreateFeedbackDto) {
const feedback = await this.feedbackService.create(dto);
return { success: true, data: { id: feedback.id } };
}
@Get()
@ApiOperation({ summary: '获取反馈列表' })
@ApiQuery({ name: 'userId', required: false })
async findAll(@Query('userId') userId?: string) {
if (userId) return this.feedbackService.findByUserId(userId);
return this.feedbackService.findAll();
}
@Get('stats')
@ApiOperation({ summary: '反馈统计' })
async getStats() {
return this.feedbackService.getStats();
}
@Patch(':id/status')
@ApiOperation({ summary: '更新反馈状态' })
async updateStatus(@Param('id') id: string, @Body('status') status: string) {
return this.feedbackService.updateStatus(id, status);
}
}

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { FeedbackController } from './feedback.controller';
import { FeedbackService } from './feedback.service';
import { FeedbackRepository } from './feedback.repository';
@Module({
controllers: [FeedbackController],
providers: [FeedbackService],
providers: [FeedbackService, FeedbackRepository],
exports: [FeedbackService],
})
export class FeedbackModule {}

View File

@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
export interface FeedbackEntry {
id: string;
userId?: string;
type: 'bug' | 'feature' | 'general';
content: string;
contact?: string;
status: 'pending' | 'reviewed' | 'resolved';
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class FeedbackRepository {
private feedbacks: FeedbackEntry[] = [];
async create(data: Partial<FeedbackEntry>): Promise<FeedbackEntry> {
const entry: FeedbackEntry = {
id: `fb_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
userId: data.userId,
type: data.type || 'general',
content: data.content || '',
contact: data.contact,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
this.feedbacks.push(entry);
return entry;
}
async findAll(): Promise<FeedbackEntry[]> {
return this.feedbacks;
}
async findByUserId(userId: string): Promise<FeedbackEntry[]> {
return this.feedbacks.filter((f) => f.userId === userId);
}
async updateStatus(id: string, status: string): Promise<FeedbackEntry | undefined> {
const feedback = this.feedbacks.find((f) => f.id === id);
if (!feedback) return undefined;
feedback.status = status as any;
feedback.updatedAt = new Date();
return feedback;
}
async getStats() {
const byType: Record<string, number> = {};
const byStatus: Record<string, number> = {};
this.feedbacks.forEach((f) => {
byType[f.type] = (byType[f.type] || 0) + 1;
byStatus[f.status] = (byStatus[f.status] || 0) + 1;
});
return { total: this.feedbacks.length, byType, byStatus };
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { FeedbackRepository } from './feedback.repository';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
@Injectable()
export class FeedbackService {
constructor(private readonly feedbackRepository: FeedbackRepository) {}
async create(dto: CreateFeedbackDto) {
return this.feedbackRepository.create(dto);
}
async findAll() {
return this.feedbackRepository.findAll();
}
async findByUserId(userId: string) {
return this.feedbackRepository.findByUserId(userId);
}
async updateStatus(id: string, status: string) {
return this.feedbackRepository.updateStatus(id, status);
}
async getStats() {
return this.feedbackRepository.getStats();
}
}

View File

@ -0,0 +1,33 @@
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { FocusItemsService } from './focus-items.service';
@ApiTags('focus-items')
@Controller('focus-items')
export class FocusItemsController {
constructor(private readonly focusItemsService: FocusItemsService) {}
@Get()
@ApiOperation({ summary: '获取待巩固项列表' })
async findAll() {
return this.focusItemsService.findAll();
}
@Post()
@ApiOperation({ summary: '创建待巩固项' })
async create(@Body() dto: any) {
return this.focusItemsService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: '更新待巩固项' })
async update(@Param('id') id: string, @Body() dto: any) {
return this.focusItemsService.update(id, dto);
}
@Post(':id/complete')
@ApiOperation({ summary: '完成待巩固项' })
async complete(@Param('id') id: string) {
return this.focusItemsService.complete(id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FocusItemsController } from './focus-items.controller';
import { FocusItemsService } from './focus-items.service';
import { FocusItemsRepository } from './focus-items.repository';
@Module({
controllers: [FocusItemsController],
providers: [FocusItemsService, FocusItemsRepository],
exports: [FocusItemsService],
})
export class FocusItemsModule {}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
import { FocusItem } from './types/focus-item.types';
@Injectable()
export class FocusItemsRepository {
private items: Map<string, FocusItem> = new Map();
async findAll(): Promise<FocusItem[]> {
return Array.from(this.items.values());
}
async findById(id: string): Promise<FocusItem | undefined> {
return this.items.get(id);
}
async create(data: Partial<FocusItem>): Promise<FocusItem> {
const now = new Date();
const item: FocusItem = {
id: data.id || generateShortId(),
title: data.title || '',
description: data.description || '',
priority: data.priority || 'normal',
status: data.status || 'open',
createdAt: data.createdAt || now,
updatedAt: now,
completedAt: data.completedAt || null,
};
this.items.set(item.id, item);
return item;
}
async update(id: string, data: Partial<FocusItem>): Promise<FocusItem | undefined> {
const item = this.items.get(id);
if (!item) return undefined;
Object.assign(item, { ...data, updatedAt: new Date() });
return item;
}
}

View File

@ -0,0 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FocusItemsRepository } from './focus-items.repository';
import { FocusItem } from './types/focus-item.types';
@Injectable()
export class FocusItemsService {
constructor(private readonly repository: FocusItemsRepository) {}
async findAll(): Promise<FocusItem[]> {
return this.repository.findAll();
}
async create(dto: any): Promise<FocusItem> {
return this.repository.create(dto);
}
async update(id: string, dto: any): Promise<FocusItem> {
const item = await this.repository.update(id, dto);
if (!item) throw new NotFoundException(`Focus item ${id} not found`);
return item;
}
async complete(id: string): Promise<FocusItem> {
const item = await this.repository.update(id, {
status: 'completed',
completedAt: new Date(),
});
if (!item) throw new NotFoundException(`Focus item ${id} not found`);
return item;
}
}

View File

@ -0,0 +1,10 @@
export interface FocusItem {
id: string;
title: string;
description: string;
priority: 'low' | 'normal' | 'high';
status: 'open' | 'in_review' | 'completed' | 'ignored';
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
}

View File

@ -0,0 +1 @@
export const MAX_KNOWLEDGE_BASE_COUNT = 20;

View File

@ -0,0 +1,41 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { KnowledgeBaseService } from './knowledge-base.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types';
@ApiTags('knowledge-base')
@Controller('knowledge-bases')
export class KnowledgeBaseController {
constructor(private readonly service: KnowledgeBaseService) {}
@Post()
@ApiOperation({ summary: '创建知识库' })
async create(@CurrentUser() user: UserPayload | undefined, @Body() dto: any) {
return this.service.create(String(user?.id || 'anonymous'), dto);
}
@Get()
@ApiOperation({ summary: '获取知识库列表' })
async findAll(@CurrentUser() user: UserPayload | undefined, @Query() query: any) {
return this.service.findAll(String(user?.id || 'anonymous'), query);
}
@Get(':id')
@ApiOperation({ summary: '获取知识库详情' })
async findOne(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string) {
return this.service.findOne(String(user?.id || 'anonymous'), id);
}
@Patch(':id')
@ApiOperation({ summary: '更新知识库' })
async update(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string, @Body() dto: any) {
return this.service.update(String(user?.id || 'anonymous'), id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除知识库' })
async remove(@CurrentUser() user: UserPayload | undefined, @Param('id') id: string) {
return this.service.remove(String(user?.id || 'anonymous'), id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { KnowledgeBaseController } from './knowledge-base.controller';
import { KnowledgeBaseService } from './knowledge-base.service';
import { KnowledgeBaseRepository } from './knowledge-base.repository';
@Module({
controllers: [KnowledgeBaseController],
providers: [KnowledgeBaseService, KnowledgeBaseRepository],
exports: [KnowledgeBaseService],
})
export class KnowledgeBaseModule {}

View File

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
import { KnowledgeBaseStatus } from './types/knowledge-base.types';
export interface KnowledgeBase {
id: string;
userId: string;
title: string;
description: string;
status: KnowledgeBaseStatus;
itemCount: number;
lastStudiedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class KnowledgeBaseRepository {
private items: Map<string, KnowledgeBase> = new Map();
async create(userId: string, dto: any): Promise<KnowledgeBase> {
const kb: KnowledgeBase = {
id: generateShortId(),
userId,
title: dto.title,
description: dto.description || '',
status: 'active',
itemCount: 0,
lastStudiedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
this.items.set(kb.id, kb);
return kb;
}
async findById(id: string): Promise<KnowledgeBase | undefined> {
return this.items.get(id);
}
async findAllByUserId(userId: string): Promise<KnowledgeBase[]> {
return Array.from(this.items.values()).filter(
(kb) => kb.userId === userId && kb.status !== 'deleted',
);
}
async countByUserId(userId: string): Promise<number> {
return Array.from(this.items.values()).filter(
(kb) => kb.userId === userId && kb.status !== 'deleted',
).length;
}
async update(id: string, dto: any): Promise<KnowledgeBase | undefined> {
const kb = this.items.get(id);
if (!kb) return undefined;
Object.assign(kb, { ...dto, updatedAt: new Date() });
this.items.set(id, kb);
return kb;
}
async softDelete(id: string): Promise<boolean> {
const kb = this.items.get(id);
if (!kb) return false;
kb.status = 'deleted';
kb.updatedAt = new Date();
return true;
}
}

View File

@ -0,0 +1,44 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { KnowledgeBaseRepository } from './knowledge-base.repository';
import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants';
@Injectable()
export class KnowledgeBaseService {
constructor(private readonly repository: KnowledgeBaseRepository) {}
async create(userId: string, dto: any) {
const count = await this.repository.countByUserId(userId);
if (count >= MAX_KNOWLEDGE_BASE_COUNT) {
throw new BadRequestException('知识库数量已达到上限');
}
return this.repository.create(userId, dto);
}
async findAll(userId: string, query: any) {
return this.repository.findAllByUserId(userId);
}
async findOne(userId: string, id: string) {
const kb = await this.repository.findById(id);
if (!kb || kb.userId !== userId) {
throw new NotFoundException('知识库不存在');
}
return kb;
}
async update(userId: string, id: string, dto: any) {
const kb = await this.repository.findById(id);
if (!kb || kb.userId !== userId) {
throw new NotFoundException('知识库不存在');
}
return this.repository.update(id, dto);
}
async remove(userId: string, id: string) {
const kb = await this.repository.findById(id);
if (!kb || kb.userId !== userId) {
throw new NotFoundException('知识库不存在');
}
return this.repository.softDelete(id);
}
}

View File

@ -0,0 +1 @@
export type KnowledgeBaseStatus = 'active' | 'archived' | 'deleted';

View File

@ -0,0 +1,35 @@
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { KnowledgeItemsService } from './knowledge-items.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types';
@ApiTags('knowledge-items')
@Controller('knowledge-items')
export class KnowledgeItemsController {
constructor(private readonly service: KnowledgeItemsService) {}
@Post()
@ApiOperation({ summary: '创建知识点' })
async create(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
return this.service.create(String(user?.id || 'anonymous'), body.knowledgeBaseId, body);
}
@Get(':id')
@ApiOperation({ summary: '获取知识点详情' })
async findOne(@Param('id') id: string) {
return this.service.findById(id);
}
@Get()
@ApiOperation({ summary: '获取知识库下的知识点列表' })
async findByKnowledgeBase(@Query('knowledgeBaseId') knowledgeBaseId: string) {
return this.service.findByKnowledgeBaseId(knowledgeBaseId);
}
@Patch(':id')
@ApiOperation({ summary: '更新知识点' })
async update(@Param('id') id: string, @Body() body: any) {
return this.service.update(id, body);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { KnowledgeItemsController } from './knowledge-items.controller';
import { KnowledgeItemsService } from './knowledge-items.service';
import { KnowledgeItemsRepository } from './knowledge-items.repository';
@Module({
controllers: [KnowledgeItemsController],
providers: [KnowledgeItemsService, KnowledgeItemsRepository],
exports: [KnowledgeItemsService],
})
export class KnowledgeItemsModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { generateShortId } from '../../common/utils/id.util';
import { KnowledgeItemType } from './types/knowledge-item.types';
export interface KnowledgeItem {
id: string;
userId: string;
knowledgeBaseId: string;
parentId: string | null;
itemType: KnowledgeItemType;
title: string;
content: string;
orderIndex: number;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class KnowledgeItemsRepository {
private items: Map<string, KnowledgeItem> = new Map();
async create(userId: string, knowledgeBaseId: string, dto: any): Promise<KnowledgeItem> {
const item: KnowledgeItem = {
id: generateShortId(),
userId,
knowledgeBaseId,
parentId: dto.parentId || null,
itemType: dto.itemType || 'lesson',
title: dto.title || '',
content: dto.content || '',
orderIndex: dto.orderIndex || 0,
createdAt: new Date(),
updatedAt: new Date(),
};
this.items.set(item.id, item);
return item;
}
async findById(id: string): Promise<KnowledgeItem | undefined> {
return this.items.get(id);
}
async findByKnowledgeBaseId(knowledgeBaseId: string): Promise<KnowledgeItem[]> {
return Array.from(this.items.values()).filter((i) => i.knowledgeBaseId === knowledgeBaseId);
}
async update(id: string, dto: any): Promise<KnowledgeItem | undefined> {
const item = this.items.get(id);
if (!item) return undefined;
Object.assign(item, { ...dto, updatedAt: new Date() });
return item;
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { KnowledgeItemsRepository } from './knowledge-items.repository';
@Injectable()
export class KnowledgeItemsService {
constructor(private readonly repository: KnowledgeItemsRepository) {}
async create(userId: string, knowledgeBaseId: string, dto: any) {
return this.repository.create(userId, knowledgeBaseId, dto);
}
async findById(id: string) {
const item = await this.repository.findById(id);
if (!item) throw new NotFoundException('知识点不存在');
return item;
}
async findByKnowledgeBaseId(knowledgeBaseId: string) {
return this.repository.findByKnowledgeBaseId(knowledgeBaseId);
}
async update(id: string, dto: any) {
const item = await this.repository.update(id, dto);
if (!item) throw new NotFoundException('知识点不存在');
return item;
}
}

View File

@ -0,0 +1 @@
export type KnowledgeItemType = 'chapter' | 'lesson' | 'concept' | 'note' | 'imported_doc';

View File

@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { LearningActivityService } from './learning-activity.service';
@ApiTags('learning-activity')
@Controller('activity')
export class LearningActivityController {
constructor(private readonly activityService: LearningActivityService) {}
@Get('heatmap')
@ApiOperation({ summary: '获取学习热力图数据' })
async getHeatmap() {
return this.activityService.getHeatmap();
}
@Get('summary')
@ApiOperation({ summary: '获取学习统计概览' })
async getSummary() {
return this.activityService.getSummary();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LearningActivityController } from './learning-activity.controller';
import { LearningActivityService } from './learning-activity.service';
import { LearningActivityRepository } from './learning-activity.repository';
@Module({
controllers: [LearningActivityController],
providers: [LearningActivityService, LearningActivityRepository],
exports: [LearningActivityService],
})
export class LearningActivityModule {}

View File

@ -0,0 +1,32 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
export interface DailyActivity {
date: string;
minutes: number;
cardsReviewed: number;
}
@Injectable()
export class LearningActivityRepository implements OnModuleInit {
private activities: Map<string, DailyActivity> = new Map();
onModuleInit() {
const today = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
this.activities.set(dateStr, {
date: dateStr,
minutes: Math.floor(Math.random() * 120) + 10,
cardsReviewed: Math.floor(Math.random() * 50) + 5,
});
}
}
async findAll(): Promise<DailyActivity[]> {
return Array.from(this.activities.values()).sort(
(a, b) => a.date.localeCompare(b.date),
);
}
}

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { LearningActivityRepository } from './learning-activity.repository';
@Injectable()
export class LearningActivityService {
constructor(private readonly repository: LearningActivityRepository) {}
async getHeatmap(): Promise<Record<string, number>> {
const activities = await this.repository.findAll();
const heatmap: Record<string, number> = {};
for (const a of activities) {
heatmap[a.date] = a.minutes;
}
return heatmap;
}
async getSummary() {
const activities = await this.repository.findAll();
const totalMinutes = activities.reduce((s, a) => s + a.minutes, 0);
const totalCards = activities.reduce((s, a) => s + a.cardsReviewed, 0);
const activeDays = activities.filter((a) => a.minutes > 0).length;
const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0;
return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage };
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { LearningSessionService } from './learning-session.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types';
@ApiTags('learning-session')
@Controller('learning-sessions')
export class LearningSessionController {
constructor(private readonly service: LearningSessionService) {}
@Post()
@ApiOperation({ summary: '开始学习会话' })
async start(@CurrentUser() user: UserPayload | undefined, @Body() body: any) {
return this.service.start(String(user?.id || 'anonymous'), body);
}
@Post(':id/end')
@ApiOperation({ summary: '结束学习会话' })
async end(@Param('id') id: string) {
return this.service.end(id);
}
@Get()
@ApiOperation({ summary: '获取学习会话列表' })
async findAll(@CurrentUser() user: UserPayload | undefined) {
return this.service.findByUserId(String(user?.id || 'anonymous'));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { LearningSessionController } from './learning-session.controller';
import { LearningSessionService } from './learning-session.service';
import { LearningSessionRepository } from './learning-session.repository';
@Module({
controllers: [LearningSessionController],
providers: [LearningSessionService, LearningSessionRepository],
exports: [LearningSessionService],
})
export class LearningSessionModule {}

Some files were not shown because too many files have changed in this diff Show More