From 35de65e99baee6b401685aa0bf3b7371dc338d0f Mon Sep 17 00:00:00 2001 From: WangDL Date: Sat, 9 May 2026 18:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20api-server=20?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=8C=96=E5=8D=95=E4=BD=93=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E6=8E=A5=E5=85=A5=20MySQL=20+=20Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 BACKEND-PLAN.md 将项目重构为 4 层架构: config/ -> common/ -> infrastructure/ -> modules/ - 15 个业务模块,遵循 Controller → Service → Repository 分层 - infrastructure: PrismaService / RedisService / QueueService / AiService / StorageService - common: guards / interceptors / filters / pipes / decorators / dto / types / utils - Prisma schema 含 27 张表,MySQL 8.0 服务器 db push 成功 - Redis 7 接入: 限流/任务状态/分布式锁/队列预留 - ai-analysis 模块: 每日 50 次限流 + 重复提交锁 + 异步任务状态追踪 - document-import 模块: 异步导入流程 + 进度追踪 - notifications 模块: BullMQ notification 队列预留 - /health 端点实时返回 database + redis 连接状态 - Swagger 注册 15 个 tag,67 个路由全部映射 --- .env.example | 8 +- BACKEND-PLAN.md | 1185 +++++++++++++ DATABASE-DESIGN.md | 814 +++++++++ REDIS-DESIGN.md | 262 +++ package-lock.json | 1496 ++++++++++++----- package.json | 10 +- prisma/schema.prisma | 534 ++++++ src/ai/ai.controller.ts | 50 - src/ai/ai.module.ts | 10 - src/ai/ai.service.ts | 156 -- src/ai/entities/ai.entity.ts | 86 - src/app.controller.spec.ts | 22 - src/app.controller.ts | 16 - src/app.module.ts | 77 +- src/app.service.ts | 8 - src/auth/auth.controller.ts | 60 - src/auth/auth.module.ts | 7 - .../decorators/current-user.decorator.ts | 8 + src/common/dto/pagination.dto.ts | 20 + src/common/filters/http-exception.filter.ts | 32 + src/common/guards/jwt-auth.guard.ts | 9 + src/common/guards/optional-auth.guard.ts | 8 + .../interceptors/response.interceptor.ts | 21 + src/common/pipes/validation.pipe.ts | 31 + src/common/types/index.ts | 17 + src/common/utils/id.util.ts | 9 + src/config/ai.config.ts | 9 + src/config/app.config.ts | 9 + src/config/database.config.ts | 7 + src/config/jwt.config.ts | 7 + src/config/redis.config.ts | 9 + src/config/storage.config.ts | 11 + src/feedback/dto/create-feedback.dto.ts | 26 - src/feedback/entities/feedback.entity.ts | 27 - src/feedback/feedback.controller.ts | 54 - src/feedback/feedback.service.ts | 56 - .../ai/ai-provider.interface.ts | 24 + src/infrastructure/ai/ai.module.ts | 13 + src/infrastructure/ai/ai.service.ts | 24 + .../ai/providers/mock-ai.provider.ts | 30 + src/infrastructure/database/prisma.module.ts | 9 + src/infrastructure/database/prisma.service.ts | 16 + .../logger/app-logger.service.ts | 4 + src/infrastructure/logger/logger.module.ts | 9 + src/infrastructure/queue/queue.module.ts | 9 + src/infrastructure/queue/queue.service.ts | 23 + src/infrastructure/redis/redis.module.ts | 9 + src/infrastructure/redis/redis.service.ts | 96 ++ src/infrastructure/storage/storage.module.ts | 9 + src/infrastructure/storage/storage.service.ts | 19 + src/knowledge/knowledge.controller.ts | 42 - src/knowledge/knowledge.module.ts | 7 - src/learning/entities/learning.entity.ts | 131 -- src/learning/learning.controller.ts | 85 - src/learning/learning.module.ts | 10 - src/learning/learning.service.ts | 172 -- src/main.ts | 115 +- .../active-recall/active-recall.controller.ts | 23 + .../active-recall/active-recall.module.ts | 11 + .../active-recall/active-recall.repository.ts | 56 + .../active-recall/active-recall.service.ts | 17 + .../ai-analysis/ai-analysis.controller.ts | 29 + src/modules/ai-analysis/ai-analysis.module.ts | 11 + .../ai-analysis/ai-analysis.repository.ts | 71 + .../ai-analysis/ai-analysis.service.ts | 99 ++ src/modules/auth/auth.controller.ts | 39 + src/modules/auth/auth.module.ts | 11 + src/modules/auth/auth.repository.ts | 29 + src/modules/auth/auth.service.ts | 39 + .../document-import.controller.ts | 22 + .../document-import/document-import.module.ts | 11 + .../document-import.repository.ts | 39 + .../document-import.service.ts | 77 + .../feedback/dto/create-feedback.dto.ts | 15 + src/modules/feedback/feedback.controller.ts | 38 + src/{ => modules}/feedback/feedback.module.ts | 5 +- src/modules/feedback/feedback.repository.ts | 58 + src/modules/feedback/feedback.service.ts | 28 + .../focus-items/focus-items.controller.ts | 33 + src/modules/focus-items/focus-items.module.ts | 11 + .../focus-items/focus-items.repository.ts | 39 + .../focus-items/focus-items.service.ts | 31 + .../focus-items/types/focus-item.types.ts | 10 + .../constants/knowledge-base.constants.ts | 1 + .../knowledge-base.controller.ts | 41 + .../knowledge-base/knowledge-base.module.ts | 11 + .../knowledge-base.repository.ts | 68 + .../knowledge-base/knowledge-base.service.ts | 44 + .../types/knowledge-base.types.ts | 1 + .../knowledge-items.controller.ts | 35 + .../knowledge-items/knowledge-items.module.ts | 11 + .../knowledge-items.repository.ts | 53 + .../knowledge-items.service.ts | 27 + .../types/knowledge-item.types.ts | 1 + .../learning-activity.controller.ts | 21 + .../learning-activity.module.ts | 11 + .../learning-activity.repository.ts | 32 + .../learning-activity.service.ts | 25 + .../learning-session.controller.ts | 29 + .../learning-session.module.ts | 11 + .../learning-session.repository.ts | 48 + .../learning-session.service.ts | 21 + .../notifications/notifications.controller.ts | 22 + .../notifications/notifications.module.ts | 11 + .../notifications/notifications.repository.ts | 66 + .../notifications/notifications.service.ts | 37 + src/modules/review/dto/submit-review.dto.ts | 13 + src/modules/review/review.controller.ts | 25 + src/modules/review/review.module.ts | 11 + src/modules/review/review.repository.ts | 80 + src/modules/review/review.service.ts | 24 + src/modules/system/system.controller.ts | 40 + src/modules/system/system.module.ts | 7 + src/modules/users/users.controller.ts | 32 + src/{ => modules}/users/users.module.ts | 5 +- src/modules/users/users.repository.ts | 37 + src/modules/users/users.service.ts | 19 + .../waitlist/dto/create-waitlist.dto.ts | 30 + src/modules/waitlist/waitlist.controller.ts | 31 + src/{ => modules}/waitlist/waitlist.module.ts | 6 +- src/modules/waitlist/waitlist.repository.ts | 26 + src/modules/waitlist/waitlist.service.ts | 38 + src/users/dto/create-user.dto.ts | 37 - src/users/entities/user.entity.ts | 64 - src/users/users.controller.ts | 60 - src/users/users.service.ts | 80 - src/waitlist/dto/create-waitlist.dto.ts | 21 - src/waitlist/waitlist.controller.ts | 43 - src/waitlist/waitlist.service.ts | 72 - src/workers/ai-analysis.worker.ts | 5 + src/workers/document-import.worker.ts | 5 + src/workers/notification.worker.ts | 5 + test-crud.ts | 184 ++ 133 files changed, 6571 insertions(+), 1935 deletions(-) create mode 100644 BACKEND-PLAN.md create mode 100644 DATABASE-DESIGN.md create mode 100644 REDIS-DESIGN.md create mode 100644 prisma/schema.prisma delete mode 100644 src/ai/ai.controller.ts delete mode 100644 src/ai/ai.module.ts delete mode 100644 src/ai/ai.service.ts delete mode 100644 src/ai/entities/ai.entity.ts delete mode 100644 src/app.controller.spec.ts delete mode 100644 src/app.controller.ts delete mode 100644 src/app.service.ts delete mode 100644 src/auth/auth.controller.ts delete mode 100644 src/auth/auth.module.ts create mode 100644 src/common/decorators/current-user.decorator.ts create mode 100644 src/common/dto/pagination.dto.ts create mode 100644 src/common/filters/http-exception.filter.ts create mode 100644 src/common/guards/jwt-auth.guard.ts create mode 100644 src/common/guards/optional-auth.guard.ts create mode 100644 src/common/interceptors/response.interceptor.ts create mode 100644 src/common/pipes/validation.pipe.ts create mode 100644 src/common/types/index.ts create mode 100644 src/common/utils/id.util.ts create mode 100644 src/config/ai.config.ts create mode 100644 src/config/app.config.ts create mode 100644 src/config/database.config.ts create mode 100644 src/config/jwt.config.ts create mode 100644 src/config/redis.config.ts create mode 100644 src/config/storage.config.ts delete mode 100644 src/feedback/dto/create-feedback.dto.ts delete mode 100644 src/feedback/entities/feedback.entity.ts delete mode 100644 src/feedback/feedback.controller.ts delete mode 100644 src/feedback/feedback.service.ts create mode 100644 src/infrastructure/ai/ai-provider.interface.ts create mode 100644 src/infrastructure/ai/ai.module.ts create mode 100644 src/infrastructure/ai/ai.service.ts create mode 100644 src/infrastructure/ai/providers/mock-ai.provider.ts create mode 100644 src/infrastructure/database/prisma.module.ts create mode 100644 src/infrastructure/database/prisma.service.ts create mode 100644 src/infrastructure/logger/app-logger.service.ts create mode 100644 src/infrastructure/logger/logger.module.ts create mode 100644 src/infrastructure/queue/queue.module.ts create mode 100644 src/infrastructure/queue/queue.service.ts create mode 100644 src/infrastructure/redis/redis.module.ts create mode 100644 src/infrastructure/redis/redis.service.ts create mode 100644 src/infrastructure/storage/storage.module.ts create mode 100644 src/infrastructure/storage/storage.service.ts delete mode 100644 src/knowledge/knowledge.controller.ts delete mode 100644 src/knowledge/knowledge.module.ts delete mode 100644 src/learning/entities/learning.entity.ts delete mode 100644 src/learning/learning.controller.ts delete mode 100644 src/learning/learning.module.ts delete mode 100644 src/learning/learning.service.ts create mode 100644 src/modules/active-recall/active-recall.controller.ts create mode 100644 src/modules/active-recall/active-recall.module.ts create mode 100644 src/modules/active-recall/active-recall.repository.ts create mode 100644 src/modules/active-recall/active-recall.service.ts create mode 100644 src/modules/ai-analysis/ai-analysis.controller.ts create mode 100644 src/modules/ai-analysis/ai-analysis.module.ts create mode 100644 src/modules/ai-analysis/ai-analysis.repository.ts create mode 100644 src/modules/ai-analysis/ai-analysis.service.ts create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/auth.repository.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/document-import/document-import.controller.ts create mode 100644 src/modules/document-import/document-import.module.ts create mode 100644 src/modules/document-import/document-import.repository.ts create mode 100644 src/modules/document-import/document-import.service.ts create mode 100644 src/modules/feedback/dto/create-feedback.dto.ts create mode 100644 src/modules/feedback/feedback.controller.ts rename src/{ => modules}/feedback/feedback.module.ts (62%) create mode 100644 src/modules/feedback/feedback.repository.ts create mode 100644 src/modules/feedback/feedback.service.ts create mode 100644 src/modules/focus-items/focus-items.controller.ts create mode 100644 src/modules/focus-items/focus-items.module.ts create mode 100644 src/modules/focus-items/focus-items.repository.ts create mode 100644 src/modules/focus-items/focus-items.service.ts create mode 100644 src/modules/focus-items/types/focus-item.types.ts create mode 100644 src/modules/knowledge-base/constants/knowledge-base.constants.ts create mode 100644 src/modules/knowledge-base/knowledge-base.controller.ts create mode 100644 src/modules/knowledge-base/knowledge-base.module.ts create mode 100644 src/modules/knowledge-base/knowledge-base.repository.ts create mode 100644 src/modules/knowledge-base/knowledge-base.service.ts create mode 100644 src/modules/knowledge-base/types/knowledge-base.types.ts create mode 100644 src/modules/knowledge-items/knowledge-items.controller.ts create mode 100644 src/modules/knowledge-items/knowledge-items.module.ts create mode 100644 src/modules/knowledge-items/knowledge-items.repository.ts create mode 100644 src/modules/knowledge-items/knowledge-items.service.ts create mode 100644 src/modules/knowledge-items/types/knowledge-item.types.ts create mode 100644 src/modules/learning-activity/learning-activity.controller.ts create mode 100644 src/modules/learning-activity/learning-activity.module.ts create mode 100644 src/modules/learning-activity/learning-activity.repository.ts create mode 100644 src/modules/learning-activity/learning-activity.service.ts create mode 100644 src/modules/learning-session/learning-session.controller.ts create mode 100644 src/modules/learning-session/learning-session.module.ts create mode 100644 src/modules/learning-session/learning-session.repository.ts create mode 100644 src/modules/learning-session/learning-session.service.ts create mode 100644 src/modules/notifications/notifications.controller.ts create mode 100644 src/modules/notifications/notifications.module.ts create mode 100644 src/modules/notifications/notifications.repository.ts create mode 100644 src/modules/notifications/notifications.service.ts create mode 100644 src/modules/review/dto/submit-review.dto.ts create mode 100644 src/modules/review/review.controller.ts create mode 100644 src/modules/review/review.module.ts create mode 100644 src/modules/review/review.repository.ts create mode 100644 src/modules/review/review.service.ts create mode 100644 src/modules/system/system.controller.ts create mode 100644 src/modules/system/system.module.ts create mode 100644 src/modules/users/users.controller.ts rename src/{ => modules}/users/users.module.ts (63%) create mode 100644 src/modules/users/users.repository.ts create mode 100644 src/modules/users/users.service.ts create mode 100644 src/modules/waitlist/dto/create-waitlist.dto.ts create mode 100644 src/modules/waitlist/waitlist.controller.ts rename src/{ => modules}/waitlist/waitlist.module.ts (54%) create mode 100644 src/modules/waitlist/waitlist.repository.ts create mode 100644 src/modules/waitlist/waitlist.service.ts delete mode 100644 src/users/dto/create-user.dto.ts delete mode 100644 src/users/entities/user.entity.ts delete mode 100644 src/users/users.controller.ts delete mode 100644 src/users/users.service.ts delete mode 100644 src/waitlist/dto/create-waitlist.dto.ts delete mode 100644 src/waitlist/waitlist.controller.ts delete mode 100644 src/waitlist/waitlist.service.ts create mode 100644 src/workers/ai-analysis.worker.ts create mode 100644 src/workers/document-import.worker.ts create mode 100644 src/workers/notification.worker.ts create mode 100644 test-crud.ts diff --git a/.env.example b/.env.example index a4e70a7..a4080a7 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,11 @@ PORT=3000 -DATABASE_URL="mysql://ai_study_user:ai_study_password@localhost:3306/ai_study" +DATABASE_URL="mysql://zhixi_user:Zhixi@2026!App@81.70.187.179:3306/zhixi" -REDIS_HOST=localhost +REDIS_HOST=81.70.187.179 REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 AI_PROVIDER=mock AI_API_KEY= @@ -15,4 +17,4 @@ NODE_ENV=development ENABLE_SWAGGER=true SWAGGER_USER=admin -SWAGGER_PASSWORD=change_me \ No newline at end of file +SWAGGER_PASSWORD=change_me diff --git a/BACKEND-PLAN.md b/BACKEND-PLAN.md new file mode 100644 index 0000000..f14ba85 --- /dev/null +++ b/BACKEND-PLAN.md @@ -0,0 +1,1185 @@ +--- +source: startup-plan/个人开发者创业 v0.1 + 完全版 + AI回答.md 架构深化方案 +updated: 2026-05-09 +--- + +# 知习 api-server 后端架构规划 + +> 「知习」是一款 AI-first 的系统化学习 App。后端需要同时服务 iOS App 和 Web 官网。 +> 核心功能包括知识库、主动回忆、AI 学习分析、间隔复习、待巩固项、学习活跃记录、消息通知和反馈。 + +--- + +## 1. 架构总览:模块化单体 + Redis + Worker + +**不要微服务。** 当前最适合的是: + +> **模块化单体架构 Monolithic Modular Architecture** +> +> 一个后端项目,内部按业务模块拆清楚。 + +```text +iOS App +Web 官网 + ↓ +统一 API 后端(NestJS 模块化单体) + ↓ +数据库 / Redis / 文件存储 / AI 服务 + ↓ +Worker 异步任务 +``` + +--- + +## 2. 最终技术栈 + +```text +后端框架:NestJS + TypeScript +ORM:Prisma +数据库:PostgreSQL(推荐),MySQL 亦可 +缓存/队列:Redis +队列系统:BullMQ +接口文档:Swagger / OpenAPI +文件存储:本地开发先本地存,后期接对象存储 +AI:Provider 抽象 + 一个真实模型 + MockProvider +部署:Docker Compose +反向代理:Nginx +域名:api.longde.cloud +``` + +Docker Compose 项目服务规划: + +```text +api-server — NestJS 后端 +postgres — 主数据库 +redis — 缓存 + 队列 +worker — 异步任务处理 +nginx — 反向代理 + HTTPS +``` + +--- + +## 3. 为什么选这个技术栈 + +### NestJS + +项目会越来越模块化。NestJS 的模块结构适合长期维护: + +```text +用户 / 知识库 / 学习记录 / AI 分析 / 复习计划 / 待巩固项 / 通知 / 反馈 / 订阅 +``` + +Express 也能做,但后期容易自己手写一堆规范,最后变乱。 + +### Prisma + +- 类型安全的 ORM,自动生成 TypeScript 类型 +- 迁移管理清晰 +- 与 NestJS 集成良好 +- AI Agent 按 schema 生成代码能力好 + +### PostgreSQL(优于 MySQL) + +- 更适合 JSON 数据(AI 分析结果) +- 支持全文检索 +- 后续可扩展 pgvector 做向量检索 +- 如果更熟悉 MySQL,也可以先用 MySQL + +### Redis + BullMQ + +Redis 不只是缓存,在知习系统里做四件事: + +```text +缓存 → 短期数据加速 +队列 → AI 分析 / 资料导入 / 通知任务 +限流 → AI 调用频控 / 用户请求限流 +临时状态 → 任务处理中状态 / 防重复提交锁 +``` + +--- + +## 4. 前后端职责分离 + +| 端 | 职责 | +|---|------| +| iOS 客户端 | 页面展示、用户交互、本地状态、登录入口、学习流程体验 | +| 后端 API | 用户身份、AI API 代理、学习记录、AI 分析、学习画像、复习计划、待巩固项、通知、反馈 | +| Worker | AI 分析任务异步处理、资料导入异步处理、通知分发 | +| AI 模型 | 分析用户输入、生成学习反馈、识别薄弱点、生成复习建议、辅助学习对话 | +| 官网 | 产品介绍、SEO、隐私政策、用户协议、支持页面、等待名单 | + +--- + +## 5. 数据库和 Redis 的分工 + +``` +PostgreSQL / MySQL:长期真实数据 +Redis:短期状态、缓存、队列、限流 +文件存储:PDF、图片、附件 +AI 服务:分析、总结、生成复习题 +``` + +一句话: + +> **数据库存事实,Redis 管状态。** + +### Redis 适合做 + +- AI 分析任务队列 +- 资料导入任务队列 +- 通知任务队列 +- 用户请求限流 +- AI 调用次数计数 +- 任务处理中状态 +- 防重复提交锁 +- 短期缓存 + +### Redis 不适合做主数据 + +这些必须进数据库: + +- 用户资料 +- 知识库 +- 学习记录 +- AI 分析结果 +- 待巩固项 +- 复习计划 +- 学习活跃记录 +- 通知记录 + +--- + +## 6. 后端目录结构 + +```text +api-server/ +├── src/ +│ ├── main.ts +│ ├── app.module.ts +│ │ +│ ├── config/ +│ │ ├── app.config.ts +│ │ ├── database.config.ts +│ │ ├── redis.config.ts +│ │ ├── jwt.config.ts +│ │ ├── ai.config.ts +│ │ └── storage.config.ts +│ │ +│ ├── common/ +│ │ ├── constants/ +│ │ ├── decorators/ +│ │ ├── guards/ +│ │ ├── interceptors/ +│ │ ├── filters/ +│ │ ├── pipes/ +│ │ ├── dto/ +│ │ ├── types/ +│ │ └── utils/ +│ │ +│ ├── infrastructure/ +│ │ ├── database/ +│ │ ├── redis/ +│ │ ├── queue/ +│ │ ├── ai/ +│ │ │ ├── prompts/ +│ │ │ └── providers/ +│ │ ├── storage/ +│ │ └── logger/ +│ │ +│ ├── modules/ +│ │ ├── auth/ +│ │ ├── users/ +│ │ ├── knowledge-base/ +│ │ ├── knowledge-items/ +│ │ ├── document-import/ +│ │ ├── learning-session/ +│ │ ├── active-recall/ +│ │ ├── ai-analysis/ +│ │ ├── review/ +│ │ ├── focus-items/ +│ │ ├── learning-activity/ +│ │ ├── notifications/ +│ │ ├── feedback/ +│ │ └── system/ +│ │ +│ └── workers/ +│ ├── ai-analysis.worker.ts +│ ├── document-import.worker.ts +│ └── notification.worker.ts +│ +├── prisma/ +│ ├── schema.prisma +│ └── migrations/ +│ +├── docker-compose.yml +├── Dockerfile +├── .env.example +└── package.json +``` + +--- + +## 7. 各模块职责 + +### 7.1 Auth 模块 + +负责 Apple 登录、JWT、刷新 Token、退出登录、账号注销。 + +App Store 上架要求 Sign in with Apple,这是必须做的。 + +### 7.2 Users 模块 + +负责用户资料、头像、昵称、学习身份、学习方向、学习偏好。 + +### 7.3 Knowledge Base 模块 + +负责知识库创建、列表、详情、编辑、删除、标签。 + +### 7.4 Knowledge Items 模块 + +负责具体知识点、章节、内容段落、关键概念、标签、关联关系。 + +### 7.5 Document Import 模块 + +负责上传文件、粘贴文本、链接导入、导入状态、解析进度、导入成功/失败。 + +这里非常适合用 Redis Queue 做异步处理。 + +### 7.6 Learning Session 模块 + +负责一次学习过程:开始学习、结束学习、学习时长、学习内容、学习模式、完成状态。 + +这是学习活跃图的数据来源之一。 + +### 7.7 Active Recall 模块 + +负责主动回忆:回忆问题、用户回答、语音回答、回答提交、回答历史。 + +### 7.8 AI Analysis 模块 + +负责提交分析任务、分析状态、AI 分析结果、掌握度、薄弱点、改进建议、下一步建议。 + +**必须走 Redis Queue 异步处理。** + +### 7.9 Focus Items 模块 + +「待巩固项」——类似 GitHub Issue,但在知习里叫待巩固项。 + +负责:AI 自动生成待巩固项、用户手动添加、状态(待处理/已加入复习/已完成)、关联知识点、关联学习记录。 + +### 7.10 Review 模块 + +负责复习卡片、到期复习、间隔复习计划、复习结果、掌握程度选择、下次复习时间。 + +### 7.11 Learning Activity 模块 + +负责学习活跃图:每日学习时长、主动回忆次数、复习次数、AI 分析次数、完成学习闭环次数、学习活跃度等级。 + +活跃图数据最终写入数据库,Redis 可做临时累计。 + +### 7.12 Notifications 模块 + +负责复习提醒、AI 分析完成提醒、学习建议、系统通知、已读/未读。 + +### 7.13 Feedback 模块 + +负责 Web 和 App 反馈:用户反馈、问题类型、邮箱、设备、处理状态。 + +### 7.14 System 模块 + +健康检查、应用状态、基础运维接口。 + +--- + +## 8. 模块优先级 + +### v0.1 必须实现 + +```text +auth +users +knowledge-base +knowledge-items +learning-session +active-recall +ai-analysis +focus-items +review +learning-activity +feedback +system +``` + +### v0.1 暂缓 + +```text +document-import — 先手动录入内容 +notifications — 先不做推送 +billing — 暂无付费 +subscription — 暂无订阅 +admin-dashboard — 暂无管理后台 +team — 单人使用 +``` + +--- + +## 9. API 路由规划 + +```text +GET /api/health + +POST /api/auth/apple +POST /api/auth/refresh +POST /api/auth/logout + +GET /api/users/me +PATCH /api/users/me +PATCH /api/users/me/preferences + +GET /api/knowledge-bases +POST /api/knowledge-bases +GET /api/knowledge-bases/:id +PATCH /api/knowledge-bases/:id +DELETE /api/knowledge-bases/:id +GET /api/knowledge-bases/:id/items + +GET /api/knowledge-items/:id +POST /api/knowledge-items + +POST /api/imports +GET /api/imports/:id/status + +POST /api/learning-sessions +POST /api/learning-sessions/:id/end + +GET /api/active-recalls +POST /api/active-recalls/:id/submit + +POST /api/ai-analysis +GET /api/ai-analysis/:id +GET /api/ai-analysis/jobs/:jobId/status + +GET /api/focus-items +POST /api/focus-items +PATCH /api/focus-items/:id +POST /api/focus-items/:id/complete + +GET /api/reviews/due +POST /api/reviews/:id/submit + +GET /api/activity/heatmap +GET /api/activity/summary + +GET /api/notifications +POST /api/notifications/:id/read + +POST /api/feedback +``` + +--- + +## 10. 关键请求流程 + +核心异步流程详见 [23. 异步模块额外文件 - AI 分析模块完整流程](#23-异步模块额外文件queue--processor)。 + +--- + +## 11. 核心 AI 接口 + +### POST /api/ai-analysis(提交异步任务) + +输入: +```json +{ + "sessionId": "session_001", + "lessonId": "lesson_001", + "userInput": "用户写下的答案或笔记", + "context": { + "lessonTitle": "材料阅读方法", + "objectives": ["理解材料结构", "提取关键要点"], + "keyPoints": ["问题", "原因", "影响", "对策"] + } +} +``` + +返回: +```json +{ + "jobId": "job_abc123", + "status": "pending" +} +``` + +### GET /api/ai-analysis/jobs/:jobId/status(查询状态) + +```json +{ + "jobId": "job_abc123", + "status": "completed", + "result": { + "masteryScore": 3, + "understandingLevel": "基本理解", + "summary": "用户能理解主要内容,但要点不完整。", + "strengths": ["表达清楚"], + "weakPoints": ["遗漏关键要点", "逻辑层次不足"], + "suggestions": ["补充第二个要点", "先概括再展开"], + "reviewNeeded": true, + "nextAction": "明天重新回答本节主动回忆问题。" + } +} +``` + +--- + +## 12. AI Provider 层设计 + +```text +AIService + ├── MiniMaxProvider + ├── DeepSeekProvider + ├── OpenAIProvider + └── MockProvider +``` + +### MockProvider + +v0.1 强烈建议做 MockProvider: +- 没有 API Key 时也能开发 +- AI 服务出问题时能测试流程 +- 降低开发成本 +- 方便录 Demo + +### 候选模型 + +MiniMax / DeepSeek / 通义千问 / OpenAI / Claude / Gemini + +### 模型选择原则 + +1. 中文能力好 +2. 成本可控 +3. API 稳定 +4. 支持结构化输出 +5. 速度可以接受 +6. 容易接入 +7. 可替换 + +--- + +## 13. 核心实体/数据模型 + +```text +User +├── id +├── appleUserId +├── displayName +├── email +├── preferredLanguage +├── learningDirection +├── createdAt +├── lastLoginAt +└── status + +KnowledgeBase +├── id +├── userId +├── title +├── description +├── language +├── tags +├── createdAt +└── updatedAt + +KnowledgeItem +├── id +├── knowledgeBaseId +├── parentId +├── title +├── content +├── type (chapter/section/concept) +├── objectives +├── keyPoints +├── recallQuestions +├── order +└── estimatedMinutes + +DocumentImport +├── id +├── userId +├── knowledgeBaseId +├── sourceType (file/text/link) +├── status (pending/processing/completed/failed) +├── result +├── createdAt +└── completedAt + +LearningSession +├── id +├── userId +├── knowledgeItemId +├── startedAt +├── endedAt +├── durationSeconds +├── mode (reading/recall/review) +└── completedAt + +ActiveRecall +├── id +├── sessionId +├── question +├── userAnswer +├── answerType (text/voice) +├── submittedAt + +AIAnalysis +├── id +├── jobId +├── userId +├── sessionId +├── inputText +├── outputJson +├── masteryScore +├── weakPoints +├── suggestions +├── modelName +├── costEstimate +├── status (pending/processing/completed/failed) +├── createdAt +└── completedAt + +FocusItem +├── id +├── userId +├── knowledgeItemId +├── aiAnalysisId +├── title +├── description +├── status (pending/in_review/completed) +├── createdAt +└── completedAt + +ReviewTask +├── id +├── userId +├── knowledgeItemId +├── focusItemId +├── reviewType +├── scheduledAt +├── completedAt +├── masteryChoice +├── nextReviewAt +└── status + +LearningActivity +├── id +├── userId +├── date +├── durationSeconds +├── recallCount +├── reviewCount +├── analysisCount +├── closedLoopCount +├── activityLevel +└── updatedAt + +Notification +├── id +├── userId +├── type +├── title +├── body +├── data (JSON) +├── isRead +└── createdAt + +Feedback +├── id +├── userId +├── type +├── content +├── email +├── device +├── status +└── createdAt +``` + +--- + +## 14. 掌握度评分(0-5分) + +```text +0 = 没有作答 / 无法判断 +1 = 基本没理解 +2 = 理解较弱 +3 = 基本理解 +4 = 理解较好 +5 = 掌握很好 +``` + +这不是考试分数,是产品内部用于安排复习和学习建议的参考值。 + +--- + +## 15. 账号体系 + +### 第一版登录方式 + +```text +Sign in with Apple +``` + +暂不做:微信登录、手机号登录、邮箱密码登录、Google 登录。 + +### 账号边界 + +第一版账号只解决:登录、识别用户、保存学习记录、保存 AI 分析结果、保存基础偏好。 + +暂不做:好友系统、用户主页、头像上传、多登录方式绑定、复杂权限系统。 + +--- + +## 16. 部署方案 + +### 当前服务器 + +- IP: 81.70.187.179 +- 配置: 4 核 4G,40G SSD +- 当前运行: Gitea(端口 3000)+ Nginx +- SSH: ubuntu 用户 + WangDL.pem 密钥 + +### 推荐部署方式 + +```text +Nginx — 反向代理,已有 +api-server (NestJS) — 端口 3001 +postgres / mysql — 端口 5432 / 3306 +redis — 端口 6379 +worker (BullMQ) — 同代码仓库,独立进程 +gitea — 端口 3000,继续保留 +``` + +建议新增 Nginx 配置 `api.longde.cloud` → 反向代理到 NestJS 端口。 + +### 资源评估 + +Gitea 当前资源占用很低(内存 ~500MB),剩余 ~3GB 内存、33GB 磁盘足够跑 api-server + PostgreSQL + Redis。 + +--- + +## 17. 域名与 Nginx 规划 + +```text +longde.cloud 官网首页 +api.longde.cloud 后端 API(需新增 Nginx 配置) +git.longde.cloud Gitea 代码仓库(已有) +``` + +### 待新增 Nginx 配置(api.longde.cloud) + +```nginx +server { + server_name api.longde.cloud; + location / { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/api.longde.cloud/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.longde.cloud/privkey.pem; +} +``` + +--- + +## 18. 技术里程碑 + +### 里程碑 1:最小后端 + +- NestJS 项目初始化 +- Prisma schema 定义 +- PostgreSQL/MySQL 连接 +- 用户表、知识库表 +- AI MockProvider +- Swagger 文档 + +### 里程碑 2:真实 AI 接入 + +- Redis + BullMQ 接入 +- AI Provider 抽象 +- 接入一个真实模型 +- AI 分析异步流程 +- Worker 处理 AI 任务 + +### 里程碑 3:TestFlight 前准备 + +- Sign in with Apple +- 后端用户身份记录 +- 基础错误处理 +- Docker Compose 一键部署 +- Nginx 反向代理配置 + +--- + +## 19. 商业化与支付(未来) + +### 支付流程 + +```text +用户在 iOS App 内付款 → Apple IAP 完成交易 → 后端记录用户权益 → 用户获得权益 +``` + +### 核心 + +不要让 Apple IAP 成为整个商业系统。真正核心的是: + +```text +用户系统 + 订单系统 + 权益系统 +``` + +### 当前暂缓 + +Apple IAP / 月订阅 / 年订阅 / 免费试用 / 订阅权益系统 / AI 成本精算 + +--- + +## 20. 当前技术不做清单 + +- 微服务 / Kubernetes / 多云部署 +- 完整后台管理系统 / CMS +- 完整 RAG / 向量数据库(第一版) +- 多模型自动路由 +- AI Agent 工具调用系统 +- 支付系统 +- 复杂数据分析平台 +- Android / Web 学习端 + +--- + +## 21. 四层代码分层原则 + +整体分四层,每一层有明确的职责边界: + +```text +Controller 层:接收请求,处理参数,返回响应 +Service 层:业务逻辑 +Repository 层:数据库读写 +Infrastructure 层:Redis、Queue、AI、Storage 等基础设施 +``` + +简单理解: + +```text +Controller 不写业务 +Service 不直接写复杂 SQL +Repository 不写业务判断 +Infrastructure 不知道具体业务,只提供能力 +``` + +一句话纪律: + +```text +Controller = 接口 Service = 业务 Repository = 数据库 +Infrastructure = 外部能力 Queue/Processor = 异步任务 DTO = 请求/响应结构 +``` + +第一版不要追求所有业务一次写完,但**架构模板必须先统一**。 + +--- + +## 22. 模块内部统一模板 + +每个业务模块都按这个结构来。以 `knowledge-base` 为例: + +```text +modules/knowledge-base/ +├── knowledge-base.module.ts +├── knowledge-base.controller.ts +├── knowledge-base.service.ts +├── knowledge-base.repository.ts +├── dto/ +│ ├── create-knowledge-base.dto.ts +│ ├── update-knowledge-base.dto.ts +│ ├── query-knowledge-base.dto.ts +│ └── knowledge-base-response.dto.ts +├── types/ +│ └── knowledge-base.types.ts +└── constants/ + └── knowledge-base.constants.ts +``` + +### 22.1 xxx.module.ts + +```text +作用: +- 注册 controller +- 注册 service +- 注册 repository +- 引入依赖模块 +- 导出给其他模块使用 +``` + +```ts +@Module({ + controllers: [KnowledgeBaseController], + providers: [KnowledgeBaseService, KnowledgeBaseRepository], + exports: [KnowledgeBaseService], +}) +export class KnowledgeBaseModule {} +``` + +### 22.2 xxx.controller.ts + +**只做这些事:** +1. 定义路由 +2. 接收参数 +3. 调用 service +4. 返回结果 + +**不要在 Controller 里写业务判断。** + +```ts +@Controller('knowledge-bases') +export class KnowledgeBaseController { + constructor(private readonly service: KnowledgeBaseService) {} + + @Post() + create(@Body() dto: CreateKnowledgeBaseDto, @CurrentUser() user: UserPayload) { + return this.service.create(user.id, dto); + } + + @Get() + findAll(@CurrentUser() user: UserPayload, @Query() query: QueryKnowledgeBaseDto) { + return this.service.findAll(user.id, query); + } + + @Get(':id') + findOne(@Param('id') id: string, @CurrentUser() user: UserPayload) { + return this.service.findOne(user.id, id); + } +} +``` + +### 22.3 xxx.service.ts + +**这里写:** +1. 权限判断 +2. 业务规则 +3. 调用 repository +4. 调用其他模块 service +5. 调用 queue / AI / Redis +6. 组织返回结果 + +```ts +@Injectable() +export class KnowledgeBaseService { + constructor( + private readonly repository: KnowledgeBaseRepository, + ) {} + + async create(userId: string, dto: CreateKnowledgeBaseDto) { + const count = await this.repository.countByUserId(userId); + if (count >= MAX_KNOWLEDGE_BASE_COUNT) { + throw new BadRequestException('知识库数量已达到上限'); + } + return this.repository.create(userId, dto); + } + + async findOne(userId: string, id: string) { + const knowledgeBase = await this.repository.findById(id); + if (!knowledgeBase || knowledgeBase.userId !== userId) { + throw new NotFoundException('知识库不存在'); + } + return knowledgeBase; + } +} +``` + +### 22.4 xxx.repository.ts + +**只写数据库操作,不写复杂业务。** + +```ts +@Injectable() +export class KnowledgeBaseRepository { + constructor(private readonly prisma: PrismaService) {} + + create(userId: string, dto: CreateKnowledgeBaseDto) { + return this.prisma.knowledgeBase.create({ + data: { userId, name: dto.name, description: dto.description }, + }); + } + + findById(id: string) { + return this.prisma.knowledgeBase.findUnique({ where: { id } }); + } + + countByUserId(userId: string) { + return this.prisma.knowledgeBase.count({ where: { userId } }); + } +} +``` + +### 22.5 dto/ + +DTO 就是接口输入输出的数据结构。 + +用途: +1. 校验请求参数 +2. 生成 Swagger 文档 +3. 让接口结构清楚 + +```ts +export class CreateKnowledgeBaseDto { + @ApiProperty({ example: '认知心理学' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ example: '系统学习认知心理学基础概念', required: false }) + @IsOptional() + @IsString() + description?: string; +} +``` + +### 22.6 types/ + +放模块内部类型。如果是数据库模型,不放这里,放 Prisma。 + +```ts +export type KnowledgeBaseStatus = 'active' | 'archived'; + +export interface KnowledgeBaseStats { + itemCount: number; + completedCount: number; + reviewDueCount: number; +} +``` + +### 22.7 constants/ + +放模块常量。 + +```ts +export const MAX_KNOWLEDGE_BASE_COUNT = 20; +export const DEFAULT_KNOWLEDGE_BASE_COVER = 'default-blue'; +``` + +--- + +## 23. 异步模块额外文件(Queue + Processor) + +涉及异步任务的模块,需要在标准模板上加 `queue`、`processor`、`jobs`。 + +以 `ai-analysis` 为例: + +```text +modules/ai-analysis/ +├── ai-analysis.module.ts +├── ai-analysis.controller.ts +├── ai-analysis.service.ts +├── ai-analysis.repository.ts +├── ai-analysis.queue.ts ← 封装 BullMQ 队列 +├── ai-analysis.processor.ts ← 消费任务,调用大模型 +├── dto/ +│ ├── create-ai-analysis.dto.ts +│ ├── query-ai-analysis.dto.ts +│ └── ai-analysis-response.dto.ts +├── jobs/ +│ └── ai-analysis.job.ts ← 队列任务数据结构 +├── types/ +│ └── ai-analysis.types.ts +└── constants/ + └── ai-analysis.constants.ts +``` + +### 文件职责 + +| 文件 | 职责 | +|------|------| +| `controller` | 接收创建分析任务、查询分析结果的接口 | +| `service` | 创建任务、查询状态、组织业务流程 | +| `repository` | 读写 AI 分析任务和分析结果 | +| `queue` | 封装 BullMQ 队列添加任务 | +| `processor` | 真正消费任务,调用大模型 | +| `jobs/xxx.job.ts` | 定义队列任务的数据结构 | + +### AI 分析模块完整流程 + +```text +App 提交回答 + ↓ +Controller 接收请求 + ↓ +Service 创建分析任务 + ↓ +Repository 写入数据库 job 记录 + ↓ +Queue 加入 Redis 队列 + ↓ +Processor 后台消费任务 + ↓ +AI Service 调用大模型 + ↓ +Repository 写入分析结果 + ↓ +生成待巩固项 + ↓ +生成复习计划 + ↓ +通知用户 +``` + +--- + +## 24. AI 层设计:不允许业务模块直接调大模型 SDK + +### 原则 + +后面肯定会换模型、换供应商,所以 AI 不要写死在业务模块里。 + +**不推荐:** +```ts +// 不要直接在业务 service 里写 +await openai.chat.completions.create(...) +``` + +**应该:** +```text +业务模块 → AiService → 具体 Provider +``` + +```ts +@Injectable() +export class AiService { + constructor(private readonly provider: AiProvider) {} + + analyzeActiveRecall(input: ActiveRecallAnalysisInput) { + return this.provider.generateActiveRecallAnalysis(input); + } +} +``` + +这样从 OpenAI 换 DeepSeek、Gemini、Claude,都不用改业务代码。 + +--- + +## 25. Infrastructure 层详细拆解 + +`infrastructure` 是所有模块共用的底层能力。 + +```text +infrastructure/ +├── database/ +│ ├── prisma.module.ts +│ └── prisma.service.ts +│ +├── redis/ +│ ├── redis.module.ts +│ ├── redis.service.ts +│ └── redis.constants.ts +│ +├── queue/ +│ ├── queue.module.ts +│ ├── queue.constants.ts +│ └── queue.service.ts +│ +├── ai/ +│ ├── ai.module.ts +│ ├── ai.service.ts +│ ├── ai-provider.interface.ts +│ ├── providers/ +│ │ ├── openai.provider.ts +│ │ ├── deepseek.provider.ts +│ │ └── mock-ai.provider.ts +│ └── prompts/ +│ ├── active-recall-analysis.prompt.ts +│ └── focus-item-generation.prompt.ts +│ +├── storage/ +│ ├── storage.module.ts +│ ├── storage.service.ts +│ └── local-storage.provider.ts +│ +└── logger/ + ├── logger.module.ts + └── app-logger.service.ts +``` + +--- + +## 26. Common 层详细拆解 + +```text +common/ +├── decorators/ +│ └── current-user.decorator.ts +├── guards/ +│ ├── jwt-auth.guard.ts +│ └── optional-auth.guard.ts +├── interceptors/ +│ └── response.interceptor.ts +├── filters/ +│ └── http-exception.filter.ts +├── pipes/ +│ └── validation.pipe.ts +├── dto/ +│ ├── pagination.dto.ts +│ └── api-response.dto.ts +├── types/ +│ ├── user-payload.type.ts +│ └── pagination.type.ts +├── constants/ +│ └── app.constants.ts +└── utils/ + ├── date.util.ts + ├── hash.util.ts + └── id.util.ts +``` + +--- + +## 27. 模块复杂度分级 + +### 简单模块 + +结构 `module + controller + service + repository + dto` + +适用于:`feedback`、`system`、`notifications`(初版) + +### 中等模块 + +结构 `module + controller + service + repository + dto + types + constants` + +适用于:`users`、`knowledge-base`、`knowledge-items`、`learning-session`、`review`、`focus-items` + +### 复杂模块 + +结构 `module + controller + service + repository + queue + processor + jobs + dto + types + constants` + +适用于:`ai-analysis`、`document-import`、`notifications`(后期) + +--- + +## 28. 初始化顺序 + +第一版不要所有业务都写完。先搭基础设施骨架: + +```text + 1. infrastructure/database + 2. infrastructure/redis + 3. infrastructure/queue + 4. infrastructure/ai + 5. common + 6. system/health + 7. auth skeleton + 8. users skeleton + 9. knowledge-base skeleton +10. ai-analysis skeleton +``` + +这样就够开始开发。 + + diff --git a/DATABASE-DESIGN.md b/DATABASE-DESIGN.md new file mode 100644 index 0000000..e1832f3 --- /dev/null +++ b/DATABASE-DESIGN.md @@ -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、外键字段添加合理索引 +``` diff --git a/REDIS-DESIGN.md b/REDIS-DESIGN.md new file mode 100644 index 0000000..149d8cb --- /dev/null +++ b/REDIS-DESIGN.md @@ -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 管过程。** diff --git a/package-lock.json b/package-lock.json index bdf700e..f79bd97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,17 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@bull-board/nestjs": "^7.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1" @@ -25,16 +30,19 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@prisma/client": "^5.22.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", - "@types/node": "^24.0.0", + "@types/node": "^24.12.3", "@types/supertest": "^7.0.0", + "bullmq": "^5.76.6", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "globals": "^17.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^5.22.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -716,6 +724,43 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@bull-board/api": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@bull-board/api/-/api-7.0.0.tgz", + "integrity": "sha512-ISNspLHVmUWUSq/eLw+wd1FuBBUnqpLbYP2xUNmehpfKhS+NoZWMbBvqjUYVeE/HLfUkRcR1edzMKpl5n9zlSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "redis-info": "^3.1.0" + }, + "peerDependencies": { + "@bull-board/ui": "7.0.0" + } + }, + "node_modules/@bull-board/nestjs": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@bull-board/nestjs/-/nestjs-7.0.0.tgz", + "integrity": "sha512-ypXm0eJHIMQzjN+3fjf84cVxugBg/K4Bpo0eYcV4u/AsteR/dnr6e7F79ICRgg1WWoczqmSMl0JhlmykpyhAMg==", + "license": "MIT", + "peerDependencies": { + "@bull-board/api": "^7.0.0", + "@nestjs/bull-shared": "^10.0.0 || ^11.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.8.1" + } + }, + "node_modules/@bull-board/ui": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@bull-board/ui/-/ui-7.0.0.tgz", + "integrity": "sha512-AnKeklpDn0iMFgu4ukDU6uTNmw4oudl07G4k2Fh95SknKDrXSiWRV0N1TGUawMqyfG1Yi5P/W/8d7raBq/Uw6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@bull-board/api": "7.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.5.0.tgz", @@ -1358,6 +1403,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1579,17 +1630,17 @@ } }, "node_modules/@jest/console": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/console/-/console-30.3.0.tgz", - "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -1597,38 +1648,39 @@ } }, "node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -1644,9 +1696,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -1654,39 +1706,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/environment/-/environment-30.3.0.tgz", - "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1697,18 +1749,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/fake-timers/-/fake-timers-30.3.0.tgz", - "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1725,47 +1777,47 @@ } }, "node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/types": "30.3.0", - "jest-mock": "30.3.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmmirror.com/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/reporters/-/reporters-30.3.0.tgz", - "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -1778,9 +1830,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1870,9 +1922,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1883,13 +1935,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", - "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1914,14 +1966,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/test-result/-/test-result-30.3.0.tgz", - "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1930,15 +1982,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", - "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -1946,23 +1998,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1972,14 +2024,14 @@ } }, "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -2066,6 +2118,84 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2079,6 +2209,35 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmmirror.com/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmmirror.com/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.21", "resolved": "https://registry.npmmirror.com/@nestjs/cli/-/cli-11.0.21.tgz", @@ -2328,6 +2487,21 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "11.1.19", "resolved": "https://registry.npmmirror.com/@nestjs/core/-/core-11.1.19.tgz", @@ -2370,6 +2544,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", @@ -2559,6 +2746,75 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -2584,9 +2840,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmmirror.com/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmmirror.com/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2752,9 +3008,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2835,6 +3091,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz", @@ -2842,11 +3108,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, + "version": "24.12.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.3.tgz", + "integrity": "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==", "license": "MIT", "peer": true, "dependencies": { @@ -2854,9 +3125,9 @@ } }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -2943,17 +3214,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2966,7 +3237,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2982,17 +3253,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -3008,14 +3279,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -3030,14 +3301,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3048,9 +3319,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -3065,15 +3336,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3090,9 +3361,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -3104,16 +3375,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3142,9 +3413,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -3171,16 +3442,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3195,13 +3466,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3226,9 +3497,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, @@ -3939,16 +4210,16 @@ "license": "MIT" }, "node_modules/babel-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/babel-jest/-/babel-jest-30.3.0.tgz", - "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.3.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.3.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3981,9 +4252,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", - "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -4021,13 +4292,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", - "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.3.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -4066,9 +4337,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "version": "2.10.28", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", + "integrity": "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4221,12 +4492,36 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.76.6", + "resolved": "https://registry.npmmirror.com/bullmq/-/bullmq-5.76.6.tgz", + "integrity": "sha512-vlmL3B3NVMRy6se3c7jPHn1Nhqxrg7+wlv1t3XAQFBYZNJDMLP0OO5x2AX5ca7DAuS1SU/C+VfYi+NHVoFK1QQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", @@ -4297,9 +4592,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { @@ -4514,6 +4809,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", @@ -4735,6 +5039,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4822,6 +5138,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", @@ -4831,6 +5156,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4862,6 +5197,45 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4883,6 +5257,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", @@ -4890,9 +5273,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "dev": true, "license": "ISC" }, @@ -4926,9 +5309,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.2", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5309,18 +5692,18 @@ } }, "node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5405,9 +5788,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -5855,9 +6238,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -6145,6 +6528,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6360,17 +6767,17 @@ } }, "node_modules/jest": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest/-/jest-30.3.0.tgz", - "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/core": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.3.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -6388,14 +6795,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-changed-files/-/jest-changed-files-30.3.0.tgz", - "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -6403,29 +6810,29 @@ } }, "node_modules/jest-circus": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-circus/-/jest-circus-30.3.0.tgz", - "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -6435,21 +6842,21 @@ } }, "node_modules/jest-cli": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-cli/-/jest-cli-30.3.0.tgz", - "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -6468,33 +6875,33 @@ } }, "node_modules/jest-config": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-config/-/jest-config-30.3.0.tgz", - "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.3.0", - "@jest/types": "30.3.0", - "babel-jest": "30.3.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.3.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-runner": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6591,25 +6998,25 @@ } }, "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmmirror.com/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -6620,56 +7027,56 @@ } }, "node_modules/jest-each": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-each/-/jest-each-30.3.0.tgz", - "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.3.0", - "pretty-format": "30.3.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-30.3.0.tgz", - "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, @@ -6681,49 +7088,50 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", - "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6732,15 +7140,15 @@ } }, "node_modules/jest-mock": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-mock/-/jest-mock-30.3.0.tgz", - "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.3.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6765,9 +7173,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmmirror.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmmirror.com/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -6775,18 +7183,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-30.3.0.tgz", - "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -6795,46 +7203,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", - "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.3.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-runner/-/jest-runner-30.3.0.tgz", - "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/environment": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-leak-detector": "30.3.0", - "jest-message-util": "30.3.0", - "jest-resolve": "30.3.0", - "jest-runtime": "30.3.0", - "jest-util": "30.3.0", - "jest-watcher": "30.3.0", - "jest-worker": "30.3.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -6864,32 +7272,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-runtime/-/jest-runtime-30.3.0.tgz", - "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "version": "30.4.2", + "resolved": "https://registry.npmmirror.com/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/globals": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -6970,9 +7378,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-snapshot/-/jest-snapshot-30.3.0.tgz", - "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -6981,20 +7389,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.3.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "pretty-format": "30.3.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -7003,13 +7411,13 @@ } }, "node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -7021,18 +7429,18 @@ } }, "node_modules/jest-validate": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-30.3.0.tgz", - "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7052,19 +7460,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-watcher/-/jest-watcher-30.3.0.tgz", - "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -7072,15 +7480,15 @@ } }, "node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -7197,6 +7605,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", @@ -7232,9 +7683,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.42", - "resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.12.42.tgz", - "integrity": "sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==", + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.13.0.tgz", + "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "license": "MIT" }, "node_modules/lines-and-columns": { @@ -7299,6 +7750,54 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7313,6 +7812,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7340,6 +7845,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", @@ -7557,6 +8071,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz", @@ -7672,7 +8217,6 @@ "version": "3.1.1", "resolved": "https://registry.npmmirror.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-emoji": { @@ -7685,6 +8229,21 @@ "lodash": "^4.17.21" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", @@ -7960,9 +8519,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.3.6", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8139,15 +8698,16 @@ } }, "node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8166,6 +8726,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8245,13 +8826,22 @@ "node": ">= 0.10" } }, - "node_modules/react-is": { + "node_modules/react-is-18": { + "name": "react-is", "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8280,6 +8870,36 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-info": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redis-info/-/redis-info-3.1.0.tgz", + "integrity": "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.11" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8382,6 +9002,7 @@ "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8435,7 +9056,6 @@ "version": "7.7.4", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8674,6 +9294,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", @@ -8935,9 +9561,9 @@ } }, "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "version": "5.47.1", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8954,9 +9580,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", "dev": true, "license": "MIT", "dependencies": { @@ -8976,12 +9602,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -9466,16 +10119,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9531,7 +10184,6 @@ "version": "7.16.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index e5a1716..afc713e 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,17 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@bull-board/nestjs": "^7.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1" @@ -36,16 +41,19 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@prisma/client": "^5.22.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", - "@types/node": "^24.0.0", + "@types/node": "^24.12.3", "@types/supertest": "^7.0.0", + "bullmq": "^5.76.6", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "globals": "^17.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^5.22.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..298a586 --- /dev/null +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/ai/ai.controller.ts b/src/ai/ai.controller.ts deleted file mode 100644 index 32123f6..0000000 --- a/src/ai/ai.controller.ts +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/ai/ai.module.ts b/src/ai/ai.module.ts deleted file mode 100644 index 0863314..0000000 --- a/src/ai/ai.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/src/ai/ai.service.ts b/src/ai/ai.service.ts deleted file mode 100644 index 6763d1d..0000000 --- a/src/ai/ai.service.ts +++ /dev/null @@ -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 = new Map(); - - async analyze(request: AnalysisRequest): Promise { - 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 { - 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 { - const sessions = this.sessions.get(userId) || []; - return sessions.find(s => s.id === sessionId); - } - - async getSessions(userId: string): Promise { - return this.sessions.get(userId) || []; - } -} \ No newline at end of file diff --git a/src/ai/entities/ai.entity.ts b/src/ai/entities/ai.entity.ts deleted file mode 100644 index 6938afe..0000000 --- a/src/ai/entities/ai.entity.ts +++ /dev/null @@ -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[]; -} \ No newline at end of file diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts deleted file mode 100644 index d22f389..0000000 --- a/src/app.controller.spec.ts +++ /dev/null @@ -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); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index 1a78637..0000000 --- a/src/app.controller.ts +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 56b2f10..2a3fd05 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,25 +1,68 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { WaitlistModule } from './waitlist/waitlist.module'; -import { UsersModule } from './users/users.module'; -import { LearningModule } from './learning/learning.module'; -import { AiModule } from './ai/ai.module'; -import { FeedbackModule } from './feedback/feedback.module'; -import { AuthModule } from './auth/auth.module'; -import { KnowledgeModule } from './knowledge/knowledge.module'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from './infrastructure/database/prisma.module'; +import { RedisModule } from './infrastructure/redis/redis.module'; +import { QueueModule } from './infrastructure/queue/queue.module'; +import { AiModule } from './infrastructure/ai/ai.module'; +import { StorageModule } from './infrastructure/storage/storage.module'; +import { LoggerModule } from './infrastructure/logger/logger.module'; +import { SystemModule } from './modules/system/system.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { UsersModule } from './modules/users/users.module'; +import { KnowledgeBaseModule } from './modules/knowledge-base/knowledge-base.module'; +import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module'; +import { DocumentImportModule } from './modules/document-import/document-import.module'; +import { LearningSessionModule } from './modules/learning-session/learning-session.module'; +import { ActiveRecallModule } from './modules/active-recall/active-recall.module'; +import { AiAnalysisModule } from './modules/ai-analysis/ai-analysis.module'; +import { ReviewModule } from './modules/review/review.module'; +import { FocusItemsModule } from './modules/focus-items/focus-items.module'; +import { LearningActivityModule } from './modules/learning-activity/learning-activity.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; +import { FeedbackModule } from './modules/feedback/feedback.module'; +import { WaitlistModule } from './modules/waitlist/waitlist.module'; + +import appConfig from './config/app.config'; +import databaseConfig from './config/database.config'; +import redisConfig from './config/redis.config'; +import jwtConfig from './config/jwt.config'; +import aiConfig from './config/ai.config'; +import storageConfig from './config/storage.config'; @Module({ imports: [ - WaitlistModule, - UsersModule, - LearningModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfig, + databaseConfig, + redisConfig, + jwtConfig, + aiConfig, + storageConfig, + ], + }), + PrismaModule, + RedisModule, + QueueModule, AiModule, - FeedbackModule, + StorageModule, + LoggerModule, + SystemModule, AuthModule, - KnowledgeModule, + UsersModule, + KnowledgeBaseModule, + KnowledgeItemsModule, + DocumentImportModule, + LearningSessionModule, + ActiveRecallModule, + AiAnalysisModule, + ReviewModule, + FocusItemsModule, + LearningActivityModule, + NotificationsModule, + FeedbackModule, + WaitlistModule, ], - controllers: [AppController], - providers: [AppService], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts deleted file mode 100644 index d01a263..0000000 --- a/src/auth/auth.controller.ts +++ /dev/null @@ -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: '已退出登录' }; - } -} \ No newline at end of file diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts deleted file mode 100644 index 9cfa255..0000000 --- a/src/auth/auth.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuthController } from './auth.controller'; - -@Module({ - controllers: [AuthController], -}) -export class AuthModule {} \ No newline at end of file diff --git a/src/common/decorators/current-user.decorator.ts b/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..7919497 --- /dev/null +++ b/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..b8f0b2f --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -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; +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..8b9f815 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,32 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + response.status(status).json({ + success: false, + statusCode: status, + message, + timestamp: new Date().toISOString(), + }); + } +} diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..10836c6 --- /dev/null +++ b/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,9 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + return !!request.user; + } +} diff --git a/src/common/guards/optional-auth.guard.ts b/src/common/guards/optional-auth.guard.ts new file mode 100644 index 0000000..83a6495 --- /dev/null +++ b/src/common/guards/optional-auth.guard.ts @@ -0,0 +1,8 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; + +@Injectable() +export class OptionalAuthGuard implements CanActivate { + canActivate(_context: ExecutionContext): boolean { + return true; + } +} diff --git a/src/common/interceptors/response.interceptor.ts b/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..347c08a --- /dev/null +++ b/src/common/interceptors/response.interceptor.ts @@ -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 { + return next.handle().pipe( + map((data) => ({ + success: true, + data, + timestamp: new Date().toISOString(), + })), + ); + } +} diff --git a/src/common/pipes/validation.pipe.ts b/src/common/pipes/validation.pipe.ts new file mode 100644 index 0000000..b1e350b --- /dev/null +++ b/src/common/pipes/validation.pipe.ts @@ -0,0 +1,31 @@ +import { + PipeTransform, + Injectable, + ArgumentMetadata, + BadRequestException, +} from '@nestjs/common'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; + +@Injectable() +export class ValidationPipe implements PipeTransform { + async transform(value: any, { metatype }: ArgumentMetadata) { + if (!metatype || !this.toValidate(metatype)) { + return value; + } + const object = plainToInstance(metatype, value); + const errors = await validate(object); + if (errors.length > 0) { + const messages = errors.map((err) => + Object.values(err.constraints || {}).join(', '), + ); + throw new BadRequestException(messages); + } + return value; + } + + private toValidate(metatype: Function): boolean { + const types: Function[] = [String, Boolean, Number, Array, Object]; + return !types.includes(metatype); + } +} diff --git a/src/common/types/index.ts b/src/common/types/index.ts new file mode 100644 index 0000000..604da24 --- /dev/null +++ b/src/common/types/index.ts @@ -0,0 +1,17 @@ +export interface UserPayload { + id: number; + email?: string; + nickname?: string; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; +} diff --git a/src/common/utils/id.util.ts b/src/common/utils/id.util.ts new file mode 100644 index 0000000..5b21a2a --- /dev/null +++ b/src/common/utils/id.util.ts @@ -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); +} diff --git a/src/config/ai.config.ts b/src/config/ai.config.ts new file mode 100644 index 0000000..c542e80 --- /dev/null +++ b/src/config/ai.config.ts @@ -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', +})); diff --git a/src/config/app.config.ts b/src/config/app.config.ts new file mode 100644 index 0000000..577329f --- /dev/null +++ b/src/config/app.config.ts @@ -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', +})); diff --git a/src/config/database.config.ts b/src/config/database.config.ts new file mode 100644 index 0000000..82ac8df --- /dev/null +++ b/src/config/database.config.ts @@ -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', +})); diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts new file mode 100644 index 0000000..28cff85 --- /dev/null +++ b/src/config/jwt.config.ts @@ -0,0 +1,7 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('jwt', () => ({ + secret: process.env.JWT_SECRET || 'change_me_in_production', + expiresIn: process.env.JWT_EXPIRES_IN || '1h', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', +})); diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts new file mode 100644 index 0000000..b3294c1 --- /dev/null +++ b/src/config/redis.config.ts @@ -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, +})); diff --git a/src/config/storage.config.ts b/src/config/storage.config.ts new file mode 100644 index 0000000..829dfea --- /dev/null +++ b/src/config/storage.config.ts @@ -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, + }, +})); diff --git a/src/feedback/dto/create-feedback.dto.ts b/src/feedback/dto/create-feedback.dto.ts deleted file mode 100644 index b9a3763..0000000 --- a/src/feedback/dto/create-feedback.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/feedback/entities/feedback.entity.ts b/src/feedback/entities/feedback.entity.ts deleted file mode 100644 index 8fde059..0000000 --- a/src/feedback/entities/feedback.entity.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/feedback/feedback.controller.ts b/src/feedback/feedback.controller.ts deleted file mode 100644 index 4112db1..0000000 --- a/src/feedback/feedback.controller.ts +++ /dev/null @@ -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 }; - } -} \ No newline at end of file diff --git a/src/feedback/feedback.service.ts b/src/feedback/feedback.service.ts deleted file mode 100644 index dd7d37f..0000000 --- a/src/feedback/feedback.service.ts +++ /dev/null @@ -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 { - 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 { - return this.feedbacks; - } - - async findByUserId(userId: string): Promise { - return this.feedbacks.filter(f => f.userId === userId); - } - - async updateStatus(id: string, status: 'pending' | 'reviewed' | 'resolved'): Promise { - 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), - byStatus: this.feedbacks.reduce((acc, f) => { - acc[f.status] = (acc[f.status] || 0) + 1; - return acc; - }, {} as Record), - }; - } -} \ No newline at end of file diff --git a/src/infrastructure/ai/ai-provider.interface.ts b/src/infrastructure/ai/ai-provider.interface.ts new file mode 100644 index 0000000..e953384 --- /dev/null +++ b/src/infrastructure/ai/ai-provider.interface.ts @@ -0,0 +1,24 @@ +export interface AiProvider { + generateAnalysis(input: { + userInput: string; + context: Record; + }): Promise<{ + masteryScore: number; + understandingLevel: string; + summary: string; + strengths: string[]; + weakPoints: string[]; + suggestions: string[]; + reviewNeeded: boolean; + nextAction: string; + }>; + + generateChatResponse(input: { + message: string; + history: Array<{ role: string; content: string }>; + context?: string; + }): Promise<{ + reply: string; + suggestedActions: string[]; + }>; +} diff --git a/src/infrastructure/ai/ai.module.ts b/src/infrastructure/ai/ai.module.ts new file mode 100644 index 0000000..701e9f8 --- /dev/null +++ b/src/infrastructure/ai/ai.module.ts @@ -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 {} diff --git a/src/infrastructure/ai/ai.service.ts b/src/infrastructure/ai/ai.service.ts new file mode 100644 index 0000000..94bd8f1 --- /dev/null +++ b/src/infrastructure/ai/ai.service.ts @@ -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; + }) { + return this.provider.generateAnalysis(input); + } + + async chat(input: { + message: string; + history: Array<{ role: string; content: string }>; + context?: string; + }) { + return this.provider.generateChatResponse(input); + } +} diff --git a/src/infrastructure/ai/providers/mock-ai.provider.ts b/src/infrastructure/ai/providers/mock-ai.provider.ts new file mode 100644 index 0000000..ab59b42 --- /dev/null +++ b/src/infrastructure/ai/providers/mock-ai.provider.ts @@ -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 }) { + const score = 3; + return { + masteryScore: score, + understandingLevel: '基本理解', + summary: '用户能理解主要内容,但要点不完整。', + strengths: ['表达清楚', '有一定基础'], + weakPoints: ['遗漏关键要点', '逻辑层次不足'], + suggestions: ['补充第二个要点', '先概括再展开', '多举例说明'], + reviewNeeded: true, + nextAction: '明天重新回答本节主动回忆问题。', + }; + } + + async generateChatResponse(input: { + message: string; + history: Array<{ role: string; content: string }>; + context?: string; + }) { + return { + reply: '好的,我理解你的问题。作为你的学习助手,我可以帮助你分析和理解这个知识点。请告诉我你想深入了解哪个部分?', + suggestedActions: ['重新解释', '给我一个例子', '检查我的理解'], + }; + } +} diff --git a/src/infrastructure/database/prisma.module.ts b/src/infrastructure/database/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/infrastructure/database/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/infrastructure/database/prisma.service.ts b/src/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000..7ffd32d --- /dev/null +++ b/src/infrastructure/database/prisma.service.ts @@ -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(); + } +} diff --git a/src/infrastructure/logger/app-logger.service.ts b/src/infrastructure/logger/app-logger.service.ts new file mode 100644 index 0000000..b796883 --- /dev/null +++ b/src/infrastructure/logger/app-logger.service.ts @@ -0,0 +1,4 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class AppLoggerService extends Logger {} diff --git a/src/infrastructure/logger/logger.module.ts b/src/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000..673b41f --- /dev/null +++ b/src/infrastructure/logger/logger.module.ts @@ -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 {} diff --git a/src/infrastructure/queue/queue.module.ts b/src/infrastructure/queue/queue.module.ts new file mode 100644 index 0000000..3e20ad4 --- /dev/null +++ b/src/infrastructure/queue/queue.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { QueueService } from './queue.service'; + +@Global() +@Module({ + providers: [QueueService], + exports: [QueueService], +}) +export class QueueModule {} diff --git a/src/infrastructure/queue/queue.service.ts b/src/infrastructure/queue/queue.service.ts new file mode 100644 index 0000000..12a226e --- /dev/null +++ b/src/infrastructure/queue/queue.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class QueueService { + private queues: Map = 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 { + const queue = this.queues.get(queueName); + if (!queue || queue.length === 0) return null; + return queue.shift(); + } + + getQueueNames(): string[] { + return Array.from(this.queues.keys()); + } +} diff --git a/src/infrastructure/redis/redis.module.ts b/src/infrastructure/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/infrastructure/redis/redis.service.ts b/src/infrastructure/redis/redis.service.ts new file mode 100644 index 0000000..a96de0a --- /dev/null +++ b/src/infrastructure/redis/redis.service.ts @@ -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('redis.url'); + if (url) { + this.client = new Redis(url); + } else { + this.client = new Redis({ + host: this.configService.get('redis.host', 'localhost'), + port: this.configService.get('redis.port', 6379), + password: this.configService.get('redis.password'), + db: this.configService.get('redis.db', 0), + }); + } + 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 { + return this.client.get(key); + } + + async set(key: string, value: string, ttl?: number): Promise { + if (ttl) { + await this.client.set(key, value, 'EX', ttl); + } else { + await this.client.set(key, value); + } + } + + async del(key: string): Promise { + await this.client.del(key); + } + + async exists(key: string): Promise { + return (await this.client.exists(key)) === 1; + } + + async expire(key: string, ttl: number): Promise { + await this.client.expire(key, ttl); + } + + async ttl(key: string): Promise { + return this.client.ttl(key); + } + + async incr(key: string): Promise { + return this.client.incr(key); + } + + async setNx(key: string, value: string): Promise { + return (await this.client.setnx(key, value)) === 1; + } + + async lock(key: string, ttlSeconds: number): Promise { + 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 { + 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; + } +} diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts new file mode 100644 index 0000000..8151be1 --- /dev/null +++ b/src/infrastructure/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/infrastructure/storage/storage.service.ts b/src/infrastructure/storage/storage.service.ts new file mode 100644 index 0000000..683c888 --- /dev/null +++ b/src/infrastructure/storage/storage.service.ts @@ -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( + 'storage.localPath', + './uploads', + ); + return `${basePath}/${filename}`; + } + + async healthCheck(): Promise { + return true; + } +} diff --git a/src/knowledge/knowledge.controller.ts b/src/knowledge/knowledge.controller.ts deleted file mode 100644 index 5608309..0000000 --- a/src/knowledge/knowledge.controller.ts +++ /dev/null @@ -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(), - }; - } -} \ No newline at end of file diff --git a/src/knowledge/knowledge.module.ts b/src/knowledge/knowledge.module.ts deleted file mode 100644 index 0d9de7e..0000000 --- a/src/knowledge/knowledge.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { KnowledgeController } from './knowledge.controller'; - -@Module({ - controllers: [KnowledgeController], -}) -export class KnowledgeModule {} \ No newline at end of file diff --git a/src/learning/entities/learning.entity.ts b/src/learning/entities/learning.entity.ts deleted file mode 100644 index 55a8359..0000000 --- a/src/learning/entities/learning.entity.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/learning/learning.controller.ts b/src/learning/learning.controller.ts deleted file mode 100644 index 14e7ae1..0000000 --- a/src/learning/learning.controller.ts +++ /dev/null @@ -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 }; - } -} \ No newline at end of file diff --git a/src/learning/learning.module.ts b/src/learning/learning.module.ts deleted file mode 100644 index 8dd6f32..0000000 --- a/src/learning/learning.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/src/learning/learning.service.ts b/src/learning/learning.service.ts deleted file mode 100644 index a7e242f..0000000 --- a/src/learning/learning.service.ts +++ /dev/null @@ -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 = new Map(); - private reviewTasks: Map = new Map(); - - async getAllPaths(): Promise { - return this.paths; - } - - async getPathById(id: string): Promise { - return this.paths.find(p => p.id === id); - } - - async getCoursesByPath(pathId: string): Promise { - return this.courses.filter(c => c.pathId === pathId); - } - - async getCourseById(id: string): Promise { - 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 { - return this.records.get(userId) || []; - } - - async updateProgress(userId: string, lessonId: string, progress: number): Promise { - 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 { - return this.reviewTasks.get(userId) || []; - } - - async createReviewTask(userId: string, lessonId: string): Promise { - 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; - } -} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 3cbe884..e5339c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,49 +2,6 @@ import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; -const SWAGGER_BASIC_AUTH_USER = process.env.SWAGGER_USER || 'admin'; -const SWAGGER_BASIC_AUTH_PASSWORD = process.env.SWAGGER_PASSWORD || 'change_me'; - -function isLocalhost(url: string): boolean { - return url.includes('localhost') || url.includes('127.0.0.1'); -} - -function isSwaggerEnabled(): boolean { - if (process.env.NODE_ENV === 'production') { - return process.env.ENABLE_SWAGGER === 'true'; - } - return process.env.ENABLE_SWAGGER !== 'false'; -} - -function needsBasicAuth(): boolean { - if (process.env.NODE_ENV === 'production') { - return process.env.ENABLE_SWAGGER === 'true'; - } - return false; -} - -function createBasicAuthMiddleware() { - return (req: any, res: any, next: any) => { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Basic ')) { - res.setHeader('WWW-Authenticate', 'Basic realm="Swagger API Docs"'); - res.status(401).send('Authentication required'); - return; - } - - const base64Credentials = authHeader.split(' ')[1]; - const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); - const [username, password] = credentials.split(':'); - - if (username === SWAGGER_BASIC_AUTH_USER && password === SWAGGER_BASIC_AUTH_PASSWORD) { - next(); - } else { - res.status(401).send('Invalid credentials'); - } - }; -} - async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -54,51 +11,41 @@ async function bootstrap() { credentials: true, }); - if (isSwaggerEnabled()) { - const config = new DocumentBuilder() - .setTitle('龙de AI 学习产品 API') - .setDescription('AI 学习产品后端 API 文档(v0.1),包含用户管理、学习路径、AI 对话、反馈等功能。') - .setVersion('0.1.0') - .addTag('health', '服务健康检查') - .addTag('auth', '用户认证') - .addTag('users', '用户管理') - .addTag('knowledge', '知识库') - .addTag('learning', '学习路径与进度') - .addTag('ai', 'AI 分析与对话') - .addTag('review', '复习任务') - .addTag('feedback', '用户反馈') - .addTag('waitlist', '等待名单') - .build(); + const config = new DocumentBuilder() + .setTitle('知习 API') + .setDescription('知习 AI-first 系统化学习产品后端 API(v0.1)') + .setVersion('0.1.0') + .addTag('health', '服务健康检查') + .addTag('auth', '用户认证') + .addTag('users', '用户管理') + .addTag('knowledge-base', '知识库') + .addTag('knowledge-items', '知识点') + .addTag('document-import', '资料导入') + .addTag('learning-session', '学习会话') + .addTag('active-recall', '主动回忆') + .addTag('ai-analysis', 'AI 分析') + .addTag('review', '复习管理') + .addTag('focus-items', '待巩固项') + .addTag('learning-activity', '学习活跃') + .addTag('notifications', '消息通知') + .addTag('feedback', '用户反馈') + .addTag('waitlist', '等待名单') + .build(); - const document = SwaggerModule.createDocument(app, config); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document, { + swaggerOptions: { persistAuthorization: true }, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: '知习 API 文档', + }); - if (needsBasicAuth()) { - app.use('/api-docs', createBasicAuthMiddleware() as any); - app.use('/api-docs-json', createBasicAuthMiddleware() as any); - } - - SwaggerModule.setup('api-docs', app, document, { - swaggerOptions: { - persistAuthorization: true, - }, - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: '龙de API 文档', - }); - - app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => { - res.json(document); - }); - - console.log('[Swagger] API 文档已启用'); - if (needsBasicAuth()) { - console.log(`[Swagger] Basic Auth 已启用 (${SWAGGER_BASIC_AUTH_USER})`); - } - } else { - console.log('[Swagger] API 文档已禁用'); - } + app.getHttpAdapter().get('/api-docs-json', (_req: any, res: any) => { + res.json(document); + }); const port = process.env.PORT ?? 3000; await app.listen(port); console.log(`[API] Server running on http://localhost:${port}`); + console.log(`[API] Swagger docs at http://localhost:${port}/api-docs`); } -bootstrap(); \ No newline at end of file +bootstrap(); diff --git a/src/modules/active-recall/active-recall.controller.ts b/src/modules/active-recall/active-recall.controller.ts new file mode 100644 index 0000000..66c5e40 --- /dev/null +++ b/src/modules/active-recall/active-recall.controller.ts @@ -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); + } +} diff --git a/src/modules/active-recall/active-recall.module.ts b/src/modules/active-recall/active-recall.module.ts new file mode 100644 index 0000000..8737487 --- /dev/null +++ b/src/modules/active-recall/active-recall.module.ts @@ -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 {} diff --git a/src/modules/active-recall/active-recall.repository.ts b/src/modules/active-recall/active-recall.repository.ts new file mode 100644 index 0000000..d882f3a --- /dev/null +++ b/src/modules/active-recall/active-recall.repository.ts @@ -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 = new Map(); + private answers: RecallAnswer[] = []; + + async findByUserId(userId: string): Promise { + return Array.from(this.questions.values()).filter((q) => q.userId === userId); + } + + async findById(id: string): Promise { + return this.questions.get(id); + } + + async createQuestion(data: Partial): Promise { + const q: RecallQuestion = { + id: data.id || generateShortId(), + userId: data.userId || '', + knowledgeItemId: data.knowledgeItemId || '', + questionText: data.questionText || '', + difficulty: data.difficulty || 'normal', + }; + this.questions.set(q.id, q); + return q; + } + + async createAnswer(userId: string, questionId: string, body: any): Promise { + const answer: RecallAnswer = { + id: generateShortId(), + userId, + questionId, + answerText: body.answerText || '', + submittedAt: new Date(), + }; + this.answers.push(answer); + return answer; + } +} diff --git a/src/modules/active-recall/active-recall.service.ts b/src/modules/active-recall/active-recall.service.ts new file mode 100644 index 0000000..dcaec80 --- /dev/null +++ b/src/modules/active-recall/active-recall.service.ts @@ -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); + } +} diff --git a/src/modules/ai-analysis/ai-analysis.controller.ts b/src/modules/ai-analysis/ai-analysis.controller.ts new file mode 100644 index 0000000..90f0b56 --- /dev/null +++ b/src/modules/ai-analysis/ai-analysis.controller.ts @@ -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); + } +} diff --git a/src/modules/ai-analysis/ai-analysis.module.ts b/src/modules/ai-analysis/ai-analysis.module.ts new file mode 100644 index 0000000..7641fe5 --- /dev/null +++ b/src/modules/ai-analysis/ai-analysis.module.ts @@ -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 {} diff --git a/src/modules/ai-analysis/ai-analysis.repository.ts b/src/modules/ai-analysis/ai-analysis.repository.ts new file mode 100644 index 0000000..16edad7 --- /dev/null +++ b/src/modules/ai-analysis/ai-analysis.repository.ts @@ -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 = new Map(); + private results: Map = new Map(); + + async createJob(userId: string, data: any): Promise { + const job: AnalysisJob = { + id: generateShortId(), + userId, + sessionId: data.sessionId || '', + inputText: data.userInput || '', + status: 'pending', + createdAt: new Date(), + }; + this.jobs.set(job.id, job); + return job; + } + + async findJobById(id: string): Promise { + return this.jobs.get(id); + } + + async updateJobStatus(id: string, status: AnalysisJob['status']) { + const job = this.jobs.get(id); + if (job) job.status = status; + } + + async createResult(jobId: string, userId: string, aiResult: any): Promise { + const result: AnalysisResult = { + id: generateShortId(), + jobId, + userId, + masteryScore: aiResult.masteryScore, + summary: aiResult.summary, + strengths: aiResult.strengths, + weakPoints: aiResult.weakPoints, + suggestions: aiResult.suggestions, + createdAt: new Date(), + }; + this.results.set(result.id, result); + return result; + } + + async findResultById(id: string): Promise { + return this.results.get(id); + } +} diff --git a/src/modules/ai-analysis/ai-analysis.service.ts b/src/modules/ai-analysis/ai-analysis.service.ts new file mode 100644 index 0000000..334cd95 --- /dev/null +++ b/src/modules/ai-analysis/ai-analysis.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiAnalysisRepository } from './ai-analysis.repository'; +import { AiService } from '../../infrastructure/ai/ai.service'; +import { RedisService } from '../../infrastructure/redis/redis.service'; +import { QueueService } from '../../infrastructure/queue/queue.service'; + +const DAILY_AI_LIMIT = 50; + +@Injectable() +export class AiAnalysisService { + private readonly logger = new Logger(AiAnalysisService.name); + + constructor( + private readonly repository: AiAnalysisRepository, + private readonly aiService: AiService, + private readonly redis: RedisService, + private readonly queue: QueueService, + ) {} + + async createJob(userId: string, body: any) { + await this.checkRateLimit(userId); + + const lockKey = `lock:ai-analysis:session:${body.sessionId || 'unknown'}`; + const lockToken = await this.redis.lock(lockKey, 300); + if (!lockToken) { + throw new Error('同一学习会话的 AI 分析正在处理中,请稍候'); + } + + const job = await this.repository.createJob(userId, body); + + await this.redis.set(`job:ai-analysis:${job.id}:status`, 'pending', 86400); + await this.redis.set(`job:ai-analysis:${job.id}:progress`, '0', 86400); + + this.queue.add('ai-analysis', { jobId: job.id, userId, sessionId: body.sessionId }); + this.processJob(job, lockKey, lockToken); + + return { jobId: job.id, status: job.status }; + } + + private async checkRateLimit(userId: string) { + const today = new Date().toISOString().split('T')[0]; + const rateKey = `rate:user:${userId}:ai:daily:${today}`; + const count = await this.redis.incr(rateKey); + if (count === 1) { + await this.redis.expire(rateKey, 86400); + } + if (count > DAILY_AI_LIMIT) { + throw new Error(`每日 AI 调用次数已达上限(${DAILY_AI_LIMIT}次)`); + } + } + + private processJob(job: any, lockKey: string, lockToken: string) { + try { + this.repository.updateJobStatus(job.id, 'processing'); + this.redis.set(`job:ai-analysis:${job.id}:status`, 'processing', 86400); + this.redis.set(`job:ai-analysis:${job.id}:progress`, '30', 86400); + + this.aiService.analyze({ + userInput: job.inputText, + context: { lessonTitle: '', objectives: [], keyPoints: [] }, + }).then(async (result) => { + await this.redis.set(`job:ai-analysis:${job.id}:progress`, '80', 86400); + await this.repository.createResult(job.id, job.userId, result); + this.repository.updateJobStatus(job.id, 'success'); + await this.redis.set(`job:ai-analysis:${job.id}:status`, 'completed', 86400); + await this.redis.set(`job:ai-analysis:${job.id}:progress`, '100', 86400); + await this.redis.unlock(lockKey, lockToken); + this.logger.log(`Job ${job.id} completed`); + }).catch(async (err) => { + this.logger.error(`Job ${job.id} failed: ${err.message}`); + this.repository.updateJobStatus(job.id, 'failed'); + await this.redis.set(`job:ai-analysis:${job.id}:status`, 'failed', 86400); + await this.redis.set(`job:ai-analysis:${job.id}:error`, err.message, 86400); + await this.redis.unlock(lockKey, lockToken); + }); + } catch (err) { + this.logger.error(`Job ${job.id} sync error: ${err}`); + this.redis.unlock(lockKey, lockToken); + } + } + + async getResult(id: string) { + return this.repository.findResultById(id); + } + + async getJobStatus(jobId: string) { + const redisStatus = await this.redis.get(`job:ai-analysis:${jobId}:status`); + const redisProgress = await this.redis.get(`job:ai-analysis:${jobId}:progress`); + const dbJob = await this.repository.findJobById(jobId); + + if (!dbJob && !redisStatus) return null; + + return { + jobId, + status: redisStatus || dbJob?.status || 'unknown', + progress: redisProgress ? parseInt(redisProgress, 10) : 0, + }; + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..b3ec622 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,39 @@ +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +class AppleLoginDto { + identityToken: string; + authorizationCode: string; + user?: { name?: { firstName?: string; lastName?: string }; email?: string }; +} + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('apple') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Apple 登录', description: '使用 Sign in with Apple 登录,返回访问令牌' }) + @ApiResponse({ status: 200, description: '登录成功' }) + async appleLogin(@Body() body: AppleLoginDto) { + return this.authService.appleLogin(body); + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '刷新令牌', description: '使用刷新令牌获取新的访问令牌' }) + @ApiResponse({ status: 200, description: '令牌刷新成功' }) + async refresh(@Body('refreshToken') refreshToken: string) { + return this.authService.refresh(refreshToken); + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '用户退出', description: '使当前会话失效' }) + @ApiResponse({ status: 200, description: '退出成功' }) + async logout() { + return this.authService.logout(); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..5804d9b --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { AuthRepository } from './auth.repository'; + +@Module({ + controllers: [AuthController], + providers: [AuthService, AuthRepository], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..34ed053 --- /dev/null +++ b/src/modules/auth/auth.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +export interface AuthUser { + id: number; + appleUserId: string; + email?: string; + displayName?: string; +} + +@Injectable() +export class AuthRepository { + private users: AuthUser[] = []; + private nextId = 1; + + async findByAppleUserId(appleUserId: string): Promise { + return this.users.find((u) => u.appleUserId === appleUserId); + } + + async createUser(data: Partial): Promise { + const user: AuthUser = { + id: data.id || this.nextId++, + appleUserId: data.appleUserId || '', + email: data.email, + displayName: data.displayName, + }; + this.users.push(user); + return user; + } +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..573bcef --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { AuthRepository } from './auth.repository'; + +@Injectable() +export class AuthService { + constructor(private readonly authRepository: AuthRepository) {} + + async appleLogin(params: { + identityToken: string; + authorizationCode: string; + user?: { name?: { firstName?: string; lastName?: string }; email?: string }; + }) { + const appleUserId = `apple_${params.identityToken.substring(0, 20)}`; + let user = await this.authRepository.findByAppleUserId(appleUserId); + if (!user) { + const displayName = + params.user?.name + ? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` + : undefined; + user = await this.authRepository.createUser({ + appleUserId, + email: params.user?.email, + displayName, + }); + } + const accessToken = `mock_token_${Date.now()}`; + const refreshToken = `mock_refresh_${Date.now()}`; + return { accessToken, refreshToken, expiresIn: 3600 }; + } + + async refresh(refreshToken: string) { + const accessToken = `mock_token_${Date.now()}`; + return { accessToken, expiresIn: 3600 }; + } + + async logout() { + return { success: true, message: '已退出登录' }; + } +} diff --git a/src/modules/document-import/document-import.controller.ts b/src/modules/document-import/document-import.controller.ts new file mode 100644 index 0000000..15959a1 --- /dev/null +++ b/src/modules/document-import/document-import.controller.ts @@ -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); + } +} diff --git a/src/modules/document-import/document-import.module.ts b/src/modules/document-import/document-import.module.ts new file mode 100644 index 0000000..68a5bd4 --- /dev/null +++ b/src/modules/document-import/document-import.module.ts @@ -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 {} diff --git a/src/modules/document-import/document-import.repository.ts b/src/modules/document-import/document-import.repository.ts new file mode 100644 index 0000000..cf38ede --- /dev/null +++ b/src/modules/document-import/document-import.repository.ts @@ -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 = new Map(); + + async create(data: any): Promise { + const job: ImportJob = { + id: generateShortId(), + fileName: data.fileName || 'unknown', + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }; + this.jobs.set(job.id, job); + return job; + } + + async findById(id: string): Promise { + return this.jobs.get(id); + } + + async updateStatus(id: string, status: ImportJob['status']): Promise { + const job = this.jobs.get(id); + if (job) { + job.status = status; + job.updatedAt = new Date(); + } + } +} diff --git a/src/modules/document-import/document-import.service.ts b/src/modules/document-import/document-import.service.ts new file mode 100644 index 0000000..8020ef9 --- /dev/null +++ b/src/modules/document-import/document-import.service.ts @@ -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, + }; + } +} diff --git a/src/modules/feedback/dto/create-feedback.dto.ts b/src/modules/feedback/dto/create-feedback.dto.ts new file mode 100644 index 0000000..40d7fc4 --- /dev/null +++ b/src/modules/feedback/dto/create-feedback.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateFeedbackDto { + @ApiPropertyOptional({ description: '用户 ID' }) + userId?: string; + + @ApiProperty({ description: '反馈类型', enum: ['bug', 'feature', 'general'] }) + type: 'bug' | 'feature' | 'general'; + + @ApiProperty({ description: '反馈内容' }) + content: string; + + @ApiPropertyOptional({ description: '联系方式' }) + contact?: string; +} diff --git a/src/modules/feedback/feedback.controller.ts b/src/modules/feedback/feedback.controller.ts new file mode 100644 index 0000000..26bfefb --- /dev/null +++ b/src/modules/feedback/feedback.controller.ts @@ -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); + } +} diff --git a/src/feedback/feedback.module.ts b/src/modules/feedback/feedback.module.ts similarity index 62% rename from src/feedback/feedback.module.ts rename to src/modules/feedback/feedback.module.ts index 9a1f415..ee9f721 100644 --- a/src/feedback/feedback.module.ts +++ b/src/modules/feedback/feedback.module.ts @@ -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 {} \ No newline at end of file +export class FeedbackModule {} diff --git a/src/modules/feedback/feedback.repository.ts b/src/modules/feedback/feedback.repository.ts new file mode 100644 index 0000000..cb95143 --- /dev/null +++ b/src/modules/feedback/feedback.repository.ts @@ -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): Promise { + const entry: FeedbackEntry = { + id: `fb_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + userId: data.userId, + type: data.type || 'general', + content: data.content || '', + contact: data.contact, + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }; + this.feedbacks.push(entry); + return entry; + } + + async findAll(): Promise { + return this.feedbacks; + } + + async findByUserId(userId: string): Promise { + return this.feedbacks.filter((f) => f.userId === userId); + } + + async updateStatus(id: string, status: string): Promise { + const feedback = this.feedbacks.find((f) => f.id === id); + if (!feedback) return undefined; + feedback.status = status as any; + feedback.updatedAt = new Date(); + return feedback; + } + + async getStats() { + const byType: Record = {}; + const byStatus: Record = {}; + this.feedbacks.forEach((f) => { + byType[f.type] = (byType[f.type] || 0) + 1; + byStatus[f.status] = (byStatus[f.status] || 0) + 1; + }); + return { total: this.feedbacks.length, byType, byStatus }; + } +} diff --git a/src/modules/feedback/feedback.service.ts b/src/modules/feedback/feedback.service.ts new file mode 100644 index 0000000..571d717 --- /dev/null +++ b/src/modules/feedback/feedback.service.ts @@ -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(); + } +} diff --git a/src/modules/focus-items/focus-items.controller.ts b/src/modules/focus-items/focus-items.controller.ts new file mode 100644 index 0000000..0565511 --- /dev/null +++ b/src/modules/focus-items/focus-items.controller.ts @@ -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); + } +} diff --git a/src/modules/focus-items/focus-items.module.ts b/src/modules/focus-items/focus-items.module.ts new file mode 100644 index 0000000..42cc511 --- /dev/null +++ b/src/modules/focus-items/focus-items.module.ts @@ -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 {} diff --git a/src/modules/focus-items/focus-items.repository.ts b/src/modules/focus-items/focus-items.repository.ts new file mode 100644 index 0000000..728eaff --- /dev/null +++ b/src/modules/focus-items/focus-items.repository.ts @@ -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 = new Map(); + + async findAll(): Promise { + return Array.from(this.items.values()); + } + + async findById(id: string): Promise { + return this.items.get(id); + } + + async create(data: Partial): Promise { + const now = new Date(); + const item: FocusItem = { + id: data.id || generateShortId(), + title: data.title || '', + description: data.description || '', + priority: data.priority || 'normal', + status: data.status || 'open', + createdAt: data.createdAt || now, + updatedAt: now, + completedAt: data.completedAt || null, + }; + this.items.set(item.id, item); + return item; + } + + async update(id: string, data: Partial): Promise { + const item = this.items.get(id); + if (!item) return undefined; + Object.assign(item, { ...data, updatedAt: new Date() }); + return item; + } +} diff --git a/src/modules/focus-items/focus-items.service.ts b/src/modules/focus-items/focus-items.service.ts new file mode 100644 index 0000000..243c2f0 --- /dev/null +++ b/src/modules/focus-items/focus-items.service.ts @@ -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 { + return this.repository.findAll(); + } + + async create(dto: any): Promise { + return this.repository.create(dto); + } + + async update(id: string, dto: any): Promise { + const item = await this.repository.update(id, dto); + if (!item) throw new NotFoundException(`Focus item ${id} not found`); + return item; + } + + async complete(id: string): Promise { + const item = await this.repository.update(id, { + status: 'completed', + completedAt: new Date(), + }); + if (!item) throw new NotFoundException(`Focus item ${id} not found`); + return item; + } +} diff --git a/src/modules/focus-items/types/focus-item.types.ts b/src/modules/focus-items/types/focus-item.types.ts new file mode 100644 index 0000000..9cd3fa1 --- /dev/null +++ b/src/modules/focus-items/types/focus-item.types.ts @@ -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; +} diff --git a/src/modules/knowledge-base/constants/knowledge-base.constants.ts b/src/modules/knowledge-base/constants/knowledge-base.constants.ts new file mode 100644 index 0000000..3ae36b5 --- /dev/null +++ b/src/modules/knowledge-base/constants/knowledge-base.constants.ts @@ -0,0 +1 @@ +export const MAX_KNOWLEDGE_BASE_COUNT = 20; diff --git a/src/modules/knowledge-base/knowledge-base.controller.ts b/src/modules/knowledge-base/knowledge-base.controller.ts new file mode 100644 index 0000000..b566fbd --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.controller.ts @@ -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); + } +} diff --git a/src/modules/knowledge-base/knowledge-base.module.ts b/src/modules/knowledge-base/knowledge-base.module.ts new file mode 100644 index 0000000..eeb6b01 --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.module.ts @@ -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 {} diff --git a/src/modules/knowledge-base/knowledge-base.repository.ts b/src/modules/knowledge-base/knowledge-base.repository.ts new file mode 100644 index 0000000..b20039b --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.repository.ts @@ -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 = new Map(); + + async create(userId: string, dto: any): Promise { + const kb: KnowledgeBase = { + id: generateShortId(), + userId, + title: dto.title, + description: dto.description || '', + status: 'active', + itemCount: 0, + lastStudiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.items.set(kb.id, kb); + return kb; + } + + async findById(id: string): Promise { + return this.items.get(id); + } + + async findAllByUserId(userId: string): Promise { + return Array.from(this.items.values()).filter( + (kb) => kb.userId === userId && kb.status !== 'deleted', + ); + } + + async countByUserId(userId: string): Promise { + return Array.from(this.items.values()).filter( + (kb) => kb.userId === userId && kb.status !== 'deleted', + ).length; + } + + async update(id: string, dto: any): Promise { + const kb = this.items.get(id); + if (!kb) return undefined; + Object.assign(kb, { ...dto, updatedAt: new Date() }); + this.items.set(id, kb); + return kb; + } + + async softDelete(id: string): Promise { + const kb = this.items.get(id); + if (!kb) return false; + kb.status = 'deleted'; + kb.updatedAt = new Date(); + return true; + } +} diff --git a/src/modules/knowledge-base/knowledge-base.service.ts b/src/modules/knowledge-base/knowledge-base.service.ts new file mode 100644 index 0000000..7f89655 --- /dev/null +++ b/src/modules/knowledge-base/knowledge-base.service.ts @@ -0,0 +1,44 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { KnowledgeBaseRepository } from './knowledge-base.repository'; +import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants'; + +@Injectable() +export class KnowledgeBaseService { + constructor(private readonly repository: KnowledgeBaseRepository) {} + + async create(userId: string, dto: any) { + const count = await this.repository.countByUserId(userId); + if (count >= MAX_KNOWLEDGE_BASE_COUNT) { + throw new BadRequestException('知识库数量已达到上限'); + } + return this.repository.create(userId, dto); + } + + async findAll(userId: string, query: any) { + return this.repository.findAllByUserId(userId); + } + + async findOne(userId: string, id: string) { + const kb = await this.repository.findById(id); + if (!kb || kb.userId !== userId) { + throw new NotFoundException('知识库不存在'); + } + return kb; + } + + async update(userId: string, id: string, dto: any) { + const kb = await this.repository.findById(id); + if (!kb || kb.userId !== userId) { + throw new NotFoundException('知识库不存在'); + } + return this.repository.update(id, dto); + } + + async remove(userId: string, id: string) { + const kb = await this.repository.findById(id); + if (!kb || kb.userId !== userId) { + throw new NotFoundException('知识库不存在'); + } + return this.repository.softDelete(id); + } +} diff --git a/src/modules/knowledge-base/types/knowledge-base.types.ts b/src/modules/knowledge-base/types/knowledge-base.types.ts new file mode 100644 index 0000000..6dae3ae --- /dev/null +++ b/src/modules/knowledge-base/types/knowledge-base.types.ts @@ -0,0 +1 @@ +export type KnowledgeBaseStatus = 'active' | 'archived' | 'deleted'; diff --git a/src/modules/knowledge-items/knowledge-items.controller.ts b/src/modules/knowledge-items/knowledge-items.controller.ts new file mode 100644 index 0000000..18cbb1e --- /dev/null +++ b/src/modules/knowledge-items/knowledge-items.controller.ts @@ -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); + } +} diff --git a/src/modules/knowledge-items/knowledge-items.module.ts b/src/modules/knowledge-items/knowledge-items.module.ts new file mode 100644 index 0000000..c5c83c6 --- /dev/null +++ b/src/modules/knowledge-items/knowledge-items.module.ts @@ -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 {} diff --git a/src/modules/knowledge-items/knowledge-items.repository.ts b/src/modules/knowledge-items/knowledge-items.repository.ts new file mode 100644 index 0000000..cc4f777 --- /dev/null +++ b/src/modules/knowledge-items/knowledge-items.repository.ts @@ -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 = new Map(); + + async create(userId: string, knowledgeBaseId: string, dto: any): Promise { + const item: KnowledgeItem = { + id: generateShortId(), + userId, + knowledgeBaseId, + parentId: dto.parentId || null, + itemType: dto.itemType || 'lesson', + title: dto.title || '', + content: dto.content || '', + orderIndex: dto.orderIndex || 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.items.set(item.id, item); + return item; + } + + async findById(id: string): Promise { + return this.items.get(id); + } + + async findByKnowledgeBaseId(knowledgeBaseId: string): Promise { + return Array.from(this.items.values()).filter((i) => i.knowledgeBaseId === knowledgeBaseId); + } + + async update(id: string, dto: any): Promise { + const item = this.items.get(id); + if (!item) return undefined; + Object.assign(item, { ...dto, updatedAt: new Date() }); + return item; + } +} diff --git a/src/modules/knowledge-items/knowledge-items.service.ts b/src/modules/knowledge-items/knowledge-items.service.ts new file mode 100644 index 0000000..7575b97 --- /dev/null +++ b/src/modules/knowledge-items/knowledge-items.service.ts @@ -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; + } +} diff --git a/src/modules/knowledge-items/types/knowledge-item.types.ts b/src/modules/knowledge-items/types/knowledge-item.types.ts new file mode 100644 index 0000000..05dfbbc --- /dev/null +++ b/src/modules/knowledge-items/types/knowledge-item.types.ts @@ -0,0 +1 @@ +export type KnowledgeItemType = 'chapter' | 'lesson' | 'concept' | 'note' | 'imported_doc'; diff --git a/src/modules/learning-activity/learning-activity.controller.ts b/src/modules/learning-activity/learning-activity.controller.ts new file mode 100644 index 0000000..fcb1865 --- /dev/null +++ b/src/modules/learning-activity/learning-activity.controller.ts @@ -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(); + } +} diff --git a/src/modules/learning-activity/learning-activity.module.ts b/src/modules/learning-activity/learning-activity.module.ts new file mode 100644 index 0000000..d88a9c1 --- /dev/null +++ b/src/modules/learning-activity/learning-activity.module.ts @@ -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 {} diff --git a/src/modules/learning-activity/learning-activity.repository.ts b/src/modules/learning-activity/learning-activity.repository.ts new file mode 100644 index 0000000..d37ff4c --- /dev/null +++ b/src/modules/learning-activity/learning-activity.repository.ts @@ -0,0 +1,32 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; + +export interface DailyActivity { + date: string; + minutes: number; + cardsReviewed: number; +} + +@Injectable() +export class LearningActivityRepository implements OnModuleInit { + private activities: Map = new Map(); + + onModuleInit() { + const today = new Date(); + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + this.activities.set(dateStr, { + date: dateStr, + minutes: Math.floor(Math.random() * 120) + 10, + cardsReviewed: Math.floor(Math.random() * 50) + 5, + }); + } + } + + async findAll(): Promise { + return Array.from(this.activities.values()).sort( + (a, b) => a.date.localeCompare(b.date), + ); + } +} diff --git a/src/modules/learning-activity/learning-activity.service.ts b/src/modules/learning-activity/learning-activity.service.ts new file mode 100644 index 0000000..efacbf8 --- /dev/null +++ b/src/modules/learning-activity/learning-activity.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { LearningActivityRepository } from './learning-activity.repository'; + +@Injectable() +export class LearningActivityService { + constructor(private readonly repository: LearningActivityRepository) {} + + async getHeatmap(): Promise> { + const activities = await this.repository.findAll(); + const heatmap: Record = {}; + for (const a of activities) { + heatmap[a.date] = a.minutes; + } + return heatmap; + } + + async getSummary() { + const activities = await this.repository.findAll(); + const totalMinutes = activities.reduce((s, a) => s + a.minutes, 0); + const totalCards = activities.reduce((s, a) => s + a.cardsReviewed, 0); + const activeDays = activities.filter((a) => a.minutes > 0).length; + const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0; + return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage }; + } +} diff --git a/src/modules/learning-session/learning-session.controller.ts b/src/modules/learning-session/learning-session.controller.ts new file mode 100644 index 0000000..606029f --- /dev/null +++ b/src/modules/learning-session/learning-session.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { LearningSessionService } from './learning-session.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; + +@ApiTags('learning-session') +@Controller('learning-sessions') +export class LearningSessionController { + constructor(private readonly service: LearningSessionService) {} + + @Post() + @ApiOperation({ summary: '开始学习会话' }) + async start(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { + return this.service.start(String(user?.id || 'anonymous'), body); + } + + @Post(':id/end') + @ApiOperation({ summary: '结束学习会话' }) + async end(@Param('id') id: string) { + return this.service.end(id); + } + + @Get() + @ApiOperation({ summary: '获取学习会话列表' }) + async findAll(@CurrentUser() user: UserPayload | undefined) { + return this.service.findByUserId(String(user?.id || 'anonymous')); + } +} diff --git a/src/modules/learning-session/learning-session.module.ts b/src/modules/learning-session/learning-session.module.ts new file mode 100644 index 0000000..3f82fe2 --- /dev/null +++ b/src/modules/learning-session/learning-session.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LearningSessionController } from './learning-session.controller'; +import { LearningSessionService } from './learning-session.service'; +import { LearningSessionRepository } from './learning-session.repository'; + +@Module({ + controllers: [LearningSessionController], + providers: [LearningSessionService, LearningSessionRepository], + exports: [LearningSessionService], +}) +export class LearningSessionModule {} diff --git a/src/modules/learning-session/learning-session.repository.ts b/src/modules/learning-session/learning-session.repository.ts new file mode 100644 index 0000000..fb37e07 --- /dev/null +++ b/src/modules/learning-session/learning-session.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { generateShortId } from '../../common/utils/id.util'; + +export interface LearningSession { + id: string; + userId: string; + knowledgeItemId: string; + mode: string; + status: 'active' | 'completed'; + startedAt: Date; + endedAt: Date | null; + durationSeconds: number; +} + +@Injectable() +export class LearningSessionRepository { + private sessions: Map = new Map(); + + async create(userId: string, dto: any): Promise { + const session: LearningSession = { + id: generateShortId(), + userId, + knowledgeItemId: dto.knowledgeItemId || '', + mode: dto.mode || 'reading', + status: 'active', + startedAt: new Date(), + endedAt: null, + durationSeconds: 0, + }; + this.sessions.set(session.id, session); + return session; + } + + async end(id: string): Promise { + const session = this.sessions.get(id); + if (!session) return undefined; + session.status = 'completed'; + session.endedAt = new Date(); + session.durationSeconds = Math.floor( + (session.endedAt.getTime() - session.startedAt.getTime()) / 1000, + ); + return session; + } + + async findByUserId(userId: string): Promise { + return Array.from(this.sessions.values()).filter((s) => s.userId === userId); + } +} diff --git a/src/modules/learning-session/learning-session.service.ts b/src/modules/learning-session/learning-session.service.ts new file mode 100644 index 0000000..126712a --- /dev/null +++ b/src/modules/learning-session/learning-session.service.ts @@ -0,0 +1,21 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { LearningSessionRepository } from './learning-session.repository'; + +@Injectable() +export class LearningSessionService { + constructor(private readonly repository: LearningSessionRepository) {} + + async start(userId: string, dto: any) { + return this.repository.create(userId, dto); + } + + async end(id: string) { + const session = await this.repository.end(id); + if (!session) throw new NotFoundException('会话不存在'); + return session; + } + + async findByUserId(userId: string) { + return this.repository.findByUserId(userId); + } +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..1a424b7 --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Post, Param, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('notifications') +@Controller('notifications') +export class NotificationsController { + constructor(private readonly service: NotificationsService) {} + + @Get() + @ApiOperation({ summary: '获取通知列表' }) + async list() { + return this.service.list(); + } + + @Post(':id/read') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '标记通知已读' }) + async markRead(@Param('id') id: string) { + return this.service.markRead(id); + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..f247d06 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { NotificationsRepository } from './notifications.repository'; + +@Module({ + controllers: [NotificationsController], + providers: [NotificationsService, NotificationsRepository], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/modules/notifications/notifications.repository.ts b/src/modules/notifications/notifications.repository.ts new file mode 100644 index 0000000..addc223 --- /dev/null +++ b/src/modules/notifications/notifications.repository.ts @@ -0,0 +1,66 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { generateShortId } from '../../common/utils/id.util'; + +export interface Notification { + id: string; + userId: string; + type: string; + title: string; + body: string; + read: boolean; + createdAt: Date; +} + +@Injectable() +export class NotificationsRepository implements OnModuleInit { + private notifications: Notification[] = []; + + onModuleInit() { + const demos: Partial[] = [ + { title: '欢迎使用', body: '欢迎来到知习,开始你的学习之旅!', type: 'system' }, + { title: '复习提醒', body: '你有 5 张卡片需要复习', type: 'review_due' }, + ]; + for (const demo of demos) { + this.notifications.push({ + id: generateShortId(), + userId: '1', + type: demo.type || 'system', + title: demo.title || '', + body: demo.body || '', + read: false, + createdAt: new Date(), + }); + } + } + + async findAll(): Promise { + return [...this.notifications].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + } + + async create(data: Partial): Promise { + const notification: Notification = { + id: data.id || generateShortId(), + userId: data.userId || 'anonymous', + type: data.type || 'system', + title: data.title || '', + body: data.body || '', + read: false, + createdAt: new Date(), + }; + this.notifications.push(notification); + return notification; + } + + async findById(id: string): Promise { + return this.notifications.find((n) => n.id === id); + } + + async markRead(id: string): Promise { + const n = this.notifications.find((x) => x.id === id); + if (!n) return undefined; + n.read = true; + return n; + } +} diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..8566cbc --- /dev/null +++ b/src/modules/notifications/notifications.service.ts @@ -0,0 +1,37 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { NotificationsRepository } from './notifications.repository'; +import { RedisService } from '../../infrastructure/redis/redis.service'; +import { QueueService } from '../../infrastructure/queue/queue.service'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + private readonly repository: NotificationsRepository, + private readonly redis: RedisService, + private readonly queue: QueueService, + ) {} + + async list() { + return this.repository.findAll(); + } + + async markRead(id: string) { + const notification = await this.repository.markRead(id); + if (!notification) throw new NotFoundException(`Notification ${id} not found`); + return notification; + } + + async send(data: { userId: string; type: string; title: string; body: string }) { + const notification = await this.repository.create(data); + this.queue.add('notification', { notificationId: notification.id, ...data }); + this.redis.set( + `session:notifications:${data.userId}:last_sent`, + new Date().toISOString(), + 86400, + ); + this.logger.log(`Notification ${notification.id} sent to user ${data.userId}`); + return notification; + } +} diff --git a/src/modules/review/dto/submit-review.dto.ts b/src/modules/review/dto/submit-review.dto.ts new file mode 100644 index 0000000..e73218d --- /dev/null +++ b/src/modules/review/dto/submit-review.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; + +export class SubmitReviewDto { + @ApiProperty({ description: '评分等级', example: 'Hard' }) + @IsString() + rating: string; + + @ApiPropertyOptional({ description: '回答文本' }) + @IsString() + @IsOptional() + responseText?: string; +} diff --git a/src/modules/review/review.controller.ts b/src/modules/review/review.controller.ts new file mode 100644 index 0000000..b1ab656 --- /dev/null +++ b/src/modules/review/review.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ReviewService } from './review.service'; +import { SubmitReviewDto } from './dto/submit-review.dto'; + +@ApiTags('review') +@Controller('reviews') +export class ReviewController { + constructor(private readonly reviewService: ReviewService) {} + + @Get('due') + @ApiOperation({ summary: '获取到期复习卡片' }) + @ApiResponse({ status: 200, description: '到期复习卡片列表' }) + async getDue() { + return this.reviewService.getDueCards(); + } + + @Post(':id/submit') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '提交复习结果' }) + @ApiResponse({ status: 201, description: '提交成功' }) + async submitReview(@Param('id') id: string, @Body() dto: SubmitReviewDto) { + return this.reviewService.submitReview(id, dto); + } +} diff --git a/src/modules/review/review.module.ts b/src/modules/review/review.module.ts new file mode 100644 index 0000000..213380c --- /dev/null +++ b/src/modules/review/review.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ReviewController } from './review.controller'; +import { ReviewService } from './review.service'; +import { ReviewRepository } from './review.repository'; + +@Module({ + controllers: [ReviewController], + providers: [ReviewService, ReviewRepository], + exports: [ReviewService], +}) +export class ReviewModule {} diff --git a/src/modules/review/review.repository.ts b/src/modules/review/review.repository.ts new file mode 100644 index 0000000..4d44edd --- /dev/null +++ b/src/modules/review/review.repository.ts @@ -0,0 +1,80 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { generateShortId } from '../../common/utils/id.util'; + +export interface ReviewCard { + id: string; + userId: string; + knowledgeItemId: string; + dueDate: Date; + reviewedAt: Date | null; + createdAt: Date; +} + +export interface ReviewLog { + id: string; + cardId: string; + rating: string; + responseText: string; + reviewedAt: Date; +} + +@Injectable() +export class ReviewRepository implements OnModuleInit { + private cards: Map = new Map(); + private logs: Map = new Map(); + + onModuleInit() { + this.seedDemoData(); + } + + async findById(id: string): Promise { + return this.cards.get(id); + } + + async findDueCards(): Promise { + const now = new Date(); + return Array.from(this.cards.values()).filter( + (c) => !c.reviewedAt && c.dueDate <= now, + ); + } + + async insertCard(card: Partial): Promise { + const newCard: ReviewCard = { + id: card.id || generateShortId(), + userId: card.userId || '', + knowledgeItemId: card.knowledgeItemId || '', + dueDate: card.dueDate || new Date(), + reviewedAt: card.reviewedAt || null, + createdAt: card.createdAt || new Date(), + }; + this.cards.set(newCard.id, newCard); + return newCard; + } + + async updateCard(id: string, update: Partial): Promise { + const card = this.cards.get(id); + if (card) Object.assign(card, update); + } + + async insertLog(log: Partial): Promise { + const newLog: ReviewLog = { + id: log.id || generateShortId(), + cardId: log.cardId || '', + rating: log.rating || '', + responseText: log.responseText || '', + reviewedAt: log.reviewedAt || new Date(), + }; + this.logs.set(newLog.id, newLog); + return newLog; + } + + private seedDemoData(): void { + const demos: Partial[] = [ + { id: 'card_demo_1', userId: '1', knowledgeItemId: 'item_1', dueDate: new Date(Date.now() - 3600000), createdAt: new Date() }, + { id: 'card_demo_2', userId: '1', knowledgeItemId: 'item_2', dueDate: new Date(Date.now() + 3600000), createdAt: new Date() }, + ]; + for (const card of demos) { + this.cards.set(card.id!, card as ReviewCard); + } + } +} diff --git a/src/modules/review/review.service.ts b/src/modules/review/review.service.ts new file mode 100644 index 0000000..a87c192 --- /dev/null +++ b/src/modules/review/review.service.ts @@ -0,0 +1,24 @@ +import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { ReviewRepository, ReviewCard, ReviewLog } from './review.repository'; +import { SubmitReviewDto } from './dto/submit-review.dto'; + +@Injectable() +export class ReviewService implements OnModuleInit { + constructor(private readonly reviewRepository: ReviewRepository) {} + + onModuleInit() {} + + async getDueCards(): Promise { + return this.reviewRepository.findDueCards(); + } + + async submitReview(id: string, dto: SubmitReviewDto): Promise { + const card = await this.reviewRepository.findById(id); + if (!card) throw new NotFoundException(`Review card ${id} not found`); + const log = await this.reviewRepository.insertLog({ + cardId: id, rating: dto.rating, responseText: dto.responseText, + }); + await this.reviewRepository.updateCard(id, { reviewedAt: new Date() }); + return log; + } +} diff --git a/src/modules/system/system.controller.ts b/src/modules/system/system.controller.ts new file mode 100644 index 0000000..aff2030 --- /dev/null +++ b/src/modules/system/system.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { RedisService } from '../../infrastructure/redis/redis.service'; + +@ApiTags('health') +@Controller() +export class SystemController { + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) {} + + @Get() + @ApiOperation({ summary: '服务健康检查' }) + @ApiResponse({ status: 200, description: 'API 运行正常' }) + getRoot() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }; + } + + @Get('health') + @ApiOperation({ summary: '详细健康检查' }) + @ApiResponse({ status: 200, description: '服务状态信息' }) + async getHealth() { + let dbStatus = 'disconnected'; + let redisStatus = 'disconnected'; + try { + await this.prisma.$queryRaw`SELECT 1`; + dbStatus = 'connected'; + } catch {} + try { + redisStatus = this.redis.isHealthy() ? 'connected' : 'disconnected'; + } catch {} + return { status: 'ok', database: dbStatus, redis: redisStatus }; + } +} diff --git a/src/modules/system/system.module.ts b/src/modules/system/system.module.ts new file mode 100644 index 0000000..1ba4501 --- /dev/null +++ b/src/modules/system/system.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { SystemController } from './system.controller'; + +@Module({ + controllers: [SystemController], +}) +export class SystemModule {} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..6887e4f --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Patch, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; + +@ApiTags('users') +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get('me') + @ApiOperation({ summary: '获取当前用户信息', description: '获取当前登录用户的资料' }) + @ApiResponse({ status: 200, description: '用户信息' }) + async getProfile(@CurrentUser() user: UserPayload | undefined) { + return this.usersService.getProfile(String(user?.id || 'anonymous')); + } + + @Patch('me') + @ApiOperation({ summary: '更新用户资料', description: '更新当前用户的资料信息' }) + @ApiResponse({ status: 200, description: '更新成功' }) + async updateProfile(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { + return this.usersService.updateProfile(String(user?.id || 'anonymous'), body); + } + + @Patch('me/preferences') + @ApiOperation({ summary: '更新用户偏好', description: '更新当前用户的学习偏好设置' }) + @ApiResponse({ status: 200, description: '更新成功' }) + async updatePreferences(@CurrentUser() user: UserPayload | undefined, @Body() body: any) { + return this.usersService.updatePreferences(String(user?.id || 'anonymous'), body); + } +} diff --git a/src/users/users.module.ts b/src/modules/users/users.module.ts similarity index 63% rename from src/users/users.module.ts rename to src/modules/users/users.module.ts index f8d31fa..8cce447 100644 --- a/src/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { UsersRepository } from './users.repository'; @Module({ controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, UsersRepository], exports: [UsersService], }) -export class UsersModule {} \ No newline at end of file +export class UsersModule {} diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts new file mode 100644 index 0000000..8f4874a --- /dev/null +++ b/src/modules/users/users.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class UsersRepository { + private profiles: Map = new Map(); + private preferences: Map = new Map(); + + async findProfileByUserId(userId: string) { + return this.profiles.get(userId) || { + userId, + nickname: '学习者', + learningDirection: '', + bio: '', + }; + } + + async updateProfile(userId: string, dto: any) { + const existing = (await this.findProfileByUserId(userId)) || {}; + const updated = { ...existing, ...dto }; + this.profiles.set(userId, updated); + return updated; + } + + async updatePreferences(userId: string, dto: any) { + const existing = this.preferences.get(userId) || { + userId, + defaultFocusMinutes: 25, + aiSuggestionLevel: 'normal', + language: 'zh-CN', + appearance: 'system', + notificationEnabled: true, + }; + const updated = { ...existing, ...dto }; + this.preferences.set(userId, updated); + return updated; + } +} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..d4a8220 --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { UsersRepository } from './users.repository'; + +@Injectable() +export class UsersService { + constructor(private readonly usersRepository: UsersRepository) {} + + async getProfile(userId: string) { + return this.usersRepository.findProfileByUserId(userId); + } + + async updateProfile(userId: string, dto: any) { + return this.usersRepository.updateProfile(userId, dto); + } + + async updatePreferences(userId: string, dto: any) { + return this.usersRepository.updatePreferences(userId, dto); + } +} diff --git a/src/modules/waitlist/dto/create-waitlist.dto.ts b/src/modules/waitlist/dto/create-waitlist.dto.ts new file mode 100644 index 0000000..fc5eb9e --- /dev/null +++ b/src/modules/waitlist/dto/create-waitlist.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsBoolean, IsOptional, IsArray } from 'class-validator'; + +export class CreateWaitlistDto { + @ApiPropertyOptional({ description: '用户昵称' }) + @IsString() + @IsOptional() + nickname?: string; + + @ApiProperty({ description: '邮箱地址' }) + @IsString() + email: string; + + @ApiProperty({ description: '使用的设备' }) + @IsArray() + devices: string[]; + + @ApiProperty({ description: '感兴趣的学习方向' }) + @IsArray() + interests: string[]; + + @ApiPropertyOptional({ description: '当前最大的痛点' }) + @IsString() + @IsOptional() + painpoint?: string; + + @ApiProperty({ description: '是否愿意参加内测' }) + @IsBoolean() + willingBeta: boolean; +} diff --git a/src/modules/waitlist/waitlist.controller.ts b/src/modules/waitlist/waitlist.controller.ts new file mode 100644 index 0000000..ba633f4 --- /dev/null +++ b/src/modules/waitlist/waitlist.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Post, Body, Get, HttpCode, HttpStatus, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { WaitlistService } from './waitlist.service'; +import { CreateWaitlistDto } from './dto/create-waitlist.dto'; + +@ApiTags('waitlist') +@Controller('waitlist') +export class WaitlistController { + constructor(private readonly waitlistService: WaitlistService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '加入等待名单' }) + @ApiResponse({ status: 201, description: '成功加入' }) + async create(@Body(new ValidationPipe({ transform: true })) dto: CreateWaitlistDto) { + const entry = await this.waitlistService.create(dto); + return { success: true, message: '已成功加入等待名单', data: { id: entry.id, email: entry.email } }; + } + + @Get() + @ApiOperation({ summary: '获取等待名单' }) + async findAll() { + return this.waitlistService.findAll(); + } + + @Get('stats') + @ApiOperation({ summary: '获取报名统计' }) + async getStats() { + return this.waitlistService.getStats(); + } +} diff --git a/src/waitlist/waitlist.module.ts b/src/modules/waitlist/waitlist.module.ts similarity index 54% rename from src/waitlist/waitlist.module.ts rename to src/modules/waitlist/waitlist.module.ts index e99b1f1..62b3b41 100644 --- a/src/waitlist/waitlist.module.ts +++ b/src/modules/waitlist/waitlist.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { WaitlistController } from './waitlist.controller'; import { WaitlistService } from './waitlist.service'; +import { WaitlistRepository } from './waitlist.repository'; @Module({ controllers: [WaitlistController], - providers: [WaitlistService], + providers: [WaitlistService, WaitlistRepository], + exports: [WaitlistService], }) -export class WaitlistModule {} \ No newline at end of file +export class WaitlistModule {} diff --git a/src/modules/waitlist/waitlist.repository.ts b/src/modules/waitlist/waitlist.repository.ts new file mode 100644 index 0000000..e5cbd17 --- /dev/null +++ b/src/modules/waitlist/waitlist.repository.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; + +export interface WaitlistEntry { + id: string; + nickname: string; + email: string; + devices: string[]; + interests: string[]; + painpoint: string; + willingBeta: boolean; + createdAt: Date; +} + +@Injectable() +export class WaitlistRepository { + private entries: WaitlistEntry[] = []; + + async findAll(): Promise { + return this.entries; + } + + async create(entry: WaitlistEntry): Promise { + this.entries.push(entry); + return entry; + } +} diff --git a/src/modules/waitlist/waitlist.service.ts b/src/modules/waitlist/waitlist.service.ts new file mode 100644 index 0000000..c5490c7 --- /dev/null +++ b/src/modules/waitlist/waitlist.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { WaitlistRepository, WaitlistEntry } from './waitlist.repository'; +import { CreateWaitlistDto } from './dto/create-waitlist.dto'; + +@Injectable() +export class WaitlistService { + constructor(private readonly waitlistRepository: WaitlistRepository) {} + + async create(dto: CreateWaitlistDto): Promise { + const entries = await this.waitlistRepository.findAll(); + const existing = entries.find((e) => e.email === dto.email); + if (existing) throw new Error('该邮箱已报名'); + const entry: WaitlistEntry = { + id: `wl_${Date.now()}`, + nickname: dto.nickname || '', + email: dto.email, + devices: dto.devices || [], + interests: dto.interests || [], + painpoint: dto.painpoint || '', + willingBeta: dto.willingBeta || false, + createdAt: new Date(), + }; + await this.waitlistRepository.create(entry); + return entry; + } + + async findAll() { + return this.waitlistRepository.findAll(); + } + + async getStats() { + const entries = await this.waitlistRepository.findAll(); + return { + total: entries.length, + betaUsers: entries.filter((e) => e.willingBeta).length, + }; + } +} diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts deleted file mode 100644 index e215f1f..0000000 --- a/src/users/dto/create-user.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateUserDto { - @ApiPropertyOptional({ description: '用户昵称', example: '学习者' }) - nickname?: string; - - @ApiProperty({ description: '邮箱地址', example: 'user@example.com' }) - email: string; - - @ApiPropertyOptional({ description: '设备类型', example: 'iPhone', enum: ['iPhone', 'Android', 'iPad', 'Mac'] }) - device?: string; - - @ApiPropertyOptional({ description: '注册来源', example: 'waitlist' }) - source?: string; -} - -export class UserPreferences { - @ApiPropertyOptional({ description: '每日学习目标(分钟)', example: 30 }) - dailyGoal?: number; - - @ApiPropertyOptional({ description: '提醒时间 HH:mm 格式', example: '09:00' }) - reminderTime?: string; - - @ApiPropertyOptional({ description: '界面主题', example: 'auto', enum: ['light', 'dark', 'auto'] }) - theme?: 'light' | 'dark' | 'auto'; -} - -export class UpdateUserDto { - @ApiPropertyOptional({ description: '用户昵称', example: '新昵称' }) - nickname?: string; - - @ApiPropertyOptional({ description: '头像 URL' }) - avatar?: string; - - @ApiPropertyOptional({ description: '用户偏好设置' }) - preferences?: UserPreferences; -} \ No newline at end of file diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts deleted file mode 100644 index 1b7f03a..0000000 --- a/src/users/entities/user.entity.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class User { - @ApiProperty({ description: '用户 ID', example: 'user_123456_abc' }) - id: string; - - @ApiProperty({ description: '邮箱地址', example: 'user@example.com' }) - email: string; - - @ApiProperty({ description: '用户昵称', example: '学习者' }) - nickname: string; - - @ApiPropertyOptional({ description: '头像 URL' }) - avatar?: string; - - @ApiProperty({ description: '设备类型', example: 'iPhone' }) - device: string; - - @ApiProperty({ description: '注册来源', example: 'api' }) - source: string; - - @ApiProperty({ description: '用户偏好设置' }) - preferences: { - dailyGoal: number; - reminderTime: string; - theme: 'light' | 'dark' | 'auto'; - }; - - @ApiProperty({ description: '账户创建时间' }) - createdAt: Date; - - @ApiProperty({ description: '最后更新时间' }) - updatedAt: Date; -} - -export class UserProfile { - @ApiProperty({ description: '用户 ID', example: 'user_123456_abc' }) - id: string; - - @ApiProperty({ description: '邮箱地址', example: 'user@example.com' }) - email: string; - - @ApiProperty({ description: '用户昵称', example: '学习者' }) - nickname: string; - - @ApiPropertyOptional({ description: '头像 URL' }) - avatar?: string; - - @ApiProperty({ description: '设备类型', example: 'iPhone' }) - device: string; - - @ApiProperty({ description: '注册来源', example: 'api' }) - source: string; - - @ApiProperty({ description: '用户统计数据' }) - stats: { - totalLearningDays: number; - completedCourses: number; - totalMinutes: number; - }; - - @ApiProperty({ description: '账户创建时间' }) - createdAt: Date; -} \ No newline at end of file diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts deleted file mode 100644 index 2a96874..0000000 --- a/src/users/users.controller.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; -import { UsersService } from './users.service'; -import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; - -@ApiTags('users') -@Controller('users') -export class UsersController { - constructor(private readonly usersService: UsersService) {} - - @Post() - @ApiOperation({ summary: '创建用户', description: '在系统中注册一个新用户' }) - @ApiResponse({ status: 201, description: '用户创建成功' }) - @ApiResponse({ status: 400, description: '邮箱无效或缺少必填字段' }) - async create(@Body() createUserDto: CreateUserDto) { - const user = await this.usersService.create(createUserDto); - return { - success: true, - data: user, - }; - } - - @Get(':id') - @ApiOperation({ summary: '获取用户信息', description: '通过用户 ID 获取用户信息' }) - @ApiParam({ name: 'id', description: '用户 ID', example: 'user_123456_abc' }) - @ApiResponse({ status: 200, description: '找到用户' }) - @ApiResponse({ status: 404, description: '用户不存在' }) - async findOne(@Param('id') id: string) { - return this.usersService.findById(id); - } - - @Get(':id/profile') - @ApiOperation({ summary: '获取用户资料', description: '获取详细的用户资料,包含统计数据' }) - @ApiParam({ name: 'id', description: '用户 ID', example: 'user_123456_abc' }) - @ApiResponse({ status: 200, description: '获取用户资料成功' }) - @ApiResponse({ status: 404, description: '用户不存在' }) - async getProfile(@Param('id') id: string) { - return this.usersService.getProfile(id); - } - - @Patch(':id') - @ApiOperation({ summary: '更新用户信息', description: '更新用户信息和偏好设置' }) - @ApiParam({ name: 'id', description: '用户 ID', example: 'user_123456_abc' }) - @ApiResponse({ status: 200, description: '用户更新成功' }) - @ApiResponse({ status: 404, description: '用户不存在' }) - async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - const user = await this.usersService.update(id, updateUserDto); - return { - success: true, - data: user, - }; - } - - @Get() - @ApiOperation({ summary: '获取用户列表', description: '获取所有已注册用户列表' }) - @ApiResponse({ status: 200, description: '用户列表' }) - async list() { - return this.usersService.list(); - } -} \ No newline at end of file diff --git a/src/users/users.service.ts b/src/users/users.service.ts deleted file mode 100644 index 6899563..0000000 --- a/src/users/users.service.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CreateUserDto, UpdateUserDto } from './dto/create-user.dto'; -import { User, UserProfile } from './entities/user.entity'; - -@Injectable() -export class UsersService { - private users: Map = new Map(); - - async create(createUserDto: CreateUserDto): Promise { - const user: User = { - id: `user_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - email: createUserDto.email, - nickname: createUserDto.nickname || createUserDto.email.split('@')[0], - device: createUserDto.device || 'unknown', - source: createUserDto.source || 'api', - preferences: { - dailyGoal: 30, - reminderTime: '09:00', - theme: 'auto', - }, - createdAt: new Date(), - updatedAt: new Date(), - }; - - this.users.set(user.id, user); - console.log('[Users] New user created:', user.email); - return user; - } - - async findById(id: string): Promise { - return this.users.get(id); - } - - async findByEmail(email: string): Promise { - return Array.from(this.users.values()).find(u => u.email === email); - } - - async update(id: string, updateUserDto: UpdateUserDto): Promise { - const user = this.users.get(id); - if (!user) return undefined; - - const updated: User = { - ...user, - nickname: updateUserDto.nickname ?? user.nickname, - avatar: updateUserDto.avatar ?? user.avatar, - preferences: { - ...user.preferences, - ...updateUserDto.preferences, - }, - updatedAt: new Date(), - }; - - this.users.set(id, updated); - return updated; - } - - async getProfile(id: string): Promise { - const user = this.users.get(id); - if (!user) return undefined; - - return { - id: user.id, - email: user.email, - nickname: user.nickname, - avatar: user.avatar, - device: user.device, - source: user.source, - stats: { - totalLearningDays: Math.floor(Math.random() * 30), - completedCourses: Math.floor(Math.random() * 5), - totalMinutes: Math.floor(Math.random() * 500), - }, - createdAt: user.createdAt, - }; - } - - async list(): Promise { - return Array.from(this.users.values()); - } -} \ No newline at end of file diff --git a/src/waitlist/dto/create-waitlist.dto.ts b/src/waitlist/dto/create-waitlist.dto.ts deleted file mode 100644 index 804e438..0000000 --- a/src/waitlist/dto/create-waitlist.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateWaitlistDto { - @ApiPropertyOptional({ description: '用户昵称', example: '小明' }) - nickname?: string; - - @ApiProperty({ description: '邮箱地址', example: 'user@example.com' }) - email: string; - - @ApiProperty({ description: '使用的设备', example: ['iPhone', 'Mac'], enum: ['iPhone', 'Android', 'iPad', 'Mac'] }) - devices: string[]; - - @ApiProperty({ description: '感兴趣的学习方向', example: ['公考申论'], enum: ['公考申论', 'AI工具学习', '程序员面试', '其他'] }) - interests: string[]; - - @ApiPropertyOptional({ description: '当前最大的痛点', example: '做题没有反馈,不知道自己掌握程度如何' }) - painpoint?: string; - - @ApiProperty({ description: '是否愿意参加内测', example: true }) - willingBeta: boolean; -} \ No newline at end of file diff --git a/src/waitlist/waitlist.controller.ts b/src/waitlist/waitlist.controller.ts deleted file mode 100644 index 6e73c2b..0000000 --- a/src/waitlist/waitlist.controller.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Controller, Post, Body, Get, HttpCode, HttpStatus, ValidationPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { WaitlistService } from './waitlist.service'; -import { CreateWaitlistDto } from './dto/create-waitlist.dto'; - -@ApiTags('waitlist') -@Controller('waitlist') -export class WaitlistController { - constructor(private readonly waitlistService: WaitlistService) {} - - @Post() - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: '加入等待名单', description: '提交邮箱加入产品等待名单' }) - @ApiResponse({ status: 201, description: '成功加入等待名单' }) - @ApiResponse({ status: 400, description: '邮箱无效或缺少必填字段' }) - @ApiResponse({ status: 409, description: '该邮箱已报名' }) - async create(@Body(new ValidationPipe({ transform: true })) createWaitlistDto: CreateWaitlistDto) { - const entry = await this.waitlistService.create(createWaitlistDto); - return { - success: true, - message: '已成功加入等待名单', - data: { - id: entry.id, - email: entry.email, - createdAt: entry.createdAt, - }, - }; - } - - @Get() - @ApiOperation({ summary: '获取等待名单', description: '获取所有等待名单报名记录(管理员)' }) - @ApiResponse({ status: 200, description: '等待名单列表' }) - async findAll() { - return this.waitlistService.findAll(); - } - - @Get('stats') - @ApiOperation({ summary: '获取报名统计', description: '获取等待名单的聚合统计数据' }) - @ApiResponse({ status: 200, description: '报名统计数据,包含设备分布和兴趣方向' }) - async getStats() { - return this.waitlistService.getStats(); - } -} \ No newline at end of file diff --git a/src/waitlist/waitlist.service.ts b/src/waitlist/waitlist.service.ts deleted file mode 100644 index 075f5a9..0000000 --- a/src/waitlist/waitlist.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CreateWaitlistDto } from './dto/create-waitlist.dto'; - -export interface WaitlistEntry { - id: string; - nickname: string; - email: string; - devices: string[]; - interests: string[]; - painpoint: string; - willingBeta: boolean; - createdAt: Date; -} - -@Injectable() -export class WaitlistService { - private entries: WaitlistEntry[] = []; - - async create(createWaitlistDto: CreateWaitlistDto): Promise { - const entry: WaitlistEntry = { - id: `wl_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - nickname: createWaitlistDto.nickname || '', - email: createWaitlistDto.email, - devices: createWaitlistDto.devices || [], - interests: createWaitlistDto.interests || [], - painpoint: createWaitlistDto.painpoint || '', - willingBeta: createWaitlistDto.willingBeta || false, - createdAt: new Date(), - }; - - this.entries.push(entry); - console.log('[Waitlist] New entry added:', entry.email); - return entry; - } - - async findAll(): Promise { - return this.entries; - } - - async findByEmail(email: string): Promise { - return this.entries.find(entry => entry.email === email); - } - - async getStats() { - return { - total: this.entries.length, - betaUsers: this.entries.filter(e => e.willingBeta).length, - byDevice: this.getDeviceStats(), - byInterest: this.getInterestStats(), - }; - } - - private getDeviceStats() { - const stats: Record = {}; - this.entries.forEach(entry => { - entry.devices.forEach(device => { - stats[device] = (stats[device] || 0) + 1; - }); - }); - return stats; - } - - private getInterestStats() { - const stats: Record = {}; - this.entries.forEach(entry => { - entry.interests.forEach(interest => { - stats[interest] = (stats[interest] || 0) + 1; - }); - }); - return stats; - } -} \ No newline at end of file diff --git a/src/workers/ai-analysis.worker.ts b/src/workers/ai-analysis.worker.ts new file mode 100644 index 0000000..367edd0 --- /dev/null +++ b/src/workers/ai-analysis.worker.ts @@ -0,0 +1,5 @@ +console.log('[Worker] AI Analysis Worker started'); + +setInterval(() => { + console.log('[Worker] AI Analysis Worker is running...'); +}, 60000); diff --git a/src/workers/document-import.worker.ts b/src/workers/document-import.worker.ts new file mode 100644 index 0000000..f902f02 --- /dev/null +++ b/src/workers/document-import.worker.ts @@ -0,0 +1,5 @@ +console.log('[Worker] Document Import Worker started'); + +setInterval(() => { + console.log('[Worker] Document Import Worker is running...'); +}, 60000); diff --git a/src/workers/notification.worker.ts b/src/workers/notification.worker.ts new file mode 100644 index 0000000..8cf1450 --- /dev/null +++ b/src/workers/notification.worker.ts @@ -0,0 +1,5 @@ +console.log('[Worker] Notification Worker started'); + +setInterval(() => { + console.log('[Worker] Notification Worker is running...'); +}, 60000); diff --git a/test-crud.ts b/test-crud.ts new file mode 100644 index 0000000..53ce708 --- /dev/null +++ b/test-crud.ts @@ -0,0 +1,184 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('=== MySQL CRUD 测试 ===\n'); + + const user = await prisma.user.create({ + data: { + email: 'test-crud@zhixi.com', + nickname: 'CRUD测试用户', + status: 'active', + }, + }); + console.log(`✅ INSERT user: id=${user.id}, email=${user.email}, nickname=${user.nickname}`); + + const profile = await prisma.userProfile.create({ + data: { + userId: user.id, + learningIdentity: '知识工作者', + learningDirection: '产品设计', + }, + }); + console.log(`✅ INSERT profile: userId=${profile.userId}, direction=${profile.learningDirection}`); + + const kb = await prisma.knowledgeBase.create({ + data: { userId: user.id, title: '测试知识库', description: 'CRUD测试' }, + }); + console.log(`✅ INSERT knowledgeBase: id=${kb.id}, title=${kb.title}`); + + const item = await prisma.knowledgeItem.create({ + data: { + userId: user.id, + knowledgeBaseId: kb.id, + itemType: 'lesson', + title: '测试知识点-数据结构入门', + content: '## 数组与链表\n数组是连续内存...', + }, + }); + console.log(`✅ INSERT knowledgeItem: id=${item.id}, title=${item.title}`); + + const foundKB = await prisma.knowledgeBase.findUnique({ where: { id: kb.id } }); + console.log(`✅ SELECT knowledgeBase: ${foundKB?.title} (status=${foundKB?.status})`); + + const items = await prisma.knowledgeItem.findMany({ where: { knowledgeBaseId: kb.id } }); + console.log(`✅ SELECT knowledgeItems: ${items.length} 条`); + + const session = await prisma.learningSession.create({ + data: { + userId: user.id, + knowledgeBaseId: kb.id, + knowledgeItemId: item.id, + mode: 'reading', + status: 'active', + startedAt: new Date(), + }, + }); + console.log(`✅ INSERT learningSession: id=${session.id}, mode=${session.mode}`); + + const updated = await prisma.learningSession.update({ + where: { id: session.id }, + data: { status: 'completed', endedAt: new Date(), durationSeconds: 300 }, + }); + console.log(`✅ UPDATE learningSession: status=${updated.status}, duration=${updated.durationSeconds}s`); + + const job = await prisma.aiAnalysisJob.create({ + data: { + userId: user.id, + sessionId: session.id, + jobType: 'active_recall_analysis', + status: 'pending', + }, + }); + console.log(`✅ INSERT aiAnalysisJob: id=${job.id}, type=${job.jobType}`); + + await prisma.aiAnalysisJob.update({ + where: { id: job.id }, + data: { status: 'success', completedAt: new Date(), progress: 100 }, + }); + console.log(`✅ UPDATE aiAnalysisJob: success`); + + await prisma.aiAnalysisResult.create({ + data: { + userId: user.id, + jobId: job.id, + summary: '用户掌握了数组的基本概念', + masteryScore: 85, + strengths: JSON.stringify(['理解清晰', '举例恰当']), + weaknesses: JSON.stringify(['链表概念有混淆']), + }, + }); + console.log(`✅ INSERT aiAnalysisResult: score=85`); + + const focus = await prisma.focusItem.create({ + data: { + userId: user.id, + knowledgeItemId: item.id, + title: '链表与数组的区别需要巩固', + priority: 'high', + status: 'open', + }, + }); + console.log(`✅ INSERT focusItem: id=${focus.id}, title=${focus.title}`); + + await prisma.focusItem.update({ + where: { id: focus.id }, + data: { status: 'completed', completedAt: new Date() }, + }); + console.log(`✅ UPDATE focusItem: completed`); + + const card = await prisma.reviewCard.create({ + data: { + userId: user.id, + knowledgeItemId: item.id, + frontText: '数组和链表的主要区别是什么?', + backText: '数组是连续内存分配...', + nextReviewAt: new Date(Date.now() + 86400000), + }, + }); + console.log(`✅ INSERT reviewCard: id=${card.id}`); + + await prisma.reviewLog.create({ + data: { + userId: user.id, + reviewCardId: card.id, + rating: 'good', + reviewedAt: new Date(), + }, + }); + console.log(`✅ INSERT reviewLog: rating=good`); + + await prisma.feedback.create({ + data: { userId: user.id, category: 'feature', content: '希望支持Markdown导入', status: 'open' }, + }); + console.log(`✅ INSERT feedback`); + + await prisma.notification.create({ + data: { userId: user.id, type: 'ai_analysis_done', title: 'AI分析完成', content: '你的学习分析已生成' }, + }); + console.log(`✅ INSERT notification`); + + await prisma.dailyLearningActivity.create({ + data: { + userId: user.id, + activityDate: new Date(), + durationSeconds: 300, + sessionsCount: 1, + activityLevel: 2, + }, + }); + console.log(`✅ INSERT dailyLearningActivity`); + + // 关联查询 - KnowledgeBase + Items + const kbWithItems = await prisma.knowledgeBase.findUnique({ + where: { id: kb.id }, + include: { items: true }, + }); + console.log(`✅ RELATION: knowledgeBase '${kbWithItems?.title}' 包含 ${kbWithItems?.items.length} 个知识点`); + + // ----- 清理测试数据 ----- + await prisma.reviewLog.deleteMany({ where: { userId: user.id } }); + await prisma.reviewCard.deleteMany({ where: { userId: user.id } }); + await prisma.focusItem.deleteMany({ where: { userId: user.id } }); + await prisma.aiAnalysisResult.deleteMany({ where: { userId: user.id } }); + await prisma.aiAnalysisJob.deleteMany({ where: { userId: user.id } }); + await prisma.learningSession.deleteMany({ where: { userId: user.id } }); + await prisma.knowledgeItem.deleteMany({ where: { userId: user.id } }); + await prisma.knowledgeBase.deleteMany({ where: { userId: user.id } }); + await prisma.userProfile.deleteMany({ where: { userId: user.id } }); + await prisma.feedback.deleteMany({ where: { userId: user.id } }); + await prisma.notification.deleteMany({ where: { userId: user.id } }); + await prisma.dailyLearningActivity.deleteMany({ where: { userId: user.id } }); + await prisma.user.delete({ where: { id: user.id } }); + + console.log('\n✅ DELETE 清理完成 - 无残留数据'); + console.log('=== MySQL CRUD 全部通过 ==='); +} + +main() + .catch((e) => { + console.error('❌ FAILED:', e.message); + process.exit(1); + }) + .finally(() => prisma.$disconnect());