Compare commits
3 Commits
bd44b7e138
...
07d6b889ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d6b889ef | |||
| ef7c1f1bc9 | |||
| 35de65e99b |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
test
|
||||
服务器密钥
|
||||
12
.env.example
12
.env.example
@ -1,18 +1,24 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
DATABASE_URL="mysql://ai_study_user:ai_study_password@localhost:3306/ai_study"
|
||||
DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@localhost:3306/zhixi"
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
AI_PROVIDER=mock
|
||||
AI_API_KEY=
|
||||
AI_BASE_URL=
|
||||
|
||||
JWT_SECRET=change_me_in_production
|
||||
|
||||
NODE_ENV=development
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
ENABLE_SWAGGER=true
|
||||
SWAGGER_USER=admin
|
||||
SWAGGER_PASSWORD=change_me
|
||||
|
||||
STORAGE_DRIVER=local
|
||||
STORAGE_LOCAL_PATH=./uploads
|
||||
|
||||
45
.gitea/workflows/deploy.yml
Normal file
45
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,45 @@
|
||||
name: Deploy API Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t zhixi-api:latest .
|
||||
|
||||
- name: Stop old container
|
||||
run: docker stop zhixi-api 2>/dev/null || true
|
||||
|
||||
- name: Remove old container
|
||||
run: docker rm zhixi-api 2>/dev/null || true
|
||||
|
||||
- name: Start new container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name zhixi-api \
|
||||
--network zhixi-net \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=3000 \
|
||||
-e DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@mysql-zhixi:3306/zhixi" \
|
||||
-e REDIS_HOST=redis-zhixi \
|
||||
-e REDIS_PORT=6379 \
|
||||
-e JWT_SECRET=98b1e7e377a40021ad7c46c55e467d2a218a89db7afc7c912780152ad64bdc45 \
|
||||
-e AI_PROVIDER=mock \
|
||||
-e ENABLE_SWAGGER=false \
|
||||
zhixi-api:latest
|
||||
|
||||
- name: Wait for startup
|
||||
run: sleep 5
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
curl -f http://localhost:3001/health || (docker logs zhixi-api --tail 30 && exit 1)
|
||||
1185
BACKEND-PLAN.md
Normal file
1185
BACKEND-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
814
DATABASE-DESIGN.md
Normal file
814
DATABASE-DESIGN.md
Normal 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、外键字段添加合理索引
|
||||
```
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
262
REDIS-DESIGN.md
Normal file
262
REDIS-DESIGN.md
Normal 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
|
||||
|
||||
以下全部必须写 MySQL,Redis 只能做缓存,不是唯一来源:
|
||||
|
||||
```text
|
||||
用户资料 → users, user_profiles
|
||||
知识库内容 → knowledge_bases
|
||||
知识点内容 → knowledge_items
|
||||
学习记录 → learning_records
|
||||
主动回忆回答 → active_recall_answers
|
||||
AI 分析结果 → ai_analysis_results
|
||||
待巩固项 → focus_items
|
||||
复习卡片 → review_cards
|
||||
复习记录 → review_logs
|
||||
学习活跃记录 → daily_learning_activities
|
||||
通知记录 → notifications
|
||||
用户设置 → user_preferences
|
||||
协议同意记录 → user_consents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. v0.1 Redis 最小落地范围
|
||||
|
||||
### 必须做
|
||||
|
||||
```text
|
||||
1. Redis 连接
|
||||
2. /health 检查 Redis
|
||||
3. RedisModule + RedisService(get/set/del/exists/expire/ttl/incr/setNx/lock/unlock)
|
||||
4. AI 每日调用限流(rate:user:{userId}:ai:daily:{date})
|
||||
5. AI 分析队列(BullMQ ai-analysis)
|
||||
6. AI 分析任务状态(job:ai-analysis:{jobId}:status/progress/error)
|
||||
7. 防重复提交锁(lock:ai-analysis:session:{sessionId})
|
||||
8. document-import 队列预留(BullMQ document-import)
|
||||
9. notification 队列预留(BullMQ notification)
|
||||
```
|
||||
|
||||
### 暂时不做
|
||||
|
||||
```text
|
||||
复杂缓存策略
|
||||
Sorted Set 复习调度
|
||||
复杂分布式任务调度
|
||||
全量通知推送(APNs)
|
||||
复杂排行榜
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. RedisService 方法清单
|
||||
|
||||
```text
|
||||
get(key) — 读取
|
||||
set(key, value) — 写入
|
||||
del(key) — 删除
|
||||
exists(key) — 判断存在
|
||||
expire(key, ttl) — 设置过期
|
||||
ttl(key) — 查看剩余时间
|
||||
incr(key) — 自增(限流计数)
|
||||
setNx(key, value) — 不存在才写入(锁)
|
||||
lock(key, ttl) — 获取分布式锁,返回 token
|
||||
unlock(key, token) — 释放锁,校验 token,防止误删
|
||||
```
|
||||
|
||||
锁的实现注意:
|
||||
|
||||
- 锁必须设置 TTL
|
||||
- 解锁时必须校验 value/token,不能误删别人的锁
|
||||
- 锁的 value 用随机 token,解锁时比对
|
||||
|
||||
---
|
||||
|
||||
## 10. 一句话总结
|
||||
|
||||
> **Redis 在知习里不是"另一个 MySQL",它是系统的加速器和调度器。MySQL 存结果,Redis 管过程。**
|
||||
169
SECURITY.md
Normal file
169
SECURITY.md
Normal file
@ -0,0 +1,169 @@
|
||||
# 知习 api-server 安全基线
|
||||
|
||||
> v0.1 安全设计文档。本后端存储用户资料、知识库、上传文件、主动回忆回答、AI 分析结果和学习记录,第一版必须建立基础安全边界。
|
||||
|
||||
---
|
||||
|
||||
## 1. 全局安全中间件
|
||||
|
||||
| 措施 | 实现 | 文件 |
|
||||
|------|------|------|
|
||||
| helmet | `app.use(helmet())` 设置安全 HTTP 头 | `src/main.ts` |
|
||||
| CORS | 仅允许配置域名。生产环境仅允许 `longde.cloud` | `src/main.ts` |
|
||||
| body size limit | JSON 请求体最大 10MB | `src/main.ts` |
|
||||
| 异常过滤 | 生产环境不返回 stack trace | `src/common/filters/global-exception.filter.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 认证与 Token
|
||||
|
||||
### JWT
|
||||
|
||||
- `accessToken`: JWT,1 小时过期
|
||||
- `refreshToken`: 128 位随机 hex,入库只存 SHA-256 hash
|
||||
- logout 时 `revokedAt = now()` 撤销所有 refresh token
|
||||
- `/users/me` 及其所有子路由强制 `@UseGuards(JwtAuthGuard)`
|
||||
|
||||
```
|
||||
POST /auth/apple → 返回 accessToken + refreshToken
|
||||
POST /auth/refresh → 消耗旧 refreshToken,发放新 token pair(rotation)
|
||||
POST /auth/logout → 撤销该用户所有 refresh token
|
||||
```
|
||||
|
||||
### 存储安全
|
||||
|
||||
```
|
||||
refresh_tokens.tokenHash = SHA-256(实际 token)
|
||||
数据库中永远不存明文 refreshToken
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限与越权防护
|
||||
|
||||
### 资源归属校验
|
||||
|
||||
所有用户资源操作必须校验 `userId` 归属:
|
||||
|
||||
```ts
|
||||
// src/common/utils/security.util.ts
|
||||
export async function findByIdAndUserId(delegate, id, userId, resourceName)
|
||||
export function ensureOwnership(record, userId, resourceName)
|
||||
```
|
||||
|
||||
### 需校验的资源
|
||||
|
||||
| 资源 | 校验字段 |
|
||||
|------|---------|
|
||||
| KnowledgeBase | `userId` |
|
||||
| KnowledgeItem | `userId` |
|
||||
| LearningSession | `userId` |
|
||||
| ActiveRecallAnswer | `userId` |
|
||||
| AiAnalysisJob | `userId` |
|
||||
| AiAnalysisResult | `userId` |
|
||||
| FocusItem | `userId` |
|
||||
| ReviewCard | `userId` |
|
||||
| ReviewLog | `userId` |
|
||||
| DocumentImport | `userId` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 参数校验
|
||||
|
||||
- 全局 `StrictValidationPipe`:
|
||||
- `whitelist: true` — 自动剥离未声明字段
|
||||
- `forbidNonWhitelisted: true` — 未知字段返回 400
|
||||
- 字符串字段最大长度 5000 字符
|
||||
- 分页 DTO: page≥1, limit 1-100
|
||||
|
||||
---
|
||||
|
||||
## 5. 限流(Redis)
|
||||
|
||||
| 场景 | Key | 限制 |
|
||||
|------|-----|------|
|
||||
| 登录 | `rate:ip:{ip}:login:{date}` | 20次/IP/天 |
|
||||
| 反馈 | `rate:ip:{ip}:feedback:hourly` | 5次/IP/时 |
|
||||
| AI 分析 | `rate:user:{userId}:ai:daily:{date}` | 50次/用户/天 |
|
||||
| 文件上传 | `rate:user:{userId}:upload:hourly` | 10次/用户/时 |
|
||||
|
||||
实现: `src/common/utils/rate-limit.service.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件上传安全
|
||||
|
||||
| 措施 | 说明 |
|
||||
|------|------|
|
||||
| 类型白名单 | PDF, Word, Excel, 纯文本, Markdown, CSV, PNG, JPEG, WebP |
|
||||
| 大小限制 | 最大 20MB |
|
||||
| 随机文件名 | `sanitizeFilename()` 生成随机 key,不信任用户原始文件名 |
|
||||
| 默认私有 | 所有文件默认私有访问 |
|
||||
| 路径隔离 | `users/{userId}/...` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Redis 安全使用
|
||||
|
||||
- 不存核心业务结果(用户资料/知识点/AI分析结果等必须在 MySQL)
|
||||
- 队列任务只存 `jobId`/`userId` 等引用 ID
|
||||
- 所有临时 key 必须设置 TTL
|
||||
- 防重复提交锁必须有 TTL,解锁校验 token
|
||||
- 不在 Redis 中存 token 明文
|
||||
|
||||
---
|
||||
|
||||
## 8. COS 安全使用
|
||||
|
||||
- Bucket 默认私有读写
|
||||
- 后端不向前端暴露 SecretId/SecretKey
|
||||
- 下载私有文件通过签名 URL
|
||||
- 上传路径按 `users/{userId}/{randomKey}` 组织
|
||||
- 预留临时上传 URL(STS)机制
|
||||
|
||||
---
|
||||
|
||||
## 9. Swagger 安全
|
||||
|
||||
- 开发环境默认开启
|
||||
- 生产环境默认关闭
|
||||
- 生产环境如需开启,必须配置 Basic Auth(`SWAGGER_USER`/`SWAGGER_PASSWORD`)
|
||||
- 生产环境手动设置 `ENABLE_SWAGGER=true`
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据库安全
|
||||
|
||||
- 不使用 root 连接业务
|
||||
- 业务账号 `zhixi_user` 仅需 SELECT/INSERT/UPDATE/DELETE
|
||||
- 迁移账号和业务账号分离(`prisma db push` 与运行时连接帐号可不同)
|
||||
- 数据库自动备份建议: `mysqldump zhixi | gzip > backup-$(date +%Y%m%d).sql.gz`
|
||||
|
||||
### 日志中禁止打印
|
||||
|
||||
```
|
||||
DATABASE_URL(含密码)
|
||||
JWT_SECRET
|
||||
AI_API_KEY
|
||||
COS SecretKey
|
||||
用户完整 refreshToken
|
||||
用户上传文件的完整内容
|
||||
Authorization header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 安全检查清单
|
||||
|
||||
- [x] helmet 已启用
|
||||
- [x] CORS 仅允许白名单域名
|
||||
- [x] JWT + refresh token rotation + hash 存储
|
||||
- [x] logout 撤销 refresh token
|
||||
- [x] 所有用户数据接口需要认证
|
||||
- [x] 资源所有权校验工具已就绪
|
||||
- [x] StrictValidationPipe 全局启用(whitelist + forbidNonWhitelisted)
|
||||
- [x] Redis 限流已实现
|
||||
- [x] 文件类型/大小白名单
|
||||
- [x] 全局异常过滤器生产环境不暴露 stack trace
|
||||
- [x] Swagger 生产环境默认关闭
|
||||
- [x] 敏感信息不在日志中打印原则已确立
|
||||
1633
package-lock.json
generated
1633
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -20,15 +20,27 @@
|
||||
"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/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"@prisma/client": "^5.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -36,16 +48,20 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"bullmq": "^5.76.6",
|
||||
"eslint": "^9.18.0",
|
||||
"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
534
prisma/schema.prisma
Normal 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
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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) || [];
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,88 @@
|
||||
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, ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { PrismaModule } from './infrastructure/database/prisma.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { QueueModule } from './infrastructure/queue/queue.module';
|
||||
import { AiModule } from './infrastructure/ai/ai.module';
|
||||
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||
import { LoggerModule } from './infrastructure/logger/logger.module';
|
||||
|
||||
import { SystemModule } from './modules/system/system.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
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 { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||
|
||||
import appConfig from './config/app.config';
|
||||
import databaseConfig from './config/database.config';
|
||||
import redisConfig from './config/redis.config';
|
||||
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,
|
||||
],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('jwt.secret'),
|
||||
signOptions: { expiresIn: config.get<string>('jwt.expiresIn', '1h') as any },
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
QueueModule,
|
||||
AiModule,
|
||||
FeedbackModule,
|
||||
StorageModule,
|
||||
LoggerModule,
|
||||
SystemModule,
|
||||
AuthModule,
|
||||
KnowledgeModule,
|
||||
UsersModule,
|
||||
KnowledgeBaseModule,
|
||||
KnowledgeItemsModule,
|
||||
DocumentImportModule,
|
||||
LearningSessionModule,
|
||||
ActiveRecallModule,
|
||||
AiAnalysisModule,
|
||||
ReviewModule,
|
||||
FocusItemsModule,
|
||||
LearningActivityModule,
|
||||
NotificationsModule,
|
||||
FeedbackModule,
|
||||
WaitlistModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@ -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: '已退出登录' };
|
||||
}
|
||||
}
|
||||
8
src/common/decorators/current-user.decorator.ts
Normal file
8
src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
20
src/common/dto/pagination.dto.ts
Normal file
20
src/common/dto/pagination.dto.ts
Normal 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;
|
||||
}
|
||||
52
src/common/filters/global-exception.filter.ts
Normal file
52
src/common/filters/global-exception.filter.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const isProduction =
|
||||
this.configService.get<string>('app.nodeEnv') === 'production';
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = '服务器内部错误';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exception.message;
|
||||
if (Array.isArray(message)) message = message.join('; ');
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`[${request.method}] ${request.url} -> ${status}: ${message}`,
|
||||
isProduction ? undefined : (exception as any)?.stack,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
message,
|
||||
...(isProduction ? {} : { path: request.url }),
|
||||
});
|
||||
}
|
||||
}
|
||||
42
src/common/guards/jwt-auth.guard.ts
Normal file
42
src/common/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('请先登录');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
request.user = { id: String(payload.sub), email: payload.email };
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已过期,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return undefined;
|
||||
return authHeader.split(' ')[1];
|
||||
}
|
||||
}
|
||||
27
src/common/guards/optional-auth.guard.ts
Normal file
27
src/common/guards/optional-auth.guard.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return true;
|
||||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret'),
|
||||
});
|
||||
request.user = { id: String(payload.sub), email: payload.email };
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
21
src/common/interceptors/response.interceptor.ts
Normal file
21
src/common/interceptors/response.interceptor.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/common/pipes/strict-validation.pipe.ts
Normal file
47
src/common/pipes/strict-validation.pipe.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
@Injectable()
|
||||
export class StrictValidationPipe implements PipeTransform<any> {
|
||||
private readonly maxStringLength = 5000;
|
||||
|
||||
async transform(value: any, { metatype }: ArgumentMetadata) {
|
||||
if (!metatype || !this.toValidate(metatype)) {
|
||||
return value;
|
||||
}
|
||||
const object = plainToInstance(metatype, value);
|
||||
const errors = await validate(object, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((err) =>
|
||||
Object.values(err.constraints || {}).join(', '),
|
||||
);
|
||||
throw new BadRequestException(messages);
|
||||
}
|
||||
this.validateStringLengths(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
private toValidate(metatype: Function): boolean {
|
||||
const types: Function[] = [String, Boolean, Number, Array, Object];
|
||||
return !types.includes(metatype);
|
||||
}
|
||||
|
||||
private validateStringLengths(obj: any) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (typeof obj[key] === 'string' && obj[key].length > this.maxStringLength) {
|
||||
throw new BadRequestException(
|
||||
`字段 ${key} 长度不能超过 ${this.maxStringLength} 字符`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/common/types/index.ts
Normal file
17
src/common/types/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface UserPayload {
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
9
src/common/utils/id.util.ts
Normal file
9
src/common/utils/id.util.ts
Normal 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);
|
||||
}
|
||||
46
src/common/utils/rate-limit.service.ts
Normal file
46
src/common/utils/rate-limit.service.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class RateLimitService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async checkLimit(
|
||||
key: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number,
|
||||
): Promise<void> {
|
||||
const count = await this.redis.incr(key);
|
||||
if (count === 1) {
|
||||
await this.redis.expire(key, windowSeconds);
|
||||
}
|
||||
if (count > maxRequests) {
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: `请求过于频繁,请${windowSeconds}秒后再试`,
|
||||
retryAfter: windowSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loginLimit(ip: string): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await this.checkLimit(`rate:ip:${ip}:login:${today}`, 20, 1800);
|
||||
}
|
||||
|
||||
async feedbackLimit(ip: string): Promise<void> {
|
||||
await this.checkLimit(`rate:ip:${ip}:feedback:hourly`, 5, 3600);
|
||||
}
|
||||
|
||||
async aiAnalysisLimit(userId: string): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await this.checkLimit(`rate:user:${userId}:ai:daily:${today}`, 50, 86400);
|
||||
}
|
||||
|
||||
async fileUploadLimit(userId: string): Promise<void> {
|
||||
await this.checkLimit(`rate:user:${userId}:upload:hourly`, 10, 3600);
|
||||
}
|
||||
}
|
||||
80
src/common/utils/security.util.ts
Normal file
80
src/common/utils/security.util.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type Delegate = {
|
||||
findUnique(args: { where: any }): Promise<any>;
|
||||
findFirst(args: { where: any }): Promise<any>;
|
||||
};
|
||||
|
||||
export async function findByIdAndUserId<T extends Delegate>(
|
||||
delegate: T,
|
||||
id: number | bigint,
|
||||
userId: number | bigint,
|
||||
resourceName: string,
|
||||
) {
|
||||
const record = await delegate.findUnique({ where: { id } } as any);
|
||||
if (!record) {
|
||||
throw new BadRequestException(`${resourceName}不存在`);
|
||||
}
|
||||
if (record.userId !== userId) {
|
||||
throw new ForbiddenException(`无权访问该${resourceName}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function ensureOwnership(
|
||||
record: any,
|
||||
userId: number | bigint,
|
||||
resourceName: string,
|
||||
) {
|
||||
if (!record) {
|
||||
throw new BadRequestException(`${resourceName}不存在`);
|
||||
}
|
||||
if (record.userId !== userId) {
|
||||
throw new ForbiddenException(`无权访问该${resourceName}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(originalName: string): string {
|
||||
const ext = originalName.split('.').pop()?.toLowerCase() || '';
|
||||
const safeExt = ext.replace(/[^a-z0-9]/g, '');
|
||||
const randomName =
|
||||
Date.now().toString(36) + Math.random().toString(36).substring(2, 15);
|
||||
return `${randomName}.${safeExt}`;
|
||||
}
|
||||
|
||||
export const ALLOWED_FILE_TYPES = [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
export function validateFileUpload(
|
||||
mimeType: string,
|
||||
sizeBytes: number,
|
||||
): void {
|
||||
if (!ALLOWED_FILE_TYPES.includes(mimeType)) {
|
||||
throw new BadRequestException(
|
||||
`不支持的文件类型: ${mimeType},仅支持 PDF/Word/Excel/文本/图片`,
|
||||
);
|
||||
}
|
||||
if (sizeBytes > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException(
|
||||
`文件大小不能超过 ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string): string {
|
||||
if (!secret || secret.length < 8) return '***';
|
||||
return secret.slice(0, 4) + '***' + secret.slice(-4);
|
||||
}
|
||||
9
src/config/ai.config.ts
Normal file
9
src/config/ai.config.ts
Normal 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
9
src/config/app.config.ts
Normal 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',
|
||||
}));
|
||||
7
src/config/database.config.ts
Normal file
7
src/config/database.config.ts
Normal 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',
|
||||
}));
|
||||
21
src/config/jwt.config.ts
Normal file
21
src/config/jwt.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret || secret === 'change_me_in_production') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'生产环境必须设置环境变量 JWT_SECRET,不能使用默认值',
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
'\n⚠️ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' +
|
||||
' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n',
|
||||
);
|
||||
}
|
||||
return {
|
||||
secret: secret || 'change_me_in_production',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
};
|
||||
});
|
||||
9
src/config/redis.config.ts
Normal file
9
src/config/redis.config.ts
Normal 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,
|
||||
}));
|
||||
11
src/config/storage.config.ts
Normal file
11
src/config/storage.config.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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>),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
src/infrastructure/ai/ai-provider.interface.ts
Normal file
24
src/infrastructure/ai/ai-provider.interface.ts
Normal 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[];
|
||||
}>;
|
||||
}
|
||||
13
src/infrastructure/ai/ai.module.ts
Normal file
13
src/infrastructure/ai/ai.module.ts
Normal 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 {}
|
||||
24
src/infrastructure/ai/ai.service.ts
Normal file
24
src/infrastructure/ai/ai.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/infrastructure/ai/providers/mock-ai.provider.ts
Normal file
30
src/infrastructure/ai/providers/mock-ai.provider.ts
Normal 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: ['重新解释', '给我一个例子', '检查我的理解'],
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/database/prisma.module.ts
Normal file
9
src/infrastructure/database/prisma.module.ts
Normal 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 {}
|
||||
16
src/infrastructure/database/prisma.service.ts
Normal file
16
src/infrastructure/database/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
4
src/infrastructure/logger/app-logger.service.ts
Normal file
4
src/infrastructure/logger/app-logger.service.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppLoggerService extends Logger {}
|
||||
9
src/infrastructure/logger/logger.module.ts
Normal file
9
src/infrastructure/logger/logger.module.ts
Normal 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 {}
|
||||
9
src/infrastructure/queue/queue.module.ts
Normal file
9
src/infrastructure/queue/queue.module.ts
Normal 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 {}
|
||||
23
src/infrastructure/queue/queue.service.ts
Normal file
23
src/infrastructure/queue/queue.service.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/redis/redis.module.ts
Normal file
9
src/infrastructure/redis/redis.module.ts
Normal 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 {}
|
||||
96
src/infrastructure/redis/redis.service.ts
Normal file
96
src/infrastructure/redis/redis.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/storage/storage.module.ts
Normal file
9
src/infrastructure/storage/storage.module.ts
Normal 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 {}
|
||||
19
src/infrastructure/storage/storage.service.ts
Normal file
19
src/infrastructure/storage/storage.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [KnowledgeController],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
116
src/main.ts
116
src/main.ts
@ -1,88 +1,79 @@
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
import helmet from 'helmet';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
const isProduction = configService.get('app.nodeEnv') === 'production';
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
app.enableCors({
|
||||
origin: ['https://longde.cloud', 'http://localhost:4321'],
|
||||
origin: isProduction
|
||||
? [configService.get('app.allowedOrigin', 'https://longde.cloud')]
|
||||
: ['https://longde.cloud', 'http://localhost:4321', 'http://localhost:5173'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
credentials: true,
|
||||
maxAge: 86400,
|
||||
});
|
||||
|
||||
if (isSwaggerEnabled()) {
|
||||
app.useBodyParser('json', { limit: '10mb' });
|
||||
|
||||
const swaggerEnabled = !isProduction || configService.get('app.enableSwagger') === true;
|
||||
if (swaggerEnabled) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('龙de AI 学习产品 API')
|
||||
.setDescription('AI 学习产品后端 API 文档(v0.1),包含用户管理、学习路径、AI 对话、反馈等功能。')
|
||||
.setTitle('知习 API')
|
||||
.setDescription('知习 AI-first 系统化学习产品后端 API')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('health', '服务健康检查')
|
||||
.addTag('auth', '用户认证')
|
||||
.addTag('users', '用户管理')
|
||||
.addTag('knowledge', '知识库')
|
||||
.addTag('learning', '学习路径与进度')
|
||||
.addTag('ai', 'AI 分析与对话')
|
||||
.addTag('review', '复习任务')
|
||||
.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);
|
||||
|
||||
if (needsBasicAuth()) {
|
||||
app.use('/api-docs', createBasicAuthMiddleware() as any);
|
||||
app.use('/api-docs-json', createBasicAuthMiddleware() as any);
|
||||
if (isProduction) {
|
||||
const swaggerUser = configService.get('app.swaggerUser', 'admin');
|
||||
const swaggerPassword = configService.get('app.swaggerPassword');
|
||||
if (swaggerPassword) {
|
||||
app.use('/api-docs', (req: any, res: any, next: any) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth?.startsWith('Basic ')) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic');
|
||||
return res.status(401).send('Authentication required');
|
||||
}
|
||||
const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64')
|
||||
.toString()
|
||||
.split(':');
|
||||
if (user === swaggerUser && pass === swaggerPassword) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).send('Invalid credentials');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SwaggerModule.setup('api-docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
},
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: '龙de API 文档',
|
||||
customSiteTitle: '知习 API 文档',
|
||||
});
|
||||
|
||||
app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => {
|
||||
@ -90,14 +81,9 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
console.log('[Swagger] API 文档已启用');
|
||||
if (needsBasicAuth()) {
|
||||
console.log(`[Swagger] Basic Auth 已启用 (${SWAGGER_BASIC_AUTH_USER})`);
|
||||
}
|
||||
} else {
|
||||
console.log('[Swagger] API 文档已禁用');
|
||||
}
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
const port = configService.get<number>('app.port', 3000);
|
||||
await app.listen(port);
|
||||
console.log(`[API] Server running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
23
src/modules/active-recall/active-recall.controller.ts
Normal file
23
src/modules/active-recall/active-recall.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/active-recall/active-recall.module.ts
Normal file
11
src/modules/active-recall/active-recall.module.ts
Normal 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 {}
|
||||
56
src/modules/active-recall/active-recall.repository.ts
Normal file
56
src/modules/active-recall/active-recall.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/modules/active-recall/active-recall.service.ts
Normal file
17
src/modules/active-recall/active-recall.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/modules/ai-analysis/ai-analysis.controller.ts
Normal file
29
src/modules/ai-analysis/ai-analysis.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/ai-analysis/ai-analysis.module.ts
Normal file
11
src/modules/ai-analysis/ai-analysis.module.ts
Normal 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 {}
|
||||
71
src/modules/ai-analysis/ai-analysis.repository.ts
Normal file
71
src/modules/ai-analysis/ai-analysis.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
src/modules/ai-analysis/ai-analysis.service.ts
Normal file
102
src/modules/ai-analysis/ai-analysis.service.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { AiAnalysisRepository } from './ai-analysis.repository';
|
||||
import { AiService } from '../../infrastructure/ai/ai.service';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
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 HttpException('同一学习会话的 AI 分析正在处理中,请稍候', HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const job = await this.repository.createJob(userId, body);
|
||||
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'pending', 86400);
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '0', 86400);
|
||||
|
||||
this.queue.add('ai-analysis', { jobId: job.id, userId, sessionId: body.sessionId });
|
||||
this.processJob(job, lockKey, lockToken);
|
||||
|
||||
return { jobId: job.id, status: job.status };
|
||||
}
|
||||
|
||||
private async checkRateLimit(userId: string) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const rateKey = `rate:user:${userId}:ai:daily:${today}`;
|
||||
const count = await this.redis.incr(rateKey);
|
||||
if (count === 1) {
|
||||
await this.redis.expire(rateKey, 86400);
|
||||
}
|
||||
if (count > DAILY_AI_LIMIT) {
|
||||
throw new HttpException(
|
||||
`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private processJob(job: any, lockKey: string, lockToken: string) {
|
||||
try {
|
||||
this.repository.updateJobStatus(job.id, 'processing');
|
||||
this.redis.set(`job:ai-analysis:${job.id}:status`, 'processing', 86400);
|
||||
this.redis.set(`job:ai-analysis:${job.id}:progress`, '30', 86400);
|
||||
|
||||
this.aiService.analyze({
|
||||
userInput: job.inputText,
|
||||
context: { lessonTitle: '', objectives: [], keyPoints: [] },
|
||||
}).then(async (result) => {
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '80', 86400);
|
||||
await this.repository.createResult(job.id, job.userId, result);
|
||||
this.repository.updateJobStatus(job.id, 'success');
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'completed', 86400);
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:progress`, '100', 86400);
|
||||
await this.redis.unlock(lockKey, lockToken);
|
||||
this.logger.log(`Job ${job.id} completed`);
|
||||
}).catch(async (err) => {
|
||||
this.logger.error(`Job ${job.id} failed: ${err.message}`);
|
||||
this.repository.updateJobStatus(job.id, 'failed');
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:status`, 'failed', 86400);
|
||||
await this.redis.set(`job:ai-analysis:${job.id}:error`, err.message, 86400);
|
||||
await this.redis.unlock(lockKey, lockToken);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Job ${job.id} sync error: ${err}`);
|
||||
this.redis.unlock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async getResult(id: string) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
69
src/modules/auth/auth.controller.ts
Normal file
69
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { Request } from 'express';
|
||||
import { IsString, Allow, IsOptional } from 'class-validator';
|
||||
|
||||
class AppleLoginDto {
|
||||
@IsString()
|
||||
identityToken: string;
|
||||
|
||||
@IsString()
|
||||
authorizationCode: string;
|
||||
|
||||
@Allow()
|
||||
@IsOptional()
|
||||
user?: any;
|
||||
}
|
||||
|
||||
class RefreshDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('apple')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Apple 登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
@ApiResponse({ status: 401, description: '身份验证失败' })
|
||||
async appleLogin(@Body() body: AppleLoginDto) {
|
||||
return this.authService.appleLogin(body);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
@ApiResponse({ status: 401, description: '刷新令牌无效' })
|
||||
async refresh(@Body() body: RefreshDto) {
|
||||
if (!body.refreshToken) {
|
||||
throw new BadRequestException('缺少 refreshToken');
|
||||
}
|
||||
return this.authService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '退出登录' })
|
||||
@ApiResponse({ status: 200, description: '退出成功' })
|
||||
async logout(@Req() req: Request) {
|
||||
const user = (req as any).user;
|
||||
if (user?.id) {
|
||||
await this.authService.logout(user.id);
|
||||
}
|
||||
return { success: true, message: '已退出登录' };
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
158
src/modules/auth/auth.service.ts
Normal file
158
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async appleLogin(params: {
|
||||
identityToken: string;
|
||||
authorizationCode: string;
|
||||
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
||||
}) {
|
||||
const appleUserId = await this.verifyAppleIdentity(
|
||||
params.identityToken,
|
||||
params.authorizationCode,
|
||||
params.user?.email,
|
||||
);
|
||||
|
||||
let account = await this.prisma.authAccount.findUnique({
|
||||
where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
const displayName =
|
||||
params.user?.name
|
||||
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}`
|
||||
: undefined;
|
||||
account = await this.prisma.authAccount.create({
|
||||
data: {
|
||||
provider: 'apple',
|
||||
providerUserId: appleUserId,
|
||||
email: params.user?.email,
|
||||
user: {
|
||||
create: {
|
||||
email: params.user?.email,
|
||||
nickname: displayName || undefined,
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
const userIdStr = String(account.user.id);
|
||||
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: userIdStr,
|
||||
email: account.user.email,
|
||||
});
|
||||
|
||||
const refreshToken = crypto.randomBytes(48).toString('hex');
|
||||
const refreshTokenHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(refreshToken)
|
||||
.digest('hex');
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: account.user.id,
|
||||
tokenHash: refreshTokenHash,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken, expiresIn: 3600 };
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const stored = await this.prisma.refreshToken.findFirst({
|
||||
where: { tokenHash: hash, revokedAt: null },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('刷新令牌无效或已过期');
|
||||
}
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
const newRefreshToken = crypto.randomBytes(48).toString('hex');
|
||||
const newHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(newRefreshToken)
|
||||
.digest('hex');
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: stored.userId,
|
||||
tokenHash: newHash,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400000),
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: String(stored.user.id),
|
||||
email: stored.user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresIn: 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(userId: number) {
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyAppleIdentity(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
email?: string | null,
|
||||
): Promise<string> {
|
||||
if (this.isMockMode()) {
|
||||
return this.verifyMockApple(identityToken, email);
|
||||
}
|
||||
return this.verifyRealApple(identityToken, authorizationCode);
|
||||
}
|
||||
|
||||
private isMockMode(): boolean {
|
||||
return this.configService.get<string>('app.nodeEnv') !== 'production';
|
||||
}
|
||||
|
||||
private verifyMockApple(identityToken: string, email?: string | null): string {
|
||||
if (!identityToken || identityToken.trim().length < 4) {
|
||||
throw new UnauthorizedException('identityToken 无效');
|
||||
}
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`apple-mock:${identityToken}:${email || 'no-email'}`)
|
||||
.digest('hex')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
private async verifyRealApple(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
): Promise<string> {
|
||||
throw new UnauthorizedException('Apple 登录尚未接入,请先配置 Apple Developer 凭证');
|
||||
}
|
||||
}
|
||||
22
src/modules/document-import/document-import.controller.ts
Normal file
22
src/modules/document-import/document-import.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/document-import/document-import.module.ts
Normal file
11
src/modules/document-import/document-import.module.ts
Normal 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 {}
|
||||
39
src/modules/document-import/document-import.repository.ts
Normal file
39
src/modules/document-import/document-import.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/modules/document-import/document-import.service.ts
Normal file
77
src/modules/document-import/document-import.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
src/modules/feedback/dto/create-feedback.dto.ts
Normal file
23
src/modules/feedback/dto/create-feedback.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateFeedbackDto {
|
||||
@ApiPropertyOptional({ description: '用户 ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@ApiProperty({ description: '反馈类型', enum: ['bug', 'feature', 'general'] })
|
||||
@IsString()
|
||||
@IsIn(['bug', 'feature', 'general'])
|
||||
type: 'bug' | 'feature' | 'general';
|
||||
|
||||
@ApiProperty({ description: '反馈内容' })
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '联系方式' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contact?: string;
|
||||
}
|
||||
38
src/modules/feedback/feedback.controller.ts
Normal file
38
src/modules/feedback/feedback.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
58
src/modules/feedback/feedback.repository.ts
Normal file
58
src/modules/feedback/feedback.repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
28
src/modules/feedback/feedback.service.ts
Normal file
28
src/modules/feedback/feedback.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
33
src/modules/focus-items/focus-items.controller.ts
Normal file
33
src/modules/focus-items/focus-items.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/focus-items/focus-items.module.ts
Normal file
11
src/modules/focus-items/focus-items.module.ts
Normal 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 {}
|
||||
39
src/modules/focus-items/focus-items.repository.ts
Normal file
39
src/modules/focus-items/focus-items.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/modules/focus-items/focus-items.service.ts
Normal file
31
src/modules/focus-items/focus-items.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/modules/focus-items/types/focus-item.types.ts
Normal file
10
src/modules/focus-items/types/focus-item.types.ts
Normal 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;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export const MAX_KNOWLEDGE_BASE_COUNT = 20;
|
||||
41
src/modules/knowledge-base/knowledge-base.controller.ts
Normal file
41
src/modules/knowledge-base/knowledge-base.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/knowledge-base/knowledge-base.module.ts
Normal file
11
src/modules/knowledge-base/knowledge-base.module.ts
Normal 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 {}
|
||||
68
src/modules/knowledge-base/knowledge-base.repository.ts
Normal file
68
src/modules/knowledge-base/knowledge-base.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/modules/knowledge-base/knowledge-base.service.ts
Normal file
44
src/modules/knowledge-base/knowledge-base.service.ts
Normal 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 || String(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 || String(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 || String(kb.userId) !== userId) {
|
||||
throw new NotFoundException('知识库不存在');
|
||||
}
|
||||
return this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
1
src/modules/knowledge-base/types/knowledge-base.types.ts
Normal file
1
src/modules/knowledge-base/types/knowledge-base.types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type KnowledgeBaseStatus = 'active' | 'archived' | 'deleted';
|
||||
35
src/modules/knowledge-items/knowledge-items.controller.ts
Normal file
35
src/modules/knowledge-items/knowledge-items.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/modules/knowledge-items/knowledge-items.module.ts
Normal file
11
src/modules/knowledge-items/knowledge-items.module.ts
Normal 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 {}
|
||||
53
src/modules/knowledge-items/knowledge-items.repository.ts
Normal file
53
src/modules/knowledge-items/knowledge-items.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/modules/knowledge-items/knowledge-items.service.ts
Normal file
27
src/modules/knowledge-items/knowledge-items.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export type KnowledgeItemType = 'chapter' | 'lesson' | 'concept' | 'note' | 'imported_doc';
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
11
src/modules/learning-activity/learning-activity.module.ts
Normal file
11
src/modules/learning-activity/learning-activity.module.ts
Normal 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 {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user