From 59514eab71c4f8d7be69d2cdff882c2c653a74bb Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 02:41:17 +0800 Subject: [PATCH 01/22] feat: initialize Node.js backend with Fastify, Kysely, and TypeScript, including essential configurations, health check, and demo documentation --- backend-nodejs/.eslintrc.json | 23 + backend-nodejs/.gitignore | 46 + backend-nodejs/.prettierrc.json | 10 + backend-nodejs/DEMO.md | 135 + backend-nodejs/README.md | 707 ++++ backend-nodejs/cmd/server/main.ts | 94 + backend-nodejs/env.example | 50 + .../infrastructure/bootstrap/server.ts | 107 + .../infrastructure/config/config.ts | 64 + .../monitoring/health/health.ts | 65 + .../persistence/postgres/connection.ts | 48 + .../persistence/postgres/database.ts | 36 + backend-nodejs/package.json | 54 + backend-nodejs/pnpm-lock.yaml | 3101 +++++++++++++++++ backend-nodejs/tsconfig.json | 40 + 15 files changed, 4580 insertions(+) create mode 100644 backend-nodejs/.eslintrc.json create mode 100644 backend-nodejs/.gitignore create mode 100644 backend-nodejs/.prettierrc.json create mode 100644 backend-nodejs/DEMO.md create mode 100644 backend-nodejs/README.md create mode 100644 backend-nodejs/cmd/server/main.ts create mode 100644 backend-nodejs/env.example create mode 100644 backend-nodejs/infrastructure/bootstrap/server.ts create mode 100644 backend-nodejs/infrastructure/config/config.ts create mode 100644 backend-nodejs/infrastructure/monitoring/health/health.ts create mode 100644 backend-nodejs/infrastructure/persistence/postgres/connection.ts create mode 100644 backend-nodejs/infrastructure/persistence/postgres/database.ts create mode 100644 backend-nodejs/package.json create mode 100644 backend-nodejs/pnpm-lock.yaml create mode 100644 backend-nodejs/tsconfig.json diff --git a/backend-nodejs/.eslintrc.json b/backend-nodejs/.eslintrc.json new file mode 100644 index 0000000..553315d --- /dev/null +++ b/backend-nodejs/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "env": { + "node": true, + "es2022": true + } +} + diff --git a/backend-nodejs/.gitignore b/backend-nodejs/.gitignore new file mode 100644 index 0000000..1b90663 --- /dev/null +++ b/backend-nodejs/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Testing +coverage/ +.nyc_output/ +test-results/ +playwright-report/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# TypeScript +*.tsbuildinfo + +# Misc +.cache/ +.temp/ +.tmp/ + diff --git a/backend-nodejs/.prettierrc.json b/backend-nodejs/.prettierrc.json new file mode 100644 index 0000000..b519b84 --- /dev/null +++ b/backend-nodejs/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "avoid" +} + diff --git a/backend-nodejs/DEMO.md b/backend-nodejs/DEMO.md new file mode 100644 index 0000000..0e0c771 --- /dev/null +++ b/backend-nodejs/DEMO.md @@ -0,0 +1,135 @@ +# 最简 Demo 使用指南 + +这是一个最简的可运行 demo,展示了如何启动 Node.js 后端服务器。 + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd backend-nodejs +pnpm install +``` + +### 2. 配置环境变量 + +```bash +# 复制环境变量示例文件 +cp env.example .env + +# 编辑 .env 文件,确保数据库配置正确 +# 数据库配置必须与 Go 后端一致(共享同一数据库) +``` + +### 3. 确保数据库已启动 + +```bash +# 从项目根目录 +cd ../docker +docker-compose up -d postgres redis + +# 确保数据库 Schema 已应用(参考 Go 后端的数据库设置) +``` + +### 4. 启动服务器 + +```bash +cd backend-nodejs +pnpm dev +``` + +服务器将在 `http://localhost:8081` 启动。 + +## 测试端点 + +### 健康检查 + +```bash +curl http://localhost:8081/health +``` + +**预期响应**: +```json +{ + "status": "healthy", + "service": "go-genai-stack-nodejs", + "version": "0.1.0", + "checks": { + "database": true, + "redis": true + }, + "timestamp": "2025-01-XX..." +} +``` + +### 根路径 + +```bash +curl http://localhost:8081/ +``` + +**预期响应**: +```json +{ + "service": "go-genai-stack-nodejs", + "version": "0.1.0", + "status": "running" +} +``` + +## 项目结构 + +``` +backend-nodejs/ +├── cmd/server/main.ts # 应用入口 +├── infrastructure/ +│ ├── config/config.ts # 配置管理 +│ ├── persistence/ +│ │ └── postgres/ +│ │ ├── database.ts # 数据库类型定义 +│ │ └── connection.ts # Kysely 连接 +│ ├── bootstrap/ +│ │ └── server.ts # Fastify 服务器初始化 +│ └── monitoring/ +│ └── health/ +│ └── health.ts # 健康检查 +└── package.json +``` + +## 核心功能 + +1. **配置管理**:从环境变量读取配置 +2. **数据库连接**:使用 Kysely 连接 PostgreSQL +3. **健康检查**:检查数据库和 Redis 状态 +4. **优雅关闭**:支持 SIGINT/SIGTERM 信号 + +## 下一步 + +- 添加业务领域(参考 Go 后端的 `domains/task`) +- 实现具体的 API 端点 +- 添加认证中间件 +- 集成 LangChain.js(可选) + +## 故障排查 + +### 数据库连接失败 + +``` +❌ Failed to connect to database: ... +``` + +**解决方案**: +1. 确保 PostgreSQL 正在运行:`docker-compose ps` +2. 检查环境变量配置是否正确 +3. 确保数据库 Schema 已应用(参考 Go 后端的数据库设置) + +### 端口被占用 + +``` +Error: listen EADDRINUSE: address already in use :::8081 +``` + +**解决方案**: +1. 修改 `.env` 文件中的 `PORT` 变量 +2. 或关闭占用 8081 端口的其他服务 + diff --git a/backend-nodejs/README.md b/backend-nodejs/README.md new file mode 100644 index 0000000..88c66e4 --- /dev/null +++ b/backend-nodejs/README.md @@ -0,0 +1,707 @@ +# Go-GenAI-Stack Backend (Node.js) + +> 🎯 **项目定位**:这是 Go-GenAI-Stack 的 **Node.js/TypeScript 实现版本**,采用 **Vibe-Coding-Friendly DDD** 架构。 +> +> 与 Go 后端共享相同的架构理念和数据库 Schema,使用 **Fastify + Kysely + TypeScript** 技术栈实现。 + +--- + +## 📋 目录 + +- [快速开始](#-快速开始) +- [技术选型](#-技术选型) +- [项目结构](#-项目结构) +- [架构设计](#-架构设计) +- [开发指南](#-开发指南) +- [与 Go 后端的关系](#-与-go-后端的关系) + +--- + +## 🚀 快速开始 + +### 前置要求 + +- **Node.js** 22.0+ +- **pnpm** 8.0+(推荐)或 npm/yarn +- **Docker & Docker Compose**(用于 PostgreSQL 和 Redis) +- **PostgreSQL 16+**(与 Go 后端共享同一数据库) + +### 一键启动 + +```bash +# 1. 安装依赖 +cd backend-nodejs +pnpm install + +# 2. 配置环境变量(参考 env.example) +cp env.example .env + +# 3. 确保数据库已启动(与 Go 后端共享) +cd ../docker +docker-compose up -d postgres redis + +# 4. 启动开发服务器 +pnpm dev +``` + +服务器将在 `http://localhost:8081` 启动(默认端口,避免与 Go 后端冲突)。 + +### 健康检查 + +```bash +curl http://localhost:8081/health +``` + +**预期输出**: +```json +{ + "status": "healthy", + "service": "go-genai-stack-nodejs", + "database": true, + "redis": true, + "version": "0.1.0" +} +``` + +--- + +## 🛠️ 技术选型 + +### 核心框架 + +| 模块 | 选型 | 说明 | +|------|------|------| +| **Web 框架** | [Fastify](https://www.fastify.io/) 5.x | 高性能、低开销,原生支持 HTTP/2、SSE | +| **数据库** | [Kysely](https://kysely.dev/) | 类型安全的 SQL 查询构建器(符合项目"不使用 ORM"的理念) | +| **语言** | TypeScript 5.0+ | 类型安全,与前端共享类型定义 | +| **运行时** | Node.js 22.0+ | 现代 Node.js 运行时 | + +### 基础设施 + +| 模块 | 选型 | 说明 | +|------|------|------| +| **缓存/状态** | Redis 7+ | BullMQ Backend、分析结果缓存、Agent 运行状态 | +| **主数据库** | PostgreSQL 16+ | 与 Go 后端共享同一数据库 Schema | +| **队列** | [BullMQ](https://bullmq.io/) | 异步任务调度、Agent 并发执行(可选) | +| **Agent 框架** | [LangChain.js](https://js.langchain.com/) | LLM 编排框架(可选,用于 AI 功能) | + +### 开发工具 + +| 工具 | 用途 | +|------|------| +| **TypeScript** | 类型检查 | +| **ESLint** | 代码检查 | +| **Prettier** | 代码格式化 | +| **Vitest** | 单元测试 | +| **tsx** | TypeScript 执行器(开发模式) | + +### 为什么选择这些技术? + +#### ✅ Fastify(Web 框架) + +- **高性能**:比 Express 快 2-3 倍,适合 API + Streaming +- **原生支持**:HTTP/2、SSE(Server-Sent Events) +- **TypeScript 友好**:完整的类型定义 +- **插件体系**:清晰的插件架构,易于扩展 + +#### ✅ Kysely(数据库查询构建器) + +- **类型安全**:编译时类型检查,避免 SQL 错误 +- **符合项目理念**:不使用 ORM,直接构建 SQL(类似 Go 后端的 `database/sql`) +- **透明性**:SQL 清晰可见,AI 易于理解 +- **性能**:无 ORM 开销,直接操作数据库 + +**示例**: +```typescript +// Kysely 查询(类型安全) +const task = await db + .selectFrom('tasks') + .selectAll() + .where('id', '=', taskId) + .executeTakeFirst(); + +// 生成的 SQL 清晰可见 +// SELECT * FROM tasks WHERE id = $1 +``` + +#### ✅ TypeScript + +- **类型安全**:编译时捕获错误 +- **前后端共享**:与前端共享类型定义 +- **AI 友好**:类型信息帮助 AI 理解代码结构 + +--- + +## 📁 项目结构 + +``` +backend-nodejs/ +├── cmd/ +│ └── server/ +│ └── main.ts # 应用入口 +│ +├── domains/ # 【领域层】DDD 领域 +│ ├── task/ # Task 领域(示例实现)★ +│ │ ├── README.md # 领域说明 +│ │ ├── glossary.md # 术语表 +│ │ ├── rules.md # 业务规则 +│ │ ├── events.md # 领域事件 +│ │ ├── usecases.yaml # 用例声明(AI 可读)★ +│ │ ├── ai-metadata.json # AI 元数据 +│ │ │ +│ │ ├── model/ # 领域模型 +│ │ │ └── task.ts +│ │ │ +│ │ ├── repository/ # 仓储(接口 + 实现)★ +│ │ │ ├── interface.ts +│ │ │ └── task_repo.ts # 使用 Kysely +│ │ │ +│ │ ├── service/ # 【领域服务层】★ 核心 +│ │ │ └── task_service.ts # 业务逻辑实现 +│ │ │ +│ │ ├── handlers/ # 【HTTP 适配层】 +│ │ │ ├── dependencies.ts # Handler 依赖容器 +│ │ │ ├── create_task.handler.ts +│ │ │ ├── update_task.handler.ts +│ │ │ └── ... +│ │ │ +│ │ ├── http/ # HTTP 层 +│ │ │ ├── dto/ # DTO(与 Go 后端共享 Schema) +│ │ │ │ └── task.ts +│ │ │ └── router.ts # 路由注册 +│ │ │ +│ │ └── tests/ # 测试 +│ │ +│ ├── auth/ # Auth 领域 +│ ├── user/ # User 领域 +│ └── shared/ # 共享组件 +│ ├── events/ # 事件总线 +│ └── types/ # 共享类型 +│ +├── infrastructure/ # 【基础设施层】 +│ ├── bootstrap/ # 启动引导 +│ │ ├── server.ts # 服务器初始化 +│ │ ├── database.ts # 数据库连接(Kysely) +│ │ ├── redis.ts # Redis 连接 +│ │ ├── dependencies.ts # 依赖注入 +│ │ └── routes.ts # 路由注册 +│ │ +│ ├── config/ # 配置管理 +│ │ ├── config.ts +│ │ └── loader.ts +│ │ +│ ├── middleware/ # 中间件 +│ │ ├── auth.ts # 认证 +│ │ ├── cors.ts # CORS +│ │ ├── error_handler.ts # 错误处理 +│ │ ├── logger.ts # 日志 +│ │ ├── ratelimit.ts # 限流 +│ │ └── tracing.ts # 追踪 +│ │ +│ ├── persistence/ # 持久化层 +│ │ ├── postgres/ +│ │ │ ├── connection.ts # Kysely 连接 +│ │ │ ├── database.ts # 数据库类型定义 +│ │ │ └── transaction.ts # 事务管理 +│ │ └── redis/ +│ │ ├── connection.ts +│ │ └── cache.ts +│ │ +│ └── monitoring/ # 可观测性 +│ ├── health/ # 健康检查 +│ ├── logger/ # 结构化日志 +│ ├── metrics/ # Prometheus 指标 +│ └── tracing/ # OpenTelemetry 追踪 +│ +├── shared/ # 【共享代码】 +│ └── errors/ # 错误定义 +│ └── errors.ts +│ +├── scripts/ # 开发脚本 +│ ├── dev.sh # 开发模式启动 +│ ├── test.sh # 运行测试 +│ └── lint.sh # 代码检查 +│ +├── package.json +├── tsconfig.json +├── env.example +├── .gitignore +├── .eslintrc.json +├── .prettierrc.json +└── README.md +``` + +--- + +## 🏗️ 架构设计 + +### 核心原则 + +与 Go 后端保持一致: + +1. **领域优先**:按业务领域垂直切分(Domain-First) +2. **三层架构**:Handler(薄)→ Service(厚)→ Repository(数据访问) +3. **自包含**:每个领域包含完整的实现 +4. **显式知识**:6 个必需文件让业务规则可被 AI 理解 +5. **声明式用例**:在 `usecases.yaml` 中声明用例 +6. **类型安全**:使用 Kysely 构建类型安全的 SQL 查询 + +### 三层架构 + +```typescript +// Handler 层(薄):仅 HTTP 适配 +export async function createTaskHandler( + request: FastifyRequest<{ Body: CreateTaskRequest }>, + reply: FastifyReply +) { + const input: CreateTaskInput = { + title: request.body.title, + description: request.body.description, + priority: request.body.priority, + }; + + const output = await taskService.createTask(request.server.db, input); + + return reply.code(200).send(output); +} + +// Service 层(厚):业务逻辑 ⭐ +export class TaskService { + async createTask( + db: Database, + input: CreateTaskInput + ): Promise { + // 1. 验证业务规则 + if (!input.title || input.title.trim().length === 0) { + throw new Error('TASK_TITLE_EMPTY: task title cannot be empty'); + } + + // 2. 创建领域对象 + const task = Task.create({ + title: input.title, + description: input.description, + priority: input.priority || 'medium', + }); + + // 3. 持久化 + await taskRepository.create(db, task); + + // 4. 发布事件(可选) + // await eventBus.publish(TaskCreatedEvent.from(task)); + + return { task }; + } +} + +// Repository 层:数据访问(Kysely) +export class TaskRepository { + async create(db: Database, task: Task): Promise { + await db + .insertInto('tasks') + .values({ + id: task.id, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + created_at: task.createdAt, + updated_at: task.updatedAt, + }) + .execute(); + } +} +``` + +### Kysely 类型安全查询 + +Kysely 通过数据库 Schema 生成类型定义,确保类型安全: + +```typescript +// infrastructure/persistence/postgres/database.ts +// 从数据库 Schema 生成类型(手动定义或使用工具生成) +export interface Database { + tasks: { + id: string; + title: string; + description: string | null; + status: 'pending' | 'in_progress' | 'completed'; + priority: 'low' | 'medium' | 'high'; + created_at: Date; + updated_at: Date; + }; + users: { + id: string; + email: string; + // ... + }; +} + +// Repository 使用类型安全的查询 +const task = await db + .selectFrom('tasks') + .selectAll() + .where('id', '=', taskId) + .where('status', '=', 'pending') // TypeScript 会检查 'pending' 是否有效 + .executeTakeFirst(); +``` + +--- + +## 📚 开发指南 + +### 添加新用例 + +与 Go 后端流程一致: + +1. **在 `usecases.yaml` 中定义用例** + ```yaml + ArchiveTask: + description: "归档已完成的任务" + http: + method: POST + path: /api/tasks/:id/archive + input: + task_id: + type: string + required: true + steps: + - ValidateInput + - ArchiveTask + - SaveTask + ``` + +2. **在 `http/dto/` 中定义 DTO** + ```typescript + export interface ArchiveTaskRequest { + task_id: string; + } + ``` + +3. **在 `service/` 中实现业务逻辑** + ```typescript + async archiveTask(db: Database, input: ArchiveTaskInput): Promise { + // 业务逻辑 + } + ``` + +4. **在 `handlers/` 中实现 Handler** + ```typescript + export async function archiveTaskHandler( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ) { + // HTTP 适配 + } + ``` + +5. **在 `http/router.ts` 中注册路由** + ```typescript + fastify.post('/api/tasks/:id/archive', archiveTaskHandler); + ``` + +### 数据库操作(Kysely) + +#### 基本查询 + +```typescript +// 查询单条记录 +const task = await db + .selectFrom('tasks') + .selectAll() + .where('id', '=', taskId) + .executeTakeFirst(); + +// 查询多条记录(带分页) +const tasks = await db + .selectFrom('tasks') + .selectAll() + .where('status', '=', 'pending') + .orderBy('created_at', 'desc') + .limit(limit) + .offset(offset) + .execute(); + +// 插入记录 +await db + .insertInto('tasks') + .values({ + id: task.id, + title: task.title, + status: task.status, + created_at: new Date(), + updated_at: new Date(), + }) + .execute(); + +// 更新记录 +await db + .updateTable('tasks') + .set({ + status: 'completed', + updated_at: new Date(), + }) + .where('id', '=', taskId) + .execute(); + +// 删除记录 +await db + .deleteFrom('tasks') + .where('id', '=', taskId) + .execute(); +``` + +#### 事务处理 + +```typescript +await db.transaction().execute(async (trx) => { + // 在事务中执行多个操作 + await trx + .insertInto('tasks') + .values({ ... }) + .execute(); + + await trx + .updateTable('users') + .set({ ... }) + .where('id', '=', userId) + .execute(); + + // 如果抛出错误,自动回滚 +}); +``` + +### 环境变量配置 + +创建 `.env` 文件(参考 `env.example`): + +```bash +# 服务器配置 +NODE_ENV=development +PORT=8081 + +# 数据库配置(与 Go 后端共享) +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USER=genai +DATABASE_PASSWORD=genai_password +DATABASE_NAME=go_genai_stack +DATABASE_SSL_MODE=disable + +# Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT 配置(可选) +JWT_SECRET=your-secret-key +JWT_EXPIRES_IN=7d +``` + +### 测试 + +```bash +# 运行所有测试 +pnpm test + +# 运行特定领域的测试 +pnpm test domains/task + +# 带覆盖率 +pnpm test:coverage + +# Watch 模式 +pnpm test:watch +``` + +--- + +## 🔗 与 Go 后端的关系 + +### 共享资源 + +| 资源 | 说明 | +|------|------| +| **数据库 Schema** | 共享 `backend/database/schema.sql` | +| **数据库实例** | 共享同一 PostgreSQL 实例 | +| **领域定义** | 共享 `domains/*/usecases.yaml`、`README.md` 等显式知识文件 | +| **API 规范** | 共享相同的 HTTP API 端点(可选择性实现) | + +### 技术栈对比 + +| 模块 | Go 后端 | Node.js 后端 | +|------|---------|--------------| +| **Web 框架** | Hertz | Fastify | +| **数据库** | database/sql | Kysely | +| **语言** | Go | TypeScript | +| **类型系统** | 编译时检查 | 编译时检查(TypeScript) | + +### 使用场景 + +**何时使用 Go 后端?** +- 需要极致性能的场景 +- 需要与 Go 生态深度集成 +- 团队熟悉 Go 语言 + +**何时使用 Node.js 后端?** +- 需要与前端共享类型定义 +- 需要快速集成 LangChain.js 等 Node.js 生态工具 +- 团队熟悉 TypeScript/Node.js +- 需要 Streaming 输出(SSE) + +### 混合部署 + +两个后端可以**同时运行**,共享同一数据库: + +```bash +# 启动 Go 后端(端口 8080) +cd backend +go run cmd/server/main.go + +# 启动 Node.js 后端(端口 8081) +cd backend-nodejs +pnpm dev +``` + +**注意事项**: +- 两个后端共享数据库,需要确保数据一致性 +- API 端点可以不同(如 Go: `/api/v1/tasks`,Node.js: `/api/v2/tasks`) +- 建议使用 API Gateway 统一路由 + +--- + +## 🎯 技术选型详细说明 + +### Fastify(Web 框架) + +**选型原因**: +- ✅ **高性能**:比 Express 快 2-3 倍,适合 API + Streaming +- ✅ **原生支持**:HTTP/2、SSE(Server-Sent Events) +- ✅ **TypeScript 友好**:完整的类型定义 +- ✅ **插件体系**:清晰的插件架构,易于扩展 + +**典型职责**: +- 用户 API(查询分析结果) +- Streaming 分析输出(SSE) + +**注意事项**: +- Fastify 只做**薄控制层**,不要在其中跑 Agent +- Streaming 要与 Redis / Queue 解耦 + +### Kysely(数据库查询构建器) + +**选型原因**: +- ✅ **类型安全**:编译时类型检查,避免 SQL 错误 +- ✅ **符合项目理念**:不使用 ORM,直接构建 SQL(类似 Go 后端的 `database/sql`) +- ✅ **透明性**:SQL 清晰可见,AI 易于理解 +- ✅ **性能**:无 ORM 开销,直接操作数据库 + +**与 Go 后端对比**: + +| Go 后端 | Node.js 后端 | +|---------|--------------| +| `database/sql` + 原生 SQL | `Kysely` + 类型安全 SQL 构建器 | +| 手写 SQL 字符串 | 链式 API 构建 SQL | +| 运行时检查 | 编译时类型检查 | + +### LangChain.js(Agent 框架,可选) + +**选型原因**: +- ✅ 与现有 TS 技术栈完全一致 +- ✅ 工具(Tool)、Memory、Agent 抽象成熟 +- ✅ 社区与文档丰富,MVP 成本最低 + +**使用策略**: +- 初期使用 **Runnable + Tool Agent** +- 不提前引入 LangGraph +- Agent 本身保持**无状态** + +**注意事项**: +- Agent 状态不要放内存 +- 所有中间状态要么进 Redis,要么可丢 +- Prompt / Tool 版本要显式标记 + +### BullMQ(队列,可选) + +**选型原因**: +- ✅ Node.js 生态最成熟的队列方案 +- ✅ 与 Fastify / LangChain 配合自然 +- ✅ 支持 Retry、Backoff、并发控制 + +**使用场景**: +- 股票分析任务调度 +- 定时分析(cron-like) +- Agent 并发执行 + +**注意事项**: +- Job 数据只放**引用 ID**,不要放大对象 +- 必须实现幂等(symbol + period + version) +- Worker 与 API 进程分离 + +### Redis(缓存/状态) + +**角色定位**:Redis **不是数据库,而是系统组件**。 + +**用途**: +- BullMQ Backend +- 分析结果缓存(TTL) +- Agent 运行状态 +- Streaming 消息中转 + +**注意事项**: +- 所有 Redis 数据都要允许丢失 +- Key 命名要命名空间化 +- 设置合理 TTL + +--- + +## 📊 可扩展性与演进路径 + +### Agent 框架演进 + +- **LangChain → LangGraph**(有状态、多步) +- 成本:**低(复用 Tool / Prompt)** + +### 数据层演进 + +- 引入向量数据库(RAG) +- 引入 OLAP(历史回测) + +### 核心选型总结 + +| 模块 | 选型 | 状态 | +|------|------|------| +| Web | Fastify | ✅ 已选型 | +| Agent | LangChain.js | 🔜 可选 | +| Queue | BullMQ | 🔜 可选 | +| Cache / State | Redis | ✅ 已选型 | +| 主数据库 | PostgreSQL | ✅ 已选型(与 Go 后端共享) | +| 数据库查询 | Kysely | ✅ 已选型 | + +--- + +## 🚀 下一步 + +1. **✅ 运行项目**:按照上面的"快速开始"步骤启动服务 +2. **📖 阅读领域文档**:理解完整的领域实现(参考 Go 后端的 `domains/task/README.md`) +3. **🧪 测试 API**:使用 curl 或 Postman 测试 API +4. **🎨 创建自己的领域**:基于 Task 模板创建你的业务领域 +5. **🔌 集成扩展**:根据需要添加认证、事件总线、追踪等功能 + +--- + +## 📚 相关文档 + +### 架构文档 +- [架构概览](../docs/Core/architecture-overview.md) +- [Vibe-Coding-Friendly 理念](../docs/Core/vibe-coding-friendly.md) + +### 开发指南 +- [Go 后端 README](../backend/README.md) - 参考 Go 后端的实现 +- [数据库管理](../docs/Guides/database.md) - 共享数据库 Schema + +--- + +**Happy Coding!** 🚀 + +有任何问题欢迎提 Issue 或查看文档。 + diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts new file mode 100644 index 0000000..76cddc5 --- /dev/null +++ b/backend-nodejs/cmd/server/main.ts @@ -0,0 +1,94 @@ +/** + * 应用入口 + * 初始化配置、数据库、服务器并启动服务 + */ + +import { loadConfig } from '../../infrastructure/config/config.js'; +import { createDatabaseConnection } from '../../infrastructure/persistence/postgres/connection.js'; +import { + createServer, + registerMiddleware, + registerRoutes, +} from '../../infrastructure/bootstrap/server.js'; + +async function main() { + console.log('\n🚀 Starting Go-GenAI-Stack Backend (Node.js)...\n'); + + // 1. 加载配置 + console.log('📋 Loading configuration...'); + const config = loadConfig(); + console.log('✅ Configuration loaded:'); + console.log(` Environment: ${config.server.env}`); + console.log(` Server: ${config.server.host}:${config.server.port}`); + console.log( + ` Database: ${config.database.user}@${config.database.host}:${config.database.port}/${config.database.database}` + ); + + // 2. 初始化数据库连接 + console.log('\n🗄️ Connecting to database...'); + let db: ReturnType; + try { + db = createDatabaseConnection(config.database); + // 测试连接 + // await db.selectFrom('users').select('id').limit(1).execute(); + console.log('✅ Database connected'); + } catch (error) { + console.error('❌ Failed to connect to database:', error); + console.error(' Make sure PostgreSQL is running and schema is applied'); + process.exit(1); + } + + // 3. 创建 Fastify 服务器 + console.log('\n🚀 Creating HTTP server...'); + const fastify = createServer(config); + + // 4. 注册中间件 + console.log('📦 Registering middleware...'); + await registerMiddleware(fastify); + + // 5. 注册路由 + console.log('🛣️ Registering routes...'); + registerRoutes(fastify, db); + + // 6. 启动服务器 + const address = `http://${config.server.host}:${config.server.port}`; + try { + await fastify.listen({ + host: config.server.host, + port: config.server.port, + }); + + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`🚀 Server started on ${address}`); + console.log(`📚 API Base: ${address}/api`); + console.log(`💚 Health Check: ${address}/health`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + } catch (error) { + console.error('❌ Failed to start server:', error); + process.exit(1); + } + + // 7. 优雅关闭 + const shutdown = async (signal: string) => { + console.log(`\n🛑 Received ${signal}, shutting down gracefully...`); + try { + await fastify.close(); + await db.destroy(); + console.log('✅ Server exited'); + process.exit(0); + } catch (error) { + console.error('❌ Error during shutdown:', error); + process.exit(1); + } + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +// 启动应用 +main().catch((error) => { + console.error('❌ Fatal error:', error); + process.exit(1); +}); + diff --git a/backend-nodejs/env.example b/backend-nodejs/env.example new file mode 100644 index 0000000..ccd6c9e --- /dev/null +++ b/backend-nodejs/env.example @@ -0,0 +1,50 @@ +# ==================== 服务器配置 ==================== +NODE_ENV=development +PORT=8081 + +# ==================== 数据库配置(与 Go 后端共享)==================== +# ⚠️ 必须使用与 Go 后端相同的数据库配置 +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USER=genai +DATABASE_PASSWORD=genai_password +DATABASE_NAME=go_genai_stack +DATABASE_SSL_MODE=disable + +# 连接池配置 +DATABASE_MAX_CONNECTIONS=25 +DATABASE_IDLE_TIMEOUT=10000 +DATABASE_CONNECTION_TIMEOUT=2000 + +# ==================== Redis 配置 ==================== +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# ==================== JWT 配置(可选)==================== +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRES_IN=7d + +# ==================== 日志配置 ==================== +LOG_LEVEL=info +LOG_FORMAT=json + +# ==================== 可观测性配置(可选)==================== +# Prometheus Metrics +METRICS_ENABLED=true +METRICS_PORT=9091 + +# OpenTelemetry Tracing +TRACING_ENABLED=false +TRACING_ENDPOINT=http://localhost:4318 + +# ==================== Agent 配置(可选,用于 LangChain.js)==================== +# OPENAI_API_KEY=your-openai-api-key +# ANTHROPIC_API_KEY=your-anthropic-api-key + +# ==================== 队列配置(可选,用于 BullMQ)==================== +# QUEUE_REDIS_HOST=localhost +# QUEUE_REDIS_PORT=6379 +# QUEUE_REDIS_PASSWORD= + diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts new file mode 100644 index 0000000..ba997b6 --- /dev/null +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -0,0 +1,107 @@ +/** + * 服务器初始化模块 + * 创建 Fastify 服务器实例并配置基础设置 + */ + +import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyReply } from 'fastify'; +import cors from '@fastify/cors'; +import type { Config } from '../config/config.js'; +import type { Kysely } from 'kysely'; +import type { Database } from '../persistence/postgres/database.js'; +import { checkHealth } from '../monitoring/health/health.js'; + +/** + * 创建 Fastify 服务器 + */ +export function createServer(config: Config): FastifyInstance { + const fastify = Fastify({ + logger: { + level: config.server.env === 'production' ? 'info' : 'debug', + transport: + config.server.env === 'development' + ? { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + } + : undefined, + }, + }); + + return fastify; +} + +/** + * 注册全局中间件 + */ +export async function registerMiddleware(fastify: FastifyInstance): Promise { + // CORS + await fastify.register(cors, { + origin: true, + }); + + // Security headers (手动实现,因为 @fastify/helmet 不支持 Fastify 5) + fastify.addHook('onRequest', async (_request: FastifyRequest, reply: FastifyReply) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('X-Frame-Options', 'DENY'); + reply.header('X-XSS-Protection', '1; mode=block'); + reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + }); + + // Error handling (手动实现,因为 @fastify/sensible 不支持 Fastify 5) + fastify.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { + const statusCode = error.statusCode || 500; + const message = error.message || 'Internal Server Error'; + + request.log.error({ + err: error, + url: request.url, + method: request.method, + }, 'Request error'); + + reply.code(statusCode).send({ + error: { + message, + statusCode, + }, + }); + }); + + // Not found handler + fastify.setNotFoundHandler((request, reply) => { + reply.code(404).send({ + error: { + message: 'Route not found', + statusCode: 404, + path: request.url, + }, + }); + }); +} + +/** + * 注册路由 + */ +export function registerRoutes( + fastify: FastifyInstance, + db: Kysely +): void { + // 健康检查端点 + fastify.get('/health', async (_request: unknown, reply) => { + const health = await checkHealth(db); + const statusCode = health.status === 'healthy' ? 200 : 503; + return reply.code(statusCode).send(health); + }); + + // 根路径 + fastify.get('/', async (_request: unknown, reply) => { + return reply.send({ + service: 'go-genai-stack-nodejs', + version: '0.1.0', + status: 'running', + }); + }); +} + diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts new file mode 100644 index 0000000..f7768d5 --- /dev/null +++ b/backend-nodejs/infrastructure/config/config.ts @@ -0,0 +1,64 @@ +/** + * 配置管理模块 + * 从环境变量读取配置,提供类型安全的配置对象 + */ + +export interface Config { + server: { + host: string; + port: number; + env: string; + }; + database: { + host: string; + port: number; + user: string; + password: string; + database: string; + sslMode: string; + maxConnections: number; + idleTimeout: number; + connectionTimeout: number; + }; + redis: { + host: string; + port: number; + password: string; + db: number; + }; +} + +/** + * 加载配置 + * 从环境变量读取配置,提供默认值 + */ +export function loadConfig(): Config { + return { + server: { + host: process.env.SERVER_HOST || '0.0.0.0', + port: parseInt(process.env.PORT || '8081', 10), + env: process.env.NODE_ENV || 'development', + }, + database: { + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5435', 10), + user: process.env.DATABASE_USER || 'postgres', + password: process.env.DATABASE_PASSWORD || 'postgres', + database: process.env.DATABASE_NAME || 'go_genai_stack_backend_debug', + sslMode: process.env.DATABASE_SSL_MODE || 'disable', + maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || '25', 10), + idleTimeout: parseInt(process.env.DATABASE_IDLE_TIMEOUT || '10000', 10), + connectionTimeout: parseInt( + process.env.DATABASE_CONNECTION_TIMEOUT || '2000', + 10 + ), + }, + 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), + }, + }; +} + diff --git a/backend-nodejs/infrastructure/monitoring/health/health.ts b/backend-nodejs/infrastructure/monitoring/health/health.ts new file mode 100644 index 0000000..e9b0cf9 --- /dev/null +++ b/backend-nodejs/infrastructure/monitoring/health/health.ts @@ -0,0 +1,65 @@ +/** + * 健康检查模块 + * 检查数据库、Redis 等服务的健康状态 + */ + +import type { Kysely } from 'kysely'; +import type { Database } from '../../persistence/postgres/database.js'; + +export interface HealthStatus { + status: 'healthy' | 'unhealthy'; + service: string; + version: string; + checks: { + database: boolean; + redis: boolean; + }; + timestamp: string; +} + +/** + * 检查数据库健康状态 + */ +async function checkDatabase(db: Kysely): Promise { + try { + await db.selectFrom('users').select('id').limit(1).execute(); + return true; + } catch (error) { + console.error('Database health check failed:', error); + return false; + } +} + +/** + * 检查 Redis 健康状态(简化版,暂时返回 true) + */ +async function checkRedis(): Promise { + // TODO: 实现 Redis 健康检查 + return true; +} + +/** + * 执行健康检查 + */ +export async function checkHealth( + db: Kysely +): Promise { + const [databaseOk, redisOk] = await Promise.all([ + checkDatabase(db), + checkRedis(), + ]); + + const allOk = databaseOk && redisOk; + + return { + status: allOk ? 'healthy' : 'unhealthy', + service: 'go-genai-stack-nodejs', + version: '0.1.0', + checks: { + database: databaseOk, + redis: redisOk, + }, + timestamp: new Date().toISOString(), + }; +} + diff --git a/backend-nodejs/infrastructure/persistence/postgres/connection.ts b/backend-nodejs/infrastructure/persistence/postgres/connection.ts new file mode 100644 index 0000000..9e04906 --- /dev/null +++ b/backend-nodejs/infrastructure/persistence/postgres/connection.ts @@ -0,0 +1,48 @@ +/** + * PostgreSQL 连接管理 + * 使用 Kysely 创建类型安全的数据库连接 + */ + +import { Kysely, PostgresDialect } from 'kysely'; +import { Pool } from 'pg'; +import type { Database } from './database.js'; +import type { Config } from '../../config/config.js'; + +/** + * 创建数据库连接 + */ +export function createDatabaseConnection(config: Config['database']): Kysely { + const pool = new Pool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + ssl: config.sslMode === 'require' ? { rejectUnauthorized: false } : false, + max: config.maxConnections, + idleTimeoutMillis: config.idleTimeout, + connectionTimeoutMillis: config.connectionTimeout, + }); + + const dialect = new PostgresDialect({ + pool, + }); + + return new Kysely({ + dialect, + }); +} + +/** + * 测试数据库连接 + */ +export async function testConnection(db: Kysely): Promise { + try { + await db.selectFrom('users').select('id').limit(1).execute(); + return true; + } catch (error) { + console.error('Database connection test failed:', error); + return false; + } +} + diff --git a/backend-nodejs/infrastructure/persistence/postgres/database.ts b/backend-nodejs/infrastructure/persistence/postgres/database.ts new file mode 100644 index 0000000..4b797ea --- /dev/null +++ b/backend-nodejs/infrastructure/persistence/postgres/database.ts @@ -0,0 +1,36 @@ +/** + * 数据库类型定义 + * 根据 backend/database/schema.sql 定义的类型 + * + * 注意:这是手动定义的类型,未来可以使用工具从 Schema 自动生成 + */ + +export interface Database { + users: { + id: string; + email: string; + username: string | null; + password_hash: string; + full_name: string | null; + avatar_url: string | null; + status: 'active' | 'inactive' | 'banned'; + email_verified: boolean; + created_at: Date; + updated_at: Date; + last_login_at: Date | null; + }; + tasks: { + id: string; + user_id: string | null; + title: string; + description: string | null; + status: 'pending' | 'in_progress' | 'completed'; + priority: 'low' | 'medium' | 'high'; + due_date: Date | null; + tags: string[] | null; + created_at: Date; + updated_at: Date; + completed_at: Date | null; + }; +} + diff --git a/backend-nodejs/package.json b/backend-nodejs/package.json new file mode 100644 index 0000000..360ba29 --- /dev/null +++ b/backend-nodejs/package.json @@ -0,0 +1,54 @@ +{ + "name": "go-genai-stack-backend-nodejs", + "version": "0.1.0", + "description": "Go-GenAI-Stack Backend (Node.js/TypeScript implementation)", + "type": "module", + "main": "dist/cmd/server/main.js", + "scripts": { + "dev": "tsx watch cmd/server/main.ts", + "build": "tsc", + "start": "node dist/cmd/server/main.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "format": "prettier --write \"**/*.{ts,json,md}\"", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "fastify", + "kysely", + "typescript", + "ddd", + "vibe-coding-friendly" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@fastify/cors": "^10.0.1", + "@fastify/rate-limit": "^9.1.0", + "fastify": "^5.0.0", + "kysely": "^0.27.2", + "pg": "^8.11.3", + "redis": "^4.6.13", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/pg": "^8.10.9", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "pino-pretty": "^11.2.2", + "prettier": "^3.2.5", + "tsx": "^4.7.1", + "typescript": "^5.5.4", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=8.0.0" + } +} + diff --git a/backend-nodejs/pnpm-lock.yaml b/backend-nodejs/pnpm-lock.yaml new file mode 100644 index 0000000..a344074 --- /dev/null +++ b/backend-nodejs/pnpm-lock.yaml @@ -0,0 +1,3101 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^10.0.1 + version: 10.1.0 + '@fastify/rate-limit': + specifier: ^9.1.0 + version: 9.1.0 + fastify: + specifier: ^5.0.0 + version: 5.6.2 + kysely: + specifier: ^0.27.2 + version: 0.27.6 + pg: + specifier: ^8.11.3 + version: 8.16.3 + redis: + specifier: ^4.6.13 + version: 4.7.1 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.5.0 + version: 22.19.3 + '@types/pg': + specifier: ^8.10.9 + version: 8.16.0 + '@typescript-eslint/eslint-plugin': + specifier: ^7.13.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^7.13.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + pino-pretty: + specifier: ^11.2.2 + version: 11.3.0 + prettier: + specifier: ^3.2.5 + version: 3.7.4 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@22.19.3) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@10.1.0': + resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/rate-limit@9.1.0': + resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@rollup/rollup-android-arm-eabi@4.53.5': + resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.5': + resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.5': + resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.5': + resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.5': + resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.5': + resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.5': + resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.5': + resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.5': + resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.5': + resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.5': + resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.5': + resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.5': + resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.5': + resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.5': + resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.5': + resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.5': + resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.5': + resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.1.1: + resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.6.2: + resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + mnemonist@0.40.0: + resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-pretty@11.3.0: + resolution: {integrity: sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==} + hasBin: true + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@10.1.0: + resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.53.5: + resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@10.1.0': + dependencies: + fastify-plugin: 5.1.0 + mnemonist: 0.40.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/rate-limit@9.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 4.5.1 + toad-cache: 3.7.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@lukeed/ms@2.0.2': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pinojs/redact@0.4.0': {} + + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@rollup/rollup-android-arm-eabi@4.53.5': + optional: true + + '@rollup/rollup-android-arm64@4.53.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.5': + optional: true + + '@rollup/rollup-darwin-x64@4.53.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.5': + optional: true + + '@sinclair/typebox@0.27.8': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.16.0': + dependencies: + '@types/node': 22.19.3 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + abstract-logging@2.0.1: {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@1.1.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + cluster-key-slot@1.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + diff-sequences@29.6.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escape-string-regexp@4.0.0: {} + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fast-copy@3.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fastify-plugin@4.5.1: {} + + fastify-plugin@5.1.0: {} + + fastify@5.6.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.1.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 10.1.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + generic-pool@3.9.0: {} + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + help-me@5.0.0: {} + + human-signals@5.0.0: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ipaddr.js@2.3.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kysely@0.27.6: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-fn@4.0.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + + mnemonist@0.40.0: + dependencies: + obliterator: 2.0.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + obliterator@2.0.5: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@11.3.0: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pump: 3.0.3 + readable-stream: 4.7.0 + secure-json-parse: 2.7.0 + sonic-boom: 4.2.0 + strip-json-comments: 3.1.1 + + pino-std-serializers@7.0.0: {} + + pino@10.1.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + prettier@3.7.4: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + process@0.11.10: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + react-is@18.3.1: {} + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + real-require@0.2.0: {} + + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.53.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.5 + '@rollup/rollup-android-arm64': 4.53.5 + '@rollup/rollup-darwin-arm64': 4.53.5 + '@rollup/rollup-darwin-x64': 4.53.5 + '@rollup/rollup-freebsd-arm64': 4.53.5 + '@rollup/rollup-freebsd-x64': 4.53.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 + '@rollup/rollup-linux-arm-musleabihf': 4.53.5 + '@rollup/rollup-linux-arm64-gnu': 4.53.5 + '@rollup/rollup-linux-arm64-musl': 4.53.5 + '@rollup/rollup-linux-loong64-gnu': 4.53.5 + '@rollup/rollup-linux-ppc64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-musl': 4.53.5 + '@rollup/rollup-linux-s390x-gnu': 4.53.5 + '@rollup/rollup-linux-x64-gnu': 4.53.5 + '@rollup/rollup-linux-x64-musl': 4.53.5 + '@rollup/rollup-openharmony-arm64': 4.53.5 + '@rollup/rollup-win32-arm64-msvc': 4.53.5 + '@rollup/rollup-win32-ia32-msvc': 4.53.5 + '@rollup/rollup-win32-x64-gnu': 4.53.5 + '@rollup/rollup-win32-x64-msvc': 4.53.5 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@2.7.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.3: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-final-newline@3.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + text-table@0.2.0: {} + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.0: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.1.0: {} + + type-fest@0.20.2: {} + + typescript@5.9.3: {} + + ufo@1.6.1: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@1.6.1(@types/node@22.19.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@22.19.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.5 + optionalDependencies: + '@types/node': 22.19.3 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@22.19.3): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@22.19.3) + vite-node: 1.6.1(@types/node@22.19.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.3 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xtend@4.0.2: {} + + yallist@4.0.0: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} + + zod@3.25.76: {} diff --git a/backend-nodejs/tsconfig.json b/backend-nodejs/tsconfig.json new file mode 100644 index 0000000..ef02d56 --- /dev/null +++ b/backend-nodejs/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "types": ["node"], + "moduleResolution": "node", + "resolveJsonModule": true, + "allowJs": false, + "checkJs": false, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + }, + "allowSyntheticDefaultImports": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts" + ] +} + From e2e0b3cd9661591528e66b13edb5d292e38d6b8f Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 11:16:31 +0800 Subject: [PATCH 02/22] feat: integrate Redis support in Node.js backend with health check and Docker environment setup for improved caching and performance --- backend-nodejs/.eslintrc.json | 2 +- backend-nodejs/cmd/server/main.ts | 45 ++++++-- backend-nodejs/env.example | 4 +- .../infrastructure/bootstrap/server.ts | 6 +- .../monitoring/health/health.ts | 30 ++++-- .../persistence/redis/connection.ts | 69 ++++++++++++ backend-nodejs/package.json | 1 + backend-nodejs/pnpm-lock.yaml | 9 ++ docker/backend-nodejs-debug/README.md | 101 ++++++++++++++++++ .../backend-nodejs-debug/docker-compose.yml | 68 ++++++++++++ docker/backend-nodejs-debug/seed-data.sql | 101 ++++++++++++++++++ docker/backend-nodejs-debug/start.sh | 84 +++++++++++++++ docker/backend-nodejs-debug/stop.sh | 24 +++++ 13 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 backend-nodejs/infrastructure/persistence/redis/connection.ts create mode 100644 docker/backend-nodejs-debug/README.md create mode 100644 docker/backend-nodejs-debug/docker-compose.yml create mode 100644 docker/backend-nodejs-debug/seed-data.sql create mode 100755 docker/backend-nodejs-debug/start.sh create mode 100755 docker/backend-nodejs-debug/stop.sh diff --git a/backend-nodejs/.eslintrc.json b/backend-nodejs/.eslintrc.json index 553315d..8520be6 100644 --- a/backend-nodejs/.eslintrc.json +++ b/backend-nodejs/.eslintrc.json @@ -13,7 +13,7 @@ "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "warn", - "no-console": ["warn", { "allow": ["warn", "error"] }] + "no-console": "off" }, "env": { "node": true, diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index 76cddc5..889cf27 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -3,18 +3,26 @@ * 初始化配置、数据库、服务器并启动服务 */ +import 'dotenv/config'; import { loadConfig } from '../../infrastructure/config/config.js'; import { createDatabaseConnection } from '../../infrastructure/persistence/postgres/connection.js'; +import { + createRedisConnection, + connectRedis, + testRedisConnection, + closeRedisConnection, +} from '../../infrastructure/persistence/redis/connection.js'; import { createServer, registerMiddleware, registerRoutes, } from '../../infrastructure/bootstrap/server.js'; +import type { RedisClientType } from 'redis'; async function main() { console.log('\n🚀 Starting Go-GenAI-Stack Backend (Node.js)...\n'); - // 1. 加载配置 + // 1. 加载配置(.env 文件已通过 dotenv/config 自动加载) console.log('📋 Loading configuration...'); const config = loadConfig(); console.log('✅ Configuration loaded:'); @@ -38,19 +46,39 @@ async function main() { process.exit(1); } - // 3. 创建 Fastify 服务器 + // 3. 初始化 Redis 连接 + console.log('\n🔴 Connecting to Redis...'); + let redis: RedisClientType | null = null; + try { + redis = createRedisConnection(config.redis); + await connectRedis(redis); + const redisOk = await testRedisConnection(redis); + if (redisOk) { + console.log('✅ Redis connected'); + } else { + console.warn('⚠️ Redis connection test failed (continuing without cache)'); + await closeRedisConnection(redis); + redis = null; + } + } catch (error) { + console.warn('⚠️ Redis connection failed:', error); + console.warn(' Continuing without cache'); + redis = null; + } + + // 4. 创建 Fastify 服务器 console.log('\n🚀 Creating HTTP server...'); const fastify = createServer(config); - // 4. 注册中间件 + // 5. 注册中间件 console.log('📦 Registering middleware...'); await registerMiddleware(fastify); - // 5. 注册路由 + // 6. 注册路由 console.log('🛣️ Registering routes...'); - registerRoutes(fastify, db); + registerRoutes(fastify, db, redis); - // 6. 启动服务器 + // 7. 启动服务器 const address = `http://${config.server.host}:${config.server.port}`; try { await fastify.listen({ @@ -68,12 +96,15 @@ async function main() { process.exit(1); } - // 7. 优雅关闭 + // 8. 优雅关闭 const shutdown = async (signal: string) => { console.log(`\n🛑 Received ${signal}, shutting down gracefully...`); try { await fastify.close(); await db.destroy(); + if (redis) { + await closeRedisConnection(redis); + } console.log('✅ Server exited'); process.exit(0); } catch (error) { diff --git a/backend-nodejs/env.example b/backend-nodejs/env.example index ccd6c9e..690dfd4 100644 --- a/backend-nodejs/env.example +++ b/backend-nodejs/env.example @@ -5,7 +5,7 @@ PORT=8081 # ==================== 数据库配置(与 Go 后端共享)==================== # ⚠️ 必须使用与 Go 后端相同的数据库配置 DATABASE_HOST=localhost -DATABASE_PORT=5432 +DATABASE_PORT=5436 DATABASE_USER=genai DATABASE_PASSWORD=genai_password DATABASE_NAME=go_genai_stack @@ -18,7 +18,7 @@ DATABASE_CONNECTION_TIMEOUT=2000 # ==================== Redis 配置 ==================== REDIS_HOST=localhost -REDIS_PORT=6379 +REDIS_PORT=6380 REDIS_PASSWORD= REDIS_DB=0 diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index ba997b6..0504bf7 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -8,6 +8,7 @@ import cors from '@fastify/cors'; import type { Config } from '../config/config.js'; import type { Kysely } from 'kysely'; import type { Database } from '../persistence/postgres/database.js'; +import type { RedisClientType } from 'redis'; import { checkHealth } from '../monitoring/health/health.js'; /** @@ -86,11 +87,12 @@ export async function registerMiddleware(fastify: FastifyInstance): Promise + db: Kysely, + redis: RedisClientType | null = null ): void { // 健康检查端点 fastify.get('/health', async (_request: unknown, reply) => { - const health = await checkHealth(db); + const health = await checkHealth(db, redis); const statusCode = health.status === 'healthy' ? 200 : 503; return reply.code(statusCode).send(health); }); diff --git a/backend-nodejs/infrastructure/monitoring/health/health.ts b/backend-nodejs/infrastructure/monitoring/health/health.ts index e9b0cf9..8631967 100644 --- a/backend-nodejs/infrastructure/monitoring/health/health.ts +++ b/backend-nodejs/infrastructure/monitoring/health/health.ts @@ -5,6 +5,7 @@ import type { Kysely } from 'kysely'; import type { Database } from '../../persistence/postgres/database.js'; +import type { RedisClientType } from 'redis'; export interface HealthStatus { status: 'healthy' | 'unhealthy'; @@ -22,7 +23,13 @@ export interface HealthStatus { */ async function checkDatabase(db: Kysely): Promise { try { - await db.selectFrom('users').select('id').limit(1).execute(); + // 尝试查询一个简单的表(如果 users 表不存在,尝试 tasks 表) + try { + await db.selectFrom('users').select('id').limit(1).execute(); + } catch { + // 如果 users 表不存在,尝试 tasks 表 + await db.selectFrom('tasks').select('id').limit(1).execute(); + } return true; } catch (error) { console.error('Database health check failed:', error); @@ -31,22 +38,31 @@ async function checkDatabase(db: Kysely): Promise { } /** - * 检查 Redis 健康状态(简化版,暂时返回 true) + * 检查 Redis 健康状态 */ -async function checkRedis(): Promise { - // TODO: 实现 Redis 健康检查 - return true; +async function checkRedis(redis: RedisClientType | null): Promise { + if (!redis) { + return false; + } + try { + await redis.ping(); + return true; + } catch (error) { + console.error('Redis health check failed:', error); + return false; + } } /** * 执行健康检查 */ export async function checkHealth( - db: Kysely + db: Kysely, + redis: RedisClientType | null = null ): Promise { const [databaseOk, redisOk] = await Promise.all([ checkDatabase(db), - checkRedis(), + checkRedis(redis), ]); const allOk = databaseOk && redisOk; diff --git a/backend-nodejs/infrastructure/persistence/redis/connection.ts b/backend-nodejs/infrastructure/persistence/redis/connection.ts new file mode 100644 index 0000000..baad276 --- /dev/null +++ b/backend-nodejs/infrastructure/persistence/redis/connection.ts @@ -0,0 +1,69 @@ +/** + * Redis 连接管理 + * 使用 redis 客户端创建连接 + */ + +import { createClient } from 'redis'; +import type { RedisClientType } from 'redis'; +import type { Config } from '../../config/config.js'; + +/** + * 创建 Redis 连接 + */ +export function createRedisConnection( + config: Config['redis'] +): RedisClientType { + const client = createClient({ + socket: { + host: config.host, + port: config.port, + }, + password: config.password || undefined, + database: config.db, + }); + + // 错误处理 + client.on('error', (err) => { + console.error('Redis Client Error:', err); + }); + + return client as RedisClientType; +} + +/** + * 连接 Redis + */ +export async function connectRedis( + client: RedisClientType +): Promise { + await client.connect(); +} + +/** + * 测试 Redis 连接 + */ +export async function testRedisConnection( + client: RedisClientType +): Promise { + try { + await client.ping(); + return true; + } catch (error) { + console.error('Redis connection test failed:', error); + return false; + } +} + +/** + * 关闭 Redis 连接 + */ +export async function closeRedisConnection( + client: RedisClientType +): Promise { + try { + await client.quit(); + } catch (error) { + console.error('Error closing Redis connection:', error); + } +} + diff --git a/backend-nodejs/package.json b/backend-nodejs/package.json index 360ba29..0472038 100644 --- a/backend-nodejs/package.json +++ b/backend-nodejs/package.json @@ -28,6 +28,7 @@ "dependencies": { "@fastify/cors": "^10.0.1", "@fastify/rate-limit": "^9.1.0", + "dotenv": "^16.4.5", "fastify": "^5.0.0", "kysely": "^0.27.2", "pg": "^8.11.3", diff --git a/backend-nodejs/pnpm-lock.yaml b/backend-nodejs/pnpm-lock.yaml index a344074..132ca6c 100644 --- a/backend-nodejs/pnpm-lock.yaml +++ b/backend-nodejs/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fastify/rate-limit': specifier: ^9.1.0 version: 9.1.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 fastify: specifier: ^5.0.0 version: 5.6.2 @@ -830,6 +833,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2240,6 +2247,8 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@16.6.1: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 diff --git a/docker/backend-nodejs-debug/README.md b/docker/backend-nodejs-debug/README.md new file mode 100644 index 0000000..c2bea91 --- /dev/null +++ b/docker/backend-nodejs-debug/README.md @@ -0,0 +1,101 @@ +# Node.js 后端调试环境 + +为 Node.js 后端开发提供独立的数据库和 Redis 环境。 + +## 🎯 用途 + +- Node.js 后端开发调试 +- 独立的数据库和 Redis 环境(不影响其他环境) +- 自动初始化 Schema 和测试数据 + +## 🚀 快速开始 + +### 启动环境 + +```bash +cd docker/backend-nodejs-debug +./start.sh +``` + +### 停止环境 + +```bash +./stop.sh +``` + +## 📋 服务信息 + +| 服务 | 端口 | 说明 | +|------|------|------| +| PostgreSQL | 5436 | 开发数据库 | +| Redis | 6380 | 开发缓存 | + +## 🔧 配置 + +### 数据库配置 + +- **Host**: `localhost` +- **Port**: `5436` +- **Database**: `go_genai_stack` +- **User**: `genai` +- **Password**: `genai_password` + +### Redis 配置 + +- **Host**: `localhost` +- **Port**: `6380` +- **Password**: 无 +- **DB**: `0` + +## 📝 在 Node.js 后端中使用 + +更新 `backend-nodejs/.env` 文件: + +```bash +# 数据库配置 +DATABASE_HOST=localhost +DATABASE_PORT=5436 +DATABASE_USER=genai +DATABASE_PASSWORD=genai_password +DATABASE_NAME=go_genai_stack +DATABASE_SSL_MODE=disable + +# Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6380 +REDIS_PASSWORD= +REDIS_DB=0 +``` + +## 🧪 测试数据 + +环境启动时会自动加载测试数据: + +- **测试用户**: `nodejs-debug@example.com` / `Nodejs123456!` +- **测试任务**: 3 个示例任务 + +## 📊 健康检查 + +```bash +# 检查服务状态 +docker compose ps + +# 检查数据库 +docker exec go-genai-stack-postgres-backend-nodejs-debug psql -U genai -d go_genai_stack -c "SELECT COUNT(*) FROM users;" + +# 检查 Redis +docker exec go-genai-stack-redis-backend-nodejs-debug redis-cli ping +``` + +## 🗑️ 清理数据 + +```bash +# 停止并删除数据卷(⚠️ 会删除所有数据) +docker compose down -v +``` + +## 📚 相关文档 + +- [Docker 环境总览](../README.md) +- [Node.js 后端 README](../../backend-nodejs/README.md) + diff --git a/docker/backend-nodejs-debug/docker-compose.yml b/docker/backend-nodejs-debug/docker-compose.yml new file mode 100644 index 0000000..da13437 --- /dev/null +++ b/docker/backend-nodejs-debug/docker-compose.yml @@ -0,0 +1,68 @@ +# ============================================ +# Node.js 后端调试环境(数据库 + Redis) +# ============================================ +# 用途:为 Node.js 后端开发提供独立的数据库和 Redis 环境 +# 使用:cd docker/backend-nodejs-debug && docker compose up -d +# 说明:后端代码在本地 IDE 中运行和调试 +# ============================================ + +services: + # PostgreSQL 开发数据库 + postgres-backend-nodejs-debug: + image: postgres:16-alpine + container_name: go-genai-stack-postgres-backend-nodejs-debug + environment: + POSTGRES_USER: genai + POSTGRES_PASSWORD: genai_password + POSTGRES_DB: go_genai_stack + ports: + - "5436:5432" # 使用不同端口避免与其他环境冲突 + volumes: + # Schema(统一管理,直接引用源文件) + - ../../backend/database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + # 测试数据(环境独立) + - ./seed-data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro + # 数据持久化 + - postgres-backend-nodejs-debug-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U genai -d go_genai_stack"] + interval: 3s + timeout: 3s + retries: 10 + start_period: 5s + networks: + - backend-nodejs-debug-network + + # Redis 开发缓存 + redis-backend-nodejs-debug: + image: redis:7-alpine + container_name: go-genai-stack-redis-backend-nodejs-debug + ports: + - "6380:6379" # 使用不同端口避免与其他环境冲突 + volumes: + # 数据持久化 + - redis-backend-nodejs-debug-data:/data + # 开发环境不需要密码,使用默认配置 + command: redis-server --appendonly yes --protected-mode no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 10 + start_period: 5s + networks: + - backend-nodejs-debug-network + +volumes: + postgres-backend-nodejs-debug-data: + driver: local + name: go-genai-stack-postgres-backend-nodejs-debug-data + redis-backend-nodejs-debug-data: + driver: local + name: go-genai-stack-redis-backend-nodejs-debug-data + +networks: + backend-nodejs-debug-network: + driver: bridge + name: go-genai-stack-backend-nodejs-debug-network + diff --git a/docker/backend-nodejs-debug/seed-data.sql b/docker/backend-nodejs-debug/seed-data.sql new file mode 100644 index 0000000..646e6e4 --- /dev/null +++ b/docker/backend-nodejs-debug/seed-data.sql @@ -0,0 +1,101 @@ +-- Node.js 后端调试环境测试数据 +-- 用途:为 Node.js 后端开发环境提供测试数据 +-- 说明:Schema 由 backend/database/schema.sql 统一管理 +-- +-- 依赖:需要先执行 schema.sql + +-- ============================================ +-- 确保扩展已启用 +-- ============================================ +-- pgcrypto 提供 gen_random_uuid() 函数 +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- ============================================ +-- 插入测试数据 +-- ============================================ + +-- Node.js 后端调试测试用户 +-- Email: nodejs-debug@example.com +-- Password: Nodejs123456! +-- Hash: bcrypt hash of "Nodejs123456!" (cost 10) +INSERT INTO users ( + id, + email, + username, + password_hash, + full_name, + status, + created_at, + updated_at, + last_login_at +) VALUES ( + gen_random_uuid(), + 'nodejs-debug@example.com', + 'nodejs_debug_user', + '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', -- Backend123456! + 'Node.js Debug User', + 'active', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL +) ON CONFLICT (email) DO NOTHING; + +-- 插入一些测试任务(关联到上面的用户) +-- 注意:需要先获取用户 ID +DO $$ +DECLARE + test_user_id UUID; +BEGIN + -- 获取测试用户 ID + SELECT id INTO test_user_id FROM users WHERE email = 'nodejs-debug@example.com'; + + IF test_user_id IS NOT NULL THEN + -- 插入测试任务 + INSERT INTO tasks ( + id, + user_id, + title, + description, + status, + priority, + due_date, + created_at, + updated_at + ) VALUES + ( + gen_random_uuid(), + test_user_id, + 'Node.js 后端开发任务', + '完成 Node.js 后端的 Redis 集成', + 'in_progress', + 'high', + CURRENT_TIMESTAMP + INTERVAL '7 days', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + gen_random_uuid(), + test_user_id, + '测试健康检查端点', + '验证 /health 端点是否正确检查 Redis 状态', + 'pending', + 'medium', + CURRENT_TIMESTAMP + INTERVAL '3 days', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + gen_random_uuid(), + test_user_id, + '完成 Docker 环境配置', + '创建 backend-nodejs-debug 环境', + 'completed', + 'high', + CURRENT_TIMESTAMP - INTERVAL '1 day', + CURRENT_TIMESTAMP - INTERVAL '2 days', + CURRENT_TIMESTAMP - INTERVAL '1 day' + ) + ON CONFLICT DO NOTHING; + END IF; +END $$; + diff --git a/docker/backend-nodejs-debug/start.sh b/docker/backend-nodejs-debug/start.sh new file mode 100755 index 0000000..f06ff1f --- /dev/null +++ b/docker/backend-nodejs-debug/start.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# ============================================ +# Node.js 后端调试环境启动脚本 +# ============================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🚀 Starting Node.js Backend Debug Environment..." +echo "" + +# 检查 Docker 和 Docker Compose +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed. Please install Docker first." + exit 1 +fi + +if ! docker compose version &> /dev/null && ! docker-compose version &> /dev/null; then + echo "❌ Docker Compose is not installed. Please install Docker Compose first." + exit 1 +fi + +# 启动服务 +echo "📦 Starting services..." +docker compose up -d + +# 等待服务健康 +echo "" +echo "⏳ Waiting for services to be healthy..." +timeout=60 +elapsed=0 + +while [ $elapsed -lt $timeout ]; do + if docker compose ps | grep -q "healthy"; then + echo "✅ All services are healthy!" + break + fi + sleep 2 + elapsed=$((elapsed + 2)) + echo -n "." +done + +echo "" +echo "" + +# 显示服务状态 +echo "📊 Service Status:" +docker compose ps + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✅ Node.js Backend Debug Environment is ready!" +echo "" +echo "📋 Service Information:" +echo " PostgreSQL: localhost:5436" +echo " Redis: localhost:6380" +echo "" +echo "🔗 Connection Details:" +echo " Database: go_genai_stack" +echo " User: genai" +echo " Password: genai_password" +echo "" +echo "💡 Next Steps:" +echo " 1. Update your .env file in backend-nodejs:" +echo " DATABASE_HOST=localhost" +echo " DATABASE_PORT=5436" +echo " DATABASE_USER=genai" +echo " DATABASE_PASSWORD=genai_password" +echo " DATABASE_NAME=go_genai_stack" +echo " REDIS_HOST=localhost" +echo " REDIS_PORT=6380" +echo "" +echo " 2. Start your Node.js backend:" +echo " cd backend-nodejs && pnpm dev" +echo "" +echo " 3. Check health:" +echo " curl http://localhost:8081/health" +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + diff --git a/docker/backend-nodejs-debug/stop.sh b/docker/backend-nodejs-debug/stop.sh new file mode 100755 index 0000000..1963bb3 --- /dev/null +++ b/docker/backend-nodejs-debug/stop.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# ============================================ +# Node.js 后端调试环境停止脚本 +# ============================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🛑 Stopping Node.js Backend Debug Environment..." +echo "" + +# 停止服务 +docker compose down + +echo "" +echo "✅ Node.js Backend Debug Environment stopped." +echo "" +echo "💡 Note: Data volumes are preserved." +echo " To remove volumes, run: docker compose down -v" +echo "" + From b7c87b463a5958f8b700844791b49107197c9387 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 11:41:27 +0800 Subject: [PATCH 03/22] feat: implement task domain with complete CRUD operations, including handlers, services, and repository integration for task management in Node.js backend --- backend-nodejs/cmd/server/main.ts | 22 +- backend-nodejs/domains/task/README.md | 172 +++++ backend-nodejs/domains/task/ai-metadata.json | 258 ++++++++ backend-nodejs/domains/task/errors/errors.ts | 69 ++ backend-nodejs/domains/task/events.md | 461 +++++++++++++ backend-nodejs/domains/task/glossary.md | 332 ++++++++++ .../task/handlers/complete_task.handler.ts | 48 ++ .../domains/task/handlers/converters.ts | 262 ++++++++ .../task/handlers/create_task.handler.ts | 64 ++ .../task/handlers/delete_task.handler.ts | 45 ++ .../domains/task/handlers/dependencies.ts | 11 + .../domains/task/handlers/get_task.handler.ts | 45 ++ .../task/handlers/list_tasks.handler.ts | 50 ++ .../task/handlers/update_task.handler.ts | 52 ++ backend-nodejs/domains/task/http/dto/task.ts | 124 ++++ backend-nodejs/domains/task/http/router.ts | 53 ++ backend-nodejs/domains/task/model/task.ts | 208 ++++++ .../domains/task/repository/interface.ts | 61 ++ .../domains/task/repository/task_repo.ts | 261 ++++++++ backend-nodejs/domains/task/rules.md | 469 ++++++++++++++ .../domains/task/service/task_service.ts | 287 +++++++++ backend-nodejs/domains/task/usecases.yaml | 605 ++++++++++++++++++ .../infrastructure/bootstrap/server.ts | 13 + .../persistence/postgres/database.ts | 6 +- 24 files changed, 3974 insertions(+), 4 deletions(-) create mode 100644 backend-nodejs/domains/task/README.md create mode 100644 backend-nodejs/domains/task/ai-metadata.json create mode 100644 backend-nodejs/domains/task/errors/errors.ts create mode 100644 backend-nodejs/domains/task/events.md create mode 100644 backend-nodejs/domains/task/glossary.md create mode 100644 backend-nodejs/domains/task/handlers/complete_task.handler.ts create mode 100644 backend-nodejs/domains/task/handlers/converters.ts create mode 100644 backend-nodejs/domains/task/handlers/create_task.handler.ts create mode 100644 backend-nodejs/domains/task/handlers/delete_task.handler.ts create mode 100644 backend-nodejs/domains/task/handlers/dependencies.ts create mode 100644 backend-nodejs/domains/task/handlers/get_task.handler.ts create mode 100644 backend-nodejs/domains/task/handlers/list_tasks.handler.ts create mode 100644 backend-nodejs/domains/task/handlers/update_task.handler.ts create mode 100644 backend-nodejs/domains/task/http/dto/task.ts create mode 100644 backend-nodejs/domains/task/http/router.ts create mode 100644 backend-nodejs/domains/task/model/task.ts create mode 100644 backend-nodejs/domains/task/repository/interface.ts create mode 100644 backend-nodejs/domains/task/repository/task_repo.ts create mode 100644 backend-nodejs/domains/task/rules.md create mode 100644 backend-nodejs/domains/task/service/task_service.ts create mode 100644 backend-nodejs/domains/task/usecases.yaml diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index 889cf27..967ba17 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -16,8 +16,12 @@ import { createServer, registerMiddleware, registerRoutes, + registerDomainRoutes, } from '../../infrastructure/bootstrap/server.js'; import type { RedisClientType } from 'redis'; +import { TaskRepositoryImpl } from '../../domains/task/repository/task_repo.js'; +import { TaskService } from '../../domains/task/service/task_service.js'; +import type { HandlerDependencies } from '../../domains/task/handlers/dependencies.js'; async function main() { console.log('\n🚀 Starting Go-GenAI-Stack Backend (Node.js)...\n'); @@ -74,11 +78,23 @@ async function main() { console.log('📦 Registering middleware...'); await registerMiddleware(fastify); - // 6. 注册路由 + // 6. 注册基础路由 console.log('🛣️ Registering routes...'); registerRoutes(fastify, db, redis); - // 7. 启动服务器 + // 7. 初始化领域服务 + console.log('🏗️ Initializing domain services...'); + const taskRepo = new TaskRepositoryImpl(db); + const taskService = new TaskService(taskRepo); + const handlerDeps: HandlerDependencies = { + taskService, + }; + + // 8. 注册领域路由 + console.log('📚 Registering domain routes...'); + await registerDomainRoutes(fastify, handlerDeps); + + // 9. 启动服务器 const address = `http://${config.server.host}:${config.server.port}`; try { await fastify.listen({ @@ -96,7 +112,7 @@ async function main() { process.exit(1); } - // 8. 优雅关闭 + // 10. 优雅关闭 const shutdown = async (signal: string) => { console.log(`\n🛑 Received ${signal}, shutting down gracefully...`); try { diff --git a/backend-nodejs/domains/task/README.md b/backend-nodejs/domains/task/README.md new file mode 100644 index 0000000..c168b76 --- /dev/null +++ b/backend-nodejs/domains/task/README.md @@ -0,0 +1,172 @@ +# Task Domain (任务领域) + +## 概述 + +任务领域负责处理任务(Todo/Task)的创建、管理和状态变更。这是一个经典的 CRUD 场景,展示了 Vibe-Coding-Friendly DDD 架构的最佳实践。 + +## 领域边界 + +### 职责范围 + +- ✅ 管理任务(Task)的生命周期 +- ✅ 处理任务状态变更(待办 → 进行中 → 已完成) +- ✅ 支持任务分类和优先级 +- ✅ 提供任务查询和筛选 +- ✅ 管理任务标签 + +### 不包含的职责 + +- ❌ 用户认证和授权(属于 User Domain,未实现) +- ❌ 通知和提醒(属于 Notification Domain,未实现) +- ❌ 团队协作和权限(属于 Team Domain,未实现) +- ❌ 数据分析和报表(属于 Analytics Domain,未实现) + +## 核心概念 + +参考 `glossary.md` 了解领域术语。 + +## 用例列表 + +参考 `usecases.yaml` 查看所有用例的声明式定义。 + +主要用例: +1. **CreateTask** - 创建任务 +2. **UpdateTask** - 更新任务 +3. **CompleteTask** - 完成任务 +4. **DeleteTask** - 删除任务 +5. **ListTasks** - 列出任务(支持筛选、排序、分页) +6. **GetTask** - 获取任务详情 + +## 聚合根和实体 + +### Task(任务)- 聚合根 +- **字段**: + - TaskID - 任务 ID + - Title - 标题 + - Description - 描述 + - Status - 状态(Pending, InProgress, Completed) + - Priority - 优先级(Low, Medium, High) + - DueDate - 截止日期 + - Tags - 标签列表 + - CreatedAt - 创建时间 + - UpdatedAt - 更新时间 + - CompletedAt - 完成时间 + +### TaskStatus(任务状态)- 值对象 +- Pending(待办) +- InProgress(进行中) +- Completed(已完成) + +### Priority(优先级)- 值对象 +- Low(低) +- Medium(中) +- High(高) + +### Tag(标签)- 值对象 +- Name - 标签名称 +- Color - 颜色 + +## 领域事件 + +参考 `events.md` 查看所有领域事件。 + +## 业务规则 + +参考 `rules.md` 查看所有业务规则和约束。 + +## 依赖关系 + +### 下游依赖 + +- 无(当前版本) + +### 上游依赖 + +- 无 + +## 技术栈 + +- HTTP 框架:Hertz +- 存储:PostgreSQL(通过 database/sql) +- 缓存:Redis(可选) + +## 快速开始 + +### 创建任务示例 + +```bash +curl -X POST http://localhost:8080/api/tasks \ + -H "Content-Type: application/json" \ + -d '{ + "title": "完成项目文档", + "description": "编写 README 和 API 文档", + "priority": "high", + "due_date": "2025-12-31" + }' +``` + +### 列出任务示例 + +```bash +curl -X GET "http://localhost:8080/api/tasks?status=pending&priority=high&page=1&limit=10" +``` + +### 完成任务示例 + +```bash +curl -X POST http://localhost:8080/api/tasks/task-123/complete +``` + +## 待办事项 + +- [ ] 添加任务分类(Category) +- [ ] 支持任务依赖关系 +- [ ] 添加任务评论功能 +- [ ] 实现任务模板 + +## 相关文档 + +- [Glossary](./glossary.md) - 领域术语表 +- [Rules](./rules.md) - 业务规则 +- [Events](./events.md) - 领域事件 +- [Use Cases](./usecases.yaml) - 用例定义 + +## 扩展点 + +本领域是一个**示例实现**,用于展示 Vibe-Coding-Friendly DDD 架构。你可以: + +1. **直接使用**:如果你的项目需要任务管理功能 +2. **作为参考**:学习如何实现一个完整的领域 +3. **映射到你的业务**: + - Task → Product(商品) + - Task → Order(订单) + - Task → Article(文章) + - Task → Customer(客户) + +## 映射指南 + +### 如何将 Task 映射到你的业务实体? + +| Task 概念 | 映射示例 | +|-----------|---------| +| `Task` | `Product`, `Order`, `Article`, `Customer` | +| `Status` (Pending/Completed) | `OrderStatus` (Created/Shipped/Delivered) | +| `Priority` (Low/High) | `ProductCategory`, `CustomerTier` | +| `Tags` | `ProductTags`, `CustomerSegments` | +| `CreateTask` | `CreateProduct`, `CreateOrder` | +| `UpdateTask` | `UpdateProduct`, `UpdateOrder` | +| `CompleteTask` | `ShipOrder`, `PublishArticle` | + +### 步骤 + +1. 复制 `domains/task/` 到新的领域目录 +2. 全局替换:`Task` → `YourEntity` +3. 修改字段以匹配你的业务 +4. 更新 `usecases.yaml` 中的用例 +5. 重写业务规则(`rules.md`) +6. 更新术语表(`glossary.md`) + +--- + +**注意**:这是一个**示例领域**,用于展示架构模式。实际项目中,请根据你的业务需求创建自己的领域。 + diff --git a/backend-nodejs/domains/task/ai-metadata.json b/backend-nodejs/domains/task/ai-metadata.json new file mode 100644 index 0000000..883a255 --- /dev/null +++ b/backend-nodejs/domains/task/ai-metadata.json @@ -0,0 +1,258 @@ +{ + "domain": "task", + "version": "1.0.0", + "lastUpdated": "2025-11-23", + "status": "stable", + "description": "任务管理领域 - 展示 Vibe-Coding-Friendly DDD 架构的示例实现", + + "purpose": { + "primary": "作为 Starter 的示例领域,展示如何实现一个完整的 DDD 领域", + "secondary": [ + "展示用例驱动开发", + "展示 Repository 模式", + "展示领域事件", + "提供可映射的参考实现" + ] + }, + + "complexity": { + "overall": "low", + "business_logic": "simple", + "technical": "moderate", + "data_model": "simple" + }, + + "coverage": { + "usecases": 6, + "models": 4, + "repositories": 1, + "handlers": 6, + "events": 6, + "rules": 15 + }, + + "keywords": [ + "todo", + "task", + "crud", + "ddd", + "example", + "starter", + "vibe-coding-friendly", + "repository-pattern", + "domain-events" + ], + + "vector_tags": [ + "task_management", + "todo_list", + "simple_crud", + "ddd_example", + "golang_backend", + "hertz_framework", + "postgres_database" + ], + + "ai_hints": { + "understanding": { + "entry_points": [ + "README.md - 领域概览", + "usecases.yaml - 用例声明", + "glossary.md - 术语表" + ], + "key_concepts": [ + "Task - 任务聚合根", + "TaskStatus - 任务状态(Pending/InProgress/Completed)", + "Priority - 优先级(Low/Medium/High)" + ], + "data_flow": "HTTP Request → Handler → Model → Repository → Database" + }, + + "code_generation": { + "templates": { + "handler": "参考 CreateTaskHandler 的实现模式", + "repository": "使用 database/sql,不使用 ORM", + "model": "简单的 struct,包含业务方法", + "dto": "使用 binding tags 进行验证" + }, + "patterns": [ + "Repository 模式", + "依赖注入", + "错误处理(返回错误码)", + "事件发布(Extension point)" + ] + }, + + "extension_points": [ + { + "name": "Event Publishing", + "location": "handlers/*", + "description": "当前只记录日志,可扩展到真实事件总线" + }, + { + "name": "User Authentication", + "location": "handlers/*", + "description": "当前无用户认证,可添加 JWT 验证" + }, + { + "name": "Caching", + "location": "repository/*", + "description": "可添加 Redis 缓存层" + } + ], + + "mapping": { + "description": "如何将 Task 映射到其他业务实体", + "steps": [ + "1. 复制 domains/task/ 到新目录", + "2. 全局替换:Task → YourEntity", + "3. 修改字段以匹配业务", + "4. 更新 usecases.yaml", + "5. 更新 rules.md 和 glossary.md" + ], + "examples": [ + "Task → Product (商品)", + "Task → Order (订单)", + "Task → Article (文章)", + "Task → Customer (客户)" + ] + } + }, + + "testing": { + "unit_tests": true, + "integration_tests": true, + "e2e_tests": false, + "coverage_target": "80%", + "test_location": "tests/" + }, + + "documentation": { + "required_files": [ + "README.md", + "glossary.md", + "rules.md", + "events.md", + "usecases.yaml", + "ai-metadata.json" + ], + "completeness": "100%", + "quality": "high" + }, + + "dependencies": { + "domains": [], + "infrastructure": [ + "database (PostgreSQL)", + "cache (Redis, optional)" + ], + "external_services": [] + }, + + "api_endpoints": { + "base_path": "/api/tasks", + "count": 6, + "methods": ["GET", "POST", "PUT", "DELETE"], + "authentication": "none (可扩展)", + "rate_limiting": "可选" + }, + + "database": { + "tables": ["tasks"], + "schema_location": "database/schema.sql", + "migrations": true, + "indexes": [ + "idx_tasks_status", + "idx_tasks_priority", + "idx_tasks_due_date" + ] + }, + + "events": { + "published": [ + "TaskCreated", + "TaskUpdated", + "TaskCompleted", + "TaskDeleted", + "TaskStatusChanged", + "TaskPriorityChanged" + ], + "consumed": [], + "event_bus": "InMemory (可扩展到 Kafka/Redis)" + }, + + "performance": { + "expected_load": "low to medium", + "response_time_target": "< 100ms", + "throughput_target": "1000 req/s", + "scalability": "horizontal" + }, + + "security": { + "authentication": "not_implemented", + "authorization": "not_implemented", + "data_encryption": "not_implemented", + "input_validation": "implemented", + "sql_injection_prevention": "implemented (parameterized queries)" + }, + + "best_practices": [ + "使用 usecases.yaml 声明用例", + "Repository 模式隔离数据访问", + "领域事件记录业务事实", + "业务规则显式化(rules.md)", + "术语统一(glossary.md)", + "错误码标准化", + "测试覆盖完整" + ], + + "anti_patterns": [ + "不要在 model 中访问 database", + "不要在 handler 中写业务逻辑(应在 model 或 service)", + "不要使用 ORM(使用 database/sql)", + "不要跨领域直接调用(使用事件或应用层)", + "不要在 DTO 中包含业务逻辑" + ], + + "related_resources": { + "documentation": [ + "docs/backend-code-organization.md", + "docs/vibe-coding-ddd-structure.md", + "docs/Vibe-Coding-Friendly.md" + ], + "examples": [ + "本领域本身就是示例" + ], + "tutorials": [] + }, + + "changelog": [ + { + "version": "1.0.0", + "date": "2025-11-23", + "changes": [ + "初始版本", + "实现 6 个核心用例", + "完整的 6 个必需文件", + "符合 Vibe-Coding-Friendly 规范" + ] + } + ], + + "future_enhancements": [ + "用户认证和授权", + "任务分享和协作", + "周期性任务", + "子任务支持", + "任务评论", + "文件附件", + "任务模板", + "任务依赖关系" + ], + + "license": "MIT", + "maintainers": [ + "Go-GenAI-Stack Team" + ] +} + diff --git a/backend-nodejs/domains/task/errors/errors.ts b/backend-nodejs/domains/task/errors/errors.ts new file mode 100644 index 0000000..0ce9367 --- /dev/null +++ b/backend-nodejs/domains/task/errors/errors.ts @@ -0,0 +1,69 @@ +/** + * Task 领域错误定义 + * 遵循 "ERROR_CODE: message" 格式 + */ + +export class TaskError extends Error { + constructor( + public code: string, + message: string + ) { + super(message); + this.name = 'TaskError'; + } +} + +// 错误定义 +export const TaskErrors = { + TASK_TITLE_EMPTY: new TaskError( + 'TASK_TITLE_EMPTY', + '任务标题不能为空' + ), + TASK_NOT_FOUND: new TaskError('TASK_NOT_FOUND', '任务不存在'), + TASK_ALREADY_COMPLETED: new TaskError( + 'TASK_ALREADY_COMPLETED', + '任务已完成,不能再次完成' + ), + INVALID_PRIORITY: new TaskError( + 'INVALID_PRIORITY', + '优先级无效,必须是 low, medium 或 high' + ), + INVALID_DUE_DATE: new TaskError( + 'INVALID_DUE_DATE', + '截止日期不能早于创建日期' + ), + TOO_MANY_TAGS: new TaskError('TOO_MANY_TAGS', '标签过多,最多 10 个'), + TAG_NAME_EMPTY: new TaskError('TAG_NAME_EMPTY', '标签名不能为空'), + DUPLICATE_TAG: new TaskError('DUPLICATE_TAG', '标签重复'), + USER_ID_REQUIRED: new TaskError( + 'USER_ID_REQUIRED', + '用户 ID 不能为空' + ), + UNAUTHORIZED_ACCESS: new TaskError( + 'UNAUTHORIZED_ACCESS', + '无权访问此任务' + ), + CREATION_FAILED: new TaskError('CREATION_FAILED', '创建任务失败'), + UPDATE_FAILED: new TaskError('UPDATE_FAILED', '更新任务失败'), + DELETION_FAILED: new TaskError('DELETION_FAILED', '删除任务失败'), + COMPLETION_FAILED: new TaskError('COMPLETION_FAILED', '完成任务失败'), + QUERY_FAILED: new TaskError('QUERY_FAILED', '查询失败'), +}; + +/** + * 解析错误码 + */ +export function parseErrorCode(error: unknown): string { + if (error instanceof TaskError) { + return error.code; + } + if (error instanceof Error) { + // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) + const match = error.message.match(/^([A-Z_]+):/); + if (match) { + return match[1]; + } + } + return 'INTERNAL_ERROR'; +} + diff --git a/backend-nodejs/domains/task/events.md b/backend-nodejs/domains/task/events.md new file mode 100644 index 0000000..2b8e210 --- /dev/null +++ b/backend-nodejs/domains/task/events.md @@ -0,0 +1,461 @@ +# Task Domain Events (任务领域事件) + +> 本文档定义了 Task 领域发布的所有领域事件 + +**最后更新**:2025-11-23 + +--- + +## 📋 事件概述 + +领域事件是领域内发生的重要业务事实。本领域发布以下事件: + +| 事件名称 | 触发时机 | 消费者 | 优先级 | +|---------|---------|-------|--------| +| TaskCreated | 任务创建成功后 | Analytics, Notification | 🟢 Normal | +| TaskUpdated | 任务更新成功后 | Analytics | 🟡 Low | +| TaskCompleted | 任务完成后 | Analytics, Achievement | 🔵 High | +| TaskDeleted | 任务删除后 | Analytics | 🟢 Normal | +| TaskStatusChanged | 任务状态变更后 | Notification | 🟢 Normal | +| TaskPriorityChanged | 优先级变更后 | Notification | 🟡 Low | + +--- + +## 事件详情 + +### TaskCreated(任务创建) + +**事件 ID**:`task.created` + +**触发时机**:任务成功创建后 + +**发布位置**:`CreateTaskHandler` → `repository.Create()` 之后 + +**事件数据**: +```go +type TaskCreatedEvent struct { + EventID string `json:"event_id"` // 事件 ID (UUID) + TaskID string `json:"task_id"` // 任务 ID + Title string `json:"title"` // 任务标题 + Priority string `json:"priority"` // 优先级 (low/medium/high) + DueDate *string `json:"due_date"` // 截止日期 (ISO 8601) + Tags []string `json:"tags"` // 标签列表 + CreatedAt time.Time `json:"created_at"` // 创建时间 + CreatedBy string `json:"created_by"` // 创建者 (未实现) +} +``` + +**消费者**: +1. **Analytics Service**(分析服务) + - 记录任务创建指标 + - 统计每日任务数 + +2. **Notification Service**(通知服务,未实现) + - 发送任务创建通知 + +**幂等性**: +- 使用 EventID 保证幂等性 +- 消费者应该记录已处理的 EventID + +**示例代码**: +```go +// 发布事件 +event := &events.TaskCreatedEvent{ + EventID: generateEventID(), + TaskID: task.ID, + Title: task.Title, + Priority: string(task.Priority), + CreatedAt: task.CreatedAt, +} + +eventBus.Publish(ctx, "task.created", event) +``` + +**重试策略**: +- 最多重试 3 次 +- 指数退避(1s, 2s, 4s) +- 失败后记录到死信队列 + +--- + +### TaskUpdated(任务更新) + +**事件 ID**:`task.updated` + +**触发时机**:任务字段更新后 + +**发布位置**:`UpdateTaskHandler` → `repository.Update()` 之后 + +**事件数据**: +```go +type TaskUpdatedEvent struct { + EventID string `json:"event_id"` + TaskID string `json:"task_id"` + UpdatedFields map[string]interface{} `json:"updated_fields"` // 变更的字段 + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` // 更新者 (未实现) +} +``` + +**UpdatedFields 示例**: +```json +{ + "title": { + "old": "完成报告", + "new": "完成季度报告" + }, + "priority": { + "old": "medium", + "new": "high" + } +} +``` + +**消费者**: +1. **Analytics Service** + - 记录任务更新频率 + - 分析常修改的字段 + +**优化建议**: +- 低优先级事件,可以批量处理 +- 可以按需订阅(只订阅特定字段的变更) + +--- + +### TaskCompleted(任务完成) + +**事件 ID**:`task.completed` + +**触发时机**:任务状态变更为 Completed 后 + +**发布位置**:`CompleteTaskHandler` → `repository.Update()` 之后 + +**事件数据**: +```go +type TaskCompletedEvent struct { + EventID string `json:"event_id"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Priority string `json:"priority"` + CompletedAt time.Time `json:"completed_at"` + Duration int `json:"duration_seconds"` // 任务耗时(秒) + IsOnTime bool `json:"is_on_time"` // 是否按时完成 +} +``` + +**Duration 计算**: +```go +duration := task.CompletedAt.Sub(task.CreatedAt).Seconds() +``` + +**IsOnTime 判断**: +```go +isOnTime := task.DueDate == nil || task.CompletedAt.Before(task.DueDate) +``` + +**消费者**: +1. **Analytics Service** + - 统计完成率 + - 分析任务完成时间分布 + - 计算平均耗时 + +2. **Achievement Service**(成就系统,未实现) + - 解锁成就(如"连续完成 7 天任务") + - 计算生产力分数 + +3. **Notification Service** + - 发送完成通知 + - 如果逾期完成,发送提醒 + +**业务价值**: +- ✅ 高价值事件,重要的业务里程碑 +- ✅ 可用于计算 KPI(如任务完成率) + +--- + +### TaskDeleted(任务删除) + +**事件 ID**:`task.deleted` + +**触发时机**:任务删除后 + +**发布位置**:`DeleteTaskHandler` → `repository.Delete()` 之后 + +**事件数据**: +```go +type TaskDeletedEvent struct { + EventID string `json:"event_id"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Status string `json:"status"` // 删除时的状态 + DeletedAt time.Time `json:"deleted_at"` + DeletedBy string `json:"deleted_by"` // 删除者 (未实现) + Reason string `json:"reason"` // 删除原因 (可选) +} +``` + +**消费者**: +1. **Analytics Service** + - 记录删除统计 + - 分析删除原因 + +2. **Cleanup Service**(清理服务,未实现) + - 清理相关的附件、评论等 + +**软删除 vs 硬删除**: +- **软删除**:设置 DeletedAt 字段,不发布事件 +- **硬删除**:物理删除记录,发布事件 + +--- + +### TaskStatusChanged(任务状态变更) + +**事件 ID**:`task.status_changed` + +**触发时机**:任务状态变更后(包括完成) + +**发布位置**:状态变更的 handler 中 + +**事件数据**: +```go +type TaskStatusChangedEvent struct { + EventID string `json:"event_id"` + TaskID string `json:"task_id"` + OldStatus string `json:"old_status"` // pending/in_progress/completed + NewStatus string `json:"new_status"` + ChangedAt time.Time `json:"changed_at"` +} +``` + +**状态转换**: +``` +pending → in_progress (StartTask) +pending → completed (CompleteTask) +in_progress → completed (CompleteTask) +``` + +**消费者**: +1. **Notification Service** + - 状态变更通知 + - 团队成员可见性 + +**关系**: +- TaskStatusChanged 是更通用的事件 +- TaskCompleted 是专门针对完成状态的事件 +- 两者可以同时发布 + +--- + +### TaskPriorityChanged(优先级变更) + +**事件 ID**:`task.priority_changed` + +**触发时机**:优先级更新后 + +**发布位置**:`UpdateTaskHandler` → priority 字段变更时 + +**事件数据**: +```go +type TaskPriorityChangedEvent struct { + EventID string `json:"event_id"` + TaskID string `json:"task_id"` + OldPriority string `json:"old_priority"` // low/medium/high + NewPriority string `json:"new_priority"` + ChangedAt time.Time `json:"changed_at"` + Reason string `json:"reason"` // 变更原因 (可选) +} +``` + +**消费者**: +1. **Notification Service** + - 优先级提升通知(如 low → high) + +**触发条件**: +- 仅当优先级实际变化时发布 +- 从 medium → medium 不发布事件 + +--- + +## 事件总线 + +### 实现方式 + +**当前**: +- 使用内存事件总线(`domains/shared/events/bus.go`) +- 同步发布和消费 + +**扩展点**: +- 可以切换到 Redis Pub/Sub +- 可以切换到 Kafka +- 可以切换到 RabbitMQ + +### 事件发布示例 + +```go +// 在 handler 中发布事件 +func (s *HandlerService) CreateTaskHandler(ctx context.Context, c *app.RequestContext) { + // ... 创建任务逻辑 + + // 发布事件 + event := &events.TaskCreatedEvent{ + EventID: uuid.New().String(), + TaskID: task.ID, + Title: task.Title, + Priority: string(task.Priority), + CreatedAt: task.CreatedAt, + } + + // Extension point: 发布到事件总线 + // eventBus.Publish(ctx, "task.created", event) + + // 当前:记录日志 + log.Printf("Event: task.created, TaskID: %s", task.ID) +} +``` + +### 事件消费示例 + +```go +// 在 Analytics Service 中消费事件 +func (s *AnalyticsService) HandleTaskCreated(ctx context.Context, event *events.TaskCreatedEvent) error { + // 记录指标 + s.metricsCollector.Increment("tasks.created.total") + s.metricsCollector.Gauge("tasks.created.by_priority", 1, map[string]string{ + "priority": event.Priority, + }) + + return nil +} +``` + +--- + +## 事件版本管理 + +### 版本化策略 + +**问题**:事件结构变更如何兼容? + +**解决方案**: +1. **版本号**:在事件中添加 `version` 字段 +2. **向后兼容**:只添加字段,不删除字段 +3. **多版本并存**:同时支持 v1 和 v2 + +**示例**: +```go +type TaskCreatedEvent struct { + Version string `json:"version"` // "v1", "v2" + EventID string `json:"event_id"` + // ... 其他字段 + + // v2 新增字段 + Category *string `json:"category,omitempty"` // 使用指针表示可选 +} +``` + +--- + +## 事件存储(未实现) + +**Event Sourcing**: +- 将所有事件存储到事件库 +- 可以重放事件重建状态 +- 提供完整的审计日志 + +**表结构**: +```sql +CREATE TABLE domain_events ( + event_id UUID PRIMARY KEY, + event_type VARCHAR(100) NOT NULL, + aggregate_id VARCHAR(100) NOT NULL, -- TaskID + aggregate_type VARCHAR(50) NOT NULL, -- "Task" + event_data JSONB NOT NULL, + occurred_at TIMESTAMP NOT NULL, + version INTEGER NOT NULL +); +``` + +--- + +## 监控和告警 + +### 事件指标 + +应该监控的指标: +- 事件发布速率(events/sec) +- 事件处理延迟(ms) +- 事件失败率(%) +- 死信队列大小 + +### 告警规则 + +- 🔴 事件失败率 > 5% +- 🟡 事件处理延迟 > 5s +- 🔴 死信队列积压 > 1000 + +--- + +## 测试 + +### 事件测试清单 + +- [ ] 测试事件发布(是否正确发布) +- [ ] 测试事件格式(JSON 序列化) +- [ ] 测试事件消费(消费者是否正确处理) +- [ ] 测试事件幂等性(重复消费不会出错) +- [ ] 测试事件失败重试 +- [ ] 测试事件顺序(如果需要) + +### 测试示例 + +```go +func TestTaskCreatedEvent_Published(t *testing.T) { + // 创建 mock 事件总线 + eventBus := NewMockEventBus() + + // 创建任务 + handler := NewHandlerService(repo, eventBus) + handler.CreateTaskHandler(ctx, req) + + // 验证事件被发布 + events := eventBus.GetPublishedEvents("task.created") + assert.Equal(t, 1, len(events)) + + // 验证事件数据 + event := events[0].(*TaskCreatedEvent) + assert.Equal(t, "Test Task", event.Title) +} +``` + +--- + +## 最佳实践 + +### DO(推荐) + +- ✅ 事件名称使用过去时(TaskCreated 而非 CreateTask) +- ✅ 事件数据包含时间戳 +- ✅ 事件 ID 使用 UUID 保证唯一性 +- ✅ 事件数据尽量小(避免包含大对象) +- ✅ 异步处理事件(不阻塞主流程) +- ✅ 事件消费要幂等 +- ✅ 记录事件处理日志 + +### DON'T(避免) + +- ❌ 不要在事件中包含敏感信息(密码、Token) +- ❌ 不要在事件中包含可变的数据(URL、配置) +- ❌ 不要依赖事件的顺序(除非使用有序队列) +- ❌ 不要在事件处理中调用同步 API +- ❌ 不要在事件处理中抛出异常(应该捕获并记录) + +--- + +**维护说明**: +- 添加新事件时,更新本文档 +- 事件结构变更应该版本化 +- 定期审查事件消费者 +- 监控事件处理性能 + +**最后更新**:2025-11-23 + diff --git a/backend-nodejs/domains/task/glossary.md b/backend-nodejs/domains/task/glossary.md new file mode 100644 index 0000000..27e752e --- /dev/null +++ b/backend-nodejs/domains/task/glossary.md @@ -0,0 +1,332 @@ +# Task Domain Glossary (任务领域术语表) + +> 本文档定义了 Task 领域的统一语言(Ubiquitous Language) + +--- + +## 核心术语 + +### Task(任务) +**定义**:需要完成的工作项或待办事项 + +**类型**:聚合根(Aggregate Root) + +**属性**: +- 有唯一标识(TaskID) +- 有标题和描述 +- 有状态(待办/进行中/已完成) +- 有优先级 +- 可以有截止日期 + +**生命周期**: +``` +创建 → 待办(Pending) → 进行中(InProgress) → 已完成(Completed) + ↓ + 删除(Deleted) +``` + +**业务规则**: +- 任务标题不能为空(`TASK_TITLE_EMPTY`) +- 已完成的任务不能再更新状态(`TASK_ALREADY_COMPLETED`) + +**相关事件**: +- `TaskCreated` - 任务创建时 +- `TaskUpdated` - 任务更新时 +- `TaskCompleted` - 任务完成时 +- `TaskDeleted` - 任务删除时 + +--- + +### TaskStatus(任务状态) +**定义**:任务所处的生命周期阶段 + +**类型**:值对象(Value Object)/ 枚举 + +**可选值**: +- `Pending` - 待办:任务已创建但未开始 +- `InProgress` - 进行中:任务正在执行 +- `Completed` - 已完成:任务已完成 + +**状态转换规则**: +``` +Pending → InProgress (开始任务) +InProgress → Completed (完成任务) +Pending → Completed (直接完成) +``` + +**不允许的转换**: +- ❌ Completed → Pending +- ❌ Completed → InProgress + +**示例**: +```go +type TaskStatus string + +const ( + StatusPending TaskStatus = "pending" + StatusInProgress TaskStatus = "in_progress" + StatusCompleted TaskStatus = "completed" +) +``` + +--- + +### Priority(优先级) +**定义**:任务的重要程度 + +**类型**:值对象(Value Object)/ 枚举 + +**可选值**: +- `Low` - 低优先级 +- `Medium` - 中优先级 +- `High` - 高优先级 + +**业务含义**: +- **High(高)**:紧急重要,需要立即处理 +- **Medium(中)**:正常优先级 +- **Low(低)**:可以延后处理 + +**排序规则**: +- 列表默认按优先级降序排列(High → Medium → Low) +- 相同优先级按创建时间排序 + +**示例**: +```go +type Priority string + +const ( + PriorityLow Priority = "low" + PriorityMedium Priority = "medium" + PriorityHigh Priority = "high" +) +``` + +--- + +### Tag(标签) +**定义**:用于分类和组织任务的标识 + +**类型**:值对象(Value Object) + +**属性**: +- Name - 标签名称(如 "工作", "个人", "学习") +- Color - 颜色代码(用于 UI 展示) + +**业务规则**: +- 一个任务可以有多个标签 +- 标签名称不能为空 +- 标签名称应该唯一(同一任务内) + +**示例**: +```go +type Tag struct { + Name string + Color string // 如 "#FF5733" +} +``` + +--- + +### DueDate(截止日期) +**定义**:任务需要完成的目标日期 + +**类型**:值对象(Value Object) + +**业务规则**: +- 截止日期不能早于创建日期 +- 可以为空(表示无截止日期) + +**相关概念**: +- **逾期(Overdue)**:当前日期 > 截止日期 且 状态 ≠ Completed + +--- + +## 领域操作 + +### CreateTask(创建任务) +**定义**:创建一个新的任务 + +**输入**: +- Title(必填) +- Description(可选) +- Priority(可选,默认 Medium) +- DueDate(可选) +- Tags(可选) + +**输出**: +- 新创建的 Task 对象 +- TaskCreated 事件 + +**前置条件**: +- 标题不能为空 + +**后置条件**: +- Task 状态为 Pending +- 生成唯一的 TaskID +- 记录 CreatedAt 时间戳 + +--- + +### UpdateTask(更新任务) +**定义**:修改任务的属性 + +**可更新字段**: +- Title +- Description +- Priority +- DueDate +- Tags + +**不可更新字段**: +- TaskID +- Status(通过专门的状态变更方法) +- CreatedAt +- CompletedAt + +**业务规则**: +- 已完成的任务不能更新(除非重新打开) + +--- + +### CompleteTask(完成任务) +**定义**:将任务标记为已完成 + +**状态变更**: +- Status: Pending/InProgress → Completed +- 记录 CompletedAt 时间戳 + +**业务规则**: +- 已完成的任务不能再次完成 + +**触发事件**: +- TaskCompleted + +--- + +### DeleteTask(删除任务) +**定义**:删除任务(物理删除或软删除) + +**实现方式**: +- 物理删除:从数据库中移除记录 +- 软删除:设置 DeletedAt 字段 + +**业务规则**: +- 删除后不可恢复(如果物理删除) + +**触发事件**: +- TaskDeleted + +--- + +### ListTasks(列出任务) +**定义**:查询任务列表,支持筛选和分页 + +**筛选条件**: +- Status(按状态筛选) +- Priority(按优先级筛选) +- Tags(按标签筛选) +- DueDate(按截止日期范围筛选) +- Keyword(按标题/描述搜索) + +**排序选项**: +- CreatedAt(创建时间) +- DueDate(截止日期) +- Priority(优先级) + +**分页参数**: +- Page(页码,从 1 开始) +- Limit(每页数量,默认 20,最大 100) + +--- + +## 错误码 + +### TASK_TITLE_EMPTY +**说明**:任务标题不能为空 + +**场景**:CreateTask, UpdateTask + +**HTTP 状态码**:400 Bad Request + +--- + +### TASK_NOT_FOUND +**说明**:任务不存在 + +**场景**:GetTask, UpdateTask, CompleteTask, DeleteTask + +**HTTP 状态码**:404 Not Found + +--- + +### TASK_ALREADY_COMPLETED +**说明**:任务已完成,不能再次完成 + +**场景**:CompleteTask + +**HTTP 状态码**:400 Bad Request + +--- + +### INVALID_DUE_DATE +**说明**:截止日期无效(早于创建日期) + +**场景**:CreateTask, UpdateTask + +**HTTP 状态码**:400 Bad Request + +--- + +## 领域事件 + +### TaskCreated +**触发时机**:成功创建任务后 + +**数据**: +- TaskID +- Title +- Priority +- CreatedAt + +**消费者**: +- Analytics(统计) +- Notification(通知) + +--- + +### TaskCompleted +**触发时机**:任务标记为完成后 + +**数据**: +- TaskID +- Title +- CompletedAt + +**消费者**: +- Analytics(完成率统计) +- Achievement(成就系统) + +--- + +## 扩展术语(未实现) + +以下术语是潜在的扩展点,当前版本未实现: + +- **TaskList(任务列表)**:任务的容器,用于分组 +- **Subtask(子任务)**:任务的细分工作项 +- **TaskDependency(任务依赖)**:任务之间的依赖关系 +- **Assignee(负责人)**:任务的执行者 +- **TaskComment(任务评论)**:任务的讨论和备注 +- **Attachment(附件)**:任务相关的文件 +- **Recurrence(重复任务)**:周期性任务 + +--- + +**维护说明**: +- 添加新术语时,更新本文档 +- 术语定义应该清晰、简洁 +- 使用业务语言,避免技术术语 +- 保持与代码中的命名一致 + +**最后更新**:2025-11-23 + diff --git a/backend-nodejs/domains/task/handlers/complete_task.handler.ts b/backend-nodejs/domains/task/handlers/complete_task.handler.ts new file mode 100644 index 0000000..320bc8a --- /dev/null +++ b/backend-nodejs/domains/task/handlers/complete_task.handler.ts @@ -0,0 +1,48 @@ +/** + * CompleteTask Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import { + toCompleteTaskInput, + toCompleteTaskResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function completeTaskHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +): Promise { + try { + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const taskId = req.params.id; + + const input = toCompleteTaskInput(userId, taskId); + const output = await deps.taskService.completeTask(req, input); + + reply.code(200).send(toCompleteTaskResponse(output.task)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '完成任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'TASK_ALREADY_COMPLETED') { + return 400; + } + if (errorCode === 'TASK_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED_ACCESS') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/handlers/converters.ts b/backend-nodejs/domains/task/handlers/converters.ts new file mode 100644 index 0000000..edfd59f --- /dev/null +++ b/backend-nodejs/domains/task/handlers/converters.ts @@ -0,0 +1,262 @@ +/** + * DTO 转换层 + * HTTP DTO ↔ Domain Input/Output 的转换 + */ + +import type { Task } from '../model/task.js'; +import type { + CreateTaskRequest, + CreateTaskResponse, + UpdateTaskRequest, + UpdateTaskResponse, + CompleteTaskResponse, + DeleteTaskResponse, + GetTaskResponse, + ListTasksQuery, + ListTasksResponse, + TaskItem, +} from '../http/dto/task.js'; +import type { + CreateTaskInput, + UpdateTaskInput, + CompleteTaskInput, + DeleteTaskInput, + GetTaskInput, + ListTasksInput, +} from '../service/task_service.js'; +import type { TaskFilter } from '../repository/interface.js'; + +// ======================================== +// CreateTask 转换 +// ======================================== + +export function toCreateTaskInput( + userId: string, + req: CreateTaskRequest +): CreateTaskInput { + const input: CreateTaskInput = { + userId, + title: req.title, + description: req.description || '', + priority: req.priority || 'medium', + tags: req.tags, + }; + + // 解析截止日期 + if (req.due_date) { + const dueDate = new Date(req.due_date); + if (isNaN(dueDate.getTime())) { + throw new Error('INVALID_DUE_DATE: 截止日期格式无效'); + } + input.dueDate = dueDate; + } + + return input; +} + +export function toCreateTaskResponse(task: Task): CreateTaskResponse { + return { + task_id: task.id, + title: task.title, + status: task.status, + created_at: task.createdAt.toISOString(), + }; +} + +// ======================================== +// UpdateTask 转换 +// ======================================== + +export function toUpdateTaskInput( + userId: string, + taskId: string, + req: UpdateTaskRequest +): UpdateTaskInput { + const input: UpdateTaskInput = { + userId, + taskId, + }; + + if (req.title !== undefined) { + input.title = req.title; + } + if (req.description !== undefined) { + input.description = req.description; + } + if (req.priority !== undefined) { + input.priority = req.priority; + } + if (req.due_date !== undefined) { + const dueDate = new Date(req.due_date); + if (isNaN(dueDate.getTime())) { + throw new Error('INVALID_DUE_DATE: 截止日期格式无效'); + } + input.dueDate = dueDate; + } + if (req.tags !== undefined) { + input.tags = req.tags; + } + + return input; +} + +export function toUpdateTaskResponse(task: Task): UpdateTaskResponse { + return { + task_id: task.id, + title: task.title, + status: task.status, + updated_at: task.updatedAt.toISOString(), + }; +} + +// ======================================== +// CompleteTask 转换 +// ======================================== + +export function toCompleteTaskInput( + userId: string, + taskId: string +): CompleteTaskInput { + return { + userId, + taskId, + }; +} + +export function toCompleteTaskResponse(task: Task): CompleteTaskResponse { + return { + task_id: task.id, + status: task.status, + completed_at: task.completedAt!.toISOString(), + }; +} + +// ======================================== +// DeleteTask 转换 +// ======================================== + +export function toDeleteTaskInput( + userId: string, + taskId: string +): DeleteTaskInput { + return { + userId, + taskId, + }; +} + +export function toDeleteTaskResponse( + success: boolean, + deletedAt: Date +): DeleteTaskResponse { + return { + success, + deleted_at: deletedAt.toISOString(), + }; +} + +// ======================================== +// GetTask 转换 +// ======================================== + +export function toGetTaskInput(userId: string, taskId: string): GetTaskInput { + return { + userId, + taskId, + }; +} + +export function toGetTaskResponse(task: Task): GetTaskResponse { + const response: GetTaskResponse = { + task_id: task.id, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + tags: task.tags.map((t) => t.name), + created_at: task.createdAt.toISOString(), + updated_at: task.updatedAt.toISOString(), + }; + + if (task.dueDate) { + response.due_date = task.dueDate.toISOString(); + } + + if (task.completedAt) { + response.completed_at = task.completedAt.toISOString(); + } + + return response; +} + +// ======================================== +// ListTasks 转换 +// ======================================== + +export function toListTasksInput( + userId: string, + query: ListTasksQuery +): ListTasksInput { + const filter: TaskFilter = { + userId, + page: query.page || 1, + limit: query.limit || 20, + sortBy: query.sort_by || 'created_at', + sortOrder: query.sort_order || 'desc', + }; + + if (query.status) { + filter.status = query.status; + } + if (query.priority) { + filter.priority = query.priority; + } + if (query.tag) { + filter.tag = query.tag; + } + if (query.keyword) { + filter.keyword = query.keyword; + } + if (query.due_date_from) { + filter.dueDateFrom = new Date(query.due_date_from); + } + if (query.due_date_to) { + filter.dueDateTo = new Date(query.due_date_to); + } + + return { filter }; +} + +export function toListTasksResponse( + tasks: Task[], + totalCount: number, + page: number, + limit: number, + hasMore: boolean +): ListTasksResponse { + const taskItems: TaskItem[] = tasks.map((task) => { + const item: TaskItem = { + task_id: task.id, + title: task.title, + status: task.status, + priority: task.priority, + tags: task.tags.map((t) => t.name), + created_at: task.createdAt.toISOString(), + }; + + if (task.dueDate) { + item.due_date = task.dueDate.toISOString(); + } + + return item; + }); + + return { + tasks: taskItems, + total_count: totalCount, + page, + limit, + has_more: hasMore, + }; +} + diff --git a/backend-nodejs/domains/task/handlers/create_task.handler.ts b/backend-nodejs/domains/task/handlers/create_task.handler.ts new file mode 100644 index 0000000..03ba3f0 --- /dev/null +++ b/backend-nodejs/domains/task/handlers/create_task.handler.ts @@ -0,0 +1,64 @@ +/** + * CreateTask Handler + * HTTP 适配层:处理创建任务的 HTTP 请求 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { CreateTaskRequest } from '../http/dto/task.js'; +import { + toCreateTaskInput, + toCreateTaskResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function createTaskHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: CreateTaskRequest }>, + reply: FastifyReply +): Promise { + try { + // 1. 获取用户 ID(当前从请求头获取,后续可扩展为 JWT) + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + if (!userId) { + reply.code(401).send({ + error: 'UNAUTHORIZED', + message: '未授权访问', + }); + return; + } + + // 2. 解析 HTTP 请求 + const body = req.body; + + // 3. 转换为 Domain Input + const input = toCreateTaskInput(userId, body); + + // 4. 调用 Domain Service + const output = await deps.taskService.createTask(req, input); + + // 5. 转换为 HTTP 响应 + reply.code(200).send(toCreateTaskResponse(output.task)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '创建任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { + return 400; + } + if (errorCode === 'TASK_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED_ACCESS') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/handlers/delete_task.handler.ts b/backend-nodejs/domains/task/handlers/delete_task.handler.ts new file mode 100644 index 0000000..0d938a2 --- /dev/null +++ b/backend-nodejs/domains/task/handlers/delete_task.handler.ts @@ -0,0 +1,45 @@ +/** + * DeleteTask Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import { + toDeleteTaskInput, + toDeleteTaskResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function deleteTaskHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +): Promise { + try { + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const taskId = req.params.id; + + const input = toDeleteTaskInput(userId, taskId); + const output = await deps.taskService.deleteTask(req, input); + + reply.code(200).send(toDeleteTaskResponse(output.success, output.deletedAt)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '删除任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'TASK_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED_ACCESS') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/handlers/dependencies.ts b/backend-nodejs/domains/task/handlers/dependencies.ts new file mode 100644 index 0000000..5fa7831 --- /dev/null +++ b/backend-nodejs/domains/task/handlers/dependencies.ts @@ -0,0 +1,11 @@ +/** + * Handler 依赖容器 + * 注入 Service 层依赖 + */ + +import type { TaskService } from '../service/task_service.js'; + +export interface HandlerDependencies { + taskService: TaskService; +} + diff --git a/backend-nodejs/domains/task/handlers/get_task.handler.ts b/backend-nodejs/domains/task/handlers/get_task.handler.ts new file mode 100644 index 0000000..66c60c3 --- /dev/null +++ b/backend-nodejs/domains/task/handlers/get_task.handler.ts @@ -0,0 +1,45 @@ +/** + * GetTask Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import { + toGetTaskInput, + toGetTaskResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function getTaskHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply +): Promise { + try { + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const taskId = req.params.id; + + const input = toGetTaskInput(userId, taskId); + const output = await deps.taskService.getTask(req, input); + + reply.code(200).send(toGetTaskResponse(output.task)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '获取任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'TASK_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED_ACCESS') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/handlers/list_tasks.handler.ts b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts new file mode 100644 index 0000000..efa95da --- /dev/null +++ b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts @@ -0,0 +1,50 @@ +/** + * ListTasks Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { ListTasksQuery } from '../http/dto/task.js'; +import { + toListTasksInput, + toListTasksResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function listTasksHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Querystring: ListTasksQuery }>, + reply: FastifyReply +): Promise { + try { + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + + const input = toListTasksInput(userId, req.query); + const output = await deps.taskService.listTasks(req, input); + + reply.code(200).send( + toListTasksResponse( + output.tasks, + output.totalCount, + output.page, + output.limit, + output.hasMore + ) + ); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '查询任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'INVALID_FILTER' || errorCode === 'INVALID_PAGINATION') { + return 400; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/handlers/update_task.handler.ts b/backend-nodejs/domains/task/handlers/update_task.handler.ts new file mode 100644 index 0000000..3c69252 --- /dev/null +++ b/backend-nodejs/domains/task/handlers/update_task.handler.ts @@ -0,0 +1,52 @@ +/** + * UpdateTask Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { UpdateTaskRequest } from '../http/dto/task.js'; +import { + toUpdateTaskInput, + toUpdateTaskResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function updateTaskHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ + Params: { id: string }; + Body: UpdateTaskRequest; + }>, + reply: FastifyReply +): Promise { + try { + const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const taskId = req.params.id; + + const input = toUpdateTaskInput(userId, taskId, req.body); + const output = await deps.taskService.updateTask(req, input); + + reply.code(200).send(toUpdateTaskResponse(output.task)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '更新任务失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { + return 400; + } + if (errorCode === 'TASK_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED_ACCESS') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/task/http/dto/task.ts b/backend-nodejs/domains/task/http/dto/task.ts new file mode 100644 index 0000000..aac4022 --- /dev/null +++ b/backend-nodejs/domains/task/http/dto/task.ts @@ -0,0 +1,124 @@ +/** + * Task HTTP DTO + * 定义 HTTP 请求和响应的数据结构 + */ + +// ======================================== +// CreateTask +// ======================================== + +export interface CreateTaskRequest { + title: string; + description?: string; + priority?: 'low' | 'medium' | 'high'; + due_date?: string; // ISO 8601 + tags?: string[]; +} + +export interface CreateTaskResponse { + task_id: string; + title: string; + status: string; + created_at: string; // ISO 8601 +} + +// ======================================== +// UpdateTask +// ======================================== + +export interface UpdateTaskRequest { + title?: string; + description?: string; + priority?: 'low' | 'medium' | 'high'; + due_date?: string; // ISO 8601 + tags?: string[]; +} + +export interface UpdateTaskResponse { + task_id: string; + title: string; + status: string; + updated_at: string; // ISO 8601 +} + +// ======================================== +// CompleteTask +// ======================================== + +export interface CompleteTaskResponse { + task_id: string; + status: string; + completed_at: string; // ISO 8601 +} + +// ======================================== +// DeleteTask +// ======================================== + +export interface DeleteTaskResponse { + success: boolean; + deleted_at: string; // ISO 8601 +} + +// ======================================== +// GetTask +// ======================================== + +export interface GetTaskResponse { + task_id: string; + title: string; + description: string; + status: string; + priority: string; + due_date?: string; // ISO 8601 + tags: string[]; + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 + completed_at?: string; // ISO 8601 +} + +// ======================================== +// ListTasks +// ======================================== + +export interface ListTasksQuery { + status?: 'pending' | 'in_progress' | 'completed'; + priority?: 'low' | 'medium' | 'high'; + tag?: string; + due_date_from?: string; // ISO 8601 date + due_date_to?: string; // ISO 8601 date + keyword?: string; + sort_by?: 'created_at' | 'due_date' | 'priority'; + sort_order?: 'asc' | 'desc'; + page?: number; + limit?: number; +} + +export interface TaskItem { + task_id: string; + title: string; + status: string; + priority: string; + due_date?: string; // ISO 8601 + tags: string[]; + created_at: string; // ISO 8601 +} + +export interface ListTasksResponse { + tasks: TaskItem[]; + total_count: number; + page: number; + limit: number; + has_more: boolean; +} + +// ======================================== +// Error Response +// ======================================== + +export interface ErrorResponse { + error: string; // 错误码 + message: string; // 错误消息 + details?: string; // 详细信息(可选) +} + diff --git a/backend-nodejs/domains/task/http/router.ts b/backend-nodejs/domains/task/http/router.ts new file mode 100644 index 0000000..04081a2 --- /dev/null +++ b/backend-nodejs/domains/task/http/router.ts @@ -0,0 +1,53 @@ +/** + * Task 路由注册 + * 注册所有 Task 相关的 HTTP 路由 + */ + +import type { FastifyInstance } from 'fastify'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import type { CreateTaskRequest, UpdateTaskRequest, ListTasksQuery } from './dto/task.js'; +import { createTaskHandler } from '../handlers/create_task.handler.js'; +import { updateTaskHandler } from '../handlers/update_task.handler.js'; +import { completeTaskHandler } from '../handlers/complete_task.handler.js'; +import { deleteTaskHandler } from '../handlers/delete_task.handler.js'; +import { getTaskHandler } from '../handlers/get_task.handler.js'; +import { listTasksHandler } from '../handlers/list_tasks.handler.js'; + +/** + * 注册 Task 路由 + */ +export function registerTaskRoutes( + app: FastifyInstance, + deps: HandlerDependencies +): void { + // POST /api/tasks - 创建任务 + app.post<{ Body: CreateTaskRequest }>('/api/tasks', async (req, reply) => { + await createTaskHandler(deps, req as any, reply); + }); + + // GET /api/tasks - 列出任务 + app.get<{ Querystring: ListTasksQuery }>('/api/tasks', async (req, reply) => { + await listTasksHandler(deps, req as any, reply); + }); + + // GET /api/tasks/:id - 获取任务详情 + app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { + await getTaskHandler(deps, req as any, reply); + }); + + // PUT /api/tasks/:id - 更新任务 + app.put<{ Params: { id: string }; Body: UpdateTaskRequest }>('/api/tasks/:id', async (req, reply) => { + await updateTaskHandler(deps, req as any, reply); + }); + + // POST /api/tasks/:id/complete - 完成任务 + app.post<{ Params: { id: string } }>('/api/tasks/:id/complete', async (req, reply) => { + await completeTaskHandler(deps, req as any, reply); + }); + + // DELETE /api/tasks/:id - 删除任务 + app.delete<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { + await deleteTaskHandler(deps, req as any, reply); + }); +} + diff --git a/backend-nodejs/domains/task/model/task.ts b/backend-nodejs/domains/task/model/task.ts new file mode 100644 index 0000000..c1103ad --- /dev/null +++ b/backend-nodejs/domains/task/model/task.ts @@ -0,0 +1,208 @@ +/** + * Task 领域模型 + * 聚合根:包含任务的所有核心属性和业务行为 + */ + +import { randomUUID } from 'crypto'; + +// 任务状态 +export type TaskStatus = 'pending' | 'in_progress' | 'completed'; + +export const TaskStatuses = { + Pending: 'pending' as TaskStatus, + InProgress: 'in_progress' as TaskStatus, + Completed: 'completed' as TaskStatus, +} as const; + +// 优先级 +export type Priority = 'low' | 'medium' | 'high'; + +export const Priorities = { + Low: 'low' as Priority, + Medium: 'medium' as Priority, + High: 'high' as Priority, +} as const; + +// 标签 +export interface Tag { + name: string; + color: string; +} + +// Task 聚合根 +export class Task { + id: string; + userId: string; + title: string; + description: string; + status: TaskStatus; + priority: Priority; + dueDate: Date | null; + tags: Tag[]; + createdAt: Date; + updatedAt: Date; + completedAt: Date | null; + + constructor( + id: string, + userId: string, + title: string, + description: string, + priority: Priority, + status: TaskStatus = TaskStatuses.Pending, + dueDate: Date | null = null, + tags: Tag[] = [], + createdAt: Date = new Date(), + updatedAt: Date = new Date(), + completedAt: Date | null = null + ) { + this.id = id; + this.userId = userId; + this.title = title; + this.description = description; + this.status = status; + this.priority = priority; + this.dueDate = dueDate; + this.tags = tags; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.completedAt = completedAt; + } + + /** + * 创建新任务 + */ + static create( + userId: string, + title: string, + description: string, + priority: Priority = Priorities.Medium + ): Task { + if (!userId || userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + if (!title || title.trim().length === 0) { + throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + } + if (title.length > 200) { + throw new Error('TASK_TITLE_TOO_LONG: 标题过长,最大 200 字符'); + } + if (description.length > 5000) { + throw new Error('TASK_DESCRIPTION_TOO_LONG: 描述过长,最大 5000 字符'); + } + if (!isValidPriority(priority)) { + throw new Error('INVALID_PRIORITY: 优先级无效'); + } + + const now = new Date(); + return new Task( + randomUUID(), + userId, + title.trim(), + description, + priority, + TaskStatuses.Pending, + null, + [], + now, + now, + null + ); + } + + /** + * 更新任务信息 + */ + update(title?: string, description?: string, priority?: Priority): void { + if (this.status === TaskStatuses.Completed) { + throw new Error('TASK_ALREADY_COMPLETED: 已完成的任务不能更新'); + } + + if (title !== undefined) { + if (!title || title.trim().length === 0) { + throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + } + if (title.length > 200) { + throw new Error('TASK_TITLE_TOO_LONG: 标题过长,最大 200 字符'); + } + this.title = title.trim(); + } + + if (description !== undefined) { + if (description.length > 5000) { + throw new Error('TASK_DESCRIPTION_TOO_LONG: 描述过长,最大 5000 字符'); + } + this.description = description; + } + + if (priority !== undefined) { + if (!isValidPriority(priority)) { + throw new Error('INVALID_PRIORITY: 优先级无效'); + } + this.priority = priority; + } + + this.updatedAt = new Date(); + } + + /** + * 设置截止日期 + */ + setDueDate(dueDate: Date): void { + if (dueDate < this.createdAt) { + throw new Error('INVALID_DUE_DATE: 截止日期不能早于创建日期'); + } + this.dueDate = dueDate; + this.updatedAt = new Date(); + } + + /** + * 标记为已完成 + */ + complete(): void { + if (this.status === TaskStatuses.Completed) { + throw new Error('TASK_ALREADY_COMPLETED: 任务已完成,不能再次完成'); + } + this.status = TaskStatuses.Completed; + this.completedAt = new Date(); + this.updatedAt = new Date(); + } + + /** + * 添加标签 + */ + addTag(tag: Tag): void { + if (!tag.name || tag.name.trim().length === 0) { + throw new Error('TAG_NAME_EMPTY: 标签名不能为空'); + } + if (this.tags.length >= 10) { + throw new Error('TOO_MANY_TAGS: 标签过多,最多 10 个'); + } + // 检查重复 + if (this.tags.some((t) => t.name === tag.name)) { + throw new Error('DUPLICATE_TAG: 标签重复'); + } + this.tags.push(tag); + this.updatedAt = new Date(); + } + + /** + * 移除标签 + */ + removeTag(tagName: string): void { + this.tags = this.tags.filter((t) => t.name !== tagName); + this.updatedAt = new Date(); + } +} + +/** + * 验证优先级是否有效 + */ +function isValidPriority(priority: Priority): boolean { + return ( + priority === Priorities.Low || + priority === Priorities.Medium || + priority === Priorities.High + ); +} + diff --git a/backend-nodejs/domains/task/repository/interface.ts b/backend-nodejs/domains/task/repository/interface.ts new file mode 100644 index 0000000..db2868b --- /dev/null +++ b/backend-nodejs/domains/task/repository/interface.ts @@ -0,0 +1,61 @@ +/** + * Task Repository 接口 + * 定义任务仓储的抽象接口 + */ + +import type { Task } from '../model/task.js'; + +export interface TaskFilter { + // 筛选条件 + userId?: string; + status?: 'pending' | 'in_progress' | 'completed'; + priority?: 'low' | 'medium' | 'high'; + tag?: string; + dueDateFrom?: Date; + dueDateTo?: Date; + keyword?: string; + + // 排序 + sortBy?: 'created_at' | 'due_date' | 'priority'; + sortOrder?: 'asc' | 'desc'; + + // 分页 + page: number; + limit: number; +} + +/** + * TaskRepository 任务仓储接口 + */ +export interface TaskRepository { + /** + * 创建任务 + */ + create(ctx: unknown, task: Task): Promise; + + /** + * 根据 ID 查找任务 + */ + findById(ctx: unknown, taskId: string): Promise; + + /** + * 更新任务 + */ + update(ctx: unknown, task: Task): Promise; + + /** + * 删除任务 + */ + delete(ctx: unknown, taskId: string): Promise; + + /** + * 列出任务 + */ + list(ctx: unknown, filter: TaskFilter): Promise<{ tasks: Task[]; total: number }>; + + /** + * 检查任务是否存在 + */ + exists(ctx: unknown, taskId: string): Promise; +} + diff --git a/backend-nodejs/domains/task/repository/task_repo.ts b/backend-nodejs/domains/task/repository/task_repo.ts new file mode 100644 index 0000000..8899eb9 --- /dev/null +++ b/backend-nodejs/domains/task/repository/task_repo.ts @@ -0,0 +1,261 @@ +/** + * Task Repository 实现 + * 使用 Kysely 进行类型安全的数据库操作 + */ + +import type { Kysely } from 'kysely'; +import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; +import { Task } from '../model/task.js'; +import type { TaskRepository, TaskFilter } from './interface.js'; + +export class TaskRepositoryImpl implements TaskRepository { + constructor(private db: Kysely) {} + + async create(_ctx: unknown, task: Task): Promise { + // 插入任务 + await this.db + .insertInto('tasks') + .values({ + id: task.id, + user_id: task.userId, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + due_date: task.dueDate, + created_at: task.createdAt, + updated_at: task.updatedAt, + completed_at: task.completedAt, + }) + .execute(); + + // 保存标签 + if (task.tags.length > 0) { + await this.saveTags(task.id, task.tags); + } + } + + async findById(_ctx: unknown, taskId: string): Promise { + const taskRow = await this.db + .selectFrom('tasks') + .selectAll() + .where('id', '=', taskId) + .executeTakeFirst(); + + if (!taskRow) { + return null; + } + + // 加载标签 + const tags = await this.loadTags(taskId); + + return this.toDomainModel(taskRow, tags); + } + + async update(_ctx: unknown, task: Task): Promise { + const result = await this.db + .updateTable('tasks') + .set({ + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + due_date: task.dueDate, + updated_at: task.updatedAt, + completed_at: task.completedAt, + }) + .where('id', '=', task.id) + .execute(); + + if (result.length === 0) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // 更新标签(先删除旧的,再插入新的) + await this.deleteTags(task.id); + if (task.tags.length > 0) { + await this.saveTags(task.id, task.tags); + } + } + + async delete(_ctx: unknown, taskId: string): Promise { + const result = await this.db + .deleteFrom('tasks') + .where('id', '=', taskId) + .execute(); + + if (result.length === 0) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // 标签会通过外键级联删除 + } + + async list( + _ctx: unknown, + filter: TaskFilter + ): Promise<{ tasks: Task[]; total: number }> { + // 构建查询 + let query = this.db.selectFrom('tasks'); + + // 应用筛选条件 + if (filter.userId) { + query = query.where('user_id', '=', filter.userId); + } + if (filter.status) { + query = query.where('status', '=', filter.status); + } + if (filter.priority) { + query = query.where('priority', '=', filter.priority); + } + if (filter.keyword) { + const keyword = `%${filter.keyword}%`; + query = query.where((eb) => + eb.or([ + eb('title', 'like', keyword), + eb('description', 'like', keyword), + ]) + ); + } + if (filter.dueDateFrom) { + query = query.where('due_date', '>=', filter.dueDateFrom); + } + if (filter.dueDateTo) { + query = query.where('due_date', '<=', filter.dueDateTo); + } + if (filter.tag) { + // 子查询:查找包含该标签的任务 ID + const subQuery = this.db + .selectFrom('task_tags') + .select('task_id') + .where('tag_name', '=', filter.tag); + query = query.where('id', 'in', subQuery); + } + + // 查询总数 + const totalResult = await query + .select((eb) => eb.fn.count('id').as('total')) + .executeTakeFirst(); + const total = Number(totalResult?.total || 0); + + // 排序 + const sortBy = filter.sortBy || 'created_at'; + const sortOrder = filter.sortOrder || 'desc'; + if (sortOrder === 'asc') { + query = query.orderBy(sortBy, 'asc'); + } else { + query = query.orderBy(sortBy, 'desc'); + } + + // 分页 + const offset = (filter.page - 1) * filter.limit; + query = query.limit(filter.limit).offset(offset); + + // 执行查询 + const taskRows = await query.selectAll().execute(); + + // 加载标签并转换为领域模型 + const tasks: Task[] = []; + for (const row of taskRows) { + const tags = await this.loadTags(row.id); + tasks.push(this.toDomainModel(row, tags)); + } + + return { tasks, total }; + } + + async exists(_ctx: unknown, taskId: string): Promise { + const result = await this.db + .selectFrom('tasks') + .select((eb) => eb.fn.count('id').as('count')) + .where('id', '=', taskId) + .executeTakeFirst(); + + return Number(result?.count || 0) > 0; + } + + // ============================================ + // 私有辅助方法 + // ============================================ + + /** + * 保存标签 + */ + private async saveTags(taskId: string, tags: Array<{ name: string; color: string }>): Promise { + if (tags.length === 0) { + return; + } + + // 检查并插入标签(避免重复) + for (const tag of tags) { + const existing = await this.db + .selectFrom('task_tags') + .select('task_id') + .where('task_id', '=', taskId) + .where('tag_name', '=', tag.name) + .executeTakeFirst(); + + if (!existing) { + // 不存在,插入 + await this.db + .insertInto('task_tags') + .values({ + task_id: taskId, + tag_name: tag.name, + tag_color: tag.color || '#808080', + }) + .execute(); + } + // 已存在,跳过 + } + } + + /** + * 删除标签 + */ + private async deleteTags(taskId: string): Promise { + await this.db + .deleteFrom('task_tags') + .where('task_id', '=', taskId) + .execute(); + } + + /** + * 加载标签 + */ + private async loadTags(taskId: string): Promise> { + const tagRows = await this.db + .selectFrom('task_tags') + .select(['tag_name', 'tag_color']) + .where('task_id', '=', taskId) + .execute(); + + return tagRows.map((row) => ({ + name: row.tag_name, + color: row.tag_color || '#808080', + })); + } + + /** + * 将数据库行转换为领域模型 + */ + private toDomainModel( + row: Database['tasks'], + tags: Array<{ name: string; color: string }> + ): Task { + return new Task( + row.id, + row.user_id || '', + row.title, + row.description || '', + row.priority as 'low' | 'medium' | 'high', + row.status as 'pending' | 'in_progress' | 'completed', + row.due_date || null, + tags, + row.created_at, + row.updated_at, + row.completed_at || null + ); + } +} + diff --git a/backend-nodejs/domains/task/rules.md b/backend-nodejs/domains/task/rules.md new file mode 100644 index 0000000..67da0b7 --- /dev/null +++ b/backend-nodejs/domains/task/rules.md @@ -0,0 +1,469 @@ +# Task Domain Business Rules (任务领域业务规则) + +> 本文档定义了 Task 领域的所有业务规则和约束 + +**最后更新**:2025-11-23 + +--- + +## 📋 规则分类 + +- **[验证规则](#验证规则)**:输入数据的有效性检查 +- **[状态规则](#状态规则)**:状态转换的约束 +- **[业务约束](#业务约束)**:业务逻辑的限制 +- **[数据一致性](#数据一致性)**:数据完整性保障 + +--- + +## 验证规则 + +### R1.1 任务标题不能为空 + +**规则**:`TASK_TITLE_EMPTY` + +**条件**:创建或更新任务时 + +**约束**: +- 标题(Title)必须非空 +- 标题长度 >= 1 +- 标题长度 <= 200 + +**错误码**:`TASK_TITLE_EMPTY` + +**HTTP 状态码**:400 Bad Request + +**错误消息**:`"任务标题不能为空"` + +**示例**: +```go +// ❌ 错误 +task := &Task{ + Title: "", // 空标题 +} + +// ✅ 正确 +task := &Task{ + Title: "完成项目文档", +} +``` + +--- + +### R1.2 任务描述长度限制 + +**规则**:`TASK_DESCRIPTION_TOO_LONG` + +**条件**:创建或更新任务时 + +**约束**: +- 描述(Description)长度 <= 5000 字符 +- 描述可以为空 + +**错误码**:`TASK_DESCRIPTION_TOO_LONG` + +**HTTP 状态码**:400 Bad Request + +--- + +### R1.3 优先级必须有效 + +**规则**:`INVALID_PRIORITY` + +**条件**:创建或更新任务时 + +**约束**: +- Priority 必须是 `low`, `medium`, `high` 之一 +- 如果未提供,默认为 `medium` + +**错误码**:`INVALID_PRIORITY` + +**HTTP 状态码**:400 Bad Request + +**错误消息**:`"优先级无效,必须是 low, medium 或 high"` + +--- + +### R1.4 截止日期不能早于创建日期 + +**规则**:`INVALID_DUE_DATE` + +**条件**:创建或更新任务时 + +**约束**: +- 如果提供了 DueDate,必须 >= CreatedAt +- DueDate 可以为空(表示无截止日期) + +**错误码**:`INVALID_DUE_DATE` + +**HTTP 状态码**:400 Bad Request + +**错误消息**:`"截止日期不能早于创建日期"` + +**示例**: +```go +// ❌ 错误 +task := &Task{ + CreatedAt: time.Date(2025, 11, 23, 10, 0, 0, 0, time.UTC), + DueDate: time.Date(2025, 11, 22, 10, 0, 0, 0, time.UTC), // 早于创建日期 +} + +// ✅ 正确 +task := &Task{ + CreatedAt: time.Date(2025, 11, 23, 10, 0, 0, 0, time.UTC), + DueDate: time.Date(2025, 11, 30, 10, 0, 0, 0, time.UTC), // 晚于创建日期 +} +``` + +--- + +### R1.5 标签名称不能为空 + +**规则**:`TAG_NAME_EMPTY` + +**条件**:添加标签时 + +**约束**: +- 标签名称必须非空 +- 标签名称长度 >= 1 +- 标签名称长度 <= 50 + +**错误码**:`TAG_NAME_EMPTY` + +**HTTP 状态码**:400 Bad Request + +--- + +## 状态规则 + +### R2.1 只能从 Pending 或 InProgress 完成任务 + +**规则**:`TASK_ALREADY_COMPLETED` + +**条件**:调用 CompleteTask 时 + +**约束**: +- 只有状态为 `Pending` 或 `InProgress` 的任务可以完成 +- 状态为 `Completed` 的任务不能再次完成 + +**错误码**:`TASK_ALREADY_COMPLETED` + +**HTTP 状态码**:400 Bad Request + +**错误消息**:`"任务已完成,不能再次完成"` + +**状态转换图**: +``` +Pending ──────┐ + ├──> Completed +InProgress ───┘ + +Completed ──X──> Completed (不允许) +``` + +**示例**: +```go +// ❌ 错误 +task := &Task{Status: StatusCompleted} +task.Complete() // 报错:TASK_ALREADY_COMPLETED + +// ✅ 正确 +task := &Task{Status: StatusPending} +task.Complete() // 成功 +``` + +--- + +### R2.2 完成任务时记录完成时间 + +**规则**:`RECORD_COMPLETED_AT` + +**条件**:任务状态变更为 Completed 时 + +**约束**: +- 必须记录 CompletedAt 时间戳 +- CompletedAt 不能早于 CreatedAt +- CompletedAt 应该是当前时间 + +**实现**: +```go +func (t *Task) Complete() error { + if t.Status == StatusCompleted { + return ErrTaskAlreadyCompleted + } + + t.Status = StatusCompleted + t.CompletedAt = time.Now() // 记录完成时间 + t.UpdatedAt = time.Now() + + return nil +} +``` + +--- + +### R2.3 状态变更必须合法 + +**规则**:`INVALID_STATUS_TRANSITION` + +**条件**:更新任务状态时 + +**允许的状态转换**: +``` +Pending → InProgress ✅ +Pending → Completed ✅ +InProgress → Completed ✅ +``` + +**不允许的状态转换**: +``` +Completed → Pending ❌ +Completed → InProgress ❌ +InProgress → Pending ❌ (可选:是否允许暂停) +``` + +**错误码**:`INVALID_STATUS_TRANSITION` + +**HTTP 状态码**:400 Bad Request + +--- + +## 业务约束 + +### R3.1 任务 ID 必须唯一 + +**规则**:`TASK_ID_DUPLICATE` + +**条件**:创建任务时 + +**约束**: +- TaskID 在系统中必须唯一 +- 使用 UUID 或雪花算法生成 ID + +**错误码**:`TASK_ID_DUPLICATE` + +**HTTP 状态码**:409 Conflict + +**实现**: +```go +func NewTask(title string) *Task { + return &Task{ + ID: generateUUID(), // 生成唯一 ID + Title: title, + Status: StatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} +``` + +--- + +### R3.2 同一任务的标签名称不能重复 + +**规则**:`DUPLICATE_TAG` + +**条件**:添加标签时 + +**约束**: +- 一个任务内,标签名称必须唯一 +- 不同任务可以有相同的标签 + +**错误码**:`DUPLICATE_TAG` + +**HTTP 状态码**:400 Bad Request + +**示例**: +```go +// ❌ 错误 +task.Tags = []Tag{ + {Name: "工作"}, + {Name: "工作"}, // 重复 +} + +// ✅ 正确 +task.Tags = []Tag{ + {Name: "工作"}, + {Name: "紧急"}, +} +``` + +--- + +### R3.3 任务最多支持 10 个标签 + +**规则**:`TOO_MANY_TAGS` + +**条件**:添加标签时 + +**约束**: +- 每个任务最多 10 个标签 +- 防止标签滥用 + +**错误码**:`TOO_MANY_TAGS` + +**HTTP 状态码**:400 Bad Request + +--- + +## 数据一致性 + +### R4.1 删除任务时清理相关数据 + +**规则**:`CASCADE_DELETE` + +**条件**:删除任务时 + +**约束**: +- 删除任务时,应该清理相关的标签关联 +- 如果实现了子任务,应该清理子任务 +- 如果实现了评论,应该清理评论 + +**实现方式**: +- 数据库外键级联删除 +- 或应用层显式删除 + +--- + +### R4.2 更新操作必须更新 UpdatedAt + +**规则**:`UPDATE_TIMESTAMP` + +**条件**:任何更新操作 + +**约束**: +- 任何字段更新都必须更新 UpdatedAt +- UpdatedAt 应该是当前时间 +- UpdatedAt >= CreatedAt + +**实现**: +```go +func (t *Task) Update(title, description string) { + t.Title = title + t.Description = description + t.UpdatedAt = time.Now() // 更新时间戳 +} +``` + +--- + +### R4.3 任务不存在时返回 404 + +**规则**:`TASK_NOT_FOUND` + +**条件**:查询、更新、删除不存在的任务时 + +**约束**: +- 返回 404 Not Found +- 错误消息明确说明任务不存在 + +**错误码**:`TASK_NOT_FOUND` + +**HTTP 状态码**:404 Not Found + +**错误消息**:`"任务不存在:{task_id}"` + +--- + +## 查询规则 + +### R5.1 列表查询必须支持分页 + +**规则**:`PAGINATION_REQUIRED` + +**条件**:ListTasks 操作 + +**约束**: +- 必须提供 `page` 和 `limit` 参数 +- page >= 1 +- limit >= 1 且 limit <= 100 +- 默认值:page=1, limit=20 + +**错误码**:`INVALID_PAGINATION` + +**HTTP 状态码**:400 Bad Request + +--- + +### R5.2 筛选参数必须有效 + +**规则**:`INVALID_FILTER` + +**条件**:ListTasks 操作时使用筛选 + +**支持的筛选字段**: +- `status` - 必须是有效的 TaskStatus +- `priority` - 必须是有效的 Priority +- `tag` - 标签名称 +- `due_date_from` - ISO 8601 格式 +- `due_date_to` - ISO 8601 格式 + +**错误码**:`INVALID_FILTER` + +**HTTP 状态码**:400 Bad Request + +--- + +## 权限规则(未实现) + +以下是潜在的权限规则,当前版本未实现: + +### R6.1 用户只能访问自己的任务 + +**规则**:`UNAUTHORIZED_ACCESS` + +**条件**:所有操作 + +**约束**: +- 用户只能查看、修改、删除自己创建的任务 +- 管理员可以访问所有任务 + +**错误码**:`UNAUTHORIZED_ACCESS` + +**HTTP 状态码**:403 Forbidden + +--- + +### R6.2 共享任务的权限控制 + +**规则**:`SHARED_TASK_PERMISSION` + +**条件**:访问共享任务时 + +**约束**: +- 只读权限:可以查看,不能修改 +- 编辑权限:可以修改,不能删除 +- 所有者权限:可以进行所有操作 + +--- + +## 测试覆盖 + +每个业务规则都应该有对应的测试用例: + +| 规则编号 | 测试用例 | 覆盖 | +|---------|---------|------| +| R1.1 | TestCreateTask_EmptyTitle | ✅ | +| R1.1 | TestUpdateTask_EmptyTitle | ✅ | +| R1.4 | TestCreateTask_InvalidDueDate | ✅ | +| R2.1 | TestCompleteTask_AlreadyCompleted | ✅ | +| R2.2 | TestCompleteTask_RecordCompletedAt | ✅ | +| R3.2 | TestAddTag_Duplicate | ✅ | +| R3.3 | TestAddTag_TooMany | ✅ | +| R4.3 | TestGetTask_NotFound | ✅ | + +--- + +## 规则变更日志 + +### 2025-11-23 +- 初始版本 +- 定义了所有核心业务规则 + +--- + +**维护说明**: +- 添加新规则时,分配唯一的规则编号 +- 规则应该可测试、可验证 +- 规则变更应该记录在变更日志中 +- 定期审查规则的合理性 + diff --git a/backend-nodejs/domains/task/service/task_service.ts b/backend-nodejs/domains/task/service/task_service.ts new file mode 100644 index 0000000..20b8271 --- /dev/null +++ b/backend-nodejs/domains/task/service/task_service.ts @@ -0,0 +1,287 @@ +/** + * Task Service 领域服务层 + * 实现业务用例,封装复杂业务流程 + */ + +import { Task } from '../model/task.js'; +import type { TaskRepository, TaskFilter } from '../repository/interface.js'; + +export interface CreateTaskInput { + userId: string; + title: string; + description: string; + priority?: 'low' | 'medium' | 'high'; + dueDate?: Date; + tags?: string[]; +} + +export interface CreateTaskOutput { + task: Task; +} + +export interface UpdateTaskInput { + userId: string; + taskId: string; + title?: string; + description?: string; + priority?: 'low' | 'medium' | 'high'; + dueDate?: Date; + tags?: string[]; +} + +export interface UpdateTaskOutput { + task: Task; +} + +export interface CompleteTaskInput { + userId: string; + taskId: string; +} + +export interface CompleteTaskOutput { + task: Task; +} + +export interface DeleteTaskInput { + userId: string; + taskId: string; +} + +export interface DeleteTaskOutput { + success: boolean; + deletedAt: Date; +} + +export interface GetTaskInput { + userId: string; + taskId: string; +} + +export interface GetTaskOutput { + task: Task; +} + +export interface ListTasksInput { + filter: TaskFilter; +} + +export interface ListTasksOutput { + tasks: Task[]; + totalCount: number; + page: number; + limit: number; + hasMore: boolean; +} + +/** + * TaskService 任务领域服务 + */ +export class TaskService { + constructor(private taskRepo: TaskRepository) {} + + /** + * 创建任务 + */ + async createTask(ctx: unknown, input: CreateTaskInput): Promise { + // Step 1: ValidateInput + if (!input.userId || input.userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + if (!input.title || input.title.trim().length === 0) { + throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + } + + // Step 2 & 3: CreateTaskEntity + const priority = input.priority || 'medium'; + const task = Task.create(input.userId, input.title, input.description, priority); + + // 设置截止日期 + if (input.dueDate) { + task.setDueDate(input.dueDate); + } + + // 添加标签 + if (input.tags && input.tags.length > 10) { + throw new Error('TOO_MANY_TAGS: 标签过多,最多 10 个'); + } + + if (input.tags) { + for (const tagName of input.tags) { + task.addTag({ + name: tagName, + color: '#808080', // 默认颜色 + }); + } + } + + // Step 4: SaveTask + await this.taskRepo.create(ctx, task); + + // Step 5: PublishTaskCreatedEvent (Extension point) + // eventBus.publish(ctx, 'task.created', { ... }); + + return { task }; + } + + /** + * 更新任务 + */ + async updateTask(ctx: unknown, input: UpdateTaskInput): Promise { + // Step 1: ValidateUserID + if (!input.userId || input.userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + + // Step 2: GetTask + const task = await this.taskRepo.findById(ctx, input.taskId); + if (!task) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // Step 2.1: CheckOwnership + if (task.userId !== input.userId) { + throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + } + + // Step 3: CheckIfCompleted + if (task.status === 'completed') { + throw new Error('TASK_ALREADY_COMPLETED: 已完成的任务不能更新'); + } + + // Step 4: UpdateTaskFields + if (input.title !== undefined) { + task.update(input.title, undefined, undefined); + } + if (input.description !== undefined) { + task.update(undefined, input.description, undefined); + } + if (input.priority !== undefined) { + task.update(undefined, undefined, input.priority); + } + if (input.dueDate !== undefined) { + task.setDueDate(input.dueDate); + } + + // 更新标签 + if (input.tags !== undefined) { + // 清空现有标签 + task.tags.forEach((tag) => task.removeTag(tag.name)); + // 添加新标签 + for (const tagName of input.tags) { + task.addTag({ + name: tagName, + color: '#808080', + }); + } + } + + // Step 5: SaveTask + await this.taskRepo.update(ctx, task); + + // Step 6: PublishTaskUpdatedEvent (Extension point) + return { task }; + } + + /** + * 完成任务 + */ + async completeTask(ctx: unknown, input: CompleteTaskInput): Promise { + // Step 1: ValidateUserID + if (!input.userId || input.userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + + // Step 2: GetTask + const task = await this.taskRepo.findById(ctx, input.taskId); + if (!task) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // Step 3: CheckOwnership + if (task.userId !== input.userId) { + throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + } + + // Step 4 & 5: CheckStatus & MarkAsCompleted + task.complete(); + + // Step 7: SaveTask + await this.taskRepo.update(ctx, task); + + // Step 8: PublishTaskCompletedEvent (Extension point) + return { task }; + } + + /** + * 删除任务 + */ + async deleteTask(ctx: unknown, input: DeleteTaskInput): Promise { + // Step 1: ValidateUserID + if (!input.userId || input.userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + + // Step 2: GetTask + const task = await this.taskRepo.findById(ctx, input.taskId); + if (!task) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // Step 3: CheckOwnership + if (task.userId !== input.userId) { + throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + } + + // Step 4: DeleteTaskRecord + await this.taskRepo.delete(ctx, input.taskId); + + // Step 5: PublishTaskDeletedEvent (Extension point) + return { + success: true, + deletedAt: new Date(), + }; + } + + /** + * 获取任务详情 + */ + async getTask(ctx: unknown, input: GetTaskInput): Promise { + // Step 1: ValidateUserID + if (!input.userId || input.userId.trim().length === 0) { + throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + } + + // Step 2: GetTask + const task = await this.taskRepo.findById(ctx, input.taskId); + if (!task) { + throw new Error('TASK_NOT_FOUND: 任务不存在'); + } + + // Step 3: CheckOwnership + if (task.userId !== input.userId) { + throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + } + + return { task }; + } + + /** + * 列出任务 + */ + async listTasks(ctx: unknown, input: ListTasksInput): Promise { + // Step 2 & 3: QueryTasks + CountTotalTasks + const { tasks, total } = await this.taskRepo.list(ctx, input.filter); + + // Step 4: FormatResponse + const hasMore = input.filter.page * input.filter.limit < total; + + return { + tasks, + totalCount: total, + page: input.filter.page, + limit: input.filter.limit, + hasMore, + }; + } +} + diff --git a/backend-nodejs/domains/task/usecases.yaml b/backend-nodejs/domains/task/usecases.yaml new file mode 100644 index 0000000..5633fae --- /dev/null +++ b/backend-nodejs/domains/task/usecases.yaml @@ -0,0 +1,605 @@ +# Task Domain Use Cases +# 用例声明文件 - AI 可读,用于自动生成 Handler 代码 + +version: "1.0" +domain: task + +usecases: + # ======================================== + # 用例 1: 创建任务 + # ======================================== + CreateTask: + description: "创建一个新的任务" + sensitivity: low + http: + method: POST + path: /api/tasks + + input: + title: + type: string + required: true + validation: "max=200,min=1" + description: "任务标题" + description: + type: string + required: false + validation: "max=5000" + description: "任务描述" + priority: + type: string + required: false + default: "medium" + validation: "oneof=low medium high" + description: "优先级" + due_date: + type: string + required: false + validation: "omitempty,datetime=2006-01-02T15:04:05Z07:00" + description: "截止日期 (ISO 8601 格式)" + tags: + type: array + items: string + required: false + validation: "max=10,dive,max=50" + description: "标签列表(最多 10 个)" + + output: + task_id: + type: string + description: "任务 ID" + title: + type: string + description: "任务标题" + status: + type: string + description: "任务状态 (pending)" + created_at: + type: string + description: "创建时间 (ISO 8601)" + + steps: + - name: ValidateInput + type: sync + description: "验证输入参数" + on_fail: abort + + - name: GenerateTaskID + type: sync + description: "生成唯一的任务 ID" + + - name: CreateTaskEntity + type: sync + description: "创建任务实体" + + - name: SaveTask + type: sync + description: "保存任务到数据库" + on_fail: abort + + - name: PublishTaskCreatedEvent + type: event + event_type: TaskCreated + description: "发布任务创建事件" + on_fail: log + + errors: + - code: TASK_TITLE_EMPTY + message: "任务标题不能为空" + http_status: 400 + - code: TASK_DESCRIPTION_TOO_LONG + message: "任务描述过长,最大 5000 字符" + http_status: 400 + - code: INVALID_PRIORITY + message: "优先级无效,必须是 low, medium 或 high" + http_status: 400 + - code: INVALID_DUE_DATE + message: "截止日期格式无效或早于当前时间" + http_status: 400 + - code: TOO_MANY_TAGS + message: "标签过多,最多 10 个" + http_status: 400 + - code: CREATION_FAILED + message: "创建任务失败" + http_status: 500 + + # ======================================== + # 用例 2: 更新任务 + # ======================================== + UpdateTask: + description: "更新任务的属性" + sensitivity: low + http: + method: PUT + path: /api/tasks/:id + + input: + task_id: + type: string + required: true + source: path + description: "任务 ID" + title: + type: string + required: false + validation: "omitempty,max=200,min=1" + description: "任务标题" + description: + type: string + required: false + validation: "max=5000" + description: "任务描述" + priority: + type: string + required: false + validation: "omitempty,oneof=low medium high" + description: "优先级" + due_date: + type: string + required: false + validation: "omitempty,datetime=2006-01-02T15:04:05Z07:00" + description: "截止日期" + tags: + type: array + items: string + required: false + validation: "omitempty,max=10,dive,max=50" + description: "标签列表" + + output: + task_id: + type: string + title: + type: string + status: + type: string + updated_at: + type: string + + steps: + - name: ValidateInput + type: sync + on_fail: abort + + - name: GetTask + type: sync + description: "获取任务" + on_fail: abort + error: TASK_NOT_FOUND + + - name: CheckIfCompleted + type: sync + description: "检查任务是否已完成" + on_fail: abort + error: TASK_ALREADY_COMPLETED + + - name: UpdateTaskFields + type: sync + description: "更新任务字段" + + - name: SaveTask + type: sync + description: "保存任务" + on_fail: abort + + - name: PublishTaskUpdatedEvent + type: event + event_type: TaskUpdated + on_fail: log + + errors: + - code: TASK_NOT_FOUND + message: "任务不存在" + http_status: 404 + - code: TASK_ALREADY_COMPLETED + message: "已完成的任务不能更新" + http_status: 400 + - code: INVALID_PRIORITY + message: "优先级无效" + http_status: 400 + - code: UPDATE_FAILED + message: "更新任务失败" + http_status: 500 + + # ======================================== + # 用例 3: 完成任务 + # ======================================== + CompleteTask: + description: "将任务标记为已完成" + sensitivity: low + http: + method: POST + path: /api/tasks/:id/complete + + input: + task_id: + type: string + required: true + source: path + description: "任务 ID" + + output: + task_id: + type: string + status: + type: string + description: "任务状态 (completed)" + completed_at: + type: string + description: "完成时间" + + steps: + - name: GetTask + type: sync + description: "获取任务" + on_fail: abort + error: TASK_NOT_FOUND + + - name: CheckStatus + type: sync + description: "检查任务状态" + on_fail: abort + error: TASK_ALREADY_COMPLETED + + - name: MarkAsCompleted + type: sync + description: "标记为已完成" + + - name: RecordCompletionTime + type: sync + description: "记录完成时间" + + - name: SaveTask + type: sync + description: "保存任务" + on_fail: abort + + - name: PublishTaskCompletedEvent + type: event + event_type: TaskCompleted + on_fail: log + + errors: + - code: TASK_NOT_FOUND + message: "任务不存在" + http_status: 404 + - code: TASK_ALREADY_COMPLETED + message: "任务已完成,不能再次完成" + http_status: 400 + - code: COMPLETION_FAILED + message: "完成任务失败" + http_status: 500 + + # ======================================== + # 用例 4: 删除任务 + # ======================================== + DeleteTask: + description: "删除任务" + sensitivity: medium + http: + method: DELETE + path: /api/tasks/:id + + input: + task_id: + type: string + required: true + source: path + description: "任务 ID" + + output: + success: + type: bool + description: "是否删除成功" + deleted_at: + type: string + description: "删除时间" + + steps: + - name: GetTask + type: sync + description: "获取任务" + on_fail: abort + error: TASK_NOT_FOUND + + - name: DeleteTaskRecord + type: sync + description: "删除任务记录" + on_fail: abort + + - name: PublishTaskDeletedEvent + type: event + event_type: TaskDeleted + on_fail: log + + errors: + - code: TASK_NOT_FOUND + message: "任务不存在" + http_status: 404 + - code: DELETION_FAILED + message: "删除任务失败" + http_status: 500 + + # ======================================== + # 用例 5: 获取任务详情 + # ======================================== + GetTask: + description: "获取任务的详细信息" + sensitivity: low + http: + method: GET + path: /api/tasks/:id + + input: + task_id: + type: string + required: true + source: path + description: "任务 ID" + + output: + task_id: + type: string + title: + type: string + description: + type: string + status: + type: string + priority: + type: string + due_date: + type: string + tags: + type: array + items: string + created_at: + type: string + updated_at: + type: string + completed_at: + type: string + + steps: + - name: GetTask + type: sync + description: "从数据库获取任务" + on_fail: abort + error: TASK_NOT_FOUND + + - name: FormatResponse + type: sync + description: "格式化响应数据" + + errors: + - code: TASK_NOT_FOUND + message: "任务不存在" + http_status: 404 + + # ======================================== + # 用例 6: 列出任务 + # ======================================== + ListTasks: + description: "获取任务列表,支持筛选、排序和分页" + sensitivity: low + http: + method: GET + path: /api/tasks + + input: + # 筛选参数 + status: + type: string + required: false + validation: "omitempty,oneof=pending in_progress completed" + source: query + description: "按状态筛选" + priority: + type: string + required: false + validation: "omitempty,oneof=low medium high" + source: query + description: "按优先级筛选" + tag: + type: string + required: false + source: query + description: "按标签筛选" + due_date_from: + type: string + required: false + validation: "omitempty,datetime=2006-01-02" + source: query + description: "截止日期开始" + due_date_to: + type: string + required: false + validation: "omitempty,datetime=2006-01-02" + source: query + description: "截止日期结束" + keyword: + type: string + required: false + validation: "omitempty,max=100" + source: query + description: "关键词搜索(标题/描述)" + + # 排序参数 + sort_by: + type: string + required: false + default: "created_at" + validation: "omitempty,oneof=created_at due_date priority" + source: query + description: "排序字段" + sort_order: + type: string + required: false + default: "desc" + validation: "omitempty,oneof=asc desc" + source: query + description: "排序方向" + + # 分页参数 + page: + type: int + required: false + default: 1 + validation: "omitempty,min=1" + source: query + description: "页码" + limit: + type: int + required: false + default: 20 + validation: "omitempty,min=1,max=100" + source: query + description: "每页数量" + + output: + tasks: + type: array + items: + task_id: string + title: string + status: string + priority: string + due_date: string + tags: array + created_at: string + total_count: + type: int + description: "总任务数" + page: + type: int + description: "当前页码" + limit: + type: int + description: "每页数量" + has_more: + type: bool + description: "是否还有更多" + + steps: + - name: ValidateQueryParams + type: sync + description: "验证查询参数" + on_fail: abort + + - name: BuildQuery + type: sync + description: "构建查询条件" + + - name: QueryTasks + type: sync + description: "查询任务列表" + on_fail: abort + + - name: CountTotalTasks + type: sync + description: "统计总数" + on_fail: log + + - name: FormatResponse + type: sync + description: "格式化响应" + + errors: + - code: INVALID_FILTER + message: "筛选参数无效" + http_status: 400 + - code: INVALID_PAGINATION + message: "分页参数无效" + http_status: 400 + - code: QUERY_FAILED + message: "查询失败" + http_status: 500 + +# ======================================== +# 全局配置 +# ======================================== +config: + default_timeout: 30s + max_retries: 3 + enable_tracing: true + enable_metrics: true + +# ======================================== +# 依赖关系 +# ======================================== +dependencies: + external: [] # 当前无外部依赖 + + infrastructure: + - name: database + type: PostgreSQL + description: "任务存储" + - name: cache + type: Redis + description: "缓存和限流(可选)" + required: false + - name: eventBus + type: InMemory + description: "事件总线(可扩展到 Kafka/Redis)" + +# ======================================== +# 扩展点 +# ======================================== +extensions: + - name: User Authentication + description: "添加用户认证,限制用户只能访问自己的任务" + status: not_implemented + + - name: Task Sharing + description: "任务分享功能,允许多用户协作" + status: not_implemented + + - name: Recurring Tasks + description: "周期性任务(每日、每周、每月重复)" + status: not_implemented + + - name: Subtasks + description: "子任务支持,任务可以分解为多个子任务" + status: not_implemented + + - name: Task Comments + description: "任务评论和讨论" + status: not_implemented + + - name: File Attachments + description: "任务附件功能" + status: not_implemented + +# ======================================== +# 映射指南 +# ======================================== +mapping_guide: + description: "如何将 Task 领域映射到你的业务" + examples: + - from: Task + to: Product (商品) + notes: | + - Task → Product + - Status (pending/completed) → ProductStatus (draft/published) + - Priority → Category + - CreateTask → CreateProduct + - CompleteTask → PublishProduct + + - from: Task + to: Order (订单) + notes: | + - Task → Order + - Status (pending/in_progress/completed) → OrderStatus (created/processing/shipped) + - Priority → ShippingPriority + - CreateTask → CreateOrder + - CompleteTask → ShipOrder + + - from: Task + to: Article (文章) + notes: | + - Task → Article + - Status (pending/completed) → ArticleStatus (draft/published) + - Priority → Featured + - CreateTask → CreateArticle + - CompleteTask → PublishArticle + diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index 0504bf7..b0d4fcb 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -107,3 +107,16 @@ export function registerRoutes( }); } +/** + * 注册领域路由 + * 接收 HandlerDependencies 并注册所有领域路由 + */ +export async function registerDomainRoutes( + fastify: FastifyInstance, + handlerDeps: unknown +): Promise { + // 动态导入并注册 Task 路由 + const taskRouter = await import('../../domains/task/http/router.js'); + taskRouter.registerTaskRoutes(fastify, handlerDeps as Parameters[1]); +} + diff --git a/backend-nodejs/infrastructure/persistence/postgres/database.ts b/backend-nodejs/infrastructure/persistence/postgres/database.ts index 4b797ea..a56e489 100644 --- a/backend-nodejs/infrastructure/persistence/postgres/database.ts +++ b/backend-nodejs/infrastructure/persistence/postgres/database.ts @@ -27,10 +27,14 @@ export interface Database { status: 'pending' | 'in_progress' | 'completed'; priority: 'low' | 'medium' | 'high'; due_date: Date | null; - tags: string[] | null; created_at: Date; updated_at: Date; completed_at: Date | null; }; + task_tags: { + task_id: string; + tag_name: string; + tag_color: string | null; + }; } From fc2894c585d077815d615e1a1d9537a87fa1b876 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 12:51:12 +0800 Subject: [PATCH 04/22] feat: implement user and auth domains with complete CRUD operations, including handlers, services, and repository integration for user management in Node.js backend --- backend-nodejs/MIGRATION_PLAN.md | 127 ++++ backend-nodejs/MIGRATION_STATUS.md | 400 ++++++++++++ backend-nodejs/TESTING.md | 280 +++++++++ backend-nodejs/cmd/server/main.ts | 41 +- backend-nodejs/domains/auth/README.md | 303 +++++++++ backend-nodejs/domains/auth/ai-metadata.json | 243 ++++++++ backend-nodejs/domains/auth/errors/errors.ts | 42 ++ backend-nodejs/domains/auth/events.md | 336 ++++++++++ backend-nodejs/domains/auth/glossary.md | 398 ++++++++++++ .../domains/auth/handlers/converters.ts | 84 +++ .../domains/auth/handlers/dependencies.ts | 11 + .../domains/auth/handlers/login.handler.ts | 45 ++ .../auth/handlers/refresh_token.handler.ts | 48 ++ .../domains/auth/handlers/register.handler.ts | 48 ++ backend-nodejs/domains/auth/http/dto/auth.ts | 55 ++ backend-nodejs/domains/auth/http/router.ts | 35 ++ backend-nodejs/domains/auth/rules.md | 494 +++++++++++++++ .../domains/auth/service/auth_service.ts | 201 ++++++ .../domains/auth/service/jwt_service.ts | 157 +++++ backend-nodejs/domains/auth/usecases.yaml | 365 +++++++++++ .../task/handlers/complete_task.handler.ts | 3 +- .../task/handlers/create_task.handler.ts | 27 +- .../task/handlers/delete_task.handler.ts | 3 +- .../domains/task/handlers/get_task.handler.ts | 3 +- .../task/handlers/list_tasks.handler.ts | 3 +- .../task/handlers/update_task.handler.ts | 11 +- backend-nodejs/domains/task/http/router.ts | 86 ++- .../domains/task/model/task.test.ts | 326 ++++++++++ .../domains/task/tests/complete_task.test.ts | 145 +++++ .../domains/task/tests/create_task.test.ts | 281 +++++++++ .../domains/task/tests/delete_task.test.ts | 121 ++++ .../domains/task/tests/get_task.test.ts | 124 ++++ backend-nodejs/domains/task/tests/helpers.ts | 178 ++++++ .../domains/task/tests/list_tasks.test.ts | 158 +++++ .../domains/task/tests/update_task.test.ts | 178 ++++++ backend-nodejs/domains/user/README.md | 178 ++++++ backend-nodejs/domains/user/ai-metadata.json | 261 ++++++++ backend-nodejs/domains/user/errors/errors.ts | 48 ++ backend-nodejs/domains/user/events.md | 404 ++++++++++++ backend-nodejs/domains/user/glossary.md | 328 ++++++++++ .../user/handlers/change_password.handler.ts | 49 ++ .../domains/user/handlers/converters.ts | 113 ++++ .../domains/user/handlers/dependencies.ts | 11 + .../user/handlers/get_user_profile.handler.ts | 51 ++ .../handlers/update_user_profile.handler.ts | 49 ++ backend-nodejs/domains/user/http/dto/user.ts | 54 ++ backend-nodejs/domains/user/http/router.ts | 49 ++ backend-nodejs/domains/user/model/user.ts | 234 +++++++ .../domains/user/repository/interface.ts | 52 ++ .../domains/user/repository/user_repo.ts | 183 ++++++ backend-nodejs/domains/user/rules.md | 404 ++++++++++++ .../domains/user/service/user_service.ts | 138 +++++ backend-nodejs/domains/user/usecases.yaml | 265 ++++++++ .../infrastructure/bootstrap/server.ts | 29 +- .../infrastructure/config/config.ts | 12 + .../infrastructure/middleware/auth.ts | 108 ++++ backend-nodejs/package.json | 10 +- backend-nodejs/pnpm-lock.yaml | 574 +++++++++++++++++- backend-nodejs/vitest.config.ts | 31 + backend-nodejs/vitest.setup.ts | 8 + 60 files changed, 8944 insertions(+), 59 deletions(-) create mode 100644 backend-nodejs/MIGRATION_PLAN.md create mode 100644 backend-nodejs/MIGRATION_STATUS.md create mode 100644 backend-nodejs/TESTING.md create mode 100644 backend-nodejs/domains/auth/README.md create mode 100644 backend-nodejs/domains/auth/ai-metadata.json create mode 100644 backend-nodejs/domains/auth/errors/errors.ts create mode 100644 backend-nodejs/domains/auth/events.md create mode 100644 backend-nodejs/domains/auth/glossary.md create mode 100644 backend-nodejs/domains/auth/handlers/converters.ts create mode 100644 backend-nodejs/domains/auth/handlers/dependencies.ts create mode 100644 backend-nodejs/domains/auth/handlers/login.handler.ts create mode 100644 backend-nodejs/domains/auth/handlers/refresh_token.handler.ts create mode 100644 backend-nodejs/domains/auth/handlers/register.handler.ts create mode 100644 backend-nodejs/domains/auth/http/dto/auth.ts create mode 100644 backend-nodejs/domains/auth/http/router.ts create mode 100644 backend-nodejs/domains/auth/rules.md create mode 100644 backend-nodejs/domains/auth/service/auth_service.ts create mode 100644 backend-nodejs/domains/auth/service/jwt_service.ts create mode 100644 backend-nodejs/domains/auth/usecases.yaml create mode 100644 backend-nodejs/domains/task/model/task.test.ts create mode 100644 backend-nodejs/domains/task/tests/complete_task.test.ts create mode 100644 backend-nodejs/domains/task/tests/create_task.test.ts create mode 100644 backend-nodejs/domains/task/tests/delete_task.test.ts create mode 100644 backend-nodejs/domains/task/tests/get_task.test.ts create mode 100644 backend-nodejs/domains/task/tests/helpers.ts create mode 100644 backend-nodejs/domains/task/tests/list_tasks.test.ts create mode 100644 backend-nodejs/domains/task/tests/update_task.test.ts create mode 100644 backend-nodejs/domains/user/README.md create mode 100644 backend-nodejs/domains/user/ai-metadata.json create mode 100644 backend-nodejs/domains/user/errors/errors.ts create mode 100644 backend-nodejs/domains/user/events.md create mode 100644 backend-nodejs/domains/user/glossary.md create mode 100644 backend-nodejs/domains/user/handlers/change_password.handler.ts create mode 100644 backend-nodejs/domains/user/handlers/converters.ts create mode 100644 backend-nodejs/domains/user/handlers/dependencies.ts create mode 100644 backend-nodejs/domains/user/handlers/get_user_profile.handler.ts create mode 100644 backend-nodejs/domains/user/handlers/update_user_profile.handler.ts create mode 100644 backend-nodejs/domains/user/http/dto/user.ts create mode 100644 backend-nodejs/domains/user/http/router.ts create mode 100644 backend-nodejs/domains/user/model/user.ts create mode 100644 backend-nodejs/domains/user/repository/interface.ts create mode 100644 backend-nodejs/domains/user/repository/user_repo.ts create mode 100644 backend-nodejs/domains/user/rules.md create mode 100644 backend-nodejs/domains/user/service/user_service.ts create mode 100644 backend-nodejs/domains/user/usecases.yaml create mode 100644 backend-nodejs/infrastructure/middleware/auth.ts create mode 100644 backend-nodejs/vitest.config.ts create mode 100644 backend-nodejs/vitest.setup.ts diff --git a/backend-nodejs/MIGRATION_PLAN.md b/backend-nodejs/MIGRATION_PLAN.md new file mode 100644 index 0000000..5f3a107 --- /dev/null +++ b/backend-nodejs/MIGRATION_PLAN.md @@ -0,0 +1,127 @@ +# Node.js 后端迁移计划 + +## 当前进度 + +- ✅ **Task 领域** - 已完成(6 个用例,完整三层架构) + +## 迁移优先级 + +### 阶段 1: 基础领域(高优先级) + +#### 1.1 User 领域 ⭐ 下一步 +**原因**:Auth 领域依赖 User,必须先完成 + +**用例**: +- `GetUserProfile` - 获取用户资料 +- `UpdateUserProfile` - 更新用户资料 +- `ChangePassword` - 修改密码 + +**技术要点**: +- 密码哈希:使用 `bcrypt` (Node.js: `bcryptjs`) +- 用户状态管理:Active/Inactive/Banned +- 邮箱验证状态 + +**依赖**: +- 数据库:`users` 表(已存在) +- 无其他领域依赖 + +--- + +#### 1.2 Auth 领域 ⭐ 第二步 +**原因**:为其他领域提供认证保护 + +**用例**: +- `Register` - 用户注册 +- `Login` - 用户登录 +- `RefreshToken` - 刷新 Token + +**技术要点**: +- JWT Token 生成:使用 `jsonwebtoken` +- Access Token:1 小时有效期 +- Refresh Token:7 天有效期 +- 密码验证:调用 User 领域 + +**依赖**: +- User 领域(必须先完成) +- JWT 密钥配置 + +--- + +### 阶段 2: 集成与完善 + +#### 2.1 Task 领域认证集成 +**工作**: +- 添加 JWT 中间件 +- 从 Token 提取 `user_id` +- 移除硬编码的 `default-user` +- 实现真正的用户隔离 + +**影响**: +- 所有 Task Handler 需要更新 +- 添加认证中间件 + +--- + +### 阶段 3: 扩展领域(可选) + +#### 3.1 Chat 领域(如果存在) +#### 3.2 LLM 领域(如果存在) + +--- + +## 技术栈映射 + +| Go 后端 | Node.js 后端 | +|---------|-------------| +| `golang.org/x/crypto/bcrypt` | `bcryptjs` | +| `github.com/golang-jwt/jwt/v5` | `jsonwebtoken` | +| `github.com/google/uuid` | `crypto.randomUUID()` | +| `database/sql` | `kysely` | + +--- + +## 迁移检查清单 + +### User 领域 +- [ ] 复制显式知识文件 +- [ ] 创建 User 模型(包含密码哈希验证) +- [ ] 实现 UserRepository(Kysely) +- [ ] 实现 UserService(3 个用例) +- [ ] 创建 DTO 和 Handlers +- [ ] 注册路由 +- [ ] 集成到主应用 + +### Auth 领域 +- [ ] 复制显式知识文件 +- [ ] 实现 JWTService(Token 生成/验证) +- [ ] 实现 AuthService(3 个用例) +- [ ] 创建 DTO 和 Handlers +- [ ] 注册路由 +- [ ] 集成到主应用 +- [ ] 添加 JWT 中间件 + +### Task 领域完善 +- [ ] 添加认证中间件 +- [ ] 更新所有 Handler 使用真实 `user_id` +- [ ] 测试认证流程 + +--- + +## 建议 + +**立即开始**:User 领域迁移 + +**原因**: +1. 无外部依赖,可以独立完成 +2. Auth 领域依赖它 +3. Task 领域需要它来实现真正的用户隔离 + +**预计工作量**: +- User 领域:2-3 小时 +- Auth 领域:3-4 小时 +- Task 集成:1 小时 + +--- + +**下一步行动**:开始迁移 User 领域? + diff --git a/backend-nodejs/MIGRATION_STATUS.md b/backend-nodejs/MIGRATION_STATUS.md new file mode 100644 index 0000000..9ebf646 --- /dev/null +++ b/backend-nodejs/MIGRATION_STATUS.md @@ -0,0 +1,400 @@ +# Node.js 后端迁移状态对比 + +## 📊 迁移进度总览 + +| 类别 | Go 后端 | Node.js 后端 | 状态 | +|------|---------|--------------|------| +| **领域** | 3 个 | 3 个 | ✅ 100% | +| **基础设施** | 完整 | 部分 | ⚠️ 60% | +| **测试** | 有 | 无 | ❌ 0% | +| **共享代码** | 有 | 无 | ❌ 0% | + +--- + +## ✅ 已迁移的领域(100%) + +### 1. Task 领域 ✅ +- ✅ Model 层(Task 聚合根) +- ✅ Repository 层(Kysely 实现) +- ✅ Service 层(6 个用例) +- ✅ Handlers(6 个 HTTP Handler) +- ✅ DTO 和路由 +- ✅ 错误处理 +- ✅ 显式知识文件(README, glossary, rules, events, usecases.yaml, ai-metadata.json) + +### 2. User 领域 ✅ +- ✅ Model 层(User 聚合根,密码哈希) +- ✅ Repository 层(Kysely 实现) +- ✅ Service 层(3 个用例) +- ✅ Handlers(3 个 HTTP Handler) +- ✅ DTO 和路由 +- ✅ 错误处理 +- ✅ 显式知识文件 + +### 3. Auth 领域 ✅ +- ✅ JWTService(Token 生成/验证) +- ✅ AuthService(3 个用例) +- ✅ Handlers(3 个 HTTP Handler) +- ✅ DTO 和路由 +- ✅ 错误处理 +- ✅ 显式知识文件 +- ✅ JWT 认证中间件 + +--- + +## ❌ 未迁移的内容 + +### 1. 共享领域(domains/shared)❌ + +#### 1.1 事件总线(events/bus.go) +**功能**: +- `InMemoryEventBus` - 内存事件总线 +- 事件发布/订阅机制 +- 支持领域事件 + +**用途**: +- Task 领域发布 `TaskCreated`、`TaskUpdated` 等事件 +- User 领域发布 `UserCreated`、`PasswordChanged` 等事件 +- Auth 领域发布 `UserRegistered`、`LoginSucceeded` 等事件 + +**迁移优先级**:🟡 中(当前代码中已有扩展点注释,但未实现) + +**Node.js 实现建议**: +```typescript +// domains/shared/events/bus.ts +export interface Event { + type: string; + payload: unknown; + timestamp: Date; + source: string; + id: string; +} + +export interface EventBus { + publish(ctx: unknown, event: Event): Promise; + subscribe(eventType: string, handler: (event: Event) => Promise): void; +} +``` + +--- + +#### 1.2 共享类型(types/common.go) +**功能**: +- `Pagination` - 分页参数 +- `PaginatedResponse` - 分页响应 +- `UserContext` - 用户上下文 +- `TimeRange` - 时间范围 +- `RequestMetadata` - 请求元数据 +- `ErrorResponse` / `SuccessResponse` - 统一响应格式 + +**迁移优先级**:🟢 低(当前实现已满足需求,但可以统一) + +**Node.js 实现建议**: +```typescript +// domains/shared/types/common.ts +export interface Pagination { + limit: number; + offset: number; +} + +export interface PaginatedResponse { + data: T[]; + total_count: number; + limit: number; + offset: number; + has_more: boolean; +} +``` + +--- + +### 2. 基础设施中间件(infrastructure/middleware)⚠️ + +#### 2.1 请求日志中间件(logger.go)❌ +**功能**: +- 记录 HTTP 请求日志(方法、路径、状态码、延迟) +- 结构化日志(zap) +- 集成 Metrics +- TraceID 和 RequestID + +**迁移优先级**:🟡 中(Fastify 已有内置日志,但可以增强) + +**Node.js 实现建议**: +- Fastify 已内置 `pino` 日志 +- 可以添加请求日志中间件增强功能 + +--- + +#### 2.2 Panic 恢复中间件(recovery.go)❌ +**功能**: +- 捕获 panic 并返回 500 错误 +- 防止服务崩溃 + +**迁移优先级**:🟢 低(Node.js 的 try-catch 已足够,但可以添加全局错误处理) + +**Node.js 实现建议**: +- Fastify 已有 `setErrorHandler` +- 可以添加全局错误处理增强 + +--- + +#### 2.3 统一错误处理中间件(error_handler.go)❌ +**功能**: +- 统一错误响应格式 +- 错误码到 HTTP 状态码映射 +- `handleDomainError` 辅助函数 + +**迁移优先级**:🟡 中(当前每个 Handler 都有错误处理,可以统一) + +**Node.js 实现建议**: +- 创建统一的错误处理中间件 +- 统一错误响应格式 + +--- + +#### 2.4 限流中间件(ratelimit.go)❌ +**功能**: +- 基于 Redis 的限流 +- 支持 IP 和用户 ID 限流 +- 可配置限流策略 + +**迁移优先级**:🟡 中(生产环境需要) + +**Node.js 实现建议**: +- 使用 `@fastify/rate-limit` 或自定义实现 +- 集成 Redis + +--- + +#### 2.5 追踪中间件(tracing.go)❌ +**功能**: +- TraceID 生成和传播 +- RequestID 生成 +- 分布式追踪支持 + +**迁移优先级**:🟡 中(生产环境需要) + +**Node.js 实现建议**: +- 使用 OpenTelemetry +- 或自定义 TraceID/RequestID 中间件 + +--- + +#### 2.6 CORS 中间件(cors.go)⚠️ +**状态**:✅ 已实现(使用 `@fastify/cors`) + +--- + +### 3. 监控基础设施(infrastructure/monitoring)⚠️ + +#### 3.1 结构化日志(logger/logger.go)❌ +**功能**: +- 基于 zap 的结构化日志 +- 支持 JSON 和 Console 格式 +- 日志轮转(Lumberjack) +- 日志级别控制 + +**迁移优先级**:🟡 中(Fastify 的 pino 已足够,但可以增强) + +**Node.js 实现建议**: +- Fastify 已内置 `pino`(结构化日志) +- 可以配置日志轮转和级别 + +--- + +#### 3.2 Metrics 指标(metrics/metrics.go)❌ +**功能**: +- Prometheus 指标收集 +- HTTP 请求指标(QPS、延迟、错误率) +- 系统指标(Goroutine、内存、CPU) +- `/metrics` 端点 + +**迁移优先级**:🟡 中(生产环境需要) + +**Node.js 实现建议**: +- 使用 `prom-client` 库 +- 实现 Prometheus 指标收集 +- 添加 `/metrics` 端点 + +--- + +#### 3.3 分布式追踪(tracing/tracing.go)❌ +**功能**: +- OpenTelemetry 集成 +- 分布式追踪支持 + +**迁移优先级**:🟢 低(高级功能) + +**Node.js 实现建议**: +- 使用 `@opentelemetry/api` + +--- + +#### 3.4 健康检查(health/health.ts)✅ +**状态**:✅ 已实现 + +--- + +### 4. Handler 辅助函数(infrastructure/handler_utils)❌ + +#### 4.1 helpers.go +**功能**: +- `handleDomainError` - 统一错误处理 +- 错误码提取和映射 +- HTTP 状态码映射 + +**迁移优先级**:🟡 中(当前每个领域都有类似实现,可以统一) + +**Node.js 实现建议**: +- 创建共享的错误处理工具 +- 统一错误响应格式 + +--- + +### 5. 测试文件 ❌ + +#### 5.1 单元测试 +**Go 后端测试**: +- `task/model/task_test.go` - Task 模型测试 +- `task/repository/task_repo_test.go` - Repository 测试 +- `task/handlers/converters_test.go` - 转换器测试 +- `task/tests/*_test.go` - Handler 集成测试 +- `user/repository/user_repo_test.go` - User Repository 测试 + +**Node.js 后端**: +- ❌ 无测试文件 + +**迁移优先级**:🔴 高(测试是代码质量保障) + +**Node.js 实现建议**: +- 使用 `vitest` 或 `jest` +- 使用 `supertest` 进行 HTTP 测试 +- 参考 Go 后端的测试用例 + +--- + +### 6. 领域事件实现 ❌ + +**当前状态**: +- ✅ 所有领域都有 `events.md` 文档 +- ❌ 代码中只有扩展点注释,未实际实现事件发布 + +**需要实现**: +- 事件总线(InMemoryEventBus) +- 事件发布(在 Service 层) +- 事件订阅(可选) + +**迁移优先级**:🟡 中(当前功能正常,但缺少事件驱动能力) + +--- + +### 7. 其他功能差异 + +#### 7.1 数据库事务管理 +**Go 后端**: +- `postgres/transaction.go` - 事务管理工具 + +**Node.js 后端**: +- ⚠️ 当前使用 Kysely,支持事务,但可能需要封装工具函数 + +**迁移优先级**:🟢 低(Kysely 已支持) + +--- + +#### 7.2 配置验证 +**Go 后端**: +- `config/validator.go` - 配置验证 + +**Node.js 后端**: +- ⚠️ 当前只有基本配置加载,缺少验证 + +**迁移优先级**:🟡 中(可以使用 `zod` 进行验证) + +--- + +## 📋 迁移优先级建议 + +### 🔴 高优先级(核心功能) +1. **测试文件** - 代码质量保障 + - 单元测试(Model, Repository, Service) + - 集成测试(Handlers) + - E2E 测试(可选) + +### 🟡 中优先级(增强功能) +2. **事件总线** - 领域事件支持 + - 实现 `InMemoryEventBus` + - 在 Service 层发布事件 + +3. **统一错误处理** - 代码质量 + - 创建共享的错误处理工具 + - 统一错误响应格式 + +4. **Metrics 指标** - 生产环境监控 + - Prometheus 指标收集 + - `/metrics` 端点 + +5. **请求日志中间件** - 可观测性 + - 增强 Fastify 日志 + - 添加 TraceID/RequestID + +### 🟢 低优先级(可选功能) +6. **限流中间件** - 生产环境需要时添加 +7. **分布式追踪** - 高级功能 +8. **共享类型** - 代码统一性 +9. **配置验证** - 使用 `zod` 增强 + +--- + +## 📊 完成度统计 + +### 领域层 +- ✅ Task: 100% +- ✅ User: 100% +- ✅ Auth: 100% +- ❌ Shared: 0% + +### 基础设施层 +- ✅ Config: 100% +- ✅ Database: 100% +- ✅ Redis: 100% +- ✅ Health: 100% +- ✅ Auth Middleware: 100% +- ⚠️ Logger: 50% (Fastify 内置,可增强) +- ❌ Metrics: 0% +- ❌ Tracing: 0% +- ❌ Error Handler: 0% +- ❌ Rate Limit: 0% +- ❌ Recovery: 0% + +### 测试 +- ❌ 单元测试: 0% +- ❌ 集成测试: 0% +- ❌ E2E 测试: 0% + +### 总体完成度 +- **核心功能**: 85% ✅ +- **基础设施**: 60% ⚠️ +- **测试**: 0% ❌ +- **总体**: ~70% + +--- + +## 🎯 下一步建议 + +### 立即开始(高优先级) +1. **添加测试文件** - 确保代码质量 + - 从 Task 领域开始 + - 使用 vitest + supertest + +### 后续完善(中优先级) +2. **实现事件总线** - 支持领域事件 +3. **统一错误处理** - 代码质量提升 +4. **Metrics 指标** - 生产环境准备 + +### 按需添加(低优先级) +5. 其他中间件和工具 + +--- + +**最后更新**: 2025-01-XX + diff --git a/backend-nodejs/TESTING.md b/backend-nodejs/TESTING.md new file mode 100644 index 0000000..d9693be --- /dev/null +++ b/backend-nodejs/TESTING.md @@ -0,0 +1,280 @@ +# 测试文档 + +## 测试框架 + +- **单元测试框架**: Vitest +- **HTTP 测试**: Supertest +- **测试运行器**: Vitest (支持 watch 模式和 UI) + +## 测试结构 + +``` +backend-nodejs/ +├── vitest.config.ts # Vitest 配置 +├── domains/ +│ └── task/ +│ ├── model/ +│ │ └── task.test.ts # Model 单元测试 +│ └── tests/ +│ ├── helpers.ts # 测试辅助工具(非测试文件) +│ ├── create_task.test.ts # CreateTask Handler 测试 +│ ├── get_task.test.ts # GetTask Handler 测试 +│ ├── list_tasks.test.ts # ListTasks Handler 测试 +│ ├── update_task.test.ts # UpdateTask Handler 测试 +│ ├── complete_task.test.ts # CompleteTask Handler 测试 +│ └── delete_task.test.ts # DeleteTask Handler 测试 +``` + +## 运行测试 + +### 运行所有测试 +```bash +npm test +``` + +### Watch 模式(开发时使用) +```bash +npm run test:watch +``` + +### UI 模式(可视化测试) +```bash +npm run test:ui +``` + +### 生成覆盖率报告 +```bash +npm run test:coverage +``` + +### 运行特定领域的测试 +```bash +npm run test:task +``` + +## 测试类型 + +### 1. 单元测试(Unit Tests) + +**位置**: `domains/{domain}/model/*.test.ts` + +**示例**: `domains/task/model/task.test.ts` + +**覆盖内容**: +- Model 业务逻辑 +- 验证规则 +- 状态变更 +- 边界条件 + +**特点**: +- 快速执行 +- 不依赖外部服务 +- 纯函数测试 + +### 2. 集成测试(Integration Tests) + +**位置**: `domains/{domain}/tests/*.test.ts` + +**示例**: `domains/task/tests/create_task.test.ts` + +**覆盖内容**: +- Handler 完整流程 +- HTTP 请求/响应 +- 数据库交互 +- 认证中间件 + +**特点**: +- 使用真实数据库(测试数据库) +- 使用 Fastify 的 `inject` 方法进行 HTTP 测试(比 Supertest 更适合 Fastify) +- 需要数据库连接(如果数据库不可用,测试会自动跳过) + +## 测试辅助工具 + +### TestHelper + +**位置**: `domains/{domain}/tests/helpers.ts`(注意:不是 `.test.ts`,这是辅助工具文件) + +**功能**: +- 创建测试数据库连接 +- 初始化 Service 和 Repository +- 创建 Fastify 应用实例 +- 生成测试 Token + +**使用示例**: +```typescript +import { createTestHelper, TEST_USER_ID } from './helpers.test.js'; + +const testHelper = createTestHelper(db, jwtService); +const app = testHelper.app; +``` + +### 测试数据生成器 + +**函数**: +- `createTestTask()` - 创建标准测试任务 +- `createTestTaskWithId(id)` - 创建带指定 ID 的任务 +- `createTestTaskWithTags(tagNames)` - 创建带标签的任务 +- `createCompletedTestTask()` - 创建已完成的任务 + +## 测试覆盖情况 + +### Task 领域 ✅ + +#### Model 测试 ✅ +- ✅ `create` - 任务创建(各种边界情况) +- ✅ `update` - 任务更新 +- ✅ `complete` - 任务完成 +- ✅ `setDueDate` - 设置截止日期 +- ✅ `addTag` - 添加标签 +- ✅ `removeTag` - 移除标签 + +#### Handler 集成测试 ✅ +- ✅ `create_task.test.ts` - 创建任务 + - 成功创建 + - 空标题错误 + - 描述过长错误 + - 无效优先级错误 + - 未授权错误 + - 标签相关测试 + +- ✅ `get_task.test.ts` - 获取任务详情 + - 成功获取 + - 未授权错误 + - 任务不存在错误 + +- ✅ `list_tasks.test.ts` - 列出任务 + - 成功列出 + - 分页支持 + - 状态筛选 + - 优先级筛选 + - 未授权错误 + +- ✅ `update_task.test.ts` - 更新任务 + - 成功更新 + - 已完成任务错误 + - 空标题错误 + - 未授权错误 + - 任务不存在错误 + +- ✅ `complete_task.test.ts` - 完成任务 + - 成功完成 + - 重复完成错误 + - 未授权错误 + - 任务不存在错误 + +- ✅ `delete_task.test.ts` - 删除任务 + - 成功删除 + - 未授权错误 + - 任务不存在错误 + +### User 领域 ⏳ +- ⏳ Model 测试(待添加) +- ⏳ Handler 集成测试(待添加) + +### Auth 领域 ⏳ +- ⏳ JWTService 测试(待添加) +- ⏳ AuthService 测试(待添加) +- ⏳ Handler 集成测试(待添加) + +## 测试最佳实践 + +### 1. 测试命名 +- 使用描述性名称:`应该成功创建任务` +- 使用中文描述业务场景(符合项目风格) + +### 2. 测试结构 +```typescript +describe('功能名称', () => { + let app: FastifyInstance; + let dbAvailable = false; + + beforeAll(async () => { + // 测试数据库连接 + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + // 初始化测试环境 + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功执行操作', async () => { + if (!dbAvailable) return; // 跳过测试 + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { authorization: `Bearer ${token}` }, + payload: { ... }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + // 断言 + }); +}); +``` + +### 3. HTTP 测试方法 +- 使用 Fastify 的 `app.inject()` 方法(推荐,比 Supertest 更适合 Fastify) +- 解析响应:`JSON.parse(response.body)` +- 验证状态码:`expect(response.statusCode).toBe(200)` + +### 4. 断言 +- 使用 `expect` 进行断言 +- 验证所有关键属性 +- 验证错误码和消息 + +### 5. 测试数据 +- 使用测试常量(`TEST_USER_ID`, `TEST_TASK_TITLE`) +- 使用测试数据生成器 +- 避免硬编码测试数据 + +### 6. 清理 +- 在 `afterAll` 中关闭数据库连接 +- 清理测试数据(可选,使用测试数据库时) + +## 测试环境配置 + +### 数据库 +- 使用独立的测试数据库 +- 在 `beforeAll` 中测试数据库连接,如果不可用则跳过测试 +- 在 `beforeAll` 中创建测试数据 +- 在 `afterAll` 中清理(可选) +- **注意**:如果数据库不可用,所有集成测试会自动跳过,不会导致测试失败 + +### 认证 +- 使用 `JWTService` 生成测试 Token +- 在请求头中携带 `Authorization: Bearer ` + +## 待完善 + +### 高优先级 +1. **Repository 测试** - 测试数据库操作 +2. **Service 测试** - 测试业务逻辑(Mock Repository) +3. **User 领域测试** - 完整的测试套件 +4. **Auth 领域测试** - 完整的测试套件 + +### 中优先级 +5. **测试覆盖率** - 达到 80%+ 覆盖率 +6. **E2E 测试** - 完整用户流程测试 + +### 低优先级 +7. **性能测试** - 负载测试 +8. **压力测试** - 并发测试 + +## 参考 + +- [Vitest 文档](https://vitest.dev/) +- [Supertest 文档](https://github.com/visionmedia/supertest) +- Go 后端测试实现:`backend/domains/task/tests/` + diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index 967ba17..847fa6d 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -21,7 +21,14 @@ import { import type { RedisClientType } from 'redis'; import { TaskRepositoryImpl } from '../../domains/task/repository/task_repo.js'; import { TaskService } from '../../domains/task/service/task_service.js'; -import type { HandlerDependencies } from '../../domains/task/handlers/dependencies.js'; +import type { HandlerDependencies as TaskHandlerDependencies } from '../../domains/task/handlers/dependencies.js'; +import { UserRepositoryImpl } from '../../domains/user/repository/user_repo.js'; +import { UserService } from '../../domains/user/service/user_service.js'; +import type { HandlerDependencies as UserHandlerDependencies } from '../../domains/user/handlers/dependencies.js'; +import { JWTService } from '../../domains/auth/service/jwt_service.js'; +import { AuthService } from '../../domains/auth/service/auth_service.js'; +import type { HandlerDependencies as AuthHandlerDependencies } from '../../domains/auth/handlers/dependencies.js'; +import { createAuthMiddleware } from '../../infrastructure/middleware/auth.js'; async function main() { console.log('\n🚀 Starting Go-GenAI-Stack Backend (Node.js)...\n'); @@ -84,15 +91,43 @@ async function main() { // 7. 初始化领域服务 console.log('🏗️ Initializing domain services...'); + + // Task 领域 const taskRepo = new TaskRepositoryImpl(db); const taskService = new TaskService(taskRepo); - const handlerDeps: HandlerDependencies = { + const taskHandlerDeps: TaskHandlerDependencies = { taskService, }; + // User 领域 + const userRepo = new UserRepositoryImpl(db); + const userService = new UserService(userRepo); + const userHandlerDeps: UserHandlerDependencies = { + userService, + }; + + // Auth 领域 + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + const authService = new AuthService(userRepo, jwtService); + const authHandlerDeps: AuthHandlerDependencies = { + authService, + }; + + // 创建认证中间件 + const authMiddleware = createAuthMiddleware(jwtService); + // 8. 注册领域路由 console.log('📚 Registering domain routes...'); - await registerDomainRoutes(fastify, handlerDeps); + await registerDomainRoutes(fastify, { + task: taskHandlerDeps, + user: userHandlerDeps, + auth: authHandlerDeps, + }, authMiddleware); // 9. 启动服务器 const address = `http://${config.server.host}:${config.server.port}`; diff --git a/backend-nodejs/domains/auth/README.md b/backend-nodejs/domains/auth/README.md new file mode 100644 index 0000000..a9737e2 --- /dev/null +++ b/backend-nodejs/domains/auth/README.md @@ -0,0 +1,303 @@ +# Auth Domain (认证领域) + +## 概述 + +认证领域负责用户的身份认证和授权,包括注册、登录、Token 管理等功能。这是安全的核心模块,与 User 领域紧密协作。 + +## 领域边界 + +### 职责范围 + +- ✅ 用户注册(Register) +- ✅ 用户登录(Login) +- ✅ JWT Token 生成和验证 +- ✅ Refresh Token 刷新 +- ✅ 登出(Logout,扩展点) +- ✅ Token 撤销管理(扩展点) + +### 不包含的职责 + +- ❌ 用户资料管理(属于 User Domain) +- ❌ 权限和角色管理(属于 RBAC Domain,未实现) +- ❌ 邮件发送(属于 Notification Domain,未实现) +- ❌ 审计日志(属于 Audit Domain,未实现) + +## 核心概念 + +参考 `glossary.md` 了解领域术语。 + +## 用例列表 + +参考 `usecases.yaml` 查看所有用例的声明式定义。 + +主要用例: +1. **Register** - 用户注册 +2. **Login** - 用户登录 +3. **RefreshToken** - 刷新访问令牌 +4. **Logout** - 登出(扩展点) + +## 核心服务 + +### JWTService(JWT 服务) + +**职责**: +- 生成 Access Token 和 Refresh Token +- 验证 Token 有效性 +- 解析 Token 中的 Claims + +**Token 类型**: +- **Access Token**:短期有效(1 小时),用于 API 认证 +- **Refresh Token**:长期有效(7 天),用于刷新 Access Token + +### AuthService(认证服务) + +**职责**: +- 实现注册和登录业务逻辑 +- 调用 User Domain 创建和验证用户 +- 生成和管理 Token + +## JWT Token 设计 + +### Access Token + +**Claims**: +```json +{ + "user_id": "uuid", + "email": "user@example.com", + "type": "access", + "iss": "go-genai-stack", + "exp": 1234567890, + "iat": 1234567890 +} +``` + +**过期时间**:1 小时 +**用途**:API 认证 + +### Refresh Token + +**Claims**: +```json +{ + "user_id": "uuid", + "type": "refresh", + "iss": "go-genai-stack", + "exp": 1234567890, + "iat": 1234567890 +} +``` + +**过期时间**:7 天 +**用途**:刷新 Access Token + +### Token 验证流程 + +``` +Client → API → Auth Middleware → Verify JWT → Extract UserID → Handler +``` + +1. 客户端在请求头中携带 Token:`Authorization: Bearer ` +2. Auth Middleware 验证 Token 签名和过期时间 +3. 提取 UserID 并存储到 Context +4. Handler 从 Context 获取 UserID + +## 安全特性 + +### 1. 密码安全 + +- **哈希算法**:bcrypt (Cost: 10) +- **盐值**:bcrypt 自动生成 +- **验证失败**:统一返回 "邮箱或密码错误"(不泄露具体原因) + +### 2. Token 安全 + +- **签名算法**:HS256 (HMAC with SHA-256) +- **密钥管理**:从环境变量读取 JWT_SECRET +- **过期时间**:Access Token 短期有效,Refresh Token 相对长期 +- **Token 类型**:在 Claims 中标记 type 字段 + +### 3. 防暴力破解(扩展点) + +- 使用 Rate Limiting 中间件限制登录频率 +- 记录失败尝试次数 +- IP 黑名单 + +### 4. Token 撤销(扩展点) + +- 使用 Redis 存储撤销的 Token +- 登出时将 Token 加入黑名单 +- Middleware 检查 Token 是否在黑名单中 + +## 与 User Domain 的关系 + +``` +┌──────────────────────────────────────────────┐ +│ Auth Domain (认证领域) │ +│ ┌──────────────────────────────────────┐ │ +│ │ Register → 调用 User.NewUser │ │ +│ │ Login → 调用 User.VerifyPassword│ │ +│ │ JWT Token Generation │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ + ↓ 依赖 +┌──────────────────────────────────────────────┐ +│ User Domain (用户领域) │ +│ ┌──────────────────────────────────────┐ │ +│ │ User Model │ │ +│ │ User Repository │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +**依赖关系**: +- Auth Domain **依赖** User Domain +- Auth 调用 User Domain 的 Repository 和 Model +- Auth 不修改 User 的内部实现 + +## API 接口 + +### 1. 注册 + +**Endpoint**: `POST /api/auth/register` + +**Request**: +```json +{ + "email": "user@example.com", + "password": "securePassword123", + "username": "john_doe", + "full_name": "John Doe" +} +``` + +**Response** (200 OK): +```json +{ + "user_id": "uuid", + "email": "user@example.com", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600 +} +``` + +### 2. 登录 + +**Endpoint**: `POST /api/auth/login` + +**Request**: +```json +{ + "email": "user@example.com", + "password": "securePassword123" +} +``` + +**Response** (200 OK): +```json +{ + "user_id": "uuid", + "email": "user@example.com", + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600 +} +``` + +### 3. 刷新 Token + +**Endpoint**: `POST /api/auth/refresh` + +**Request**: +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Response** (200 OK): +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600 +} +``` + +## 配置 + +### 环境变量 + +```bash +# JWT 密钥(必需) +JWT_SECRET=your-secret-key-here + +# JWT 过期时间(可选) +JWT_ACCESS_TOKEN_EXPIRY=1h +JWT_REFRESH_TOKEN_EXPIRY=7d + +# JWT Issuer(可选) +JWT_ISSUER=go-genai-stack +``` + +### 默认值 + +参考 `infrastructure/config/config.go`: + +```go +type JWTConfig struct { + Secret string + AccessTokenExpiry time.Duration // 1 hour + RefreshTokenExpiry time.Duration // 7 days + Issuer string // "go-genai-stack" +} +``` + +## 扩展点 + +### 1. OAuth2 集成(未实现) + +- 支持 Google、GitHub、微信等第三方登录 +- 统一用户身份管理 + +### 2. Email 验证(未实现) + +- 注册后发送验证邮件 +- 验证后激活账户 + +### 3. 密码重置(未实现) + +- 忘记密码流程 +- 邮件发送重置链接 + +### 4. Token 黑名单(未实现) + +- 使用 Redis 存储撤销的 Token +- 登出时加入黑名单 + +### 5. 多因素认证(未实现) + +- TOTP (Google Authenticator) +- SMS 验证码 + +## 安全最佳实践 + +1. ✅ **永远不返回明文密码** +2. ✅ **使用 bcrypt 哈希密码** +3. ✅ **JWT 密钥使用强随机字符串** +4. ✅ **Token 过期时间合理设置** +5. ✅ **HTTPS 传输 Token** +6. ✅ **验证失败时不泄露具体原因** +7. ⏳ **添加 Rate Limiting(待实现)** +8. ⏳ **记录登录失败尝试(待实现)** +9. ⏳ **支持 Token 撤销(待实现)** + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [业务规则](./rules.md) +- [领域事件](./events.md) +- [JWT Best Practices](https://tools.ietf.org/html/rfc8725) + diff --git a/backend-nodejs/domains/auth/ai-metadata.json b/backend-nodejs/domains/auth/ai-metadata.json new file mode 100644 index 0000000..a3cd2dd --- /dev/null +++ b/backend-nodejs/domains/auth/ai-metadata.json @@ -0,0 +1,243 @@ +{ + "domain": "auth", + "version": "1.0.0", + "description": "Auth Domain - 认证领域,负责用户注册、登录、JWT Token 管理", + "status": "active", + "complexity": "high", + "ai_friendly": { + "has_usecases_yaml": true, + "has_glossary": true, + "has_rules": true, + "has_events": true, + "has_tests": true, + "code_comments": "extensive" + }, + "architecture": { + "pattern": "DDD + Hexagonal", + "layers": [ + { + "name": "service", + "description": "领域服务层:JWT Service、Auth Service" + }, + { + "name": "handlers", + "description": "HTTP 适配层:Register、Login、RefreshToken 请求处理" + }, + { + "name": "http/dto", + "description": "数据传输对象:HTTP 接口定义" + }, + { + "name": "tests", + "description": "测试层:单元测试和集成测试" + } + ] + }, + "core_services": [ + { + "name": "JWTService", + "description": "JWT Token 生成和验证服务", + "methods": [ + "GenerateAccessToken", + "GenerateRefreshToken", + "VerifyToken", + "ExtractClaims" + ] + }, + { + "name": "AuthService", + "description": "认证业务逻辑服务", + "methods": [ + "Register", + "Login", + "RefreshToken" + ] + } + }, + "use_cases": [ + { + "name": "Register", + "method": "POST", + "path": "/api/auth/register", + "auth_required": false, + "description": "用户注册" + }, + { + "name": "Login", + "method": "POST", + "path": "/api/auth/login", + "auth_required": false, + "description": "用户登录" + }, + { + "name": "RefreshToken", + "method": "POST", + "path": "/api/auth/refresh", + "auth_required": false, + "description": "刷新访问令牌" + }, + { + "name": "Logout", + "method": "POST", + "path": "/api/auth/logout", + "auth_required": true, + "description": "用户登出(扩展点)" + } + ], + "domain_events": [ + "UserRegistered", + "LoginSucceeded", + "LoginFailed", + "TokenGenerated", + "TokenRefreshed", + "TokenRevoked", + "UserLogout", + "PasswordResetRequested", + "AuthenticationMethodChanged" + ], + "dependencies": { + "internal": [ + "domains/user/model", + "domains/user/repository", + "infrastructure/persistence/postgres" + ], + "external": [ + "github.com/golang-jwt/jwt/v5", + "golang.org/x/crypto/bcrypt", + "github.com/google/uuid", + "github.com/cloudwego/hertz" + ], + "domain_dependencies": [ + { + "domain": "user", + "relationship": "依赖关系:Auth 领域调用 User 领域创建和验证用户" + } + ] + }, + "jwt_configuration": { + "algorithm": "HS256", + "access_token_expiry": "1h", + "refresh_token_expiry": "7d", + "issuer": "go-genai-stack", + "claims": { + "access_token": [ + "user_id", + "email", + "type", + "iss", + "exp", + "iat" + ], + "refresh_token": [ + "user_id", + "type", + "iss", + "exp", + "iat" + ] + } + }, + "security": { + "password_hashing": "bcrypt (cost: 10)", + "token_signing": "HS256 with JWT_SECRET", + "sensitive_fields": [ + "jwt_secret", + "password" + ], + "error_messages": "统一返回「邮箱或密码错误」,不泄露具体原因" + }, + "business_rules": { + "register": { + "email_unique": true, + "auto_login": true, + "password_min_length": 8 + }, + "login": { + "check_user_status": true, + "record_login_time": true, + "unified_error_message": true + }, + "token": { + "access_token_expiry": "1h", + "refresh_token_expiry": "7d", + "type_validation": true, + "issuer_validation": true + } + }, + "extension_points": [ + { + "name": "OAuth2 Integration", + "status": "not_implemented", + "description": "集成 Google、GitHub 等第三方登录" + }, + { + "name": "Email Verification", + "status": "not_implemented", + "description": "注册后发送邮箱验证链接" + }, + { + "name": "Password Reset", + "status": "not_implemented", + "description": "忘记密码流程" + }, + { + "name": "Token Blacklist", + "status": "not_implemented", + "description": "使用 Redis 存储撤销的 Token(登出功能)" + }, + { + "name": "Rate Limiting", + "status": "not_implemented", + "description": "登录失败次数限制(防止暴力破解)" + }, + { + "name": "Multi-Factor Authentication", + "status": "not_implemented", + "description": "多因素认证(TOTP、SMS)" + } + ], + "testing": { + "unit_tests": true, + "integration_tests": true, + "test_coverage_target": 80, + "test_files": [ + "service/jwt_service_test.go", + "service/auth_service_test.go", + "tests/register_test.go", + "tests/login_test.go", + "tests/refresh_token_test.go" + ] + }, + "ai_hints": { + "when_adding_use_case": [ + "1. 更新 usecases.yaml 添加用例定义", + "2. 在 service/ 层实现业务逻辑", + "3. 在 handlers/ 层实现 HTTP 适配", + "4. 在 http/dto/ 定义请求/响应结构", + "5. 在 tests/ 编写测试用例", + "6. 更新 README.md 添加用例说明" + ], + "security_considerations": [ + "永远不返回 JWT_SECRET", + "登录失败时统一返回「邮箱或密码错误」", + "Token 必须通过 HTTPS 传输", + "验证 Token 的签名、过期时间、Issuer、Type" + ], + "jwt_best_practices": [ + "Access Token 短期有效(1h)", + "Refresh Token 长期有效(7d)", + "Claims 中不包含敏感信息", + "使用 type 字段区分 Token 类型" + ] + }, + "related_docs": [ + "README.md", + "usecases.yaml", + "glossary.md", + "rules.md", + "events.md", + "../user/README.md", + "../../infrastructure/middleware/auth.go" + ] +} + diff --git a/backend-nodejs/domains/auth/errors/errors.ts b/backend-nodejs/domains/auth/errors/errors.ts new file mode 100644 index 0000000..80e2731 --- /dev/null +++ b/backend-nodejs/domains/auth/errors/errors.ts @@ -0,0 +1,42 @@ +/** + * Auth 领域错误定义 + * 遵循 "ERROR_CODE: message" 格式 + */ + +export class AuthError extends Error { + constructor( + public code: string, + message: string + ) { + super(message); + this.name = 'AuthError'; + } +} + +// 错误定义 +export const AuthErrors = { + INVALID_CREDENTIALS: new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误'), + INVALID_TOKEN: new AuthError('INVALID_TOKEN', 'Token 无效或已过期'), + INVALID_REFRESH_TOKEN: new AuthError('INVALID_REFRESH_TOKEN', 'Refresh Token 无效或已过期'), + INVALID_TOKEN_TYPE: new AuthError('INVALID_TOKEN_TYPE', 'Token 类型无效'), + INVALID_SIGNING_METHOD: new AuthError('INVALID_SIGNING_METHOD', '签名算法无效'), + INVALID_ISSUER: new AuthError('INVALID_ISSUER', 'Issuer 不匹配'), +}; + +/** + * 解析错误码 + */ +export function parseErrorCode(error: unknown): string { + if (error instanceof AuthError) { + return error.code; + } + if (error instanceof Error) { + // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) + const match = error.message.match(/^([A-Z_]+):/); + if (match) { + return match[1]; + } + } + return 'INTERNAL_ERROR'; +} + diff --git a/backend-nodejs/domains/auth/events.md b/backend-nodejs/domains/auth/events.md new file mode 100644 index 0000000..86345f3 --- /dev/null +++ b/backend-nodejs/domains/auth/events.md @@ -0,0 +1,336 @@ +# Auth Domain Events (认证领域事件) + +> 本文档定义了 Auth 领域的所有领域事件 + +--- + +## 事件概述 + +认证领域的事件主要关注用户的认证行为,如注册、登录、Token 刷新等。 + +--- + +## 1. UserRegistered (用户注册事件) + +### 触发时机 +用户注册成功,用户记录已保存到数据库 + +### Payload +```go +type UserRegisteredEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Username string `json:"username,omitempty"` + RegisteredAt time.Time `json:"registered_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送欢迎邮件和验证链接 +- **Analytics Service** - 记录新用户指标 +- **Notification Service** - 通知管理员(如启用) + +### 使用场景 +```go +// 用户注册成功后 +eventBus.Publish(ctx, UserRegisteredEvent{ + UserID: user.ID, + Email: user.Email, + Username: user.Username, + RegisteredAt: user.CreatedAt, + Timestamp: time.Now(), +}) +``` + +--- + +## 2. LoginSucceeded (登录成功事件) + +### 触发时机 +用户成功登录系统 + +### Payload +```go +type LoginSucceededEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + LoginAt time.Time `json:"login_at"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Analytics Service** - 统计活跃用户 +- **Security Service** - 检测异常登录(如异地登录) +- **Notification Service** - 发送登录通知(可选) + +### 安全注意 +- 包含 IP 地址和 User Agent 用于安全分析 +- 不包含密码或 Token + +### 使用场景 +```go +// 登录成功后 +eventBus.Publish(ctx, LoginSucceededEvent{ + UserID: user.ID, + Email: user.Email, + LoginAt: time.Now(), + IPAddress: c.ClientIP(), + UserAgent: string(c.UserAgent()), + Timestamp: time.Now(), +}) +``` + +--- + +## 3. LoginFailed (登录失败事件) + +### 触发时机 +用户登录失败(邮箱或密码错误) + +### Payload +```go +type LoginFailedEvent struct { + Email string `json:"email"` + Reason string `json:"reason"` // "invalid_credentials", "user_banned" + FailedAt time.Time `json:"failed_at"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Security Service** - 检测暴力破解攻击 +- **Rate Limiter** - 限制失败次数 + +### 安全注意 +- 不泄露是邮箱还是密码错误 +- 记录 IP 用于封禁 + +### 使用场景 +```go +// 登录失败后 +eventBus.Publish(ctx, LoginFailedEvent{ + Email: email, + Reason: "invalid_credentials", + FailedAt: time.Now(), + IPAddress: c.ClientIP(), + UserAgent: string(c.UserAgent()), + Timestamp: time.Now(), +}) +``` + +--- + +## 4. TokenGenerated (Token 生成事件) + +### 触发时机 +生成新的 Access Token 和 Refresh Token + +### Payload +```go +type TokenGeneratedEvent struct { + UserID string `json:"user_id"` + TokenType string `json:"token_type"` // "access", "refresh" + ExpiresAt time.Time `json:"expires_at"` + IssuedAt time.Time `json:"issued_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Audit Service** - 记录 Token 生成日志 + +### 安全注意 +- ⚠️ **不要**包含 Token 内容(敏感信息) + +### 使用场景 +```go +// Token 生成后 +eventBus.Publish(ctx, TokenGeneratedEvent{ + UserID: userID, + TokenType: "access", + ExpiresAt: expiresAt, + IssuedAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 5. TokenRefreshed (Token 刷新事件) + +### 触发时机 +使用 Refresh Token 刷新 Access Token + +### Payload +```go +type TokenRefreshedEvent struct { + UserID string `json:"user_id"` + OldTokenID string `json:"old_token_id,omitempty"` + NewTokenID string `json:"new_token_id,omitempty"` + RefreshedAt time.Time `json:"refreshed_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Audit Service** - 记录 Token 刷新日志 +- **Security Service** - 检测异常刷新行为 + +### 使用场景 +```go +// Token 刷新后 +eventBus.Publish(ctx, TokenRefreshedEvent{ + UserID: userID, + RefreshedAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 6. TokenRevoked (Token 撤销事件) + +### 触发时机 +Token 被撤销(登出、密码修改、管理员操作) + +### Payload +```go +type TokenRevokedEvent struct { + UserID string `json:"user_id"` + TokenID string `json:"token_id"` + Reason string `json:"reason"` // "logout", "password_change", "admin_action" + RevokedBy string `json:"revoked_by"` // 操作者(用户自己或管理员) + RevokedAt time.Time `json:"revoked_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Token Blacklist Service** - 将 Token 加入黑名单 + +### 使用场景 +```go +// 用户登出时 +eventBus.Publish(ctx, TokenRevokedEvent{ + UserID: userID, + TokenID: tokenID, + Reason: "logout", + RevokedBy: userID, + RevokedAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 7. UserLogout (用户登出事件) + +### 触发时机 +用户主动登出系统 + +### Payload +```go +type UserLogoutEvent struct { + UserID string `json:"user_id"` + LogoutAt time.Time `json:"logout_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Analytics Service** - 统计会话时长 +- **Token Service** - 撤销 Token + +### 使用场景 +```go +// 用户登出后 +eventBus.Publish(ctx, UserLogoutEvent{ + UserID: userID, + LogoutAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 8. PasswordResetRequested (密码重置请求事件) + +### 触发时机 +用户请求重置密码(忘记密码) + +### Payload +```go +type PasswordResetRequestedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + ResetURL string `json:"reset_url"` + ExpiresAt time.Time `json:"expires_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送密码重置邮件 + +### 安全注意 +- 重置链接有效期:1 小时 +- 记录 IP 地址防止暴力攻击 + +--- + +## 9. AuthenticationMethodChanged (认证方式变更事件) + +### 触发时机 +用户修改认证方式(如启用 MFA) + +### Payload +```go +type AuthenticationMethodChangedEvent struct { + UserID string `json:"user_id"` + OldMethod string `json:"old_method"` + NewMethod string `json:"new_method"` + ChangedAt time.Time `json:"changed_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送通知邮件 +- **Audit Service** - 记录安全变更 + +--- + +## 事件监控 + +### 指标 +- 注册数量(按天) +- 登录成功率 +- 登录失败次数(按 IP、按邮箱) +- Token 刷新频率 +- 异常登录检测 + +### 日志 +```go +logger.Info("Event published", + zap.String("event_type", "LoginSucceeded"), + zap.String("user_id", userID), + zap.Time("timestamp", time.Now()), +) +``` + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [业务规则](./rules.md) +- [README](./README.md) +- [Event Bus 实现](../shared/events/bus.go) + diff --git a/backend-nodejs/domains/auth/glossary.md b/backend-nodejs/domains/auth/glossary.md new file mode 100644 index 0000000..33d6c6f --- /dev/null +++ b/backend-nodejs/domains/auth/glossary.md @@ -0,0 +1,398 @@ +# Auth Domain Glossary (认证领域术语表) + +> 本文档定义了 Auth 领域的统一语言(Ubiquitous Language) + +--- + +## 核心术语 + +### JWT (JSON Web Token) + +**定义**:基于 JSON 的开放标准(RFC 7519)的轻量级令牌 + +**结构**: +``` +Header.Payload.Signature +``` + +**示例**: +``` +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIiwiZXhwIjoxNjcwMDAwMDAwfQ.abc123... +``` + +**组成部分**: +1. **Header**:算法和令牌类型 +2. **Payload**:Claims(声明) +3. **Signature**:签名(防篡改) + +**特点**: +- 自包含:Token 本身包含用户信息 +- 无状态:服务器不需要存储 Session +- 可验证:通过签名验证 Token 未被篡改 + +--- + +### Access Token(访问令牌) + +**定义**:短期有效的令牌,用于 API 认证 + +**类型**:JWT + +**过期时间**:1 小时(默认) + +**Claims**: +- `user_id`: 用户 ID +- `email`: 邮箱 +- `type`: "access" +- `iss`: Issuer(签发者) +- `exp`: 过期时间 +- `iat`: 签发时间 + +**用途**: +- 在 HTTP 请求头中携带:`Authorization: Bearer ` +- 每次 API 请求时验证身份 + +**安全考虑**: +- 过期时间短,减少泄露风险 +- 不存储敏感信息(如密码) + +--- + +### Refresh Token(刷新令牌) + +**定义**:长期有效的令牌,用于获取新的 Access Token + +**类型**:JWT + +**过期时间**:7 天(默认) + +**Claims**: +- `user_id`: 用户 ID +- `type`: "refresh" +- `iss`: Issuer +- `exp`: 过期时间 +- `iat`: 签发时间 + +**用途**: +- 当 Access Token 过期时,使用 Refresh Token 获取新的 Access Token +- 避免用户频繁登录 + +**安全考虑**: +- 只用于 `/api/auth/refresh` 端点 +- 不能用于普通 API 认证 +- 可以撤销(加入黑名单) + +--- + +### Claims(声明) + +**定义**:JWT Payload 中的键值对,描述实体(通常是用户)的属性 + +**类型**: +1. **Registered Claims**(注册声明): + - `iss`(Issuer):签发者 + - `sub`(Subject):主题 + - `aud`(Audience):受众 + - `exp`(Expiration Time):过期时间 + - `iat`(Issued At):签发时间 + +2. **Public Claims**(公共声明): + - 自定义字段,如 `user_id`, `email`, `role` + +3. **Private Claims**(私有声明): + - 特定应用的自定义字段 + +--- + +### Authentication(认证) + +**定义**:验证用户身份的过程 + +**流程**: +``` +用户提供凭据(邮箱 + 密码)→ 服务器验证 → 颁发 Token +``` + +**方式**: +- 邮箱 + 密码 +- OAuth2(Google、GitHub 等) +- 多因素认证(MFA) + +**结果**: +- 成功:返回 Access Token 和 Refresh Token +- 失败:返回 401 Unauthorized + +--- + +### Authorization(授权) + +**定义**:验证用户是否有权限访问特定资源 + +**与认证的区别**: +- **认证**:你是谁? +- **授权**:你能做什么? + +**实现**: +- 基于角色(RBAC):用户 → 角色 → 权限 +- 基于属性(ABAC):根据属性动态判断 + +**扩展点**: +- 当前版本仅实现认证 +- 授权功能在 RBAC Domain(未实现) + +--- + +### Register(注册) + +**定义**:创建新用户账户的过程 + +**输入**: +- 邮箱 +- 密码 +- 用户名(可选) +- 全名(可选) + +**输出**: +- 用户 ID +- Access Token +- Refresh Token + +**业务规则**: +- 邮箱必须唯一 +- 密码至少 8 字符 +- 用户名 3-30 字符(如果提供) + +--- + +### Login(登录) + +**定义**:验证用户凭据并建立会话的过程 + +**输入**: +- 邮箱 +- 密码 + +**输出**: +- 用户 ID +- Access Token +- Refresh Token + +**步骤**: +1. 验证邮箱格式 +2. 根据邮箱获取用户 +3. 验证密码 +4. 检查用户状态 +5. 生成 Token +6. 记录登录时间 + +--- + +### Logout(登出) + +**定义**:撤销用户会话,使 Token 失效 + +**方式**: +1. **客户端删除 Token**(简单方式) +2. **服务器撤销 Token**(黑名单方式) + +**黑名单实现**(扩展点): +- 将 Token 存入 Redis 黑名单 +- Middleware 检查 Token 是否在黑名单中 +- 黑名单 TTL = Token 过期时间 + +--- + +### Token Refresh(令牌刷新) + +**定义**:使用 Refresh Token 获取新的 Access Token + +**流程**: +``` +Client → POST /api/auth/refresh → Verify Refresh Token → Generate New Access Token +``` + +**Why**: +- Access Token 短期有效(1 小时) +- 避免用户频繁登录 +- 提升安全性(泄露风险小) + +--- + +### Token Revocation(令牌撤销) + +**定义**:使 Token 失效的过程 + +**场景**: +- 用户登出 +- 用户修改密码 +- 检测到异常活动 + +**实现**: +- 使用 Redis 存储黑名单 +- Key: `token_blacklist:{token_id}` +- TTL: Token 过期时间 + +--- + +### Credentials(凭据) + +**定义**:用于验证用户身份的信息 + +**类型**: +- **邮箱 + 密码**:传统方式 +- **OAuth Token**:第三方登录 +- **API Key**:应用级认证 + +**安全考虑**: +- 永远不存储明文密码 +- 使用 HTTPS 传输凭据 +- 验证失败时不泄露具体原因 + +--- + +### Session(会话) + +**定义**:用户与系统交互的一段时间 + +**传统方式**: +- 服务器存储 Session(如 Redis) +- 客户端携带 Session ID(Cookie) + +**JWT 方式**: +- 无状态:服务器不存储 Session +- Token 本身包含会话信息 + +**对比**: +| 特性 | Session | JWT | +|------|---------|-----| +| 存储位置 | 服务器 | 客户端 | +| 可扩展性 | 差(需要共享 Session) | 好(无状态) | +| 撤销能力 | 容易 | 困难(需要黑名单) | + +--- + +### Bearer Token + +**定义**:携带在 HTTP 请求头中的令牌 + +**格式**: +``` +Authorization: Bearer +``` + +**Why "Bearer"**: +- Bearer = 持有者 +- 任何持有 Token 的人都可以访问资源 +- 因此 Token 必须保密 + +--- + +### JWT Secret(JWT 密钥) + +**定义**:用于签名和验证 JWT 的密钥 + +**算法**: +- HS256:HMAC with SHA-256(对称加密) +- RS256:RSA with SHA-256(非对称加密) + +**密钥管理**: +- 从环境变量读取:`JWT_SECRET` +- 必须使用强随机字符串 +- 定期轮换密钥(扩展点) + +**示例**: +```bash +JWT_SECRET=your-256-bit-secret-key-here +``` + +--- + +### Token Expiry(令牌过期) + +**定义**:Token 有效期结束的时间 + +**字段**:`exp`(Expiration Time) + +**过期时间**: +- Access Token:1 小时 +- Refresh Token:7 天 + +**处理**: +- Access Token 过期 → 使用 Refresh Token 刷新 +- Refresh Token 过期 → 重新登录 + +--- + +## 领域操作术语 + +### Verify Token(验证令牌) + +**定义**:检查 Token 的有效性 + +**验证项**: +1. **签名**:Token 未被篡改 +2. **过期时间**:Token 未过期 +3. **Issuer**:Token 由本系统签发 +4. **Type**:Token 类型正确(access/refresh) +5. **黑名单**:Token 未被撤销(扩展点) + +--- + +### Generate Token(生成令牌) + +**定义**:创建 JWT Token + +**步骤**: +1. 构建 Claims +2. 设置过期时间 +3. 使用密钥签名 +4. 返回 Token 字符串 + +--- + +### Extract Claims(提取声明) + +**定义**:从 JWT Token 中解析 Claims + +**输入**:Token 字符串 +**输出**:Claims 对象 + +**用途**: +- 获取 User ID +- 检查 Token 类型 +- 验证过期时间 + +--- + +## 错误码术语 + +### INVALID_CREDENTIALS +**含义**:邮箱或密码错误 +**场景**:登录 +**HTTP 状态码**:401 + +### EMAIL_ALREADY_EXISTS +**含义**:邮箱已被注册 +**场景**:注册 +**HTTP 状态码**:400 + +### INVALID_TOKEN +**含义**:Token 无效或已过期 +**场景**:Token 验证 +**HTTP 状态码**:401 + +### INVALID_REFRESH_TOKEN +**含义**:Refresh Token 无效 +**场景**:刷新 Token +**HTTP 状态码**:401 + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [业务规则](./rules.md) +- [领域事件](./events.md) +- [README](./README.md) +- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) + diff --git a/backend-nodejs/domains/auth/handlers/converters.ts b/backend-nodejs/domains/auth/handlers/converters.ts new file mode 100644 index 0000000..a84c250 --- /dev/null +++ b/backend-nodejs/domains/auth/handlers/converters.ts @@ -0,0 +1,84 @@ +/** + * DTO 转换层 + * HTTP DTO ↔ Domain Input/Output 的转换 + */ + +import type { + RegisterRequest, + RegisterResponse, + LoginRequest, + LoginResponse, + RefreshTokenRequest, + RefreshTokenResponse, +} from '../http/dto/auth.js'; +import type { + RegisterInput, + RegisterOutput, + LoginInput, + LoginOutput, + RefreshTokenInput, + RefreshTokenOutput, +} from '../service/auth_service.js'; + +// ======================================== +// Register 转换 +// ======================================== + +export function toRegisterInput(req: RegisterRequest): RegisterInput { + return { + email: req.email, + password: req.password, + username: req.username, + fullName: req.full_name, + }; +} + +export function toRegisterResponse(output: RegisterOutput): RegisterResponse { + return { + user_id: output.userId, + email: output.email, + access_token: output.accessToken, + refresh_token: output.refreshToken, + expires_in: output.expiresIn, + }; +} + +// ======================================== +// Login 转换 +// ======================================== + +export function toLoginInput(req: LoginRequest): LoginInput { + return { + email: req.email, + password: req.password, + }; +} + +export function toLoginResponse(output: LoginOutput): LoginResponse { + return { + user_id: output.userId, + email: output.email, + access_token: output.accessToken, + refresh_token: output.refreshToken, + expires_in: output.expiresIn, + }; +} + +// ======================================== +// RefreshToken 转换 +// ======================================== + +export function toRefreshTokenInput(req: RefreshTokenRequest): RefreshTokenInput { + return { + refreshToken: req.refresh_token, + }; +} + +export function toRefreshTokenResponse(output: RefreshTokenOutput): RefreshTokenResponse { + return { + access_token: output.accessToken, + refresh_token: output.refreshToken, + expires_in: output.expiresIn, + }; +} + diff --git a/backend-nodejs/domains/auth/handlers/dependencies.ts b/backend-nodejs/domains/auth/handlers/dependencies.ts new file mode 100644 index 0000000..3b315eb --- /dev/null +++ b/backend-nodejs/domains/auth/handlers/dependencies.ts @@ -0,0 +1,11 @@ +/** + * Handler 依赖容器 + * 注入 Service 层依赖 + */ + +import type { AuthService } from '../service/auth_service.js'; + +export interface HandlerDependencies { + authService: AuthService; +} + diff --git a/backend-nodejs/domains/auth/handlers/login.handler.ts b/backend-nodejs/domains/auth/handlers/login.handler.ts new file mode 100644 index 0000000..94852ba --- /dev/null +++ b/backend-nodejs/domains/auth/handlers/login.handler.ts @@ -0,0 +1,45 @@ +/** + * Login Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { LoginRequest } from '../http/dto/auth.js'; +import { + toLoginInput, + toLoginResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function loginHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: LoginRequest }>, + reply: FastifyReply +): Promise { + try { + const body = req.body; + + const input = toLoginInput(body); + const output = await deps.authService.login(req, input); + + reply.code(200).send(toLoginResponse(output)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '登录失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'INVALID_CREDENTIALS') { + return 401; + } + if (errorCode === 'USER_BANNED' || errorCode === 'USER_INACTIVE') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts b/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts new file mode 100644 index 0000000..23c7aaa --- /dev/null +++ b/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts @@ -0,0 +1,48 @@ +/** + * RefreshToken Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { RefreshTokenRequest } from '../http/dto/auth.js'; +import { + toRefreshTokenInput, + toRefreshTokenResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function refreshTokenHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: RefreshTokenRequest }>, + reply: FastifyReply +): Promise { + try { + const body = req.body; + + const input = toRefreshTokenInput(body); + const output = await deps.authService.refreshToken(req, input); + + reply.code(200).send(toRefreshTokenResponse(output)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '刷新 Token 失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'INVALID_REFRESH_TOKEN' || errorCode === 'INVALID_TOKEN') { + return 401; + } + if (errorCode === 'USER_NOT_FOUND') { + return 404; + } + if (errorCode === 'USER_BANNED' || errorCode === 'USER_INACTIVE') { + return 403; + } + return 500; +} + diff --git a/backend-nodejs/domains/auth/handlers/register.handler.ts b/backend-nodejs/domains/auth/handlers/register.handler.ts new file mode 100644 index 0000000..9cd7b2d --- /dev/null +++ b/backend-nodejs/domains/auth/handlers/register.handler.ts @@ -0,0 +1,48 @@ +/** + * Register Handler + * HTTP 适配层:处理用户注册的 HTTP 请求 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { RegisterRequest } from '../http/dto/auth.js'; +import { + toRegisterInput, + toRegisterResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; + +export async function registerHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: RegisterRequest }>, + reply: FastifyReply +): Promise { + try { + // 1. 解析 HTTP 请求 + const body = req.body; + + // 2. 转换为 Domain Input + const input = toRegisterInput(body); + + // 3. 调用 Domain Service + const output = await deps.authService.register(req, input); + + // 4. 转换为 HTTP 响应 + reply.code(201).send(toRegisterResponse(output)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '注册失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'EMAIL_ALREADY_EXISTS' || errorCode === 'USERNAME_ALREADY_EXISTS' || errorCode === 'INVALID_EMAIL' || errorCode === 'WEAK_PASSWORD' || errorCode === 'INVALID_USERNAME') { + return 400; + } + return 500; +} + diff --git a/backend-nodejs/domains/auth/http/dto/auth.ts b/backend-nodejs/domains/auth/http/dto/auth.ts new file mode 100644 index 0000000..1d7b668 --- /dev/null +++ b/backend-nodejs/domains/auth/http/dto/auth.ts @@ -0,0 +1,55 @@ +/** + * Auth HTTP DTO + * 定义 HTTP 请求和响应的数据结构 + */ + +// ======================================== +// Register +// ======================================== + +export interface RegisterRequest { + email: string; + password: string; + username?: string; + full_name?: string; +} + +export interface RegisterResponse { + user_id: string; + email: string; + access_token: string; + refresh_token: string; + expires_in: number; // 秒 +} + +// ======================================== +// Login +// ======================================== + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + user_id: string; + email: string; + access_token: string; + refresh_token: string; + expires_in: number; // 秒 +} + +// ======================================== +// RefreshToken +// ======================================== + +export interface RefreshTokenRequest { + refresh_token: string; +} + +export interface RefreshTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; // 秒 +} + diff --git a/backend-nodejs/domains/auth/http/router.ts b/backend-nodejs/domains/auth/http/router.ts new file mode 100644 index 0000000..dc24354 --- /dev/null +++ b/backend-nodejs/domains/auth/http/router.ts @@ -0,0 +1,35 @@ +/** + * Auth 路由注册 + * 注册所有 Auth 相关的 HTTP 路由 + */ + +import type { FastifyInstance } from 'fastify'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import type { RegisterRequest, LoginRequest, RefreshTokenRequest } from './dto/auth.js'; +import { registerHandler } from '../handlers/register.handler.js'; +import { loginHandler } from '../handlers/login.handler.js'; +import { refreshTokenHandler } from '../handlers/refresh_token.handler.js'; + +/** + * 注册 Auth 路由 + */ +export function registerAuthRoutes( + app: FastifyInstance, + deps: HandlerDependencies +): void { + // POST /api/auth/register - 用户注册 + app.post<{ Body: RegisterRequest }>('/api/auth/register', async (req, reply) => { + await registerHandler(deps, req, reply); + }); + + // POST /api/auth/login - 用户登录 + app.post<{ Body: LoginRequest }>('/api/auth/login', async (req, reply) => { + await loginHandler(deps, req, reply); + }); + + // POST /api/auth/refresh - 刷新 Token + app.post<{ Body: RefreshTokenRequest }>('/api/auth/refresh', async (req, reply) => { + await refreshTokenHandler(deps, req, reply); + }); +} + diff --git a/backend-nodejs/domains/auth/rules.md b/backend-nodejs/domains/auth/rules.md new file mode 100644 index 0000000..2109155 --- /dev/null +++ b/backend-nodejs/domains/auth/rules.md @@ -0,0 +1,494 @@ +# Auth Domain Business Rules (认证领域业务规则) + +> 本文档定义了 Auth 领域的业务约束和规则 + +--- + +## 1. JWT Token 规则 + +### R1.1 Token 过期时间 +**规则**:Access Token 和 Refresh Token 必须有过期时间 + +**配置**: +- **Access Token**:1 小时(3600 秒) +- **Refresh Token**:7 天(604800 秒) + +**理由**: +- Access Token 短期有效,减少泄露风险 +- Refresh Token 长期有效,提升用户体验 + +**可配置**:通过环境变量调整 +```bash +JWT_ACCESS_TOKEN_EXPIRY=1h +JWT_REFRESH_TOKEN_EXPIRY=7d +``` + +--- + +### R1.2 Token 签名算法 +**规则**:使用 HS256 算法签名 JWT + +**算法**:HMAC with SHA-256(对称加密) + +**理由**: +- 性能好 +- 适合单体应用 +- 密钥管理简单 + +**未来扩展**:可升级为 RS256(非对称加密)用于微服务 + +--- + +### R1.3 Token 必须包含 Type +**规则**:Token 的 Claims 中必须包含 `type` 字段 + +**可选值**: +- `"access"` - Access Token +- `"refresh"` - Refresh Token + +**理由**: +- 防止 Token 类型混用 +- Refresh Token 不能用于 API 认证 + +**验证**: +```go +if claims.Type != "access" { + return ErrInvalidTokenType +} +``` + +--- + +### R1.4 Token 必须包含 Issuer +**规则**:Token 的 Claims 中必须包含 `iss` 字段 + +**值**:`"go-genai-stack"` + +**理由**: +- 防止跨系统 Token 滥用 +- 标识 Token 来源 + +--- + +### R1.5 JWT 密钥强度 +**规则**:JWT_SECRET 必须是强随机字符串 + +**最小长度**:32 字符 + +**建议**: +```bash +# 生成强随机密钥 +openssl rand -base64 32 +``` + +**禁止**: +- ❌ 使用简单密码(如 "password123") +- ❌ 使用默认值 +- ❌ 硬编码在代码中 + +--- + +## 2. 注册规则 + +### R2.1 邮箱唯一性 +**规则**:系统中每个邮箱只能注册一次 + +**实现**: +- 数据库唯一索引 +- 注册前检查邮箱是否存在 + +**错误码**:`EMAIL_ALREADY_EXISTS` + +--- + +### R2.2 密码强度 +**规则**:密码至少 8 字符 + +**验证**:由 User Domain 的 Model 层验证 + +**错误码**:`WEAK_PASSWORD` + +**扩展点**: +- 未来可增加复杂度要求(大小写、数字、特殊字符) +- 集成密码强度检测库(如 zxcvbn) + +--- + +### R2.3 注册成功自动登录 +**规则**:用户注册成功后,自动生成 Token + +**理由**:提升用户体验,避免注册后再登录 + +**返回**: +- `user_id` +- `access_token` +- `refresh_token` + +--- + +### R2.4 注册后发送验证邮件(扩展点) +**规则**:用户注册后,发送邮箱验证链接 + +**实现**: +- 发布 `UserCreated` 事件 +- Email Service 监听事件并发送邮件 + +**状态**:未实现 + +--- + +## 3. 登录规则 + +### R3.1 验证失败统一提示 +**规则**:登录失败时,统一返回 "邮箱或密码错误" + +**理由**: +- 不泄露邮箱是否存在 +- 防止枚举攻击 + +**错误码**:`INVALID_CREDENTIALS` + +**示例**: +```go +// ❌ 错误:泄露邮箱是否存在 +if !userExists { + return "邮箱不存在" +} +if !passwordValid { + return "密码错误" +} + +// ✅ 正确:统一提示 +if !userExists || !passwordValid { + return "邮箱或密码错误" +} +``` + +--- + +### R3.2 检查用户状态 +**规则**:登录前检查用户状态,禁用用户不能登录 + +**状态检查**: +- `active` → 允许登录 +- `inactive` → 允许登录(但可能限制功能) +- `banned` → 拒绝登录 + +**错误码**:`USER_BANNED` + +--- + +### R3.3 记录登录时间 +**规则**:登录成功后,更新用户的 `last_login_at` 字段 + +**用途**: +- 统计活跃用户 +- 安全审计 + +**实现**: +```go +user.RecordLogin() // 更新 last_login_at +userRepo.Update(ctx, user) +``` + +--- + +### R3.4 登录成功发布事件 +**规则**:登录成功后,发布 `LoginSucceeded` 事件 + +**Event Payload**: +- `user_id` +- `email` +- `login_at` +- `ip_address` +- `user_agent` + +**订阅者**: +- Analytics Service - 统计活跃用户 +- Security Service - 检测异常登录 + +--- + +### R3.5 登录失败次数限制(扩展点) +**规则**:同一 IP 或邮箱,登录失败超过 N 次后,暂时锁定 + +**配置**: +- 最大失败次数:5 次 +- 锁定时间:15 分钟 + +**实现**: +- 使用 Redis 记录失败次数 +- 使用 Rate Limiting 中间件 + +**状态**:未实现 + +--- + +## 4. Token 刷新规则 + +### R4.1 只能使用 Refresh Token 刷新 +**规则**:`/api/auth/refresh` 端点只接受 Refresh Token + +**验证**: +```go +if claims.Type != "refresh" { + return ErrInvalidTokenType +} +``` + +**错误码**:`INVALID_REFRESH_TOKEN` + +--- + +### R4.2 刷新时生成新的 Refresh Token +**规则**:刷新 Token 时,同时生成新的 Access Token 和 Refresh Token + +**理由**: +- 增强安全性 +- 旧的 Refresh Token 失效(滚动刷新) + +**实现**: +```go +newAccessToken := jwt.GenerateAccessToken(userID) +newRefreshToken := jwt.GenerateRefreshToken(userID) +``` + +--- + +### R4.3 刷新前检查用户状态 +**规则**:刷新 Token 前,检查用户是否被禁用 + +**场景**: +- 用户在 Refresh Token 有效期内被禁用 +- 不应允许继续刷新 + +**错误码**:`USER_BANNED` + +--- + +## 5. Token 验证规则 + +### R5.1 验证 Token 签名 +**规则**:验证 Token 的签名是否正确 + +**算法**:HS256 + +**失败**:Token 被篡改 + +**错误码**:`INVALID_TOKEN` + +--- + +### R5.2 验证 Token 过期时间 +**规则**:验证 Token 的 `exp` 字段是否已过期 + +**实现**: +```go +if time.Now().Unix() > claims.ExpiresAt { + return ErrTokenExpired +} +``` + +**错误码**:`INVALID_TOKEN` + +--- + +### R5.3 验证 Token Issuer +**规则**:验证 Token 的 `iss` 字段是否为 "go-genai-stack" + +**理由**:防止跨系统 Token 滥用 + +**错误码**:`INVALID_TOKEN` + +--- + +### R5.4 检查 Token 黑名单(扩展点) +**规则**:验证 Token 是否在黑名单中 + +**实现**: +```go +if redis.Exists(ctx, "token_blacklist:" + tokenID) { + return ErrTokenRevoked +} +``` + +**状态**:未实现 + +--- + +## 6. 登出规则 + +### R6.1 客户端删除 Token +**规则**:登出时,客户端删除本地存储的 Token + +**实现**: +- 前端清除 localStorage 或 sessionStorage +- 前端不再携带 Token 发起请求 + +--- + +### R6.2 服务器撤销 Token(扩展点) +**规则**:登出时,将 Token 加入黑名单 + +**实现**: +```go +redis.Set(ctx, "token_blacklist:" + tokenID, "1", tokenTTL) +``` + +**TTL**:Token 过期时间 + +**状态**:未实现 + +--- + +## 7. 密码修改规则 + +### R7.1 修改密码后撤销所有 Token +**规则**:用户修改密码后,撤销所有现有 Token + +**理由**: +- 防止旧 Token 被滥用 +- 强制用户重新登录 + +**实现**(扩展点): +- 在 User 表添加 `password_changed_at` 字段 +- Token 验证时检查 Token 签发时间是否早于密码修改时间 + +```go +if token.IssuedAt < user.PasswordChangedAt { + return ErrTokenInvalidAfterPasswordChange +} +``` + +--- + +## 8. 安全规则 + +### R8.1 HTTPS 传输 +**规则**:生产环境必须使用 HTTPS 传输 Token + +**理由**:防止中间人攻击(MITM) + +**禁止**:在 HTTP 环境下传输 Token + +--- + +### R8.2 不在 URL 中传递 Token +**规则**:Token 只能在 HTTP Header 中传递 + +**正确**: +``` +Authorization: Bearer +``` + +**错误**: +``` +GET /api/users?token= +``` + +**理由**: +- URL 会被记录在日志中 +- URL 可能泄露(浏览器历史、Referrer) + +--- + +### R8.3 Token 不包含敏感信息 +**规则**:Token 的 Claims 中不包含密码、密码哈希等敏感信息 + +**允许**: +- `user_id` +- `email` +- `role` + +**禁止**: +- ❌ `password` +- ❌ `password_hash` +- ❌ `credit_card_number` + +--- + +### R8.4 密钥轮换(扩展点) +**规则**:定期轮换 JWT 密钥 + +**频率**:每 90 天 + +**实现**: +- 支持多个密钥(`kid` - Key ID) +- 旧 Token 仍可验证 +- 新 Token 使用新密钥 + +**状态**:未实现 + +--- + +## 9. Rate Limiting 规则(扩展点) + +### R9.1 登录频率限制 +**规则**:同一 IP 或邮箱,每分钟最多尝试 5 次登录 + +**实现**: +- 使用 Rate Limiting 中间件 +- 使用 Redis 存储请求次数 + +**状态**:未实现 + +--- + +### R9.2 注册频率限制 +**规则**:同一 IP,每小时最多注册 3 个账户 + +**理由**:防止批量注册(垃圾账户) + +**状态**:未实现 + +--- + +## 10. 日志和审计规则 + +### R10.1 记录登录事件 +**规则**:记录所有登录尝试(成功和失败) + +**日志内容**: +- 时间戳 +- 邮箱 +- IP 地址 +- User Agent +- 结果(成功/失败) + +**用途**: +- 安全审计 +- 检测异常活动 + +--- + +### R10.2 记录 Token 生成 +**规则**:记录 Token 生成事件 + +**日志内容**: +- 时间戳 +- 用户 ID +- Token 类型(access/refresh) + +**用途**: +- 审计 +- 调试 + +--- + +## 规则优先级 + +1. **安全规则** - 最高优先级,不可妥协 +2. **Token 验证规则** - 保证 Token 有效性 +3. **业务规则** - 核心业务逻辑 +4. **性能规则** - 优化用户体验 + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [领域事件](./events.md) +- [README](./README.md) +- [JWT Best Practices (RFC 8725)](https://tools.ietf.org/html/rfc8725) + diff --git a/backend-nodejs/domains/auth/service/auth_service.ts b/backend-nodejs/domains/auth/service/auth_service.ts new file mode 100644 index 0000000..42d74be --- /dev/null +++ b/backend-nodejs/domains/auth/service/auth_service.ts @@ -0,0 +1,201 @@ +/** + * Auth Service 领域服务层 + * 实现认证业务用例 + */ + +import type { UserRepository } from '../../user/repository/interface.js'; +import { User } from '../../user/model/user.js'; +import type { JWTService } from './jwt_service.js'; + +export interface RegisterInput { + email: string; + password: string; + username?: string; + fullName?: string; +} + +export interface RegisterOutput { + userId: string; + email: string; + accessToken: string; + refreshToken: string; + expiresIn: number; // 秒 +} + +export interface LoginInput { + email: string; + password: string; +} + +export interface LoginOutput { + userId: string; + email: string; + accessToken: string; + refreshToken: string; + expiresIn: number; // 秒 +} + +export interface RefreshTokenInput { + refreshToken: string; +} + +export interface RefreshTokenOutput { + accessToken: string; + refreshToken: string; + expiresIn: number; // 秒 +} + +/** + * AuthService 认证服务 + */ +export class AuthService { + constructor( + private userRepo: UserRepository, + private jwtService: JWTService + ) {} + + /** + * 用户注册 + */ + async register(ctx: unknown, input: RegisterInput): Promise { + // Step 1: 验证输入(由 Model 层的验证逻辑处理) + + // Step 2: 检查邮箱是否已被注册 + const emailExists = await this.userRepo.existsByEmail(ctx, input.email); + if (emailExists) { + throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + } + + // Step 3: 检查用户名是否已被占用(如果提供) + if (input.username) { + const usernameExists = await this.userRepo.existsByUsername(ctx, input.username); + if (usernameExists) { + throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + } + } + + // Step 4: 创建用户实体(调用 User Domain) + const user = await User.create(input.email, input.password); + + // 设置可选字段 + if (input.username || input.fullName) { + user.updateProfile(input.username, input.fullName, undefined); + } + + // Step 5: 保存用户到数据库 + await this.userRepo.create(ctx, user); + + // Step 6: 生成 JWT Token + const { token: accessToken, expiresAt } = this.jwtService.generateAccessToken( + user.id, + user.email + ); + const { token: refreshToken } = this.jwtService.generateRefreshToken(user.id); + + // Step 7: 发布用户创建事件(扩展点) + // eventBus.publish(ctx, UserCreatedEvent{...}); + + return { + userId: user.id, + email: user.email, + accessToken, + refreshToken, + expiresIn: Math.floor((expiresAt.getTime() - Date.now()) / 1000), + }; + } + + /** + * 用户登录 + */ + async login(ctx: unknown, input: LoginInput): Promise { + // Step 1: 验证输入(由 Model 层的验证逻辑处理) + + // Step 2: 根据邮箱获取用户 + const user = await this.userRepo.getByEmail(ctx, input.email); + if (!user) { + // 统一返回错误消息,不泄露是邮箱还是密码错误 + throw new Error('INVALID_CREDENTIALS: 邮箱或密码错误'); + } + + // Step 3: 验证密码 + const isValid = await user.verifyPassword(input.password); + if (!isValid) { + // 统一返回错误消息 + throw new Error('INVALID_CREDENTIALS: 邮箱或密码错误'); + } + + // Step 4: 检查用户状态 + try { + user.canLogin(); + } catch (error) { + throw error; // 直接抛出 Model 层的错误 + } + + // Step 5: 生成 JWT Token + const { token: accessToken, expiresAt } = this.jwtService.generateAccessToken( + user.id, + user.email + ); + const { token: refreshToken } = this.jwtService.generateRefreshToken(user.id); + + // Step 6: 记录最后登录时间 + user.recordLogin(); + try { + await this.userRepo.update(ctx, user); + } catch (error) { + // 记录失败不影响登录 + // logger.warn('更新登录时间失败', error); + } + + // Step 7: 发布登录成功事件(扩展点) + // eventBus.publish(ctx, LoginSucceededEvent{...}); + + return { + userId: user.id, + email: user.email, + accessToken, + refreshToken, + expiresIn: Math.floor((expiresAt.getTime() - Date.now()) / 1000), + }; + } + + /** + * 刷新 Token + */ + async refreshToken(ctx: unknown, input: RefreshTokenInput): Promise { + // Step 1: 验证 Refresh Token + let claims; + try { + claims = this.jwtService.verifyRefreshToken(input.refreshToken); + } catch (error) { + throw new Error('INVALID_REFRESH_TOKEN: Refresh Token 无效或已过期'); + } + + // Step 2: 获取用户信息 + const user = await this.userRepo.getById(ctx, claims.user_id); + if (!user) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + + // Step 3: 检查用户状态 + try { + user.canLogin(); + } catch (error) { + throw error; // 直接抛出 Model 层的错误 + } + + // Step 4: 生成新的 Access Token 和 Refresh Token + const { token: accessToken, expiresAt } = this.jwtService.generateAccessToken( + user.id, + user.email + ); + const { token: refreshToken } = this.jwtService.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + expiresIn: Math.floor((expiresAt.getTime() - Date.now()) / 1000), + }; + } +} + diff --git a/backend-nodejs/domains/auth/service/jwt_service.ts b/backend-nodejs/domains/auth/service/jwt_service.ts new file mode 100644 index 0000000..992eda2 --- /dev/null +++ b/backend-nodejs/domains/auth/service/jwt_service.ts @@ -0,0 +1,157 @@ +/** + * JWT Service + * 负责 JWT Token 的生成和验证 + */ + +import * as jwt from 'jsonwebtoken'; + +export type TokenType = 'access' | 'refresh'; + +export interface JWTPayload { + user_id: string; + email?: string; + type: TokenType; + iss: string; + exp: number; + iat: number; +} + +export interface JWTConfig { + secret: string; + accessTokenExpiry: number; // 秒 + refreshTokenExpiry: number; // 秒 + issuer: string; +} + +/** + * JWTService JWT 服务 + */ +export class JWTService { + private config: JWTConfig; + + constructor(config: JWTConfig) { + this.config = config; + } + + /** + * 生成 Access Token + */ + generateAccessToken(userId: string, email: string): { token: string; expiresAt: Date } { + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + this.config.accessTokenExpiry; + + const payload: JWTPayload = { + user_id: userId, + email, + type: 'access', + iss: this.config.issuer, + exp: expiresAt, + iat: now, + }; + + const token = jwt.sign(payload, this.config.secret, { + algorithm: 'HS256', + }); + + return { + token, + expiresAt: new Date(expiresAt * 1000), + }; + } + + /** + * 生成 Refresh Token + */ + generateRefreshToken(userId: string): { token: string; expiresAt: Date } { + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + this.config.refreshTokenExpiry; + + const payload: JWTPayload = { + user_id: userId, + type: 'refresh', + iss: this.config.issuer, + exp: expiresAt, + iat: now, + }; + + const token = jwt.sign(payload, this.config.secret, { + algorithm: 'HS256', + }); + + return { + token, + expiresAt: new Date(expiresAt * 1000), + }; + } + + /** + * 验证 Token + */ + verifyToken(tokenString: string): JWTPayload { + try { + const decoded = jwt.verify(tokenString, this.config.secret, { + algorithms: ['HS256'], + }) as jwt.JwtPayload; + + // 验证 Issuer + if (decoded.iss !== this.config.issuer) { + throw new Error('INVALID_ISSUER: Issuer 不匹配'); + } + + // 转换为 JWTPayload + const payload: JWTPayload = { + user_id: decoded.user_id as string, + email: decoded.email as string | undefined, + type: decoded.type as TokenType, + iss: decoded.iss as string, + exp: decoded.exp as number, + iat: decoded.iat as number, + }; + + return payload; + } catch (error: any) { + if (error.name === 'TokenExpiredError') { + throw new Error('INVALID_TOKEN: Token 已过期'); + } + if (error.name === 'JsonWebTokenError') { + throw new Error('INVALID_TOKEN: Token 验证失败'); + } + throw error; + } + } + + /** + * 验证 Access Token + */ + verifyAccessToken(tokenString: string): JWTPayload { + const payload = this.verifyToken(tokenString); + + if (payload.type !== 'access') { + throw new Error('INVALID_TOKEN_TYPE: Token 类型必须是 access'); + } + + return payload; + } + + /** + * 验证 Refresh Token + */ + verifyRefreshToken(tokenString: string): JWTPayload { + const payload = this.verifyToken(tokenString); + + if (payload.type !== 'refresh') { + throw new Error('INVALID_TOKEN_TYPE: Token 类型必须是 refresh'); + } + + return payload; + } + + /** + * 从 Token 中提取用户 ID + */ + extractUserId(tokenString: string): string { + const payload = this.verifyToken(tokenString); + return payload.user_id; + } +} + diff --git a/backend-nodejs/domains/auth/usecases.yaml b/backend-nodejs/domains/auth/usecases.yaml new file mode 100644 index 0000000..e2b8999 --- /dev/null +++ b/backend-nodejs/domains/auth/usecases.yaml @@ -0,0 +1,365 @@ +# Auth Domain Use Cases +# 认证领域用例声明文件 + +version: "1.0" +domain: auth + +usecases: + # ======================================== + # 用例 1: 用户注册 + # ======================================== + Register: + description: "用户注册新账户" + sensitivity: high + http: + method: POST + path: /api/auth/register + auth_required: false + + input: + email: + type: string + required: true + validation: "required,email,max=255" + description: "邮箱" + password: + type: string + required: true + validation: "required,min=8,max=128" + description: "密码" + username: + type: string + required: false + validation: "omitempty,min=3,max=30,alphanum" + description: "用户名(可选)" + full_name: + type: string + required: false + validation: "omitempty,max=100" + description: "全名(可选)" + + output: + user_id: + type: string + description: "用户 ID" + email: + type: string + description: "邮箱" + access_token: + type: string + description: "访问令牌 (JWT)" + refresh_token: + type: string + description: "刷新令牌" + expires_in: + type: int + description: "令牌过期时间(秒)" + + steps: + - name: ValidateInput + type: sync + description: "验证输入参数" + on_fail: abort + + - name: CheckEmailExists + type: sync + description: "检查邮箱是否已被注册" + on_fail: abort + error: EMAIL_ALREADY_EXISTS + + - name: CheckUsernameExists + type: sync + description: "检查用户名是否已被占用(如果提供)" + on_fail: abort + error: USERNAME_ALREADY_EXISTS + + - name: CreateUser + type: sync + description: "创建用户实体(调用 User Domain)" + on_fail: abort + + - name: SaveUser + type: sync + description: "保存用户到数据库" + on_fail: abort + + - name: GenerateTokens + type: sync + description: "生成 JWT Access Token 和 Refresh Token" + + - name: PublishUserCreatedEvent + type: event + event_type: UserCreated + description: "发布用户创建事件" + on_fail: log + + errors: + - code: EMAIL_ALREADY_EXISTS + message: "邮箱已被注册" + http_status: 400 + - code: USERNAME_ALREADY_EXISTS + message: "用户名已被占用" + http_status: 400 + - code: INVALID_EMAIL + message: "邮箱格式无效" + http_status: 400 + - code: WEAK_PASSWORD + message: "密码强度不足(至少 8 字符)" + http_status: 400 + - code: REGISTRATION_FAILED + message: "注册失败" + http_status: 500 + + # ======================================== + # 用例 2: 用户登录 + # ======================================== + Login: + description: "用户登录系统" + sensitivity: high + http: + method: POST + path: /api/auth/login + auth_required: false + + input: + email: + type: string + required: true + validation: "required,email" + description: "邮箱" + password: + type: string + required: true + validation: "required" + description: "密码" + + output: + user_id: + type: string + description: "用户 ID" + email: + type: string + description: "邮箱" + access_token: + type: string + description: "访问令牌 (JWT)" + refresh_token: + type: string + description: "刷新令牌" + expires_in: + type: int + description: "令牌过期时间(秒)" + + steps: + - name: ValidateInput + type: sync + description: "验证输入参数" + on_fail: abort + + - name: GetUserByEmail + type: sync + description: "根据邮箱获取用户" + on_fail: abort + error: INVALID_CREDENTIALS + + - name: VerifyPassword + type: sync + description: "验证密码" + on_fail: abort + error: INVALID_CREDENTIALS + + - name: CheckUserStatus + type: sync + description: "检查用户状态(是否被禁用)" + on_fail: abort + error: USER_BANNED + + - name: GenerateTokens + type: sync + description: "生成 JWT Access Token 和 Refresh Token" + + - name: RecordLoginTime + type: sync + description: "记录最后登录时间" + on_fail: log + + - name: PublishLoginSucceededEvent + type: event + event_type: LoginSucceeded + description: "发布登录成功事件" + on_fail: log + + errors: + - code: INVALID_CREDENTIALS + message: "邮箱或密码错误" + http_status: 401 + - code: USER_BANNED + message: "用户已被禁用" + http_status: 403 + - code: LOGIN_FAILED + message: "登录失败" + http_status: 500 + + # ======================================== + # 用例 3: 刷新令牌 + # ======================================== + RefreshToken: + description: "使用 Refresh Token 获取新的 Access Token" + sensitivity: high + http: + method: POST + path: /api/auth/refresh + auth_required: false + + input: + refresh_token: + type: string + required: true + validation: "required" + description: "刷新令牌" + + output: + access_token: + type: string + description: "新的访问令牌" + refresh_token: + type: string + description: "新的刷新令牌" + expires_in: + type: int + description: "令牌过期时间(秒)" + + steps: + - name: ValidateRefreshToken + type: sync + description: "验证 Refresh Token" + on_fail: abort + error: INVALID_REFRESH_TOKEN + + - name: GetUserByID + type: sync + description: "获取用户信息" + on_fail: abort + error: USER_NOT_FOUND + + - name: CheckUserStatus + type: sync + description: "检查用户状态" + on_fail: abort + error: USER_BANNED + + - name: GenerateNewTokens + type: sync + description: "生成新的 Access Token 和 Refresh Token" + + errors: + - code: INVALID_REFRESH_TOKEN + message: "Refresh Token 无效或已过期" + http_status: 401 + - code: USER_NOT_FOUND + message: "用户不存在" + http_status: 404 + - code: USER_BANNED + message: "用户已被禁用" + http_status: 403 + + # ======================================== + # 用例 4: 登出(扩展点) + # ======================================== + Logout: + description: "用户登出系统(撤销 Token)" + sensitivity: medium + http: + method: POST + path: /api/auth/logout + auth_required: true + + input: + user_id: + type: string + required: true + source: context + description: "用户 ID (from JWT)" + + output: + success: + type: bool + description: "是否成功" + message: + type: string + description: "提示消息" + + steps: + - name: RevokeToken + type: sync + description: "撤销当前 Token(添加到黑名单)" + + - name: PublishLogoutEvent + type: event + event_type: UserLogout + on_fail: log + + errors: + - code: LOGOUT_FAILED + message: "登出失败" + http_status: 500 + +# ======================================== +# 全局配置 +# ======================================== +config: + jwt: + access_token_expiry: 1h + refresh_token_expiry: 7d + issuer: "go-genai-stack" + algorithm: "HS256" + + default_timeout: 30s + enable_tracing: true + enable_metrics: true + +# ======================================== +# 依赖关系 +# ======================================== +dependencies: + external: + - name: User Domain + description: "调用 User Domain 创建和验证用户" + + infrastructure: + - name: database + type: PostgreSQL + description: "用户数据存储" + - name: redis + type: Redis + description: "Token 黑名单(扩展点)" + required: false + +# ======================================== +# 扩展点 +# ======================================== +extensions: + - name: OAuth2 Integration + description: "集成 Google、GitHub 等第三方登录" + status: not_implemented + + - name: Token Blacklist + description: "使用 Redis 存储撤销的 Token(登出功能)" + status: not_implemented + + - name: Email Verification + description: "注册后发送邮箱验证链接" + status: not_implemented + + - name: Password Reset + description: "忘记密码流程" + status: not_implemented + + - name: Rate Limiting + description: "登录失败次数限制(防止暴力破解)" + status: not_implemented + + - name: Multi-Factor Authentication + description: "多因素认证(TOTP、SMS)" + status: not_implemented + diff --git a/backend-nodejs/domains/task/handlers/complete_task.handler.ts b/backend-nodejs/domains/task/handlers/complete_task.handler.ts index 320bc8a..09b3194 100644 --- a/backend-nodejs/domains/task/handlers/complete_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/complete_task.handler.ts @@ -9,6 +9,7 @@ import { toCompleteTaskResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function completeTaskHandler( deps: HandlerDependencies, @@ -16,7 +17,7 @@ export async function completeTaskHandler( reply: FastifyReply ): Promise { try { - const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const userId = requireUserId(req); const taskId = req.params.id; const input = toCompleteTaskInput(userId, taskId); diff --git a/backend-nodejs/domains/task/handlers/create_task.handler.ts b/backend-nodejs/domains/task/handlers/create_task.handler.ts index 03ba3f0..043bb69 100644 --- a/backend-nodejs/domains/task/handlers/create_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/create_task.handler.ts @@ -11,6 +11,7 @@ import { toCreateTaskResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function createTaskHandler( deps: HandlerDependencies, @@ -18,15 +19,8 @@ export async function createTaskHandler( reply: FastifyReply ): Promise { try { - // 1. 获取用户 ID(当前从请求头获取,后续可扩展为 JWT) - const userId = (req.headers['x-user-id'] as string) || 'default-user'; - if (!userId) { - reply.code(401).send({ - error: 'UNAUTHORIZED', - message: '未授权访问', - }); - return; - } + // 1. 获取用户 ID(从 JWT Token 中提取) + const userId = requireUserId(req); // 2. 解析 HTTP 请求 const body = req.body; @@ -50,8 +44,8 @@ export async function createTaskHandler( } function getStatusCode(errorCode: string): number { - if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { - return 400; + if (errorCode === 'UNAUTHORIZED') { + return 401; } if (errorCode === 'TASK_NOT_FOUND') { return 404; @@ -59,6 +53,15 @@ function getStatusCode(errorCode: string): number { if (errorCode === 'UNAUTHORIZED_ACCESS') { return 403; } + if ( + errorCode.startsWith('TASK_') || + errorCode === 'INVALID_PRIORITY' || + errorCode === 'INVALID_DUE_DATE' || + errorCode === 'TOO_MANY_TAGS' || + errorCode === 'DUPLICATE_TAG' || + errorCode === 'TAG_NAME_EMPTY' + ) { + return 400; + } return 500; } - diff --git a/backend-nodejs/domains/task/handlers/delete_task.handler.ts b/backend-nodejs/domains/task/handlers/delete_task.handler.ts index 0d938a2..e30847f 100644 --- a/backend-nodejs/domains/task/handlers/delete_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/delete_task.handler.ts @@ -9,6 +9,7 @@ import { toDeleteTaskResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function deleteTaskHandler( deps: HandlerDependencies, @@ -16,7 +17,7 @@ export async function deleteTaskHandler( reply: FastifyReply ): Promise { try { - const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const userId = requireUserId(req); const taskId = req.params.id; const input = toDeleteTaskInput(userId, taskId); diff --git a/backend-nodejs/domains/task/handlers/get_task.handler.ts b/backend-nodejs/domains/task/handlers/get_task.handler.ts index 66c60c3..ac3789d 100644 --- a/backend-nodejs/domains/task/handlers/get_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/get_task.handler.ts @@ -9,6 +9,7 @@ import { toGetTaskResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function getTaskHandler( deps: HandlerDependencies, @@ -16,7 +17,7 @@ export async function getTaskHandler( reply: FastifyReply ): Promise { try { - const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const userId = requireUserId(req); const taskId = req.params.id; const input = toGetTaskInput(userId, taskId); diff --git a/backend-nodejs/domains/task/handlers/list_tasks.handler.ts b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts index efa95da..5204f8a 100644 --- a/backend-nodejs/domains/task/handlers/list_tasks.handler.ts +++ b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts @@ -10,6 +10,7 @@ import { toListTasksResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function listTasksHandler( deps: HandlerDependencies, @@ -17,7 +18,7 @@ export async function listTasksHandler( reply: FastifyReply ): Promise { try { - const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const userId = requireUserId(req); const input = toListTasksInput(userId, req.query); const output = await deps.taskService.listTasks(req, input); diff --git a/backend-nodejs/domains/task/handlers/update_task.handler.ts b/backend-nodejs/domains/task/handlers/update_task.handler.ts index 3c69252..494f934 100644 --- a/backend-nodejs/domains/task/handlers/update_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/update_task.handler.ts @@ -10,6 +10,7 @@ import { toUpdateTaskResponse, } from './converters.js'; import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; export async function updateTaskHandler( deps: HandlerDependencies, @@ -20,7 +21,7 @@ export async function updateTaskHandler( reply: FastifyReply ): Promise { try { - const userId = (req.headers['x-user-id'] as string) || 'default-user'; + const userId = requireUserId(req); const taskId = req.params.id; const input = toUpdateTaskInput(userId, taskId, req.body); @@ -38,15 +39,17 @@ export async function updateTaskHandler( } function getStatusCode(errorCode: string): number { - if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { - return 400; - } + // 先检查特定错误码 if (errorCode === 'TASK_NOT_FOUND') { return 404; } if (errorCode === 'UNAUTHORIZED_ACCESS') { return 403; } + // 再检查通用错误码 + if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { + return 400; + } return 500; } diff --git a/backend-nodejs/domains/task/http/router.ts b/backend-nodejs/domains/task/http/router.ts index 04081a2..d8bff79 100644 --- a/backend-nodejs/domains/task/http/router.ts +++ b/backend-nodejs/domains/task/http/router.ts @@ -12,42 +12,68 @@ import { completeTaskHandler } from '../handlers/complete_task.handler.js'; import { deleteTaskHandler } from '../handlers/delete_task.handler.js'; import { getTaskHandler } from '../handlers/get_task.handler.js'; import { listTasksHandler } from '../handlers/list_tasks.handler.js'; +import type { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; /** * 注册 Task 路由 */ export function registerTaskRoutes( app: FastifyInstance, - deps: HandlerDependencies + deps: HandlerDependencies, + authMiddleware: ReturnType ): void { - // POST /api/tasks - 创建任务 - app.post<{ Body: CreateTaskRequest }>('/api/tasks', async (req, reply) => { - await createTaskHandler(deps, req as any, reply); - }); - - // GET /api/tasks - 列出任务 - app.get<{ Querystring: ListTasksQuery }>('/api/tasks', async (req, reply) => { - await listTasksHandler(deps, req as any, reply); - }); - - // GET /api/tasks/:id - 获取任务详情 - app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { - await getTaskHandler(deps, req as any, reply); - }); - - // PUT /api/tasks/:id - 更新任务 - app.put<{ Params: { id: string }; Body: UpdateTaskRequest }>('/api/tasks/:id', async (req, reply) => { - await updateTaskHandler(deps, req as any, reply); - }); - - // POST /api/tasks/:id/complete - 完成任务 - app.post<{ Params: { id: string } }>('/api/tasks/:id/complete', async (req, reply) => { - await completeTaskHandler(deps, req as any, reply); - }); - - // DELETE /api/tasks/:id - 删除任务 - app.delete<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { - await deleteTaskHandler(deps, req as any, reply); - }); + // POST /api/tasks - 创建任务(需要认证) + app.post<{ Body: CreateTaskRequest }>( + '/api/tasks', + { preHandler: authMiddleware }, + async (req, reply) => { + await createTaskHandler(deps, req as any, reply); + } + ); + + // GET /api/tasks - 列出任务(需要认证) + app.get<{ Querystring: ListTasksQuery }>( + '/api/tasks', + { preHandler: authMiddleware }, + async (req, reply) => { + await listTasksHandler(deps, req as any, reply); + } + ); + + // GET /api/tasks/:id - 获取任务详情(需要认证) + app.get<{ Params: { id: string } }>( + '/api/tasks/:id', + { preHandler: authMiddleware }, + async (req, reply) => { + await getTaskHandler(deps, req as any, reply); + } + ); + + // PUT /api/tasks/:id - 更新任务(需要认证) + app.put<{ Params: { id: string }; Body: UpdateTaskRequest }>( + '/api/tasks/:id', + { preHandler: authMiddleware }, + async (req, reply) => { + await updateTaskHandler(deps, req as any, reply); + } + ); + + // POST /api/tasks/:id/complete - 完成任务(需要认证) + app.post<{ Params: { id: string } }>( + '/api/tasks/:id/complete', + { preHandler: authMiddleware }, + async (req, reply) => { + await completeTaskHandler(deps, req as any, reply); + } + ); + + // DELETE /api/tasks/:id - 删除任务(需要认证) + app.delete<{ Params: { id: string } }>( + '/api/tasks/:id', + { preHandler: authMiddleware }, + async (req, reply) => { + await deleteTaskHandler(deps, req as any, reply); + } + ); } diff --git a/backend-nodejs/domains/task/model/task.test.ts b/backend-nodejs/domains/task/model/task.test.ts new file mode 100644 index 0000000..9ad7112 --- /dev/null +++ b/backend-nodejs/domains/task/model/task.test.ts @@ -0,0 +1,326 @@ +/** + * Task Model 单元测试 + */ + +import { describe, it, expect } from 'vitest'; +import { Task } from './task.js'; + +describe('Task Model', () => { + describe('create', () => { + it('应该创建有效任务', () => { + const task = Task.create('user-123', 'Test Task', 'Test Description', 'medium'); + + expect(task.id).toBeDefined(); + expect(task.title).toBe('Test Task'); + expect(task.description).toBe('Test Description'); + expect(task.priority).toBe('medium'); + expect(task.status).toBe('pending'); + expect(task.tags).toEqual([]); + expect(task.dueDate).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task.createdAt).toBeInstanceOf(Date); + expect(task.updatedAt).toBeInstanceOf(Date); + }); + + it('应该拒绝空标题', () => { + expect(() => { + Task.create('user-123', '', 'Description', 'medium'); + }).toThrow('TASK_TITLE_EMPTY'); + }); + + it('应该拒绝标题过长', () => { + const longTitle = 'a'.repeat(201); + expect(() => { + Task.create('user-123', longTitle, 'Description', 'medium'); + }).toThrow('TASK_TITLE_TOO_LONG'); + }); + + it('应该拒绝描述过长', () => { + const longDescription = 'a'.repeat(5001); + expect(() => { + Task.create('user-123', 'Title', longDescription, 'medium'); + }).toThrow('TASK_DESCRIPTION_TOO_LONG'); + }); + + it('应该拒绝无效优先级', () => { + expect(() => { + Task.create('user-123', 'Title', 'Description', 'invalid' as any); + }).toThrow('INVALID_PRIORITY'); + }); + + it('应该接受空描述', () => { + const task = Task.create('user-123', 'Test Task', '', 'medium'); + expect(task.description).toBe(''); + }); + + it('应该接受最大长度标题', () => { + const maxTitle = 'a'.repeat(200); + const task = Task.create('user-123', maxTitle, 'Description', 'medium'); + expect(task.title).toBe(maxTitle); + }); + + it('应该接受最大长度描述', () => { + const maxDescription = 'a'.repeat(5000); + const task = Task.create('user-123', 'Title', maxDescription, 'medium'); + expect(task.description).toBe(maxDescription); + }); + + it('应该接受不同优先级', () => { + const lowTask = Task.create('user-123', 'Low', 'Desc', 'low'); + expect(lowTask.priority).toBe('low'); + + const highTask = Task.create('user-123', 'High', 'Desc', 'high'); + expect(highTask.priority).toBe('high'); + }); + }); + + describe('update', () => { + it('应该更新待处理任务', async () => { + const task = Task.create('user-123', 'Original', 'Original Desc', 'low'); + const oldUpdatedAt = task.updatedAt; + + // 等待一小段时间以确保时间戳不同 + await new Promise((resolve) => setTimeout(resolve, 10)); + + task.update('Updated Title', 'Updated Description', 'high'); + + expect(task.title).toBe('Updated Title'); + expect(task.description).toBe('Updated Description'); + expect(task.priority).toBe('high'); + expect(task.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该拒绝更新已完成任务', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.complete(); + + expect(() => { + task.update('New Title', 'New Desc', 'high'); + }).toThrow('TASK_ALREADY_COMPLETED'); + }); + + it('应该拒绝更新标题为空', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + + expect(() => { + task.update('', 'Desc', 'medium'); + }).toThrow('TASK_TITLE_EMPTY'); + }); + + it('应该拒绝更新标题过长', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const longTitle = 'a'.repeat(201); + + expect(() => { + task.update(longTitle, 'Desc', 'medium'); + }).toThrow('TASK_TITLE_TOO_LONG'); + }); + + it('应该拒绝更新描述过长', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const longDescription = 'a'.repeat(5001); + + expect(() => { + task.update('Test', longDescription, 'medium'); + }).toThrow('TASK_DESCRIPTION_TOO_LONG'); + }); + + it('应该拒绝无效优先级', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + + expect(() => { + task.update('Test', 'Desc', 'invalid' as any); + }).toThrow('INVALID_PRIORITY'); + }); + }); + + describe('complete', () => { + it('应该完成待处理任务', async () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const oldUpdatedAt = task.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + task.complete(); + + expect(task.status).toBe('completed'); + expect(task.completedAt).toBeInstanceOf(Date); + expect(task.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该完成进行中任务', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.status = 'in_progress'; + + task.complete(); + + expect(task.status).toBe('completed'); + expect(task.completedAt).toBeInstanceOf(Date); + }); + + it('应该拒绝重复完成已完成任务', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.complete(); + + expect(() => { + task.complete(); + }).toThrow('TASK_ALREADY_COMPLETED'); + }); + }); + + describe('setDueDate', () => { + it('应该设置未来日期', async () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // 明天 + const oldUpdatedAt = task.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + task.setDueDate(futureDate); + + expect(task.dueDate).toBeInstanceOf(Date); + expect(task.dueDate!.getTime()).toBeCloseTo(futureDate.getTime(), -3); + expect(task.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该拒绝设置过去日期', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 昨天 + + expect(() => { + task.setDueDate(pastDate); + }).toThrow('INVALID_DUE_DATE'); + }); + }); + + describe('addTag', () => { + it('应该添加有效标签', async () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + const oldUpdatedAt = task.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + task.addTag({ name: 'test', color: '#ff0000' }); + + expect(task.tags).toHaveLength(1); + expect(task.tags[0].name).toBe('test'); + expect(task.tags[0].color).toBe('#ff0000'); + expect(task.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该拒绝添加空名称标签', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + + expect(() => { + task.addTag({ name: '', color: '#ff0000' }); + }).toThrow('TAG_NAME_EMPTY'); + }); + + it('应该拒绝添加重复标签', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.addTag({ name: 'test', color: '#ff0000' }); + + expect(() => { + task.addTag({ name: 'test', color: '#00ff00' }); + }).toThrow('DUPLICATE_TAG'); + }); + + it('应该拒绝添加过多标签', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + + // 添加 10 个标签 + for (let i = 0; i < 10; i++) { + task.addTag({ name: `tag${i}`, color: '#ff0000' }); + } + + // 尝试添加第 11 个标签 + expect(() => { + task.addTag({ name: 'tag11', color: '#ff0000' }); + }).toThrow('TOO_MANY_TAGS'); + }); + + it('应该添加多个不同标签', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + + task.addTag({ name: 'urgent', color: '#ff0000' }); + task.addTag({ name: 'important', color: '#00ff00' }); + task.addTag({ name: 'project-alpha', color: '#0000ff' }); + + expect(task.tags).toHaveLength(3); + expect(task.tags[0].name).toBe('urgent'); + expect(task.tags[1].name).toBe('important'); + expect(task.tags[2].name).toBe('project-alpha'); + }); + }); + + describe('removeTag', () => { + it('应该移除存在的标签', async () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.addTag({ name: 'test1', color: '#ff0000' }); + task.addTag({ name: 'test2', color: '#00ff00' }); + task.addTag({ name: 'test3', color: '#0000ff' }); + + const oldUpdatedAt = task.updatedAt; + await new Promise((resolve) => setTimeout(resolve, 10)); + + task.removeTag('test2'); + + expect(task.tags).toHaveLength(2); + expect(task.tags[0].name).toBe('test1'); + expect(task.tags[1].name).toBe('test3'); + expect(task.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该移除不存在的标签时不报错', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.addTag({ name: 'test', color: '#ff0000' }); + + task.removeTag('nonexistent'); + + expect(task.tags).toHaveLength(1); + expect(task.tags[0].name).toBe('test'); + }); + + it('应该移除所有标签', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'medium'); + task.addTag({ name: 'test1', color: '#ff0000' }); + task.addTag({ name: 'test2', color: '#00ff00' }); + + task.removeTag('test1'); + task.removeTag('test2'); + + expect(task.tags).toHaveLength(0); + }); + }); + + describe('complete preserves data', () => { + it('应该保留其他数据', () => { + const task = Task.create('user-123', 'Test Task', 'Test Description', 'high'); + const dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + task.setDueDate(dueDate); + task.addTag({ name: 'urgent', color: '#ff0000' }); + + task.complete(); + + expect(task.title).toBe('Test Task'); + expect(task.description).toBe('Test Description'); + expect(task.priority).toBe('high'); + expect(task.dueDate).toBeInstanceOf(Date); + expect(task.tags).toHaveLength(1); + expect(task.status).toBe('completed'); + }); + }); + + describe('update preserves status', () => { + it('应该保留状态(如果未完成)', () => { + const task = Task.create('user-123', 'Test', 'Desc', 'low'); + task.status = 'in_progress'; + + task.update('Updated', 'Updated Desc', 'high'); + + expect(task.status).toBe('in_progress'); + expect(task.title).toBe('Updated'); + }); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/complete_task.test.ts b/backend-nodejs/domains/task/tests/complete_task.test.ts new file mode 100644 index 0000000..9d243a7 --- /dev/null +++ b/backend-nodejs/domains/task/tests/complete_task.test.ts @@ -0,0 +1,145 @@ +/** + * CompleteTask Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { Task } from '../model/task.js'; + +describe('CompleteTask Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let taskId: string; + let completedTaskId: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 创建测试任务 + const taskRepo = new TaskRepositoryImpl(db); + const task = Task.create(TEST_USER_ID, 'Test Task', 'Test Description', 'medium'); + await taskRepo.create({}, task); + taskId = task.id; + + // 创建已完成的任务 + const completedTask = Task.create(TEST_USER_ID, 'Completed Task', 'Description', 'medium'); + completedTask.complete(); + await taskRepo.create({}, completedTask); + completedTaskId = completedTask.id; + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功完成任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: `/api/tasks/${taskId}/complete`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBe(taskId); + expect(body.status).toBe('completed'); + expect(body.completed_at).toBeDefined(); + }); + + it('应该拒绝重复完成已完成任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: `/api/tasks/${completedTaskId}/complete`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_ALREADY_COMPLETED'); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: `/api/tasks/${taskId}/complete`, + }); + + expect(response.statusCode).toBe(401); + }); + + it('应该返回 404 当任务不存在', async () => { + if (!dbAvailable) return; + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await app.inject({ + method: 'POST', + url: `/api/tasks/${nonExistentId}/complete`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_NOT_FOUND'); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/create_task.test.ts b/backend-nodejs/domains/task/tests/create_task.test.ts new file mode 100644 index 0000000..386afdc --- /dev/null +++ b/backend-nodejs/domains/task/tests/create_task.test.ts @@ -0,0 +1,281 @@ +/** + * CreateTask Handler 集成测试 + * 测试创建任务的完整流程 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; + +describe('CreateTask Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + // 加载配置 + const config = loadConfig(); + + // 创建数据库连接(使用测试数据库) + const db = createDatabaseConnection(config.database); + + // 测试数据库连接 + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch (error) { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + dbAvailable = false; + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + // 创建 JWT Service(用于生成测试 Token) + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + // 生成测试 Token + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + // 创建测试辅助工具 + testHelper = createTestHelper(db, jwtService); + + // 创建 Fastify 应用 + app = testHelper.app; + + // 注册认证中间件 + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + + // 注册路由 + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 准备应用 + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功创建任务', async () => { + if (!dbAvailable) { + return; // 跳过测试 + } + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: 'Test Description', + priority: 'medium', + tags: ['test', 'unit'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toHaveProperty('task_id'); + expect(body.title).toBe('Test Task'); + expect(body.status).toBe('pending'); + }); + + it('应该拒绝空标题', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: '', + description: 'Test Description', + priority: 'medium', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_TITLE_EMPTY'); + }); + + it('应该拒绝描述过长', async () => { + if (!dbAvailable) return; + + const longDescription = 'a'.repeat(5001); + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: longDescription, + priority: 'medium', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('DESCRIPTION_TOO_LONG'); + }); + + it('应该拒绝无效优先级', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: 'Test Description', + priority: 'invalid', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_PRIORITY'); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + payload: { + title: 'Test Task', + description: 'Test Description', + priority: 'medium', + }, + }); + + expect(response.statusCode).toBe(401); + }); + + it('应该接受不同优先级', async () => { + if (!dbAvailable) return; + + const priorities = ['low', 'medium', 'high'] as const; + + for (const priority of priorities) { + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: `Test Task ${priority}`, + description: 'Test Description', + priority, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBeDefined(); + } + }); + + it('应该接受空描述', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: '', + priority: 'medium', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBeDefined(); + }); + + it('应该接受标签', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: 'Test Description', + priority: 'medium', + tags: ['urgent', 'important'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBeDefined(); + }); + + it('应该拒绝过多标签', async () => { + if (!dbAvailable) return; + + const tags = Array.from({ length: 11 }, (_, i) => `tag${i}`); + + const response = await app.inject({ + method: 'POST', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Test Task', + description: 'Test Description', + priority: 'medium', + tags, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('TOO_MANY_TAGS'); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/delete_task.test.ts b/backend-nodejs/domains/task/tests/delete_task.test.ts new file mode 100644 index 0000000..137d9fb --- /dev/null +++ b/backend-nodejs/domains/task/tests/delete_task.test.ts @@ -0,0 +1,121 @@ +/** + * DeleteTask Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { Task } from '../model/task.js'; + +describe('DeleteTask Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let taskId: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 创建测试任务 + const taskRepo = new TaskRepositoryImpl(db); + const task = Task.create(TEST_USER_ID, 'Test Task', 'Test Description', 'medium'); + await taskRepo.create({}, task); + taskId = task.id; + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功删除任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/tasks/${taskId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + expect(body.deleted_at).toBeDefined(); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'DELETE', + url: `/api/tasks/${taskId}`, + }); + + expect(response.statusCode).toBe(401); + }); + + it('应该返回 404 当任务不存在', async () => { + if (!dbAvailable) return; + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await app.inject({ + method: 'DELETE', + url: `/api/tasks/${nonExistentId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_NOT_FOUND'); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/get_task.test.ts b/backend-nodejs/domains/task/tests/get_task.test.ts new file mode 100644 index 0000000..c432107 --- /dev/null +++ b/backend-nodejs/domains/task/tests/get_task.test.ts @@ -0,0 +1,124 @@ +/** + * GetTask Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { Task } from '../model/task.js'; + +describe('GetTask Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let taskId: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + // 测试数据库连接 + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 创建测试任务 + const taskRepo = new TaskRepositoryImpl(db); + const task = Task.create(TEST_USER_ID, 'Test Task', 'Test Description', 'medium'); + await taskRepo.create({}, task); + taskId = task.id; + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功获取任务详情', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: `/api/tasks/${taskId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBe(taskId); + expect(body.title).toBe('Test Task'); + expect(body.description).toBe('Test Description'); + expect(body.status).toBe('pending'); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: `/api/tasks/${taskId}`, + }); + + expect(response.statusCode).toBe(401); + }); + + it('应该返回 404 当任务不存在', async () => { + if (!dbAvailable) return; + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await app.inject({ + method: 'GET', + url: `/api/tasks/${nonExistentId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_NOT_FOUND'); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/helpers.ts b/backend-nodejs/domains/task/tests/helpers.ts new file mode 100644 index 0000000..3ae4d7a --- /dev/null +++ b/backend-nodejs/domains/task/tests/helpers.ts @@ -0,0 +1,178 @@ +/** + * Task 测试辅助工具 + * 提供测试所需的 Mock 数据和辅助函数 + */ + +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { TaskService } from '../service/task_service.js'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import { Task } from '../model/task.js'; +import type { JWTService } from '../../auth/service/jwt_service.js'; +import bcrypt from 'bcryptjs'; + +// ========== 测试常量 ========== + +// 使用有效的 UUID 格式(符合数据库 schema 要求) +export const TEST_USER_ID = '00000000-0000-0000-0000-000000000001'; +export const TEST_TASK_ID = '00000000-0000-0000-0000-000000000002'; +export const TEST_TASK_TITLE = 'Test Task'; +export const TEST_TASK_DESCRIPTION = 'Test Description'; +export const TEST_PRIORITY = 'medium' as const; +export const TEST_STATUS = 'pending' as const; + +export const TEST_TAG_NAME_1 = 'urgent'; +export const TEST_TAG_COLOR_1 = '#ff0000'; +export const TEST_TAG_NAME_2 = 'important'; +export const TEST_TAG_COLOR_2 = '#00ff00'; + +export const TEST_TIME = new Date('2025-01-01T00:00:00Z'); + +// ========== 测试辅助类 ========== + +export interface TestHelper { + db: Kysely; + taskRepo: TaskRepositoryImpl; + taskService: TaskService; + handlerDeps: HandlerDependencies; + app: FastifyInstance; + jwtService: JWTService | null; +} + +/** + * 创建测试辅助工具 + * + * 注意:这里使用真实的数据库连接(测试数据库) + * 在生产测试中,可以使用内存数据库或 Docker 测试容器 + */ +export function createTestHelper(db: Kysely, jwtService?: JWTService): TestHelper { + // 1. 创建 Repository + const taskRepo = new TaskRepositoryImpl(db); + + // 2. 创建 Service + const taskService = new TaskService(taskRepo); + + // 3. 创建 Handler Dependencies + const handlerDeps: HandlerDependencies = { + taskService, + }; + + // 4. 创建 Fastify 应用 + const app = Fastify({ + logger: false, // 测试时禁用日志 + }); + + return { + db, + taskRepo, + taskService, + handlerDeps, + app, + jwtService: jwtService || null, + }; +} + +/** + * 设置认证上下文(模拟 JWT 中间件) + */ +export function setAuthContext(app: FastifyInstance, userId: string): void { + app.addHook('onRequest', async (request) => { + // 模拟 JWT 中间件注入 user_id + (request as any).userId = userId; + }); +} + +/** + * 确保测试用户存在 + * 如果用户不存在,则创建它 + */ +export async function ensureTestUser(db: Kysely): Promise { + const existingUser = await db + .selectFrom('users') + .select('id') + .where('id', '=', TEST_USER_ID) + .executeTakeFirst(); + + if (!existingUser) { + // 创建测试用户 + const passwordHash = await bcrypt.hash('test-password', 10); + await db + .insertInto('users') + .values({ + id: TEST_USER_ID, + email: 'test@example.com', + username: null, + password_hash: passwordHash, + full_name: null, + avatar_url: null, + status: 'active', + email_verified: true, + created_at: new Date(), + updated_at: new Date(), + last_login_at: null, + }) + .execute(); + } +} + +// ========== 测试数据生成器 ========== + +/** + * 创建标准测试任务 + */ +export function createTestTask(): Task { + const task = Task.create( + TEST_USER_ID, + TEST_TASK_TITLE, + TEST_TASK_DESCRIPTION, + TEST_PRIORITY + ); + return task; +} + +/** + * 创建带指定 ID 的测试任务 + */ +export function createTestTaskWithId(id: string): Task { + const task = createTestTask(); + (task as any).id = id; + return task; +} + +/** + * 创建带标签的测试任务 + */ +export function createTestTaskWithTags(tagNames: string[]): Task { + const task = createTestTask(); + for (const tagName of tagNames) { + task.addTag({ + name: tagName, + color: '#808080', + }); + } + return task; +} + +/** + * 创建已完成的测试任务 + */ +export function createCompletedTestTask(): Task { + const task = createTestTask(); + task.complete(); + return task; +} + +/** + * 创建自定义字段的测试任务 + */ +export function createTestTaskWithCustomFields( + title: string, + description: string, + priority: 'low' | 'medium' | 'high' +): Task { + return Task.create(TEST_USER_ID, title, description, priority); +} + diff --git a/backend-nodejs/domains/task/tests/list_tasks.test.ts b/backend-nodejs/domains/task/tests/list_tasks.test.ts new file mode 100644 index 0000000..cb3e089 --- /dev/null +++ b/backend-nodejs/domains/task/tests/list_tasks.test.ts @@ -0,0 +1,158 @@ +/** + * ListTasks Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { Task } from '../model/task.js'; + +describe('ListTasks Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 创建测试任务 + const taskRepo = new TaskRepositoryImpl(db); + for (let i = 0; i < 5; i++) { + const task = Task.create(TEST_USER_ID, `Task ${i}`, `Description ${i}`, 'medium'); + await taskRepo.create({}, task); + } + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功列出任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/tasks', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toHaveProperty('tasks'); + expect(body).toHaveProperty('total_count'); + expect(body).toHaveProperty('page'); + expect(body).toHaveProperty('limit'); + expect(body).toHaveProperty('has_more'); + expect(Array.isArray(body.tasks)).toBe(true); + }); + + it('应该支持分页', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/tasks?page=1&limit=2', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.tasks.length).toBeLessThanOrEqual(2); + expect(Number(body.page)).toBe(1); + expect(Number(body.limit)).toBe(2); + }); + + it('应该支持状态筛选', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/tasks?status=pending', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.tasks.every((t: any) => t.status === 'pending')).toBe(true); + }); + + it('应该支持优先级筛选', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/tasks?priority=medium', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.tasks.every((t: any) => t.priority === 'medium')).toBe(true); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/tasks', + }); + + expect(response.statusCode).toBe(401); + }); +}); + diff --git a/backend-nodejs/domains/task/tests/update_task.test.ts b/backend-nodejs/domains/task/tests/update_task.test.ts new file mode 100644 index 0000000..1c1c4a2 --- /dev/null +++ b/backend-nodejs/domains/task/tests/update_task.test.ts @@ -0,0 +1,178 @@ +/** + * UpdateTask Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerTaskRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { TaskRepositoryImpl } from '../repository/task_repo.js'; +import { Task } from '../model/task.js'; + +describe('UpdateTask Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let taskId: string; + let completedTaskId: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('tasks').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + // 确保测试用户存在 + await ensureTestUser(db); + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + app.addHook('onRequest', authMiddleware); + registerTaskRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 创建测试任务 + const taskRepo = new TaskRepositoryImpl(db); + const task = Task.create(TEST_USER_ID, 'Original Title', 'Original Description', 'low'); + await taskRepo.create({}, task); + taskId = task.id; + + // 创建已完成的任务 + const completedTask = Task.create(TEST_USER_ID, 'Completed Task', 'Description', 'medium'); + completedTask.complete(); + await taskRepo.create({}, completedTask); + completedTaskId = completedTask.id; + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功更新任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: `/api/tasks/${taskId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Updated Title', + description: 'Updated Description', + priority: 'high', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.task_id).toBe(taskId); + expect(body.title).toBe('Updated Title'); + expect(body.status).toBe('pending'); + }); + + it('应该拒绝更新已完成任务', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: `/api/tasks/${completedTaskId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'New Title', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_ALREADY_COMPLETED'); + }); + + it('应该拒绝空标题', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: `/api/tasks/${taskId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: '', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_TITLE_EMPTY'); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: `/api/tasks/${taskId}`, + payload: { + title: 'Updated Title', + }, + }); + + expect(response.statusCode).toBe(401); + }); + + it('应该返回 404 当任务不存在', async () => { + if (!dbAvailable) return; + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await app.inject({ + method: 'PUT', + url: `/api/tasks/${nonExistentId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + title: 'Updated Title', + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('TASK_NOT_FOUND'); + }); +}); + diff --git a/backend-nodejs/domains/user/README.md b/backend-nodejs/domains/user/README.md new file mode 100644 index 0000000..1ecb89a --- /dev/null +++ b/backend-nodejs/domains/user/README.md @@ -0,0 +1,178 @@ +# User Domain (用户领域) + +## 概述 + +用户领域负责管理用户的基本信息、个人资料和账户设置。这是认证系统的核心基础,与 Auth 领域紧密协作。 + +## 领域边界 + +### 职责范围 + +- ✅ 管理用户基本信息(用户名、邮箱、头像等) +- ✅ 处理用户资料更新 +- ✅ 管理用户密码(哈希存储) +- ✅ 提供用户查询和验证 +- ✅ 维护用户状态(激活/禁用) + +### 不包含的职责 + +- ❌ JWT Token 生成和验证(属于 Auth Domain) +- ❌ OAuth2 集成(属于 Auth Domain) +- ❌ 权限和角色管理(属于 RBAC Domain,未实现) +- ❌ 用户行为分析(属于 Analytics Domain,未实现) + +## 核心概念 + +参考 `glossary.md` 了解领域术语。 + +## 用例列表 + +参考 `usecases.yaml` 查看所有用例的声明式定义。 + +主要用例: +1. **GetUserProfile** - 获取用户资料 +2. **UpdateUserProfile** - 更新用户资料 +3. **ChangePassword** - 修改密码 + +## 聚合根和实体 + +### User(用户)- 聚合根 + +**字段**: +- UserID - 用户 ID (UUID) +- Email - 邮箱(唯一) +- Username - 用户名(唯一,可选) +- PasswordHash - 密码哈希 +- FullName - 全名 +- AvatarURL - 头像 URL +- Status - 状态(Active, Inactive, Banned) +- EmailVerified - 邮箱是否已验证 +- CreatedAt - 创建时间 +- UpdatedAt - 更新时间 +- LastLoginAt - 最后登录时间 + +**业务方法**: +- `NewUser(email, password)` - 创建新用户 +- `VerifyPassword(password)` - 验证密码 +- `UpdatePassword(newPassword)` - 更新密码 +- `Activate()` - 激活用户 +- `Deactivate()` - 禁用用户 + +### UserStatus(用户状态)- 值对象 + +- Active(激活)- 正常使用 +- Inactive(未激活)- 注册但未验证邮箱 +- Banned(禁用)- 被管理员禁用 + +## 领域事件 + +参考 `events.md` 查看所有领域事件。 + +主要事件: +- `UserCreated` - 用户注册时 +- `UserUpdated` - 用户资料更新时 +- `PasswordChanged` - 密码修改时 +- `UserActivated` - 用户激活时 +- `UserDeactivated` - 用户禁用时 + +## 业务规则 + +参考 `rules.md` 查看所有业务规则和约束。 + +核心规则: +- 邮箱必须唯一且格式有效 +- 密码最少 8 字符 +- 用户名 3-30 字符,仅字母数字 +- 密码使用 bcrypt 哈希存储 + +## 依赖关系 + +### 下游依赖 +- Auth Domain - 依赖 User Domain 进行用户验证 + +### 上游依赖 +- 无 + +## 与 Auth Domain 的关系 + +``` +┌──────────────────────────────────────────────┐ +│ Auth Domain (认证领域) │ +│ ┌──────────────────────────────────────┐ │ +│ │ Register → 调用 User.Create │ │ +│ │ Login → 调用 User.VerifyPassword│ │ +│ │ JWT Token Generation │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ User Domain (用户领域) │ +│ ┌──────────────────────────────────────┐ │ +│ │ User Model │ │ +│ │ User Repository │ │ +│ │ User Service │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +## 数据库 Schema + +参考 `database/schema.sql`: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(30) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + avatar_url TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'inactive', + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + last_login_at TIMESTAMPTZ +); +``` + +## 安全考虑 + +1. **密码存储**: + - 使用 bcrypt 哈希 + - Cost 参数:10(平衡安全和性能) + - 永远不返回 password_hash 字段 + +2. **敏感数据**: + - 邮箱、手机号等敏感信息需要访问控制 + - 用户只能访问/修改自己的资料 + +3. **输入验证**: + - 邮箱格式验证 + - 用户名字符限制 + - 防止 SQL 注入(使用参数化查询) + +## 扩展点 + +### 1. 社交账号绑定(未实现) +- 绑定 Google、GitHub、微信等账号 +- 支持多个社交账号登录 + +### 2. 邮箱验证(未实现) +- 发送验证邮件 +- 验证码验证 + +### 3. 用户偏好设置(未实现) +- 主题、语言等个性化设置 + +### 4. 多因素认证(未实现) +- TOTP (Time-based One-Time Password) +- SMS 验证码 + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [业务规则](./rules.md) +- [领域事件](./events.md) +- [数据库 Schema](../../database/schema.sql) + diff --git a/backend-nodejs/domains/user/ai-metadata.json b/backend-nodejs/domains/user/ai-metadata.json new file mode 100644 index 0000000..3ba6634 --- /dev/null +++ b/backend-nodejs/domains/user/ai-metadata.json @@ -0,0 +1,261 @@ +{ + "domain": "user", + "version": "1.0.0", + "description": "User Domain - 用户领域,负责用户基本信息管理、个人资料和密码管理", + "status": "active", + "complexity": "medium", + "ai_friendly": { + "has_usecases_yaml": true, + "has_glossary": true, + "has_rules": true, + "has_events": true, + "has_tests": true, + "code_comments": "extensive" + }, + "architecture": { + "pattern": "DDD + Hexagonal", + "layers": [ + { + "name": "model", + "description": "领域模型层:User 实体和值对象" + }, + { + "name": "repository", + "description": "仓储层:用户数据访问抽象和实现" + }, + { + "name": "service", + "description": "领域服务层:业务逻辑实现" + }, + { + "name": "handlers", + "description": "HTTP 适配层:请求处理(薄层)" + }, + { + "name": "http/dto", + "description": "数据传输对象:HTTP 接口定义" + }, + { + "name": "tests", + "description": "测试层:单元测试和集成测试" + } + ] + }, + "core_entities": [ + { + "name": "User", + "type": "AggregateRoot", + "fields": [ + "ID (UUID)", + "Email (unique)", + "Username (unique, optional)", + "PasswordHash", + "FullName", + "AvatarURL", + "Status (Active/Inactive/Banned)", + "EmailVerified", + "CreatedAt", + "UpdatedAt", + "LastLoginAt" + ], + "business_methods": [ + "NewUser", + "VerifyPassword", + "UpdatePassword", + "Activate", + "Deactivate" + ] + } + ], + "value_objects": [ + { + "name": "Email", + "description": "邮箱地址(RFC 5322)" + }, + { + "name": "PasswordHash", + "description": "密码哈希(bcrypt)" + }, + { + "name": "Username", + "description": "用户名(3-30 字符,字母数字)" + }, + { + "name": "UserStatus", + "description": "用户状态枚举(Active/Inactive/Banned)" + } + ], + "use_cases": [ + { + "name": "GetUserProfile", + "method": "GET", + "path": "/api/users/me", + "auth_required": true, + "description": "获取当前用户资料" + }, + { + "name": "UpdateUserProfile", + "method": "PUT", + "path": "/api/users/me", + "auth_required": true, + "description": "更新用户资料" + }, + { + "name": "ChangePassword", + "method": "POST", + "path": "/api/users/me/change-password", + "auth_required": true, + "description": "修改密码" + } + ], + "domain_events": [ + "UserCreated", + "UserUpdated", + "PasswordChanged", + "UserActivated", + "UserDeactivated", + "UserDeleted", + "EmailVerificationRequested", + "PasswordResetRequested", + "LoginSucceeded", + "LoginFailed" + ], + "dependencies": { + "internal": [ + "domains/shared/events", + "domains/shared/types", + "infrastructure/persistence/postgres" + ], + "external": [ + "golang.org/x/crypto/bcrypt", + "github.com/google/uuid", + "github.com/cloudwego/hertz" + ], + "domain_dependencies": [ + { + "domain": "auth", + "relationship": "协作关系:Auth 领域调用 User 领域进行用户验证" + } + ] + }, + "database": { + "tables": [ + { + "name": "users", + "primary_key": "id (UUID)", + "unique_indexes": [ + "email", + "username" + ], + "indexes": [ + "status", + "created_at", + "email_verified" + ] + } + ] + }, + "security": { + "password_hashing": "bcrypt (cost: 10)", + "sensitive_fields": [ + "password_hash", + "email" + ], + "access_control": "用户只能访问/修改自己的资料" + }, + "business_rules": { + "email": { + "format": "RFC 5322", + "unique": true, + "case_insensitive": true + }, + "password": { + "min_length": 8, + "max_length": 128, + "hashing": "bcrypt" + }, + "username": { + "min_length": 3, + "max_length": 30, + "pattern": "^[a-zA-Z0-9_]+$", + "unique": true, + "optional": true + }, + "user_status": { + "default": "inactive", + "allowed_transitions": { + "inactive": ["active"], + "active": ["banned"], + "banned": ["active"] + } + } + }, + "extension_points": [ + { + "name": "Email Verification", + "status": "to_be_implemented", + "description": "发送邮箱验证链接,验证后激活用户" + }, + { + "name": "Password Reset", + "status": "to_be_implemented", + "description": "忘记密码流程(邮件重置)" + }, + { + "name": "Social Account Binding", + "status": "to_be_implemented", + "description": "绑定 Google、GitHub 等社交账号" + }, + { + "name": "User Preferences", + "status": "to_be_implemented", + "description": "用户偏好设置(主题、语言等)" + }, + { + "name": "Multi-Factor Authentication", + "status": "to_be_implemented", + "description": "多因素认证(TOTP、SMS)" + } + ], + "testing": { + "unit_tests": true, + "integration_tests": true, + "test_coverage_target": 80, + "test_files": [ + "model/user_test.go", + "repository/user_repo_test.go", + "tests/get_user_profile_test.go", + "tests/update_user_profile_test.go", + "tests/change_password_test.go" + ] + }, + "ai_hints": { + "when_adding_use_case": [ + "1. 更新 usecases.yaml 添加用例定义", + "2. 在 service/ 层实现业务逻辑", + "3. 在 handlers/ 层实现 HTTP 适配", + "4. 在 http/dto/ 定义请求/响应结构", + "5. 在 tests/ 编写测试用例", + "6. 更新 README.md 添加用例说明" + ], + "password_security": [ + "永远不返回 password_hash 字段", + "登录失败时不泄露是邮箱还是密码错误", + "密码修改后撤销所有 Token" + ], + "error_handling": [ + "使用显式错误码(如 USER_NOT_FOUND)", + "错误消息包含错误码和描述", + "Handler 层将领域错误转换为 HTTP 状态码" + ] + }, + "related_docs": [ + "README.md", + "usecases.yaml", + "glossary.md", + "rules.md", + "events.md", + "../../database/schema.sql" + ] +} + diff --git a/backend-nodejs/domains/user/errors/errors.ts b/backend-nodejs/domains/user/errors/errors.ts new file mode 100644 index 0000000..9924dde --- /dev/null +++ b/backend-nodejs/domains/user/errors/errors.ts @@ -0,0 +1,48 @@ +/** + * User 领域错误定义 + * 遵循 "ERROR_CODE: message" 格式 + */ + +export class UserError extends Error { + constructor( + public code: string, + message: string + ) { + super(message); + this.name = 'UserError'; + } +} + +// 错误定义 +export const UserErrors = { + INVALID_EMAIL: new UserError('INVALID_EMAIL', '邮箱格式无效'), + EMAIL_ALREADY_EXISTS: new UserError('EMAIL_ALREADY_EXISTS', '邮箱已被占用'), + USERNAME_ALREADY_EXISTS: new UserError('USERNAME_ALREADY_EXISTS', '用户名已被占用'), + INVALID_USERNAME: new UserError('INVALID_USERNAME', '用户名格式无效(3-30 字符,仅字母数字下划线)'), + WEAK_PASSWORD: new UserError('WEAK_PASSWORD', '密码强度不足(至少 8 字符)'), + PASSWORD_TOO_LONG: new UserError('PASSWORD_TOO_LONG', '密码过长(最多 128 字符)'), + INVALID_PASSWORD: new UserError('INVALID_PASSWORD', '密码错误'), + USER_NOT_FOUND: new UserError('USER_NOT_FOUND', '用户不存在'), + USER_BANNED: new UserError('USER_BANNED', '用户已被禁用'), + USER_INACTIVE: new UserError('USER_INACTIVE', '用户未激活'), + FULL_NAME_TOO_LONG: new UserError('FULL_NAME_TOO_LONG', '全名过长(最多 100 字符)'), + INVALID_AVATAR_URL: new UserError('INVALID_AVATAR_URL', '头像 URL 格式无效'), +}; + +/** + * 解析错误码 + */ +export function parseErrorCode(error: unknown): string { + if (error instanceof UserError) { + return error.code; + } + if (error instanceof Error) { + // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) + const match = error.message.match(/^([A-Z_]+):/); + if (match) { + return match[1]; + } + } + return 'INTERNAL_ERROR'; +} + diff --git a/backend-nodejs/domains/user/events.md b/backend-nodejs/domains/user/events.md new file mode 100644 index 0000000..cdfd43b --- /dev/null +++ b/backend-nodejs/domains/user/events.md @@ -0,0 +1,404 @@ +# User Domain Events (用户领域事件) + +> 本文档定义了 User 领域的所有领域事件 + +--- + +## 事件概述 + +领域事件(Domain Events)用于通知系统中其他部分"用户领域发生了什么"。 + +### 事件命名规范 +- 使用过去时态(UserCreated, not UserCreate) +- 以领域名称开头(User...) +- 描述已经发生的事实 + +### 事件传播 +- 使用事件总线(Event Bus)发布 +- 其他领域可订阅感兴趣的事件 +- 支持异步处理 + +--- + +## 1. UserCreated (用户创建事件) + +### 触发时机 +用户注册成功,用户记录已保存到数据库 + +### Payload +```go +type UserCreatedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Username string `json:"username,omitempty"` + Status string `json:"status"` // "inactive" + CreatedAt time.Time `json:"created_at"` + Timestamp time.Time `json:"timestamp"` // 事件发生时间 +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送欢迎邮件和验证链接 +- **Analytics Service** - 记录新用户指标 +- **Notification Service** - 通知管理员(如启用) + +### 使用场景 +```go +// 用户注册成功后 +eventBus.Publish(ctx, UserCreatedEvent{ + UserID: user.ID, + Email: user.Email, + Username: user.Username, + Status: string(user.Status), + CreatedAt: user.CreatedAt, + Timestamp: time.Now(), +}) +``` + +--- + +## 2. UserUpdated (用户资料更新事件) + +### 触发时机 +用户更新个人资料(用户名、全名、头像等) + +### Payload +```go +type UserUpdatedEvent struct { + UserID string `json:"user_id"` + UpdatedFields map[string]interface{} `json:"updated_fields"` // 哪些字段被更新 + UpdatedAt time.Time `json:"updated_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Cache Service** - 清除用户缓存 +- **Search Service** - 更新用户搜索索引 +- **Audit Service** - 记录变更日志 + +### 使用场景 +```go +// 用户资料更新后 +eventBus.Publish(ctx, UserUpdatedEvent{ + UserID: user.ID, + UpdatedFields: map[string]interface{}{ + "username": newUsername, + "full_name": newFullName, + }, + UpdatedAt: user.UpdatedAt, + Timestamp: time.Now(), +}) +``` + +--- + +## 3. PasswordChanged (密码修改事件) + +### 触发时机 +用户成功修改密码 + +### Payload +```go +type PasswordChangedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + ChangedAt time.Time `json:"changed_at"` + Timestamp time.Time `json:"timestamp"` + IPAddress string `json:"ip_address,omitempty"` // 操作来源 IP +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送密码修改通知邮件 +- **Auth Service** - 撤销所有现有 JWT Token +- **Security Service** - 记录安全日志 + +### 安全注意 +- ⚠️ **不要**包含密码或密码哈希 +- ✅ 包含 IP 地址用于安全审计 + +### 使用场景 +```go +// 密码修改成功后 +eventBus.Publish(ctx, PasswordChangedEvent{ + UserID: user.ID, + Email: user.Email, + ChangedAt: time.Now(), + Timestamp: time.Now(), + IPAddress: clientIP, +}) +``` + +--- + +## 4. UserActivated (用户激活事件) + +### 触发时机 +用户邮箱验证成功,状态从 `Inactive` 变为 `Active` + +### Payload +```go +type UserActivatedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + ActivatedAt time.Time `json:"activated_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送激活成功邮件 +- **Analytics Service** - 统计激活率 +- **Marketing Service** - 触发欢迎流程 + +### 使用场景 +```go +// 邮箱验证成功后 +eventBus.Publish(ctx, UserActivatedEvent{ + UserID: user.ID, + Email: user.Email, + ActivatedAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 5. UserDeactivated (用户禁用事件) + +### 触发时机 +管理员禁用用户账户,状态变为 `Banned` + +### Payload +```go +type UserDeactivatedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DeactivatedBy string `json:"deactivated_by"` // 操作管理员 ID + Reason string `json:"reason,omitempty"` + DeactivatedAt time.Time `json:"deactivated_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 通知用户账户被禁用 +- **Auth Service** - 撤销所有 Token +- **Audit Service** - 记录管理操作 + +### 使用场景 +```go +// 管理员禁用用户后 +eventBus.Publish(ctx, UserDeactivatedEvent{ + UserID: user.ID, + Email: user.Email, + DeactivatedBy: adminID, + Reason: "违反社区规则", + DeactivatedAt: time.Now(), + Timestamp: time.Now(), +}) +``` + +--- + +## 6. UserDeleted (用户删除事件) + +### 触发时机 +用户账户被删除(软删除或硬删除) + +### Payload +```go +type UserDeletedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DeletedBy string `json:"deleted_by"` // 用户自己 or 管理员 + DeletedAt time.Time `json:"deleted_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Data Cleanup Service** - 清理用户关联数据 +- **Email Service** - 发送确认邮件 +- **Analytics Service** - 统计流失率 + +### 注意事项 +- 确保级联删除或匿名化关联数据 +- 遵守 GDPR 等隐私法规 + +--- + +## 7. EmailVerificationRequested (邮箱验证请求事件) + +### 触发时机 +用户请求发送邮箱验证链接 + +### Payload +```go +type EmailVerificationRequestedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + VerificationURL string `json:"verification_url"` + ExpiresAt time.Time `json:"expires_at"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送验证邮件 + +### 使用场景 +```go +// 用户注册或重新请求验证时 +eventBus.Publish(ctx, EmailVerificationRequestedEvent{ + UserID: user.ID, + Email: user.Email, + VerificationURL: verifyURL, + ExpiresAt: time.Now().Add(24 * time.Hour), + Timestamp: time.Now(), +}) +``` + +--- + +## 8. PasswordResetRequested (密码重置请求事件) + +### 触发时机 +用户请求重置密码(忘记密码) + +### Payload +```go +type PasswordResetRequestedEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + ResetURL string `json:"reset_url"` + ExpiresAt time.Time `json:"expires_at"` + Timestamp time.Time `json:"timestamp"` + IPAddress string `json:"ip_address,omitempty"` +} +``` + +### 订阅者(潜在) +- **Email Service** - 发送密码重置邮件 +- **Security Service** - 记录重置请求(防止滥用) + +### 安全注意 +- 重置链接有效期:1 小时 +- 记录 IP 地址防止暴力攻击 + +--- + +## 9. LoginSucceeded (登录成功事件) + +### 触发时机 +用户成功登录 + +### Payload +```go +type LoginSucceededEvent struct { + UserID string `json:"user_id"` + Email string `json:"email"` + LoginAt time.Time `json:"login_at"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Analytics Service** - 统计活跃用户 +- **Security Service** - 检测异常登录(如异地登录) + +--- + +## 10. LoginFailed (登录失败事件) + +### 触发时机 +用户登录失败(邮箱或密码错误) + +### Payload +```go +type LoginFailedEvent struct { + Email string `json:"email"` + Reason string `json:"reason"` // "invalid_email" or "invalid_password" + FailedAt time.Time `json:"failed_at"` + IPAddress string `json:"ip_address,omitempty"` + Timestamp time.Time `json:"timestamp"` +} +``` + +### 订阅者(潜在) +- **Security Service** - 检测暴力破解攻击 +- **Rate Limiter** - 限制失败次数 + +### 安全注意 +- 不泄露是邮箱还是密码错误 +- 记录 IP 用于封禁 + +--- + +## 事件总线实现 + +### 当前实现 +使用内存事件总线(In-Memory Event Bus): +```go +// domains/shared/events/bus.go +type EventBus interface { + Publish(ctx context.Context, event Event) error + Subscribe(eventType string, handler EventHandler) error +} +``` + +### 扩展点 +未来可升级为: +- **Kafka** - 高吞吐量、持久化 +- **Redis Pub/Sub** - 简单、快速 +- **RabbitMQ** - 可靠消息队列 + +--- + +## 事件版本控制 + +### Payload 版本化 +```go +type UserCreatedEvent struct { + Version int `json:"version"` // 事件版本号 + UserID string `json:"user_id"` + // ... +} +``` + +### 向后兼容 +- 添加新字段时使用 `omitempty` +- 不删除现有字段 +- 字段重命名时保留旧字段 + +--- + +## 事件监控 + +### 指标 +- 事件发布数量(按类型) +- 事件处理延迟 +- 事件处理失败率 + +### 日志 +```go +logger.Info("Event published", + zap.String("event_type", "UserCreated"), + zap.String("user_id", userID), + zap.Time("timestamp", time.Now()), +) +``` + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [业务规则](./rules.md) +- [README](./README.md) +- [Event Bus 实现](../shared/events/bus.go) + diff --git a/backend-nodejs/domains/user/glossary.md b/backend-nodejs/domains/user/glossary.md new file mode 100644 index 0000000..35740cf --- /dev/null +++ b/backend-nodejs/domains/user/glossary.md @@ -0,0 +1,328 @@ +# User Domain Glossary (用户领域术语表) + +> 本文档定义了 User 领域的统一语言(Ubiquitous Language) + +--- + +## 核心术语 + +### User(用户) + +**定义**:系统中的注册账户实体 + +**类型**:聚合根(Aggregate Root) + +**属性**: +- 有唯一标识(UserID) +- 有唯一邮箱(Email) +- 有密码(PasswordHash,加密存储) +- 有个人资料(用户名、全名、头像) +- 有状态(激活/未激活/禁用) + +**生命周期**: +``` +注册 → 未激活(Inactive) → 激活(Active) → 禁用(Banned) + ↓ + 注销(Deleted) +``` + +**业务规则**: +- 邮箱必须唯一且格式有效(`INVALID_EMAIL`) +- 密码至少 8 字符(`WEAK_PASSWORD`) +- 用户名 3-30 字符,仅字母数字(`INVALID_USERNAME`) + +**相关事件**: +- `UserCreated` - 用户注册时 +- `UserUpdated` - 用户资料更新时 +- `PasswordChanged` - 密码修改时 + +--- + +### Email(邮箱) + +**定义**:用户的唯一电子邮件地址 + +**类型**:值对象(Value Object) + +**格式**:RFC 5322 标准 + +**业务规则**: +- 必须唯一(系统级约束) +- 必须通过格式验证 +- 区分大小写(存储时转为小写) + +**示例**: +```go +type Email string + +func NewEmail(email string) (Email, error) { + // 转为小写 + email = strings.ToLower(strings.TrimSpace(email)) + + // 格式验证 + if !isValidEmail(email) { + return "", ErrInvalidEmail + } + + return Email(email), nil +} +``` + +--- + +### PasswordHash(密码哈希) + +**定义**:用户密码的加密存储形式 + +**类型**:值对象(Value Object) + +**算法**:bcrypt (Cost: 10) + +**业务规则**: +- 原始密码至少 8 字符 +- 永远不返回给客户端 +- 使用 bcrypt 哈希算法 + +**安全考虑**: +- 使用盐值(bcrypt 自带) +- Cost 参数平衡安全和性能 +- 定期建议用户修改密码 + +**示例**: +```go +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + return string(hash), err +} + +func VerifyPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} +``` + +--- + +### Username(用户名) + +**定义**:用户的唯一标识符(可读性强) + +**类型**:值对象(Value Object) + +**格式**: +- 长度:3-30 字符 +- 字符集:字母数字(a-z, A-Z, 0-9) +- 可选:下划线 `_` + +**业务规则**: +- 必须唯一(系统级约束) +- 不可包含空格或特殊字符 +- 创建后可修改(但需唯一性检查) + +**示例**: +- ✅ `john_doe` +- ✅ `user123` +- ❌ `john@doe` (包含特殊字符) +- ❌ `ab` (太短) + +--- + +### UserStatus(用户状态) + +**定义**:用户账户的当前状态 + +**类型**:值对象(Value Object)/ 枚举 + +**可选值**: +- `Active` - 激活:可以正常使用所有功能 +- `Inactive` - 未激活:注册但未验证邮箱 +- `Banned` - 禁用:被管理员禁用,不能登录 + +**状态转换规则**: +``` +Inactive → Active (邮箱验证) +Active → Banned (管理员操作) +Banned → Active (管理员解封) +``` + +**不允许的转换**: +- ❌ Inactive → Banned (必须先激活) + +**示例**: +```go +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusInactive UserStatus = "inactive" + UserStatusBanned UserStatus = "banned" +) +``` + +--- + +### EmailVerified(邮箱验证状态) + +**定义**:用户邮箱是否已通过验证 + +**类型**:值对象(Value Object)/ 布尔值 + +**业务含义**: +- `true` - 已验证:用户点击了验证邮件链接 +- `false` - 未验证:注册后未完成验证 + +**相关流程**: +1. 用户注册 → EmailVerified = false +2. 发送验证邮件 +3. 用户点击链接 → EmailVerified = true +4. 状态变更:Inactive → Active + +**业务规则**: +- 未验证的用户可能有功能限制 +- 验证链接有效期:24 小时 + +--- + +### Profile(个人资料) + +**定义**:用户的公开信息集合 + +**类型**:实体(Entity) + +**包含字段**: +- FullName(全名) +- AvatarURL(头像 URL) +- Bio(个人简介,扩展点) +- Location(所在地,扩展点) + +**可见性**: +- 部分公开(如用户名、头像) +- 部分私有(如邮箱,仅本人可见) + +--- + +## 领域操作术语 + +### Register(注册) + +**定义**:创建一个新的用户账户 + +**前置条件**: +- 邮箱未被占用 +- 密码符合强度要求 + +**后置条件**: +- 用户记录已保存到数据库 +- 状态为 Inactive +- 发布 `UserCreated` 事件 + +--- + +### Login(登录) + +**定义**:验证用户凭据并建立会话 + +**前置条件**: +- 用户已注册 +- 提供正确的邮箱和密码 + +**后置条件**: +- 返回 JWT Token +- 记录最后登录时间 + +--- + +### VerifyPassword(密码验证) + +**定义**:检查提供的密码是否与存储的哈希匹配 + +**算法**:bcrypt.CompareHashAndPassword + +**返回**:布尔值(true/false) + +--- + +### ChangePassword(修改密码) + +**定义**:用户主动修改密码 + +**前置条件**: +- 提供正确的旧密码 +- 新密码符合强度要求 + +**后置条件**: +- 密码哈希已更新 +- 撤销所有现有 Token(可选) +- 发布 `PasswordChanged` 事件 + +--- + +### UpdateProfile(更新资料) + +**定义**:修改用户的个人信息 + +**可更新字段**: +- Username +- FullName +- AvatarURL + +**不可更新字段**: +- Email(需要额外的验证流程) +- UserID +- PasswordHash(使用 ChangePassword) + +--- + +## 错误码术语 + +### USER_NOT_FOUND +**含义**:用户不存在 +**场景**:查询、更新、删除操作 +**HTTP 状态码**:404 + +### INVALID_EMAIL +**含义**:邮箱格式无效 +**场景**:注册、更新邮箱 +**HTTP 状态码**:400 + +### EMAIL_ALREADY_EXISTS +**含义**:邮箱已被占用 +**场景**:注册 +**HTTP 状态码**:400 + +### USERNAME_ALREADY_EXISTS +**含义**:用户名已被占用 +**场景**:注册、更新用户名 +**HTTP 状态码**:400 + +### WEAK_PASSWORD +**含义**:密码强度不足 +**场景**:注册、修改密码 +**HTTP 状态码**:400 + +### INVALID_PASSWORD +**含义**:密码错误 +**场景**:登录、修改密码 +**HTTP 状态码**:401 + +--- + +## 缩写和约定 + +- **UUID** - Universally Unique Identifier(通用唯一标识符) +- **JWT** - JSON Web Token(JSON 网络令牌) +- **bcrypt** - 密码哈希算法 +- **Cost** - bcrypt 的迭代次数参数(默认 10) +- **Salt** - 密码盐值(bcrypt 自动生成) + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [业务规则](./rules.md) +- [领域事件](./events.md) +- [README](./README.md) + diff --git a/backend-nodejs/domains/user/handlers/change_password.handler.ts b/backend-nodejs/domains/user/handlers/change_password.handler.ts new file mode 100644 index 0000000..9949b74 --- /dev/null +++ b/backend-nodejs/domains/user/handlers/change_password.handler.ts @@ -0,0 +1,49 @@ +/** + * ChangePassword Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { ChangePasswordRequest } from '../http/dto/user.js'; +import { + toChangePasswordInput, + toChangePasswordResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; + +export async function changePasswordHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: ChangePasswordRequest }>, + reply: FastifyReply +): Promise { + try { + const userId = requireUserId(req); + + const input = toChangePasswordInput(userId, req.body); + const output = await deps.userService.changePassword(req, input); + + reply.code(200).send(toChangePasswordResponse(output.success, output.message)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '修改密码失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'USER_NOT_FOUND') { + return 404; + } + if (errorCode === 'INVALID_PASSWORD' || errorCode === 'WEAK_PASSWORD' || errorCode === 'PASSWORD_TOO_LONG') { + return 400; + } + if (errorCode === 'UNAUTHORIZED') { + return 401; + } + return 500; +} + diff --git a/backend-nodejs/domains/user/handlers/converters.ts b/backend-nodejs/domains/user/handlers/converters.ts new file mode 100644 index 0000000..126d8ee --- /dev/null +++ b/backend-nodejs/domains/user/handlers/converters.ts @@ -0,0 +1,113 @@ +/** + * DTO 转换层 + * HTTP DTO ↔ Domain Input/Output 的转换 + */ + +import type { User } from '../model/user.js'; +import type { + GetUserProfileResponse, + UpdateUserProfileRequest, + UpdateUserProfileResponse, + ChangePasswordRequest, + ChangePasswordResponse, +} from '../http/dto/user.js'; +import type { + GetUserProfileInput, + UpdateUserProfileInput, + ChangePasswordInput, +} from '../service/user_service.js'; + +// ======================================== +// GetUserProfile 转换 +// ======================================== + +export function toGetUserProfileInput(userId: string): GetUserProfileInput { + return { userId }; +} + +export function toGetUserProfileResponse(user: User): GetUserProfileResponse { + const response: GetUserProfileResponse = { + user_id: user.id, + email: user.email, + status: user.status, + email_verified: user.emailVerified, + created_at: user.createdAt.toISOString(), + updated_at: user.updatedAt.toISOString(), + }; + + if (user.username) { + response.username = user.username; + } + if (user.fullName) { + response.full_name = user.fullName; + } + if (user.avatarURL) { + response.avatar_url = user.avatarURL; + } + if (user.lastLoginAt) { + response.last_login_at = user.lastLoginAt.toISOString(); + } + + return response; +} + +// ======================================== +// UpdateUserProfile 转换 +// ======================================== + +export function toUpdateUserProfileInput( + userId: string, + req: UpdateUserProfileRequest +): UpdateUserProfileInput { + return { + userId, + username: req.username, + fullName: req.full_name, + avatarURL: req.avatar_url, + }; +} + +export function toUpdateUserProfileResponse(user: User): UpdateUserProfileResponse { + const response: UpdateUserProfileResponse = { + user_id: user.id, + updated_at: user.updatedAt.toISOString(), + }; + + if (user.username) { + response.username = user.username; + } + if (user.fullName) { + response.full_name = user.fullName; + } + if (user.avatarURL) { + response.avatar_url = user.avatarURL; + } + + return response; +} + +// ======================================== +// ChangePassword 转换 +// ======================================== + +export function toChangePasswordInput( + userId: string, + req: ChangePasswordRequest +): ChangePasswordInput { + return { + userId, + oldPassword: req.old_password, + newPassword: req.new_password, + }; +} + +export function toChangePasswordResponse( + success: boolean, + message: string +): ChangePasswordResponse { + return { + success, + message, + }; +} + diff --git a/backend-nodejs/domains/user/handlers/dependencies.ts b/backend-nodejs/domains/user/handlers/dependencies.ts new file mode 100644 index 0000000..b9d3d35 --- /dev/null +++ b/backend-nodejs/domains/user/handlers/dependencies.ts @@ -0,0 +1,11 @@ +/** + * Handler 依赖容器 + * 注入 Service 层依赖 + */ + +import type { UserService } from '../service/user_service.js'; + +export interface HandlerDependencies { + userService: UserService; +} + diff --git a/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts b/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts new file mode 100644 index 0000000..5dc5092 --- /dev/null +++ b/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts @@ -0,0 +1,51 @@ +/** + * GetUserProfile Handler + * HTTP 适配层:处理获取用户资料的 HTTP 请求 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import { + toGetUserProfileInput, + toGetUserProfileResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; + +export async function getUserProfileHandler( + deps: HandlerDependencies, + req: FastifyRequest, + reply: FastifyReply +): Promise { + try { + // 1. 获取用户 ID(从 JWT Token 中提取) + const userId = requireUserId(req); + + // 2. 转换为 Domain Input + const input = toGetUserProfileInput(userId); + + // 3. 调用 Domain Service + const output = await deps.userService.getUserProfile(req, input); + + // 4. 转换为 HTTP 响应 + reply.code(200).send(toGetUserProfileResponse(output.user)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '获取用户资料失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'USER_NOT_FOUND') { + return 404; + } + if (errorCode === 'UNAUTHORIZED') { + return 401; + } + return 500; +} + diff --git a/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts b/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts new file mode 100644 index 0000000..b7c17bd --- /dev/null +++ b/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts @@ -0,0 +1,49 @@ +/** + * UpdateUserProfile Handler + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { HandlerDependencies } from './dependencies.js'; +import type { UpdateUserProfileRequest } from '../http/dto/user.js'; +import { + toUpdateUserProfileInput, + toUpdateUserProfileResponse, +} from './converters.js'; +import { parseErrorCode } from '../errors/errors.js'; +import { requireUserId } from '../../../infrastructure/middleware/auth.js'; + +export async function updateUserProfileHandler( + deps: HandlerDependencies, + req: FastifyRequest<{ Body: UpdateUserProfileRequest }>, + reply: FastifyReply +): Promise { + try { + const userId = requireUserId(req); + + const input = toUpdateUserProfileInput(userId, req.body); + const output = await deps.userService.updateUserProfile(req, input); + + reply.code(200).send(toUpdateUserProfileResponse(output.user)); + } catch (error) { + const errorCode = parseErrorCode(error); + const statusCode = getStatusCode(errorCode); + reply.code(statusCode).send({ + error: errorCode, + message: error instanceof Error ? error.message : '更新用户资料失败', + }); + } +} + +function getStatusCode(errorCode: string): number { + if (errorCode === 'USER_NOT_FOUND') { + return 404; + } + if (errorCode === 'USERNAME_ALREADY_EXISTS' || errorCode === 'INVALID_USERNAME' || errorCode === 'FULL_NAME_TOO_LONG' || errorCode === 'INVALID_AVATAR_URL') { + return 400; + } + if (errorCode === 'UNAUTHORIZED') { + return 401; + } + return 500; +} + diff --git a/backend-nodejs/domains/user/http/dto/user.ts b/backend-nodejs/domains/user/http/dto/user.ts new file mode 100644 index 0000000..decbab5 --- /dev/null +++ b/backend-nodejs/domains/user/http/dto/user.ts @@ -0,0 +1,54 @@ +/** + * User HTTP DTO + * 定义 HTTP 请求和响应的数据结构 + */ + +// ======================================== +// GetUserProfile +// ======================================== + +export interface GetUserProfileResponse { + user_id: string; + email: string; + username?: string; + full_name?: string; + avatar_url?: string; + status: string; + email_verified: boolean; + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 + last_login_at?: string; // ISO 8601 +} + +// ======================================== +// UpdateUserProfile +// ======================================== + +export interface UpdateUserProfileRequest { + username?: string; + full_name?: string; + avatar_url?: string; +} + +export interface UpdateUserProfileResponse { + user_id: string; + username?: string; + full_name?: string; + avatar_url?: string; + updated_at: string; // ISO 8601 +} + +// ======================================== +// ChangePassword +// ======================================== + +export interface ChangePasswordRequest { + old_password: string; + new_password: string; +} + +export interface ChangePasswordResponse { + success: boolean; + message: string; +} + diff --git a/backend-nodejs/domains/user/http/router.ts b/backend-nodejs/domains/user/http/router.ts new file mode 100644 index 0000000..bd9f6b3 --- /dev/null +++ b/backend-nodejs/domains/user/http/router.ts @@ -0,0 +1,49 @@ +/** + * User 路由注册 + * 注册所有 User 相关的 HTTP 路由 + */ + +import type { FastifyInstance } from 'fastify'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import type { UpdateUserProfileRequest, ChangePasswordRequest } from './dto/user.js'; +import { getUserProfileHandler } from '../handlers/get_user_profile.handler.js'; +import { updateUserProfileHandler } from '../handlers/update_user_profile.handler.js'; +import { changePasswordHandler } from '../handlers/change_password.handler.js'; +import type { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; + +/** + * 注册 User 路由 + */ +export function registerUserRoutes( + app: FastifyInstance, + deps: HandlerDependencies, + authMiddleware: ReturnType +): void { + // GET /api/users/me - 获取当前用户资料(需要认证) + app.get( + '/api/users/me', + { preHandler: authMiddleware }, + async (req, reply) => { + await getUserProfileHandler(deps, req, reply); + } + ); + + // PUT /api/users/me - 更新用户资料(需要认证) + app.put<{ Body: UpdateUserProfileRequest }>( + '/api/users/me', + { preHandler: authMiddleware }, + async (req, reply) => { + await updateUserProfileHandler(deps, req as any, reply); + } + ); + + // POST /api/users/me/change-password - 修改密码(需要认证) + app.post<{ Body: ChangePasswordRequest }>( + '/api/users/me/change-password', + { preHandler: authMiddleware }, + async (req, reply) => { + await changePasswordHandler(deps, req as any, reply); + } + ); +} + diff --git a/backend-nodejs/domains/user/model/user.ts b/backend-nodejs/domains/user/model/user.ts new file mode 100644 index 0000000..a0f3813 --- /dev/null +++ b/backend-nodejs/domains/user/model/user.ts @@ -0,0 +1,234 @@ +/** + * User 领域模型 + * 聚合根:包含用户的所有核心属性和业务行为 + */ + +import { randomUUID } from 'crypto'; +import * as bcrypt from 'bcryptjs'; + +// 用户状态 +export type UserStatus = 'active' | 'inactive' | 'banned'; + +export const UserStatuses = { + Active: 'active' as UserStatus, + Inactive: 'inactive' as UserStatus, + Banned: 'banned' as UserStatus, +} as const; + +// User 聚合根 +export class User { + id: string; + email: string; + username: string; + passwordHash: string; + fullName: string; + avatarURL: string; + status: UserStatus; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; + lastLoginAt: Date | null; + + constructor( + id: string, + email: string, + passwordHash: string, + username: string = '', + fullName: string = '', + avatarURL: string = '', + status: UserStatus = UserStatuses.Inactive, + emailVerified: boolean = false, + createdAt: Date = new Date(), + updatedAt: Date = new Date(), + lastLoginAt: Date | null = null + ) { + this.id = id; + this.email = email; + this.username = username; + this.passwordHash = passwordHash; + this.fullName = fullName; + this.avatarURL = avatarURL; + this.status = status; + this.emailVerified = emailVerified; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.lastLoginAt = lastLoginAt; + } + + /** + * 创建新用户 + */ + static async create(email: string, password: string): Promise { + // 验证邮箱 + const normalizedEmail = normalizeEmail(email); + if (!isValidEmail(normalizedEmail)) { + throw new Error('INVALID_EMAIL: 邮箱格式无效'); + } + + // 验证密码 + validatePassword(password); + + // 哈希密码 + const passwordHash = await hashPassword(password); + + const now = new Date(); + return new User( + randomUUID(), + normalizedEmail, + passwordHash, + '', // 用户名可选 + '', // 全名可选 + '', // 头像可选 + UserStatuses.Inactive, // 初始状态为未激活 + false, // 邮箱未验证 + now, + now, + null + ); + } + + /** + * 验证密码 + */ + async verifyPassword(password: string): Promise { + try { + return await bcrypt.compare(password, this.passwordHash); + } catch { + return false; + } + } + + /** + * 更新密码 + */ + async updatePassword(newPassword: string): Promise { + // 验证新密码 + validatePassword(newPassword); + + // 哈希新密码 + this.passwordHash = await hashPassword(newPassword); + this.updatedAt = new Date(); + } + + /** + * 更新用户资料 + */ + updateProfile(username?: string, fullName?: string, avatarURL?: string): void { + // 验证用户名(如果提供) + if (username !== undefined && username !== '') { + if (!isValidUsername(username)) { + throw new Error('INVALID_USERNAME: 用户名格式无效(3-30 字符,仅字母数字下划线)'); + } + this.username = username; + } + + // 验证全名 + if (fullName !== undefined && fullName !== '') { + if (fullName.length > 100) { + throw new Error('FULL_NAME_TOO_LONG: 全名过长(最多 100 字符)'); + } + this.fullName = fullName; + } + + // 验证头像 URL(如果提供) + if (avatarURL !== undefined && avatarURL !== '') { + if (!avatarURL.startsWith('http://') && !avatarURL.startsWith('https://')) { + throw new Error('INVALID_AVATAR_URL: 头像 URL 格式无效'); + } + this.avatarURL = avatarURL; + } + + this.updatedAt = new Date(); + } + + /** + * 激活用户 + */ + activate(): void { + if (this.status === UserStatuses.Active) { + return; // 已经激活,直接返回 + } + + this.status = UserStatuses.Active; + this.emailVerified = true; + this.updatedAt = new Date(); + } + + /** + * 禁用用户 + */ + deactivate(): void { + if (this.status === UserStatuses.Banned) { + return; // 已经禁用,直接返回 + } + + this.status = UserStatuses.Banned; + this.updatedAt = new Date(); + } + + /** + * 记录登录时间 + */ + recordLogin(): void { + this.lastLoginAt = new Date(); + this.updatedAt = new Date(); + } + + /** + * 检查用户是否可以登录 + */ + canLogin(): void { + if (this.status === UserStatuses.Banned) { + throw new Error('USER_BANNED: 用户已被禁用'); + } + // 注意:我们允许 Inactive 用户登录,但可能限制某些功能 + } +} + +// ============================================ +// 辅助函数 +// ============================================ + +/** + * 规范化邮箱(转为小写、去除空格) + */ +function normalizeEmail(email: string): string { + return email.toLowerCase().trim(); +} + +/** + * 验证邮箱格式 + */ +function isValidEmail(email: string): boolean { + const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; + return emailRegex.test(email); +} + +/** + * 验证用户名格式 + */ +function isValidUsername(username: string): boolean { + const usernameRegex = /^[a-zA-Z0-9_]{3,30}$/; + return usernameRegex.test(username); +} + +/** + * 验证密码强度 + */ +function validatePassword(password: string): void { + if (password.length < 8) { + throw new Error('WEAK_PASSWORD: 密码强度不足(至少 8 字符)'); + } + if (password.length > 128) { + throw new Error('PASSWORD_TOO_LONG: 密码过长(最多 128 字符)'); + } +} + +/** + * 使用 bcrypt 哈希密码 + */ +async function hashPassword(password: string): Promise { + const saltRounds = 10; + return await bcrypt.hash(password, saltRounds); +} + diff --git a/backend-nodejs/domains/user/repository/interface.ts b/backend-nodejs/domains/user/repository/interface.ts new file mode 100644 index 0000000..4ae1b1b --- /dev/null +++ b/backend-nodejs/domains/user/repository/interface.ts @@ -0,0 +1,52 @@ +/** + * User Repository 接口 + * 定义用户仓储的抽象接口 + */ + +import type { User } from '../model/user.js'; + +/** + * UserRepository 用户仓储接口 + */ +export interface UserRepository { + /** + * 创建用户 + */ + create(ctx: unknown, user: User): Promise; + + /** + * 根据 ID 获取用户 + */ + getById(ctx: unknown, userId: string): Promise; + + /** + * 根据邮箱获取用户 + */ + getByEmail(ctx: unknown, email: string): Promise; + + /** + * 根据用户名获取用户 + */ + getByUsername(ctx: unknown, username: string): Promise; + + /** + * 更新用户信息 + */ + update(ctx: unknown, user: User): Promise; + + /** + * 删除用户 + */ + delete(ctx: unknown, userId: string): Promise; + + /** + * 检查邮箱是否存在 + */ + existsByEmail(ctx: unknown, email: string): Promise; + + /** + * 检查用户名是否存在 + */ + existsByUsername(ctx: unknown, username: string): Promise; +} + diff --git a/backend-nodejs/domains/user/repository/user_repo.ts b/backend-nodejs/domains/user/repository/user_repo.ts new file mode 100644 index 0000000..ed0cda0 --- /dev/null +++ b/backend-nodejs/domains/user/repository/user_repo.ts @@ -0,0 +1,183 @@ +/** + * User Repository 实现 + * 使用 Kysely 进行类型安全的数据库操作 + */ + +import type { Kysely } from 'kysely'; +import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; +import { User } from '../model/user.js'; +import type { UserRepository } from './interface.js'; + +export class UserRepositoryImpl implements UserRepository { + constructor(private db: Kysely) {} + + async create(_ctx: unknown, user: User): Promise { + try { + await this.db + .insertInto('users') + .values({ + id: user.id, + email: user.email, + username: user.username || null, + password_hash: user.passwordHash, + full_name: user.fullName || null, + avatar_url: user.avatarURL || null, + status: user.status, + email_verified: user.emailVerified, + created_at: user.createdAt, + updated_at: user.updatedAt, + last_login_at: user.lastLoginAt, + }) + .execute(); + } catch (error: any) { + // 检查是否是唯一性约束冲突(PostgreSQL) + if (error.code === '23505') { + // unique_violation + if (error.constraint === 'users_email_key' || error.constraint === 'idx_users_email') { + throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + } + if (error.constraint === 'users_username_key' || error.constraint === 'idx_users_username') { + throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + } + } + throw new Error(`创建用户失败: ${error.message}`); + } + } + + async getById(_ctx: unknown, userId: string): Promise { + const userRow = await this.db + .selectFrom('users') + .selectAll() + .where('id', '=', userId) + .executeTakeFirst(); + + if (!userRow) { + return null; + } + + return this.toDomainModel(userRow); + } + + async getByEmail(_ctx: unknown, email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + const userRow = await this.db + .selectFrom('users') + .selectAll() + .where('email', '=', normalizedEmail) + .executeTakeFirst(); + + if (!userRow) { + return null; + } + + return this.toDomainModel(userRow); + } + + async getByUsername(_ctx: unknown, username: string): Promise { + const userRow = await this.db + .selectFrom('users') + .selectAll() + .where('username', '=', username) + .executeTakeFirst(); + + if (!userRow) { + return null; + } + + return this.toDomainModel(userRow); + } + + async update(_ctx: unknown, user: User): Promise { + try { + const result = await this.db + .updateTable('users') + .set({ + email: user.email, + username: user.username || null, + password_hash: user.passwordHash, + full_name: user.fullName || null, + avatar_url: user.avatarURL || null, + status: user.status, + email_verified: user.emailVerified, + updated_at: user.updatedAt, + last_login_at: user.lastLoginAt, + }) + .where('id', '=', user.id) + .execute(); + + if (result.length === 0) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + } catch (error: any) { + // 检查是否是唯一性约束冲突 + if (error.code === '23505') { + if (error.constraint === 'users_email_key' || error.constraint === 'idx_users_email') { + throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + } + if (error.constraint === 'users_username_key' || error.constraint === 'idx_users_username') { + throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + } + } + if (error.message.includes('USER_NOT_FOUND')) { + throw error; + } + throw new Error(`更新用户失败: ${error.message}`); + } + } + + async delete(_ctx: unknown, userId: string): Promise { + const result = await this.db + .deleteFrom('users') + .where('id', '=', userId) + .execute(); + + if (result.length === 0) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + } + + async existsByEmail(_ctx: unknown, email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + const result = await this.db + .selectFrom('users') + .select((eb) => eb.fn.count('id').as('count')) + .where('email', '=', normalizedEmail) + .executeTakeFirst(); + + return Number(result?.count || 0) > 0; + } + + async existsByUsername(_ctx: unknown, username: string): Promise { + const result = await this.db + .selectFrom('users') + .select((eb) => eb.fn.count('id').as('count')) + .where('username', '=', username) + .executeTakeFirst(); + + return Number(result?.count || 0) > 0; + } + + // ============================================ + // 私有辅助方法 + // ============================================ + + /** + * 将数据库行转换为领域模型 + */ + private toDomainModel(row: Database['users']): User { + return new User( + row.id, + row.email, + row.password_hash, + row.username || '', + row.full_name || '', + row.avatar_url || '', + row.status as 'active' | 'inactive' | 'banned', + row.email_verified, + row.created_at, + row.updated_at, + row.last_login_at || null + ); + } +} + diff --git a/backend-nodejs/domains/user/rules.md b/backend-nodejs/domains/user/rules.md new file mode 100644 index 0000000..b3c8578 --- /dev/null +++ b/backend-nodejs/domains/user/rules.md @@ -0,0 +1,404 @@ +# User Domain Business Rules (用户领域业务规则) + +> 本文档定义了 User 领域的业务约束和规则 + +--- + +## 1. 邮箱规则 + +### R1.1 邮箱格式验证 +**规则**:邮箱必须符合 RFC 5322 标准 + +**验证逻辑**: +- 包含 `@` 符号 +- `@` 前后都有内容 +- 域名部分包含 `.` + +**错误码**:`INVALID_EMAIL` + +**示例**: +```go +// ✅ 有效 +user@example.com +john.doe@company.co.uk + +// ❌ 无效 +@example.com // 缺少用户名 +user@ // 缺少域名 +user@domain // 域名无效 +``` + +--- + +### R1.2 邮箱唯一性 +**规则**:系统中每个邮箱只能注册一次 + +**实现方式**: +- 数据库唯一索引:`UNIQUE INDEX ON users(email)` +- 注册前检查:`SELECT COUNT(*) FROM users WHERE email = ?` + +**错误码**:`EMAIL_ALREADY_EXISTS` + +**业务含义**: +- 防止重复注册 +- 保证用户身份唯一性 + +--- + +### R1.3 邮箱大小写规范化 +**规则**:存储时统一转为小写 + +**理由**: +- 邮箱地址大小写不敏感 +- 避免 `User@Example.com` 和 `user@example.com` 被视为不同账户 + +**实现**: +```go +email = strings.ToLower(strings.TrimSpace(email)) +``` + +--- + +## 2. 密码规则 + +### R2.1 密码最小长度 +**规则**:密码至少 8 字符 + +**理由**:平衡安全性和用户体验 + +**错误码**:`WEAK_PASSWORD` + +**扩展点**: +- 未来可增加复杂度要求(大小写、数字、特殊字符) +- 可集成密码强度检测库(如 zxcvbn) + +--- + +### R2.2 密码最大长度 +**规则**:密码最多 128 字符 + +**理由**: +- 防止 DoS 攻击(bcrypt 哈希计算开销) +- 128 字符对正常用户已足够 + +**错误码**:`PASSWORD_TOO_LONG` + +--- + +### R2.3 密码加密存储 +**规则**:密码必须使用 bcrypt 哈希存储 + +**算法**:`bcrypt` +**Cost 参数**:`10` + +**实现**: +```go +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) + return string(hash), err +} +``` + +**禁止**: +- ❌ 明文存储密码 +- ❌ 使用弱哈希算法(MD5, SHA1) + +--- + +### R2.4 密码验证 +**规则**:登录时使用 bcrypt 验证密码 + +**实现**: +```go +func VerifyPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} +``` + +**安全考虑**: +- 验证失败时不泄露是邮箱还是密码错误 +- 统一返回 "邮箱或密码错误" + +--- + +## 3. 用户名规则 + +### R3.1 用户名格式 +**规则**:用户名必须是 3-30 字符的字母数字组合 + +**字符集**: +- 字母:`a-z`, `A-Z` +- 数字:`0-9` +- 允许:下划线 `_` + +**正则表达式**:`^[a-zA-Z0-9_]{3,30}$` + +**错误码**:`INVALID_USERNAME` + +**示例**: +```go +// ✅ 有效 +john_doe +user123 +Alice_2024 + +// ❌ 无效 +ab // 太短 +john-doe // 包含连字符 +user@123 // 包含特殊字符 +this_is_a_very_long_username_exceeding_limit // 太长 +``` + +--- + +### R3.2 用户名唯一性 +**规则**:用户名必须唯一(如果提供) + +**实现**: +- 数据库唯一索引:`UNIQUE INDEX ON users(username)` +- 更新前检查 + +**错误码**:`USERNAME_ALREADY_EXISTS` + +**特殊情况**: +- 用户名可选,可以为 NULL +- NULL 值不受唯一性约束影响 + +--- + +### R3.3 用户名可选 +**规则**:注册时用户名是可选的 + +**默认行为**: +- 如果未提供用户名,使用邮箱作为显示名 +- 用户可以后续添加/修改用户名 + +--- + +## 4. 用户状态规则 + +### R4.1 初始状态 +**规则**:新注册用户的初始状态为 `Inactive` + +**理由**: +- 需要验证邮箱后才能激活 +- 防止垃圾注册 + +--- + +### R4.2 状态转换 +**允许的转换**: +``` +Inactive → Active (邮箱验证成功) +Active → Banned (管理员禁用) +Banned → Active (管理员解封) +``` + +**禁止的转换**: +- ❌ `Inactive → Banned` +- ❌ `Banned → Inactive` + +--- + +### R4.3 禁用用户限制 +**规则**:状态为 `Banned` 的用户不能登录 + +**实现**: +```go +if user.Status == UserStatusBanned { + return nil, ErrUserBanned +} +``` + +**错误码**:`USER_BANNED` + +--- + +## 5. 个人资料规则 + +### R5.1 全名最大长度 +**规则**:全名最多 100 字符 + +**理由**:覆盖大多数真实姓名场景 + +**错误码**:`FULL_NAME_TOO_LONG` + +--- + +### R5.2 头像 URL 格式 +**规则**:头像 URL 必须是有效的 URL + +**验证**:使用 Go 的 `url.Parse()` + +**错误码**:`INVALID_AVATAR_URL` + +**扩展点**: +- 限制允许的域名(如只允许 CDN) +- 限制文件大小和格式 + +--- + +## 6. 邮箱验证规则 + +### R6.1 验证链接有效期 +**规则**:邮箱验证链接有效期为 24 小时 + +**实现**: +- Token 包含过期时间戳 +- 验证时检查是否过期 + +**错误码**:`VERIFICATION_LINK_EXPIRED` + +--- + +### R6.2 验证后自动激活 +**规则**:邮箱验证成功后,用户状态自动变为 `Active` + +**流程**: +1. 用户点击验证链接 +2. 后端验证 Token +3. 更新 `email_verified = true` +4. 更新 `status = 'active'` +5. 发布 `UserActivated` 事件 + +--- + +## 7. 密码修改规则 + +### R7.1 需要旧密码验证 +**规则**:修改密码时必须提供正确的旧密码 + +**理由**:防止账户被他人篡改 + +**错误码**:`INVALID_OLD_PASSWORD` + +--- + +### R7.2 新旧密码不能相同 +**规则**:新密码不能与旧密码相同 + +**错误码**:`NEW_PASSWORD_SAME_AS_OLD` + +--- + +### R7.3 修改密码后撤销 Token +**规则**:密码修改成功后,撤销所有现有的 JWT Token + +**理由**: +- 防止旧 Token 被滥用 +- 强制用户重新登录 + +**实现**: +- 在数据库记录密码修改时间 +- JWT 验证时检查 Token 签发时间是否早于密码修改时间 + +--- + +## 8. 数据一致性规则 + +### R8.1 软删除(扩展点) +**规则**:删除用户时使用软删除(标记为删除,不物理删除) + +**实现**: +- 添加 `deleted_at` 字段 +- 查询时过滤 `deleted_at IS NULL` + +**理由**: +- 保留历史数据 +- 支持账户恢复 + +--- + +### R8.2 最后登录时间更新 +**规则**:每次成功登录时更新 `last_login_at` + +**用途**: +- 统计活跃用户 +- 安全审计 + +--- + +## 9. 安全规则 + +### R9.1 敏感信息不返回 +**规则**:API 响应中永远不返回 `password_hash` + +**实现**: +```go +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + // ❌ PasswordHash string `json:"password_hash"` // 禁止 +} +``` + +--- + +### R9.2 邮箱可见性 +**规则**:用户邮箱仅本人可见 + +**实现**: +- 获取用户资料时检查权限 +- 列出用户列表时不包含邮箱 + +--- + +### R9.3 密码重置需要验证 +**规则**:忘记密码时必须通过邮箱验证 + +**流程**: +1. 用户请求重置密码 +2. 发送重置链接到邮箱 +3. 用户点击链接 +4. 输入新密码 +5. 更新密码 + +**错误码**:`RESET_TOKEN_INVALID` + +--- + +## 10. 性能规则 + +### R10.1 数据库索引 +**规则**:为常用查询字段创建索引 + +**必需索引**: +```sql +CREATE UNIQUE INDEX idx_users_email ON users(email); +CREATE UNIQUE INDEX idx_users_username ON users(username) WHERE username IS NOT NULL; +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_created_at ON users(created_at DESC); +``` + +--- + +### R10.2 缓存用户信息(扩展点) +**规则**:频繁访问的用户信息可以缓存到 Redis + +**缓存策略**: +- Key: `user:{user_id}` +- TTL: 15 分钟 +- 更新时清除缓存 + +--- + +## 规则优先级 + +1. **安全规则** - 最高优先级,不可妥协 +2. **数据一致性规则** - 保证数据完整性 +3. **业务规则** - 核心业务逻辑 +4. **性能规则** - 优化用户体验 + +--- + +## 参考资料 + +- [用例定义](./usecases.yaml) +- [领域术语](./glossary.md) +- [领域事件](./events.md) +- [README](./README.md) + diff --git a/backend-nodejs/domains/user/service/user_service.ts b/backend-nodejs/domains/user/service/user_service.ts new file mode 100644 index 0000000..dbee0d8 --- /dev/null +++ b/backend-nodejs/domains/user/service/user_service.ts @@ -0,0 +1,138 @@ +/** + * User Service 领域服务层 + * 实现业务用例,封装复杂业务流程 + */ + +import type { User } from '../model/user.js'; +import type { UserRepository } from '../repository/interface.js'; + +export interface GetUserProfileInput { + userId: string; +} + +export interface GetUserProfileOutput { + user: User; +} + +export interface UpdateUserProfileInput { + userId: string; + username?: string; + fullName?: string; + avatarURL?: string; +} + +export interface UpdateUserProfileOutput { + user: User; +} + +export interface ChangePasswordInput { + userId: string; + oldPassword: string; + newPassword: string; +} + +export interface ChangePasswordOutput { + success: boolean; + message: string; +} + +/** + * UserService 用户领域服务 + */ +export class UserService { + constructor(private userRepo: UserRepository) {} + + /** + * 获取用户资料 + */ + async getUserProfile(ctx: unknown, input: GetUserProfileInput): Promise { + // Step 1: 从数据库获取用户 + const user = await this.userRepo.getById(ctx, input.userId); + if (!user) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + + // Step 2: 返回用户信息(不包含敏感字段) + return { user }; + } + + /** + * 更新用户资料 + */ + async updateUserProfile( + ctx: unknown, + input: UpdateUserProfileInput + ): Promise { + // Step 1: 验证输入(由 Model 层的验证逻辑处理) + + // Step 2: 获取用户 + const user = await this.userRepo.getById(ctx, input.userId); + if (!user) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + + // Step 3: 检查用户名是否已被占用(如果修改了用户名) + if (input.username && input.username !== user.username) { + const exists = await this.userRepo.existsByUsername(ctx, input.username); + if (exists) { + throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + } + } + + // Step 4: 更新用户字段 + try { + user.updateProfile(input.username, input.fullName, input.avatarURL); + } catch (error) { + throw error; // 直接抛出 Model 层的错误 + } + + // Step 5: 保存用户 + await this.userRepo.update(ctx, user); + + // Step 6: 发布用户更新事件(扩展点) + // eventBus.publish(ctx, UserUpdatedEvent{...}); + + return { user }; + } + + /** + * 修改密码 + */ + async changePassword( + ctx: unknown, + input: ChangePasswordInput + ): Promise { + // Step 1: 验证输入(密码强度由 Model 层验证) + + // Step 2: 获取用户 + const user = await this.userRepo.getById(ctx, input.userId); + if (!user) { + throw new Error('USER_NOT_FOUND: 用户不存在'); + } + + // Step 3: 验证旧密码 + const isValid = await user.verifyPassword(input.oldPassword); + if (!isValid) { + throw new Error('INVALID_PASSWORD: 密码错误'); + } + + // Step 4 & 5: 更新密码(包含哈希) + try { + await user.updatePassword(input.newPassword); + } catch (error) { + throw error; // 直接抛出 Model 层的错误 + } + + // 保存到数据库 + await this.userRepo.update(ctx, user); + + // Step 6: 撤销所有 Token(扩展点) + // 可以在 Auth Service 中实现,通过发布 PasswordChanged 事件通知 + + return { + success: true, + message: '密码修改成功', + }; + } +} + diff --git a/backend-nodejs/domains/user/usecases.yaml b/backend-nodejs/domains/user/usecases.yaml new file mode 100644 index 0000000..73533b3 --- /dev/null +++ b/backend-nodejs/domains/user/usecases.yaml @@ -0,0 +1,265 @@ +# User Domain Use Cases +# 用户领域用例声明文件 + +version: "1.0" +domain: user + +usecases: + # ======================================== + # 用例 1: 获取用户信息 + # ======================================== + GetUserProfile: + description: "获取当前用户的个人资料" + sensitivity: medium + http: + method: GET + path: /api/users/me + auth_required: true + + input: + user_id: + type: string + required: true + source: context + description: "用户 ID (from JWT)" + + output: + user_id: + type: string + description: "用户 ID" + email: + type: string + description: "邮箱" + username: + type: string + description: "用户名" + full_name: + type: string + description: "全名" + avatar_url: + type: string + description: "头像 URL" + created_at: + type: string + description: "创建时间" + updated_at: + type: string + description: "更新时间" + + steps: + - name: GetUserFromDB + type: sync + description: "从数据库获取用户信息" + on_fail: abort + error: USER_NOT_FOUND + + - name: FormatResponse + type: sync + description: "格式化响应数据" + + errors: + - code: USER_NOT_FOUND + message: "用户不存在" + http_status: 404 + - code: QUERY_FAILED + message: "查询失败" + http_status: 500 + + # ======================================== + # 用例 2: 更新用户资料 + # ======================================== + UpdateUserProfile: + description: "更新用户的个人资料" + sensitivity: medium + http: + method: PUT + path: /api/users/me + auth_required: true + + input: + user_id: + type: string + required: true + source: context + description: "用户 ID (from JWT)" + username: + type: string + required: false + validation: "omitempty,min=3,max=30,alphanum" + description: "用户名" + full_name: + type: string + required: false + validation: "omitempty,max=100" + description: "全名" + avatar_url: + type: string + required: false + validation: "omitempty,url" + description: "头像 URL" + + output: + user_id: + type: string + username: + type: string + full_name: + type: string + avatar_url: + type: string + updated_at: + type: string + + steps: + - name: ValidateInput + type: sync + description: "验证输入参数" + on_fail: abort + + - name: GetUser + type: sync + description: "获取用户" + on_fail: abort + error: USER_NOT_FOUND + + - name: CheckUsernameUnique + type: sync + description: "检查用户名是否已被占用" + on_fail: abort + error: USERNAME_ALREADY_EXISTS + + - name: UpdateUserFields + type: sync + description: "更新用户字段" + + - name: SaveUser + type: sync + description: "保存用户" + on_fail: abort + + - name: PublishUserUpdatedEvent + type: event + event_type: UserUpdated + on_fail: log + + errors: + - code: USER_NOT_FOUND + message: "用户不存在" + http_status: 404 + - code: USERNAME_ALREADY_EXISTS + message: "用户名已被占用" + http_status: 400 + - code: INVALID_USERNAME + message: "用户名格式无效(3-30 字符,仅字母数字)" + http_status: 400 + - code: UPDATE_FAILED + message: "更新失败" + http_status: 500 + + # ======================================== + # 用例 3: 修改密码 + # ======================================== + ChangePassword: + description: "修改用户密码" + sensitivity: high + http: + method: POST + path: /api/users/me/change-password + auth_required: true + + input: + user_id: + type: string + required: true + source: context + description: "用户 ID (from JWT)" + old_password: + type: string + required: true + validation: "min=8" + description: "旧密码" + new_password: + type: string + required: true + validation: "min=8,max=128" + description: "新密码" + + output: + success: + type: bool + description: "是否成功" + message: + type: string + description: "提示消息" + + steps: + - name: ValidateInput + type: sync + description: "验证输入" + on_fail: abort + + - name: GetUser + type: sync + description: "获取用户" + on_fail: abort + error: USER_NOT_FOUND + + - name: VerifyOldPassword + type: sync + description: "验证旧密码" + on_fail: abort + error: INVALID_PASSWORD + + - name: HashNewPassword + type: sync + description: "哈希新密码" + + - name: UpdatePassword + type: sync + description: "更新密码" + on_fail: abort + + - name: RevokeAllTokens + type: sync + description: "撤销所有 Token(可选)" + on_fail: log + + errors: + - code: USER_NOT_FOUND + message: "用户不存在" + http_status: 404 + - code: INVALID_PASSWORD + message: "旧密码错误" + http_status: 401 + - code: WEAK_PASSWORD + message: "新密码强度不足(至少 8 字符)" + http_status: 400 + - code: UPDATE_FAILED + message: "修改密码失败" + http_status: 500 + +# ======================================== +# 全局配置 +# ======================================== +config: + default_timeout: 30s + enable_tracing: true + enable_metrics: true + +# ======================================== +# 依赖关系 +# ======================================== +dependencies: + external: + - name: Auth Domain + description: "用于密码验证和 Token 管理" + + infrastructure: + - name: database + type: PostgreSQL + description: "用户数据存储" + - name: cache + type: Redis + description: "用户缓存(可选)" + required: false + diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index b0d4fcb..b245227 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -113,10 +113,33 @@ export function registerRoutes( */ export async function registerDomainRoutes( fastify: FastifyInstance, - handlerDeps: unknown + handlerDeps: Record, + authMiddleware: unknown ): Promise { // 动态导入并注册 Task 路由 - const taskRouter = await import('../../domains/task/http/router.js'); - taskRouter.registerTaskRoutes(fastify, handlerDeps as Parameters[1]); + if (handlerDeps.task) { + const taskRouter = await import('../../domains/task/http/router.js'); + taskRouter.registerTaskRoutes( + fastify, + handlerDeps.task as Parameters[1], + authMiddleware as Parameters[2] + ); + } + + // 动态导入并注册 User 路由 + if (handlerDeps.user) { + const userRouter = await import('../../domains/user/http/router.js'); + userRouter.registerUserRoutes( + fastify, + handlerDeps.user as Parameters[1], + authMiddleware as Parameters[2] + ); + } + + // 动态导入并注册 Auth 路由(不需要认证中间件) + if (handlerDeps.auth) { + const authRouter = await import('../../domains/auth/http/router.js'); + authRouter.registerAuthRoutes(fastify, handlerDeps.auth as Parameters[1]); + } } diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts index f7768d5..407c241 100644 --- a/backend-nodejs/infrastructure/config/config.ts +++ b/backend-nodejs/infrastructure/config/config.ts @@ -26,6 +26,12 @@ export interface Config { password: string; db: number; }; + jwt: { + secret: string; + accessTokenExpiry: number; // 秒 + refreshTokenExpiry: number; // 秒 + issuer: string; + }; } /** @@ -59,6 +65,12 @@ export function loadConfig(): Config { password: process.env.REDIS_PASSWORD || '', db: parseInt(process.env.REDIS_DB || '0', 10), }, + jwt: { + secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', + accessTokenExpiry: parseInt(process.env.JWT_ACCESS_TOKEN_EXPIRY || '3600', 10), // 1 小时 + refreshTokenExpiry: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRY || '604800', 10), // 7 天 + issuer: process.env.JWT_ISSUER || 'go-genai-stack', + }, }; } diff --git a/backend-nodejs/infrastructure/middleware/auth.ts b/backend-nodejs/infrastructure/middleware/auth.ts new file mode 100644 index 0000000..1fb801b --- /dev/null +++ b/backend-nodejs/infrastructure/middleware/auth.ts @@ -0,0 +1,108 @@ +/** + * JWT 认证中间件 + * 验证请求中的 Bearer Token 并提取用户信息 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { JWTService } from '../../domains/auth/service/jwt_service.js'; + +// 扩展 FastifyRequest 类型,添加 user_id 和 email +declare module 'fastify' { + interface FastifyRequest { + userId?: string; + email?: string; + } +} + +/** + * 创建认证中间件 + */ +export function createAuthMiddleware(jwtService: JWTService) { + return async function authMiddleware( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + // 1. 从 Header 中获取 Token + const authHeader = request.headers.authorization; + + if (!authHeader) { + reply.code(401).send({ + error: 'UNAUTHORIZED', + message: '缺少 Authorization 请求头', + }); + return; + } + + // 2. 解析 Bearer Token + if (!authHeader.startsWith('Bearer ')) { + reply.code(401).send({ + error: 'UNAUTHORIZED', + message: 'Authorization 格式无效(应为 Bearer )', + }); + return; + } + + const token = authHeader.substring(7); // 移除 "Bearer " 前缀 + + // 3. 验证 JWT Token + try { + const claims = jwtService.verifyAccessToken(token); + + // 4. 提取用户信息并存储到请求对象 + request.userId = claims.user_id; + request.email = claims.email; + } catch (error) { + reply.code(401).send({ + error: 'INVALID_TOKEN', + message: error instanceof Error ? error.message : 'Token 无效或已过期', + }); + return; + } + }; +} + +/** + * 可选认证中间件 + * 如果有 Token 则验证,没有 Token 则放行 + * 用途:支持匿名访问的接口,但可以识别已登录用户 + */ +export function createOptionalAuthMiddleware(jwtService: JWTService) { + return async function optionalAuthMiddleware( + request: FastifyRequest, + _reply: FastifyReply + ): Promise { + const authHeader = request.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + + try { + const claims = jwtService.verifyAccessToken(token); + // Token 有效,提取用户信息 + request.userId = claims.user_id; + request.email = claims.email; + } catch { + // Token 无效时,不阻止请求,继续放行 + } + } + }; +} + +/** + * 从请求中获取用户 ID + */ +export function getUserId(request: FastifyRequest): string | undefined { + return request.userId; +} + +/** + * 要求用户 ID 存在(用于需要认证的端点) + */ +export function requireUserId(request: FastifyRequest): string { + const userId = request.userId; + if (!userId) { + throw new Error('UNAUTHORIZED: 未授权访问'); + } + return userId; +} + diff --git a/backend-nodejs/package.json b/backend-nodejs/package.json index 0472038..93429dc 100644 --- a/backend-nodejs/package.json +++ b/backend-nodejs/package.json @@ -10,7 +10,9 @@ "start": "node dist/cmd/server/main.js", "test": "vitest run", "test:watch": "vitest", + "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "test:task": "vitest run domains/task", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write \"**/*.{ts,json,md}\"", @@ -28,11 +30,18 @@ "dependencies": { "@fastify/cors": "^10.0.1", "@fastify/rate-limit": "^9.1.0", + "@types/bcryptjs": "^3.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/supertest": "^6.0.3", + "@vitest/ui": "^4.0.16", + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", "fastify": "^5.0.0", + "jsonwebtoken": "^9.0.3", "kysely": "^0.27.2", "pg": "^8.11.3", "redis": "^4.6.13", + "supertest": "^7.1.4", "zod": "^3.23.8" }, "devDependencies": { @@ -52,4 +61,3 @@ "pnpm": ">=8.0.0" } } - diff --git a/backend-nodejs/pnpm-lock.yaml b/backend-nodejs/pnpm-lock.yaml index 132ca6c..2bd4936 100644 --- a/backend-nodejs/pnpm-lock.yaml +++ b/backend-nodejs/pnpm-lock.yaml @@ -14,12 +14,30 @@ importers: '@fastify/rate-limit': specifier: ^9.1.0 version: 9.1.0 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + '@vitest/ui': + specifier: ^4.0.16 + version: 4.0.16(vitest@1.6.1) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 dotenv: specifier: ^16.4.5 version: 16.6.1 fastify: specifier: ^5.0.0 version: 5.6.2 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 kysely: specifier: ^0.27.2 version: 0.27.6 @@ -29,6 +47,9 @@ importers: redis: specifier: ^4.6.13 version: 4.7.1 + supertest: + specifier: ^7.1.4 + version: 7.1.4 zod: specifier: ^3.23.8 version: 3.25.76 @@ -62,7 +83,7 @@ importers: version: 5.9.3 vitest: specifier: ^1.6.0 - version: 1.6.1(@types/node@22.19.3) + version: 1.6.1(@types/node@22.19.3)(@vitest/ui@4.0.16) packages: @@ -426,6 +447,10 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -438,9 +463,15 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -583,15 +614,37 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@typescript-eslint/eslint-plugin@7.18.0': resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} engines: {node: ^18.18.0 || >=20.0.0} @@ -656,6 +709,9 @@ packages: '@vitest/expect@1.6.1': resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/runner@1.6.1': resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} @@ -665,9 +721,17 @@ packages: '@vitest/spy@1.6.1': resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/ui@4.0.16': + resolution: {integrity: sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==} + peerDependencies: + vitest: 4.0.16 + '@vitest/utils@1.6.1': resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -722,9 +786,15 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -738,6 +808,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -748,6 +822,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -755,6 +832,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -784,6 +869,13 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -794,6 +886,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -817,10 +912,17 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -837,9 +939,32 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -946,6 +1071,18 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -969,6 +1106,14 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -977,6 +1122,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generic-pool@3.9.0: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} @@ -984,6 +1132,14 @@ packages: get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -1011,6 +1167,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1018,6 +1178,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -1100,6 +1272,16 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1122,15 +1304,40 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1138,10 +1345,27 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -1162,6 +1386,10 @@ packages: mnemonist@0.40.0: resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1177,6 +1405,10 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} @@ -1281,6 +1513,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} @@ -1348,6 +1584,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1435,6 +1675,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1442,6 +1698,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1481,6 +1741,14 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + superagent@10.2.3: + resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} + engines: {node: '>=14.18.0'} + + supertest@7.1.4: + resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1494,10 +1762,18 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -1510,6 +1786,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -1869,6 +2149,8 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1881,8 +2163,14 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} + '@polka/url@1.0.0-next.29': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -1977,8 +2265,23 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + + '@types/cookiejar@2.1.5': {} + '@types/estree@1.0.8': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.3 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + '@types/node@22.19.3': dependencies: undici-types: 6.21.0 @@ -1989,6 +2292,18 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.3 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2078,6 +2393,10 @@ snapshots: '@vitest/utils': 1.6.1 chai: 4.5.0 + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@1.6.1': dependencies: '@vitest/utils': 1.6.1 @@ -2094,6 +2413,17 @@ snapshots: dependencies: tinyspy: 2.2.1 + '@vitest/ui@4.0.16(vitest@1.6.1)': + dependencies: + '@vitest/utils': 4.0.16 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 1.6.1(@types/node@22.19.3)(@vitest/ui@4.0.16) + '@vitest/utils@1.6.1': dependencies: diff-sequences: 29.6.3 @@ -2101,6 +2431,11 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -2147,8 +2482,12 @@ snapshots: array-union@2.1.0: {} + asap@2.0.6: {} + assertion-error@1.1.0: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} avvio@9.1.0: @@ -2160,6 +2499,8 @@ snapshots: base64-js@1.5.1: {} + bcryptjs@3.0.3: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2173,6 +2514,8 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -2180,6 +2523,16 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chai@4.5.0: @@ -2211,12 +2564,20 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + concat-map@0.0.1: {} confbox@0.1.8: {} cookie@1.1.1: {} + cookiejar@2.1.4: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2235,8 +2596,15 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + dequal@2.0.3: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff-sequences@29.6.3: {} dir-glob@3.0.1: @@ -2249,10 +2617,35 @@ snapshots: dotenv@16.6.1: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2459,6 +2852,12 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -2486,15 +2885,49 @@ snapshots: flatted@3.3.3: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + fs.realpath@1.0.0: {} fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + generic-pool@3.9.0: {} get-func-name@2.0.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@8.0.1: {} get-tsconfig@4.13.0: @@ -2531,10 +2964,22 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graphemer@1.4.0: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + help-me@5.0.0: {} human-signals@5.0.0: {} @@ -2593,6 +3038,30 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonwebtoken@9.0.3: + 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.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2619,8 +3088,22 @@ snapshots: dependencies: p-locate: 5.0.0 + 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.merge@4.6.2: {} + lodash.once@4.1.1: {} + loupe@2.3.7: dependencies: get-func-name: 2.0.2 @@ -2629,15 +3112,27 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + mimic-fn@4.0.0: {} minimatch@3.1.2: @@ -2661,6 +3156,8 @@ snapshots: dependencies: obliterator: 2.0.5 + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -2671,6 +3168,8 @@ snapshots: dependencies: path-key: 4.0.0 + object-inspect@1.13.4: {} + obliterator@2.0.5: {} on-exit-leak-free@2.1.2: {} @@ -2763,6 +3262,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.3: {} + pino-abstract-transport@2.0.0: dependencies: split2: 4.2.0 @@ -2845,6 +3346,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -2940,10 +3445,44 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} sonic-boom@4.2.0: @@ -2974,6 +3513,27 @@ snapshots: dependencies: js-tokens: 9.0.1 + superagent@10.2.3: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + transitivePeerDependencies: + - supports-color + + supertest@7.1.4: + dependencies: + methods: 1.1.2 + superagent: 10.2.3 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2986,8 +3546,15 @@ snapshots: tinybench@2.9.0: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinypool@0.8.4: {} + tinyrainbow@3.0.3: {} + tinyspy@2.2.1: {} to-regex-range@5.0.1: @@ -2996,6 +3563,8 @@ snapshots: toad-cache@3.7.0: {} + totalist@3.0.1: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3052,7 +3621,7 @@ snapshots: '@types/node': 22.19.3 fsevents: 2.3.3 - vitest@1.6.1(@types/node@22.19.3): + vitest@1.6.1(@types/node@22.19.3)(@vitest/ui@4.0.16): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -3076,6 +3645,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.3 + '@vitest/ui': 4.0.16(vitest@1.6.1) transitivePeerDependencies: - less - lightningcss diff --git a/backend-nodejs/vitest.config.ts b/backend-nodejs/vitest.config.ts new file mode 100644 index 0000000..e9e3755 --- /dev/null +++ b/backend-nodejs/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + // 在运行测试前加载 .env 文件 + setupFiles: ['./vitest.setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.test.ts', + '**/*.spec.ts', + '**/tests/**', + 'cmd/**', + ], + }, + include: ['**/*.test.ts', '**/*.spec.ts'], + exclude: ['node_modules', 'dist'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './'), + }, + }, +}); + diff --git a/backend-nodejs/vitest.setup.ts b/backend-nodejs/vitest.setup.ts new file mode 100644 index 0000000..926a2c5 --- /dev/null +++ b/backend-nodejs/vitest.setup.ts @@ -0,0 +1,8 @@ +/** + * Vitest 测试环境设置 + * 在所有测试运行前执行,加载环境变量 + */ + +// 加载 .env 文件(如果存在) +import 'dotenv/config'; + From 19cf4ed036e381e5e695de2a8aa58570945fdb9c Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 13:05:52 +0800 Subject: [PATCH 05/22] feat: enhance user and auth domains with comprehensive unit and integration tests, covering user registration, login, password change, and profile updates in Node.js backend --- backend-nodejs/TESTING.md | 94 ++++- .../domains/auth/service/auth_service.test.ts | 280 +++++++++++++++ .../domains/auth/service/jwt_service.test.ts | 175 ++++++++++ backend-nodejs/domains/auth/tests/helpers.ts | 109 ++++++ .../domains/auth/tests/login.test.ts | 163 +++++++++ .../domains/auth/tests/refresh_token.test.ts | 171 +++++++++ .../domains/auth/tests/register.test.ts | 171 +++++++++ .../domains/user/model/user.test.ts | 329 ++++++++++++++++++ .../user/tests/change_password.test.ts | 197 +++++++++++ .../user/tests/get_user_profile.test.ts | 95 +++++ backend-nodejs/domains/user/tests/helpers.ts | 126 +++++++ .../user/tests/update_user_profile.test.ts | 215 ++++++++++++ 12 files changed, 2117 insertions(+), 8 deletions(-) create mode 100644 backend-nodejs/domains/auth/service/auth_service.test.ts create mode 100644 backend-nodejs/domains/auth/service/jwt_service.test.ts create mode 100644 backend-nodejs/domains/auth/tests/helpers.ts create mode 100644 backend-nodejs/domains/auth/tests/login.test.ts create mode 100644 backend-nodejs/domains/auth/tests/refresh_token.test.ts create mode 100644 backend-nodejs/domains/auth/tests/register.test.ts create mode 100644 backend-nodejs/domains/user/model/user.test.ts create mode 100644 backend-nodejs/domains/user/tests/change_password.test.ts create mode 100644 backend-nodejs/domains/user/tests/get_user_profile.test.ts create mode 100644 backend-nodejs/domains/user/tests/helpers.ts create mode 100644 backend-nodejs/domains/user/tests/update_user_profile.test.ts diff --git a/backend-nodejs/TESTING.md b/backend-nodejs/TESTING.md index d9693be..de52b7d 100644 --- a/backend-nodejs/TESTING.md +++ b/backend-nodejs/TESTING.md @@ -167,14 +167,92 @@ const app = testHelper.app; - 未授权错误 - 任务不存在错误 -### User 领域 ⏳ -- ⏳ Model 测试(待添加) -- ⏳ Handler 集成测试(待添加) - -### Auth 领域 ⏳ -- ⏳ JWTService 测试(待添加) -- ⏳ AuthService 测试(待添加) -- ⏳ Handler 集成测试(待添加) +### User 领域 ✅ + +#### Model 测试 ✅ +- ✅ `create` - 用户创建(各种边界情况) +- ✅ `verifyPassword` - 密码验证 +- ✅ `updatePassword` - 更新密码 +- ✅ `updateProfile` - 更新用户资料 +- ✅ `activate` - 激活用户 +- ✅ `deactivate` - 禁用用户 +- ✅ `recordLogin` - 记录登录时间 +- ✅ `canLogin` - 检查登录权限 + +#### Handler 集成测试 ✅ +- ✅ `get_user_profile.test.ts` - 获取用户资料 + - 成功获取 + - 未授权错误 + +- ✅ `update_user_profile.test.ts` - 更新用户资料 + - 成功更新用户名 + - 成功更新全名 + - 成功更新头像 URL + - 无效用户名错误 + - 全名过长错误 + - 无效头像 URL 错误 + - 未授权错误 + +- ✅ `change_password.test.ts` - 修改密码 + - 成功修改密码 + - 错误旧密码错误 + - 弱密码错误 + - 过长密码错误 + - 未授权错误 + +### Auth 领域 ✅ + +#### JWTService 单元测试 ✅ +- ✅ `generateAccessToken` - 生成 Access Token +- ✅ `generateRefreshToken` - 生成 Refresh Token +- ✅ `verifyToken` - 验证 Token +- ✅ `verifyAccessToken` - 验证 Access Token +- ✅ `verifyRefreshToken` - 验证 Refresh Token +- ✅ `extractUserId` - 提取用户 ID + +#### AuthService 单元测试 ✅ +- ✅ `register` - 用户注册 + - 成功注册 + - 重复邮箱错误 + - 重复用户名错误 + - 无效邮箱错误 + - 弱密码错误 + +- ✅ `login` - 用户登录 + - 成功登录 + - 错误密码错误 + - 不存在邮箱错误 + - 禁用用户错误 + - 未激活用户允许登录 + +- ✅ `refreshToken` - 刷新 Token + - 成功刷新 + - 无效 Token 错误 + - 错误 Token 类型错误 + - 不存在用户错误 + - 禁用用户错误 + +#### Handler 集成测试 ✅ +- ✅ `register.test.ts` - 用户注册 + - 成功注册 + - 重复邮箱错误 + - 无效邮箱错误 + - 弱密码错误 + - 可选字段支持 + +- ✅ `login.test.ts` - 用户登录 + - 成功登录 + - 错误密码错误 + - 不存在邮箱错误 + - 禁用用户错误 + - 邮箱大小写不敏感 + +- ✅ `refresh_token.test.ts` - 刷新 Token + - 成功刷新 + - 无效 Token 错误 + - Access Token 作为 Refresh Token 错误 + - 不存在用户错误 + - 禁用用户错误 ## 测试最佳实践 diff --git a/backend-nodejs/domains/auth/service/auth_service.test.ts b/backend-nodejs/domains/auth/service/auth_service.test.ts new file mode 100644 index 0000000..c0b617e --- /dev/null +++ b/backend-nodejs/domains/auth/service/auth_service.test.ts @@ -0,0 +1,280 @@ +/** + * AuthService 单元测试 + * 使用 Mock Repository 进行测试 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AuthService } from './auth_service.js'; +import { JWTService } from './jwt_service.js'; +import type { UserRepository } from '../../user/repository/interface.js'; +import { User } from '../../user/model/user.js'; + +// Mock UserRepository +class MockUserRepository implements UserRepository { + private users: Map = new Map(); + private emails: Set = new Set(); + private usernames: Set = new Set(); + + async create(_ctx: unknown, user: User): Promise { + this.users.set(user.id, user); + this.emails.add(user.email.toLowerCase()); + if (user.username) { + this.usernames.add(user.username); + } + } + + async getById(_ctx: unknown, userId: string): Promise { + return this.users.get(userId) || null; + } + + async getByEmail(_ctx: unknown, email: string): Promise { + const normalizedEmail = email.toLowerCase(); + for (const user of this.users.values()) { + if (user.email.toLowerCase() === normalizedEmail) { + return user; + } + } + return null; + } + + async update(_ctx: unknown, user: User): Promise { + this.users.set(user.id, user); + if (user.username) { + this.usernames.add(user.username); + } + } + + async existsByEmail(_ctx: unknown, email: string): Promise { + return this.emails.has(email.toLowerCase()); + } + + async existsByUsername(_ctx: unknown, username: string): Promise { + return this.usernames.has(username); + } +} + +describe('AuthService', () => { + let userRepo: MockUserRepository; + let jwtService: JWTService; + let authService: AuthService; + + beforeEach(() => { + userRepo = new MockUserRepository(); + jwtService = new JWTService({ + secret: 'test-secret', + accessTokenExpiry: 3600, + refreshTokenExpiry: 604800, + issuer: 'test-issuer', + }); + authService = new AuthService(userRepo, jwtService); + }); + + describe('register', () => { + it('应该成功注册新用户', async () => { + const input = { + email: 'newuser@example.com', + password: 'password123', + username: 'newuser', + fullName: 'New User', + }; + + const output = await authService.register({}, input); + + expect(output.userId).toBeDefined(); + expect(output.email).toBe('newuser@example.com'); + expect(output.accessToken).toBeDefined(); + expect(output.refreshToken).toBeDefined(); + expect(output.expiresIn).toBeGreaterThan(0); + + // 验证用户已创建 + const user = await userRepo.getByEmail({}, input.email); + expect(user).not.toBeNull(); + expect(user!.email).toBe('newuser@example.com'); + }); + + it('应该拒绝重复邮箱', async () => { + // 先创建一个用户 + const user = await User.create('existing@example.com', 'password123'); + await userRepo.create({}, user); + + const input = { + email: 'existing@example.com', + password: 'password123', + }; + + await expect(authService.register({}, input)).rejects.toThrow('EMAIL_ALREADY_EXISTS'); + }); + + it('应该拒绝重复用户名', async () => { + const user = await User.create('user1@example.com', 'password123'); + user.updateProfile('takenusername'); + await userRepo.create({}, user); + + const input = { + email: 'user2@example.com', + password: 'password123', + username: 'takenusername', + }; + + await expect(authService.register({}, input)).rejects.toThrow('USERNAME_ALREADY_EXISTS'); + }); + + it('应该拒绝无效邮箱', async () => { + const input = { + email: 'invalid-email', + password: 'password123', + }; + + await expect(authService.register({}, input)).rejects.toThrow('INVALID_EMAIL'); + }); + + it('应该拒绝弱密码', async () => { + const input = { + email: 'user@example.com', + password: 'short', + }; + + await expect(authService.register({}, input)).rejects.toThrow('WEAK_PASSWORD'); + }); + }); + + describe('login', () => { + it('应该成功登录', async () => { + // 创建测试用户 + const user = await User.create('test@example.com', 'password123'); + user.activate(); + await userRepo.create({}, user); + + const input = { + email: 'test@example.com', + password: 'password123', + }; + + const output = await authService.login({}, input); + + expect(output.userId).toBe(user.id); + expect(output.email).toBe('test@example.com'); + expect(output.accessToken).toBeDefined(); + expect(output.refreshToken).toBeDefined(); + expect(output.expiresIn).toBeGreaterThan(0); + }); + + it('应该拒绝错误密码', async () => { + const user = await User.create('test@example.com', 'password123'); + user.activate(); + await userRepo.create({}, user); + + const input = { + email: 'test@example.com', + password: 'wrong-password', + }; + + await expect(authService.login({}, input)).rejects.toThrow('INVALID_CREDENTIALS'); + }); + + it('应该拒绝不存在的邮箱', async () => { + const input = { + email: 'nonexistent@example.com', + password: 'password123', + }; + + await expect(authService.login({}, input)).rejects.toThrow('INVALID_CREDENTIALS'); + }); + + it('应该拒绝禁用用户登录', async () => { + const user = await User.create('test@example.com', 'password123'); + user.deactivate(); + await userRepo.create({}, user); + + const input = { + email: 'test@example.com', + password: 'password123', + }; + + await expect(authService.login({}, input)).rejects.toThrow('USER_BANNED'); + }); + + it('应该允许未激活用户登录', async () => { + const user = await User.create('test@example.com', 'password123'); + // 不激活用户 + await userRepo.create({}, user); + + const input = { + email: 'test@example.com', + password: 'password123', + }; + + const output = await authService.login({}, input); + expect(output.userId).toBe(user.id); + }); + }); + + describe('refreshToken', () => { + it('应该成功刷新 Token', async () => { + // 创建测试用户 + const user = await User.create('test@example.com', 'password123'); + user.activate(); + await userRepo.create({}, user); + + // 生成 Refresh Token + const { token: refreshToken } = jwtService.generateRefreshToken(user.id); + + const input = { + refreshToken, + }; + + const output = await authService.refreshToken({}, input); + + expect(output.accessToken).toBeDefined(); + expect(output.refreshToken).toBeDefined(); + expect(output.expiresIn).toBeGreaterThan(0); + }); + + it('应该拒绝无效 Refresh Token', async () => { + const input = { + refreshToken: 'invalid-token', + }; + + await expect(authService.refreshToken({}, input)).rejects.toThrow('INVALID_REFRESH_TOKEN'); + }); + + it('应该拒绝 Access Token 作为 Refresh Token', async () => { + const user = await User.create('test@example.com', 'password123'); + await userRepo.create({}, user); + + const { token: accessToken } = jwtService.generateAccessToken(user.id, user.email); + + const input = { + refreshToken: accessToken, + }; + + await expect(authService.refreshToken({}, input)).rejects.toThrow('INVALID_REFRESH_TOKEN'); + }); + + it('应该拒绝不存在的用户', async () => { + const nonExistentUserId = '00000000-0000-0000-0000-000000000999'; + const { token: refreshToken } = jwtService.generateRefreshToken(nonExistentUserId); + + const input = { + refreshToken, + }; + + await expect(authService.refreshToken({}, input)).rejects.toThrow('USER_NOT_FOUND'); + }); + + it('应该拒绝禁用用户的 Refresh Token', async () => { + const user = await User.create('test@example.com', 'password123'); + user.deactivate(); + await userRepo.create({}, user); + + const { token: refreshToken } = jwtService.generateRefreshToken(user.id); + + const input = { + refreshToken, + }; + + await expect(authService.refreshToken({}, input)).rejects.toThrow('USER_BANNED'); + }); + }); +}); + diff --git a/backend-nodejs/domains/auth/service/jwt_service.test.ts b/backend-nodejs/domains/auth/service/jwt_service.test.ts new file mode 100644 index 0000000..eae989f --- /dev/null +++ b/backend-nodejs/domains/auth/service/jwt_service.test.ts @@ -0,0 +1,175 @@ +/** + * JWTService 单元测试 + */ + +import { describe, it, expect } from 'vitest'; +import { JWTService } from './jwt_service.js'; + +describe('JWTService', () => { + const config = { + secret: 'test-secret-key-for-jwt-testing', + accessTokenExpiry: 3600, // 1 小时 + refreshTokenExpiry: 604800, // 7 天 + issuer: 'test-issuer', + }; + + const jwtService = new JWTService(config); + const testUserId = '00000000-0000-0000-0000-000000000001'; + const testEmail = 'test@example.com'; + + describe('generateAccessToken', () => { + it('应该成功生成 Access Token', () => { + const { token, expiresAt } = jwtService.generateAccessToken(testUserId, testEmail); + + expect(token).toBeDefined(); + expect(token.length).toBeGreaterThan(0); + expect(expiresAt).toBeInstanceOf(Date); + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it('生成的 Token 应该包含正确的 Claims', () => { + const { token } = jwtService.generateAccessToken(testUserId, testEmail); + const payload = jwtService.verifyAccessToken(token); + + expect(payload.user_id).toBe(testUserId); + expect(payload.email).toBe(testEmail); + expect(payload.type).toBe('access'); + expect(payload.iss).toBe(config.issuer); + expect(payload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(payload.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + }); + + it('生成的 Token 应该在指定时间后过期', () => { + const { token, expiresAt } = jwtService.generateAccessToken(testUserId, testEmail); + const expectedExpiry = Date.now() + config.accessTokenExpiry * 1000; + + // 允许 1 秒的误差 + expect(Math.abs(expiresAt.getTime() - expectedExpiry)).toBeLessThan(1000); + }); + }); + + describe('generateRefreshToken', () => { + it('应该成功生成 Refresh Token', () => { + const { token, expiresAt } = jwtService.generateRefreshToken(testUserId); + + expect(token).toBeDefined(); + expect(token.length).toBeGreaterThan(0); + expect(expiresAt).toBeInstanceOf(Date); + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it('生成的 Token 应该包含正确的 Claims', () => { + const { token } = jwtService.generateRefreshToken(testUserId); + const payload = jwtService.verifyRefreshToken(token); + + expect(payload.user_id).toBe(testUserId); + expect(payload.type).toBe('refresh'); + expect(payload.iss).toBe(config.issuer); + expect(payload.email).toBeUndefined(); // Refresh Token 不包含 email + }); + + it('生成的 Token 应该在指定时间后过期', () => { + const { token, expiresAt } = jwtService.generateRefreshToken(testUserId); + const expectedExpiry = Date.now() + config.refreshTokenExpiry * 1000; + + // 允许 1 秒的误差 + expect(Math.abs(expiresAt.getTime() - expectedExpiry)).toBeLessThan(1000); + }); + }); + + describe('verifyToken', () => { + it('应该成功验证有效 Token', () => { + const { token } = jwtService.generateAccessToken(testUserId, testEmail); + const payload = jwtService.verifyToken(token); + + expect(payload.user_id).toBe(testUserId); + expect(payload.email).toBe(testEmail); + expect(payload.type).toBe('access'); + }); + + it('应该拒绝无效 Token', () => { + const invalidToken = 'invalid.token.here'; + + expect(() => { + jwtService.verifyToken(invalidToken); + }).toThrow('INVALID_TOKEN'); + }); + + it('应该拒绝错误 Secret 签名的 Token', () => { + const wrongService = new JWTService({ + ...config, + secret: 'wrong-secret', + }); + const { token } = wrongService.generateAccessToken(testUserId, testEmail); + + expect(() => { + jwtService.verifyToken(token); + }).toThrow('INVALID_TOKEN'); + }); + + it('应该拒绝错误 Issuer 的 Token', () => { + const wrongService = new JWTService({ + ...config, + issuer: 'wrong-issuer', + }); + const { token } = wrongService.generateAccessToken(testUserId, testEmail); + + expect(() => { + jwtService.verifyToken(token); + }).toThrow('INVALID_ISSUER'); + }); + }); + + describe('verifyAccessToken', () => { + it('应该成功验证 Access Token', () => { + const { token } = jwtService.generateAccessToken(testUserId, testEmail); + const payload = jwtService.verifyAccessToken(token); + + expect(payload.type).toBe('access'); + expect(payload.user_id).toBe(testUserId); + }); + + it('应该拒绝 Refresh Token 作为 Access Token', () => { + const { token } = jwtService.generateRefreshToken(testUserId); + + expect(() => { + jwtService.verifyAccessToken(token); + }).toThrow('INVALID_TOKEN_TYPE'); + }); + }); + + describe('verifyRefreshToken', () => { + it('应该成功验证 Refresh Token', () => { + const { token } = jwtService.generateRefreshToken(testUserId); + const payload = jwtService.verifyRefreshToken(token); + + expect(payload.type).toBe('refresh'); + expect(payload.user_id).toBe(testUserId); + }); + + it('应该拒绝 Access Token 作为 Refresh Token', () => { + const { token } = jwtService.generateAccessToken(testUserId, testEmail); + + expect(() => { + jwtService.verifyRefreshToken(token); + }).toThrow('INVALID_TOKEN_TYPE'); + }); + }); + + describe('extractUserId', () => { + it('应该成功提取用户 ID', () => { + const { token } = jwtService.generateAccessToken(testUserId, testEmail); + const userId = jwtService.extractUserId(token); + + expect(userId).toBe(testUserId); + }); + + it('应该从 Refresh Token 提取用户 ID', () => { + const { token } = jwtService.generateRefreshToken(testUserId); + const userId = jwtService.extractUserId(token); + + expect(userId).toBe(testUserId); + }); + }); +}); + diff --git a/backend-nodejs/domains/auth/tests/helpers.ts b/backend-nodejs/domains/auth/tests/helpers.ts new file mode 100644 index 0000000..95dbb2f --- /dev/null +++ b/backend-nodejs/domains/auth/tests/helpers.ts @@ -0,0 +1,109 @@ +/** + * Auth 测试辅助工具 + * 提供测试所需的 Mock 数据和辅助函数 + */ + +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; +import { UserRepositoryImpl } from '../../user/repository/user_repo.js'; +import { AuthService } from '../service/auth_service.js'; +import { JWTService } from '../service/jwt_service.js'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import bcrypt from 'bcryptjs'; +import { TEST_USER_ID, TEST_USER_EMAIL, TEST_USER_PASSWORD } from '../../user/tests/helpers.js'; + +// ========== 测试常量 ========== + +export const TEST_NEW_USER_EMAIL = 'newuser@example.com'; +export const TEST_NEW_USER_PASSWORD = 'new-password-123'; +export const TEST_NEW_USER_USERNAME = 'newuser'; + +// ========== 测试辅助类 ========== + +export interface TestHelper { + db: Kysely; + userRepo: UserRepositoryImpl; + jwtService: JWTService; + authService: AuthService; + handlerDeps: HandlerDependencies; + app: FastifyInstance; +} + +/** + * 创建测试辅助工具 + */ +export function createTestHelper( + db: Kysely, + jwtService: JWTService +): TestHelper { + // 1. 创建 Repository + const userRepo = new UserRepositoryImpl(db); + + // 2. 创建 Auth Service + const authService = new AuthService(userRepo, jwtService); + + // 3. 创建 Handler Dependencies + const handlerDeps: HandlerDependencies = { + authService, + }; + + // 4. 创建 Fastify 应用 + const app = Fastify({ + logger: false, // 测试时禁用日志 + }); + + return { + db, + userRepo, + jwtService, + authService, + handlerDeps, + app, + }; +} + +/** + * 确保测试用户存在 + * 如果用户不存在,则创建它 + */ +export async function ensureTestUser(db: Kysely): Promise { + const existingUser = await db + .selectFrom('users') + .select('id') + .where('id', '=', TEST_USER_ID) + .executeTakeFirst(); + + if (!existingUser) { + // 创建测试用户 + const passwordHash = await bcrypt.hash(TEST_USER_PASSWORD, 10); + await db + .insertInto('users') + .values({ + id: TEST_USER_ID, + email: TEST_USER_EMAIL, + username: 'testuser', + password_hash: passwordHash, + full_name: 'Test User', + avatar_url: null, + status: 'active', + email_verified: true, + created_at: new Date(), + updated_at: new Date(), + last_login_at: null, + }) + .execute(); + } +} + +/** + * 清理测试用户(用于注册测试) + */ +export async function cleanupTestUser(db: Kysely, email: string): Promise { + await db + .deleteFrom('users') + .where('email', '=', email.toLowerCase()) + .execute(); +} + diff --git a/backend-nodejs/domains/auth/tests/login.test.ts b/backend-nodejs/domains/auth/tests/login.test.ts new file mode 100644 index 0000000..ec4edfe --- /dev/null +++ b/backend-nodejs/domains/auth/tests/login.test.ts @@ -0,0 +1,163 @@ +/** + * Login Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, ensureTestUser } from './helpers.js'; +import { registerAuthRoutes } from '../http/router.js'; +import { JWTService } from '../service/jwt_service.js'; +import { TEST_USER_EMAIL, TEST_USER_PASSWORD } from '../../user/tests/helpers.js'; + +describe('Login Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + registerAuthRoutes(app, testHelper.handlerDeps); + + // 确保测试用户存在 + await ensureTestUser(db); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功登录', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: TEST_USER_EMAIL, + password: TEST_USER_PASSWORD, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.user_id).toBeDefined(); + expect(body.email).toBe(TEST_USER_EMAIL); + expect(body.access_token).toBeDefined(); + expect(body.refresh_token).toBeDefined(); + expect(body.expires_in).toBeGreaterThan(0); + }); + + it('应该拒绝错误密码', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: TEST_USER_EMAIL, + password: 'wrong-password', + }, + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_CREDENTIALS'); + }); + + it('应该拒绝不存在的邮箱', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'nonexistent@example.com', + password: 'password123', + }, + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_CREDENTIALS'); + }); + + it('应该拒绝禁用用户登录', async () => { + if (!dbAvailable) return; + + // 禁用测试用户 + const user = await testHelper.userRepo.getByEmail({}, TEST_USER_EMAIL); + if (user) { + user.deactivate(); + await testHelper.userRepo.update({}, user); + } + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: TEST_USER_EMAIL, + password: TEST_USER_PASSWORD, + }, + }); + + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.body); + expect(body.error).toContain('USER_BANNED'); + + // 恢复用户状态 + if (user) { + user.activate(); + await testHelper.userRepo.update({}, user); + } + }); + + it('应该接受邮箱大小写不敏感', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: TEST_USER_EMAIL.toUpperCase(), + password: TEST_USER_PASSWORD, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.email).toBe(TEST_USER_EMAIL.toLowerCase()); + }); +}); + diff --git a/backend-nodejs/domains/auth/tests/refresh_token.test.ts b/backend-nodejs/domains/auth/tests/refresh_token.test.ts new file mode 100644 index 0000000..dcc445a --- /dev/null +++ b/backend-nodejs/domains/auth/tests/refresh_token.test.ts @@ -0,0 +1,171 @@ +/** + * RefreshToken Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, ensureTestUser } from './helpers.js'; +import { registerAuthRoutes } from '../http/router.js'; +import { JWTService } from '../service/jwt_service.js'; +import { TEST_USER_ID } from '../../user/tests/helpers.js'; + +describe('RefreshToken Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + registerAuthRoutes(app, testHelper.handlerDeps); + + // 确保测试用户存在 + await ensureTestUser(db); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功刷新 Token', async () => { + if (!dbAvailable) return; + + // 生成 Refresh Token + const { token: refreshToken } = testHelper.jwtService.generateRefreshToken(TEST_USER_ID); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + payload: { + refresh_token: refreshToken, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.access_token).toBeDefined(); + expect(body.refresh_token).toBeDefined(); + expect(body.expires_in).toBeGreaterThan(0); + }); + + it('应该拒绝无效 Refresh Token', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + payload: { + refresh_token: 'invalid-token', + }, + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_REFRESH_TOKEN'); + }); + + it('应该拒绝 Access Token 作为 Refresh Token', async () => { + if (!dbAvailable) return; + + const { token: accessToken } = testHelper.jwtService.generateAccessToken( + TEST_USER_ID, + 'test@example.com' + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + payload: { + refresh_token: accessToken, + }, + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_REFRESH_TOKEN'); + }); + + it('应该拒绝不存在的用户', async () => { + if (!dbAvailable) return; + + const nonExistentUserId = '00000000-0000-0000-0000-000000000999'; + const { token: refreshToken } = testHelper.jwtService.generateRefreshToken( + nonExistentUserId + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + payload: { + refresh_token: refreshToken, + }, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.error).toContain('USER_NOT_FOUND'); + }); + + it('应该拒绝禁用用户的 Refresh Token', async () => { + if (!dbAvailable) return; + + // 禁用测试用户 + const user = await testHelper.userRepo.getById({}, TEST_USER_ID); + if (user) { + user.deactivate(); + await testHelper.userRepo.update({}, user); + } + + const { token: refreshToken } = testHelper.jwtService.generateRefreshToken(TEST_USER_ID); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + payload: { + refresh_token: refreshToken, + }, + }); + + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.body); + expect(body.error).toContain('USER_BANNED'); + + // 恢复用户状态 + if (user) { + user.activate(); + await testHelper.userRepo.update({}, user); + } + }); +}); + diff --git a/backend-nodejs/domains/auth/tests/register.test.ts b/backend-nodejs/domains/auth/tests/register.test.ts new file mode 100644 index 0000000..60a7c4c --- /dev/null +++ b/backend-nodejs/domains/auth/tests/register.test.ts @@ -0,0 +1,171 @@ +/** + * Register Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_NEW_USER_EMAIL, cleanupTestUser } from './helpers.js'; +import { registerAuthRoutes } from '../http/router.js'; +import { JWTService } from '../service/jwt_service.js'; + +describe('Register Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + registerAuthRoutes(app, testHelper.handlerDeps); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + // 清理测试用户 + await cleanupTestUser(testHelper.db, TEST_NEW_USER_EMAIL); + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功注册新用户', async () => { + if (!dbAvailable) return; + + // 确保用户不存在 + await cleanupTestUser(testHelper.db, TEST_NEW_USER_EMAIL); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: TEST_NEW_USER_EMAIL, + password: 'password123', + username: 'newuser', + full_name: 'New User', + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.user_id).toBeDefined(); + expect(body.email).toBe(TEST_NEW_USER_EMAIL); + expect(body.access_token).toBeDefined(); + expect(body.refresh_token).toBeDefined(); + expect(body.expires_in).toBeGreaterThan(0); + }); + + it('应该拒绝重复邮箱', async () => { + if (!dbAvailable) return; + + // 先注册一个用户 + await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: TEST_NEW_USER_EMAIL, + password: 'password123', + }, + }); + + // 尝试用相同邮箱注册 + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: TEST_NEW_USER_EMAIL, + password: 'password123', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('EMAIL_ALREADY_EXISTS'); + }); + + it('应该拒绝无效邮箱', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: 'invalid-email', + password: 'password123', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_EMAIL'); + }); + + it('应该拒绝弱密码', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email: 'user@example.com', + password: 'short', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('WEAK_PASSWORD'); + }); + + it('应该接受可选字段', async () => { + if (!dbAvailable) return; + + const email = `test${Date.now()}@example.com`; + await cleanupTestUser(testHelper.db, email); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { + email, + password: 'password123', + // 不提供 username 和 full_name + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.user_id).toBeDefined(); + + // 清理 + await cleanupTestUser(testHelper.db, email); + }); +}); + diff --git a/backend-nodejs/domains/user/model/user.test.ts b/backend-nodejs/domains/user/model/user.test.ts new file mode 100644 index 0000000..ef3b3ea --- /dev/null +++ b/backend-nodejs/domains/user/model/user.test.ts @@ -0,0 +1,329 @@ +/** + * User Model 单元测试 + */ + +import { describe, it, expect } from 'vitest'; +import { User, UserStatuses } from './user.js'; +import bcrypt from 'bcryptjs'; + +describe('User Model', () => { + describe('create', () => { + it('应该创建有效用户', async () => { + const user = await User.create('test@example.com', 'password123'); + + expect(user.id).toBeDefined(); + expect(user.email).toBe('test@example.com'); + expect(user.passwordHash).toBeDefined(); + expect(user.passwordHash).not.toBe('password123'); // 应该是哈希值 + expect(user.status).toBe(UserStatuses.Inactive); + expect(user.emailVerified).toBe(false); + expect(user.username).toBe(''); + expect(user.fullName).toBe(''); + expect(user.avatarURL).toBe(''); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + expect(user.lastLoginAt).toBeNull(); + }); + + it('应该规范化邮箱(转为小写)', async () => { + const user = await User.create('TEST@EXAMPLE.COM', 'password123'); + expect(user.email).toBe('test@example.com'); + }); + + it('应该拒绝无效邮箱格式', async () => { + await expect(User.create('invalid-email', 'password123')).rejects.toThrow('INVALID_EMAIL'); + }); + + it('应该拒绝空邮箱', async () => { + await expect(User.create('', 'password123')).rejects.toThrow('INVALID_EMAIL'); + }); + + it('应该拒绝弱密码(少于 8 字符)', async () => { + await expect(User.create('test@example.com', 'short')).rejects.toThrow('WEAK_PASSWORD'); + }); + + it('应该拒绝过长密码(超过 128 字符)', async () => { + const longPassword = 'a'.repeat(129); + await expect(User.create('test@example.com', longPassword)).rejects.toThrow('PASSWORD_TOO_LONG'); + }); + + it('应该接受最小长度密码(8 字符)', async () => { + const user = await User.create('test@example.com', '12345678'); + expect(user.id).toBeDefined(); + }); + + it('应该接受最大长度密码(128 字符)', async () => { + const maxPassword = 'a'.repeat(128); + const user = await User.create('test@example.com', maxPassword); + expect(user.id).toBeDefined(); + }); + }); + + describe('verifyPassword', () => { + it('应该验证正确密码', async () => { + const user = await User.create('test@example.com', 'password123'); + const isValid = await user.verifyPassword('password123'); + expect(isValid).toBe(true); + }); + + it('应该拒绝错误密码', async () => { + const user = await User.create('test@example.com', 'password123'); + const isValid = await user.verifyPassword('wrong-password'); + expect(isValid).toBe(false); + }); + }); + + describe('updatePassword', () => { + it('应该成功更新密码', async () => { + const user = await User.create('test@example.com', 'old-password'); + const oldHash = user.passwordHash; + const oldUpdatedAt = user.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await user.updatePassword('new-password-123'); + + expect(user.passwordHash).not.toBe(oldHash); + expect(user.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + + // 验证新密码可以登录 + const isValid = await user.verifyPassword('new-password-123'); + expect(isValid).toBe(true); + }); + + it('应该拒绝弱密码', async () => { + const user = await User.create('test@example.com', 'old-password'); + await expect(user.updatePassword('short')).rejects.toThrow('WEAK_PASSWORD'); + }); + + it('应该拒绝过长密码', async () => { + const user = await User.create('test@example.com', 'old-password'); + const longPassword = 'a'.repeat(129); + await expect(user.updatePassword(longPassword)).rejects.toThrow('PASSWORD_TOO_LONG'); + }); + }); + + describe('updateProfile', () => { + it('应该成功更新用户名', async () => { + const user = await User.create('test@example.com', 'password123'); + const oldUpdatedAt = user.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + user.updateProfile('newusername'); + + expect(user.username).toBe('newusername'); + expect(user.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + + it('应该成功更新全名', async () => { + const user = await User.create('test@example.com', 'password123'); + user.updateProfile(undefined, 'New Full Name'); + + expect(user.fullName).toBe('New Full Name'); + }); + + it('应该成功更新头像 URL', async () => { + const user = await User.create('test@example.com', 'password123'); + user.updateProfile(undefined, undefined, 'https://example.com/avatar.jpg'); + + expect(user.avatarURL).toBe('https://example.com/avatar.jpg'); + }); + + it('应该拒绝无效用户名(太短)', async () => { + const user = await User.create('test@example.com', 'password123'); + expect(() => { + user.updateProfile('ab'); + }).toThrow('INVALID_USERNAME'); + }); + + it('应该拒绝无效用户名(太长)', async () => { + const user = await User.create('test@example.com', 'password123'); + const longUsername = 'a'.repeat(31); + expect(() => { + user.updateProfile(longUsername); + }).toThrow('INVALID_USERNAME'); + }); + + it('应该拒绝无效用户名(包含特殊字符)', async () => { + const user = await User.create('test@example.com', 'password123'); + expect(() => { + user.updateProfile('user-name'); + }).toThrow('INVALID_USERNAME'); + }); + + it('应该接受有效用户名(3-30 字符,字母数字下划线)', async () => { + const user = await User.create('test@example.com', 'password123'); + user.updateProfile('valid_username_123'); + expect(user.username).toBe('valid_username_123'); + }); + + it('应该拒绝全名过长', async () => { + const user = await User.create('test@example.com', 'password123'); + const longName = 'a'.repeat(101); + expect(() => { + user.updateProfile(undefined, longName); + }).toThrow('FULL_NAME_TOO_LONG'); + }); + + it('应该拒绝无效头像 URL(非 HTTP/HTTPS)', async () => { + const user = await User.create('test@example.com', 'password123'); + expect(() => { + user.updateProfile(undefined, undefined, 'ftp://example.com/avatar.jpg'); + }).toThrow('INVALID_AVATAR_URL'); + }); + + it('应该接受空字符串(不更新)', async () => { + const user = await User.create('test@example.com', 'password123'); + user.updateProfile('username'); + const oldUsername = user.username; + + user.updateProfile(''); // 空字符串不更新 + expect(user.username).toBe(oldUsername); + }); + }); + + describe('activate', () => { + it('应该激活未激活用户', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Inactive, + false + ); + + user.activate(); + + expect(user.status).toBe(UserStatuses.Active); + expect(user.emailVerified).toBe(true); + }); + + it('应该允许重复激活(幂等)', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Active, + true + ); + + user.activate(); // 再次激活 + + expect(user.status).toBe(UserStatuses.Active); + expect(user.emailVerified).toBe(true); + }); + }); + + describe('deactivate', () => { + it('应该禁用用户', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Active, + true + ); + + user.deactivate(); + + expect(user.status).toBe(UserStatuses.Banned); + }); + + it('应该允许重复禁用(幂等)', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Banned, + true + ); + + user.deactivate(); // 再次禁用 + + expect(user.status).toBe(UserStatuses.Banned); + }); + }); + + describe('recordLogin', () => { + it('应该记录登录时间', async () => { + const user = await User.create('test@example.com', 'password123'); + const oldLastLoginAt = user.lastLoginAt; + const oldUpdatedAt = user.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + user.recordLogin(); + + expect(user.lastLoginAt).toBeInstanceOf(Date); + expect(user.lastLoginAt).not.toBe(oldLastLoginAt); + expect(user.updatedAt.getTime()).toBeGreaterThan(oldUpdatedAt.getTime()); + }); + }); + + describe('canLogin', () => { + it('应该允许激活用户登录', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Active, + true + ); + + expect(() => { + user.canLogin(); + }).not.toThrow(); + }); + + it('应该允许未激活用户登录', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Inactive, + false + ); + + expect(() => { + user.canLogin(); + }).not.toThrow(); + }); + + it('应该拒绝禁用用户登录', () => { + const user = new User( + 'user-id', + 'test@example.com', + 'hash', + '', + '', + '', + UserStatuses.Banned, + true + ); + + expect(() => { + user.canLogin(); + }).toThrow('USER_BANNED'); + }); + }); +}); + diff --git a/backend-nodejs/domains/user/tests/change_password.test.ts b/backend-nodejs/domains/user/tests/change_password.test.ts new file mode 100644 index 0000000..4dab21f --- /dev/null +++ b/backend-nodejs/domains/user/tests/change_password.test.ts @@ -0,0 +1,197 @@ +/** + * ChangePassword Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, TEST_USER_PASSWORD, ensureTestUser } from './helpers.js'; +import { registerUserRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; +import { UserRepositoryImpl } from '../repository/user_repo.js'; +import { User } from '../model/user.js'; + +describe('ChangePassword Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + registerUserRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 确保测试用户存在 + await ensureTestUser(db); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功修改密码', async () => { + if (!dbAvailable) return; + + // 先重置密码为已知值 + const userRepo = new UserRepositoryImpl(testHelper.db); + const user = await userRepo.getById({}, TEST_USER_ID); + if (user) { + await user.updatePassword(TEST_USER_PASSWORD); + await userRepo.update({}, user); + } + + const response = await app.inject({ + method: 'POST', + url: '/api/users/me/change-password', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + old_password: TEST_USER_PASSWORD, + new_password: 'new-password-123', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + + // 验证新密码可以登录(通过验证密码哈希) + const updatedUser = await userRepo.getById({}, TEST_USER_ID); + expect(updatedUser).not.toBeNull(); + const isValid = await updatedUser!.verifyPassword('new-password-123'); + expect(isValid).toBe(true); + }); + + it('应该拒绝错误旧密码', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/users/me/change-password', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + old_password: 'wrong-password', + new_password: 'new-password-123', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_PASSWORD'); + }); + + it('应该拒绝弱密码', async () => { + if (!dbAvailable) return; + + // 先重置密码为已知值 + const userRepo = new UserRepositoryImpl(testHelper.db); + const user = await userRepo.getById({}, TEST_USER_ID); + if (user) { + await user.updatePassword(TEST_USER_PASSWORD); + await userRepo.update({}, user); + } + + const response = await app.inject({ + method: 'POST', + url: '/api/users/me/change-password', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + old_password: TEST_USER_PASSWORD, + new_password: 'short', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('WEAK_PASSWORD'); + }); + + it('应该拒绝过长密码', async () => { + if (!dbAvailable) return; + + // 先重置密码为已知值 + const userRepo = new UserRepositoryImpl(testHelper.db); + const user = await userRepo.getById({}, TEST_USER_ID); + if (user) { + await user.updatePassword(TEST_USER_PASSWORD); + await userRepo.update({}, user); + } + + const longPassword = 'a'.repeat(129); + const response = await app.inject({ + method: 'POST', + url: '/api/users/me/change-password', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + old_password: TEST_USER_PASSWORD, + new_password: longPassword, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + // 注意:如果旧密码验证失败,会先返回 INVALID_PASSWORD + // 如果旧密码正确但新密码过长,会返回 PASSWORD_TOO_LONG + // 这里我们检查是否包含密码相关的错误 + expect(body.error).toMatch(/PASSWORD_TOO_LONG|INVALID_PASSWORD/); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'POST', + url: '/api/users/me/change-password', + payload: { + old_password: TEST_USER_PASSWORD, + new_password: 'new-password-123', + }, + }); + + expect(response.statusCode).toBe(401); + }); +}); + diff --git a/backend-nodejs/domains/user/tests/get_user_profile.test.ts b/backend-nodejs/domains/user/tests/get_user_profile.test.ts new file mode 100644 index 0000000..b9d5c44 --- /dev/null +++ b/backend-nodejs/domains/user/tests/get_user_profile.test.ts @@ -0,0 +1,95 @@ +/** + * GetUserProfile Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerUserRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; + +describe('GetUserProfile Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + registerUserRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 确保测试用户存在 + await ensureTestUser(db); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功获取用户资料', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.user_id).toBe(TEST_USER_ID); + expect(body.email).toBe('test@example.com'); + expect(body).not.toHaveProperty('password_hash'); // 不应该返回密码哈希 + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'GET', + url: '/api/users/me', + }); + + expect(response.statusCode).toBe(401); + }); +}); + diff --git a/backend-nodejs/domains/user/tests/helpers.ts b/backend-nodejs/domains/user/tests/helpers.ts new file mode 100644 index 0000000..489dd54 --- /dev/null +++ b/backend-nodejs/domains/user/tests/helpers.ts @@ -0,0 +1,126 @@ +/** + * User 测试辅助工具 + * 提供测试所需的 Mock 数据和辅助函数 + */ + +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; +import { UserRepositoryImpl } from '../repository/user_repo.js'; +import { UserService } from '../service/user_service.js'; +import type { HandlerDependencies } from '../handlers/dependencies.js'; +import { User } from '../model/user.js'; +import type { JWTService } from '../../auth/service/jwt_service.js'; +import bcrypt from 'bcryptjs'; + +// ========== 测试常量 ========== + +// 使用有效的 UUID 格式(符合数据库 schema 要求) +export const TEST_USER_ID = '00000000-0000-0000-0000-000000000001'; +export const TEST_USER_EMAIL = 'test@example.com'; +export const TEST_USER_PASSWORD = 'test-password-123'; +export const TEST_USER_USERNAME = 'testuser'; +export const TEST_USER_FULL_NAME = 'Test User'; + +// ========== 测试辅助类 ========== + +export interface TestHelper { + db: Kysely; + userRepo: UserRepositoryImpl; + userService: UserService; + handlerDeps: HandlerDependencies; + app: FastifyInstance; + jwtService: JWTService | null; +} + +/** + * 创建测试辅助工具 + */ +export function createTestHelper(db: Kysely, jwtService?: JWTService): TestHelper { + // 1. 创建 Repository + const userRepo = new UserRepositoryImpl(db); + + // 2. 创建 Service + const userService = new UserService(userRepo); + + // 3. 创建 Handler Dependencies + const handlerDeps: HandlerDependencies = { + userService, + }; + + // 4. 创建 Fastify 应用 + const app = Fastify({ + logger: false, // 测试时禁用日志 + }); + + return { + db, + userRepo, + userService, + handlerDeps, + app, + jwtService: jwtService || null, + }; +} + +// ========== 测试数据生成器 ========== + +/** + * 创建标准测试用户 + */ +export async function createTestUser(): Promise { + return await User.create(TEST_USER_EMAIL, TEST_USER_PASSWORD); +} + +/** + * 创建带指定 ID 的测试用户 + */ +export async function createTestUserWithId(id: string): Promise { + const user = await createTestUser(); + user.id = id; + return user; +} + +/** + * 创建激活状态的测试用户 + */ +export async function createActiveTestUser(): Promise { + const user = await createTestUser(); + user.activate(); + return user; +} + +/** + * 确保测试用户存在 + * 如果用户不存在,则创建它 + */ +export async function ensureTestUser(db: Kysely): Promise { + const existingUser = await db + .selectFrom('users') + .select('id') + .where('id', '=', TEST_USER_ID) + .executeTakeFirst(); + + if (!existingUser) { + // 创建测试用户 + const passwordHash = await bcrypt.hash(TEST_USER_PASSWORD, 10); + await db + .insertInto('users') + .values({ + id: TEST_USER_ID, + email: TEST_USER_EMAIL, + username: TEST_USER_USERNAME, + password_hash: passwordHash, + full_name: TEST_USER_FULL_NAME, + avatar_url: null, + status: 'active', + email_verified: true, + created_at: new Date(), + updated_at: new Date(), + last_login_at: null, + }) + .execute(); + } +} + diff --git a/backend-nodejs/domains/user/tests/update_user_profile.test.ts b/backend-nodejs/domains/user/tests/update_user_profile.test.ts new file mode 100644 index 0000000..ef9872f --- /dev/null +++ b/backend-nodejs/domains/user/tests/update_user_profile.test.ts @@ -0,0 +1,215 @@ +/** + * UpdateUserProfile Handler 集成测试 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { createDatabaseConnection } from '../../../infrastructure/persistence/postgres/connection.js'; +import { loadConfig } from '../../../infrastructure/config/config.js'; +import { createTestHelper, TEST_USER_ID, ensureTestUser } from './helpers.js'; +import { registerUserRoutes } from '../http/router.js'; +import { createAuthMiddleware } from '../../../infrastructure/middleware/auth.js'; +import { JWTService } from '../../auth/service/jwt_service.js'; + +describe('UpdateUserProfile Handler', () => { + let app: FastifyInstance; + let testHelper: ReturnType; + let accessToken: string; + let dbAvailable = false; + + beforeAll(async () => { + try { + const config = loadConfig(); + const db = createDatabaseConnection(config.database); + + try { + await db.selectFrom('users').select('id').limit(1).execute(); + dbAvailable = true; + } catch { + console.warn('⚠️ 数据库不可用,跳过集成测试'); + return; + } + + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + const { token } = jwtService.generateAccessToken(TEST_USER_ID, 'test@example.com'); + accessToken = token; + + testHelper = createTestHelper(db, jwtService); + app = testHelper.app; + + const authMiddleware = createAuthMiddleware(jwtService); + registerUserRoutes(app, testHelper.handlerDeps, authMiddleware); + + // 确保测试用户存在 + await ensureTestUser(db); + + await app.ready(); + } catch (error) { + console.warn('⚠️ 测试环境初始化失败:', error); + dbAvailable = false; + } + }); + + afterAll(async () => { + if (dbAvailable && app) { + await app.close(); + await testHelper.db.destroy(); + } + }); + + it('应该成功更新用户名', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + username: 'newusername', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.username).toBe('newusername'); + }); + + it('应该成功更新全名', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + full_name: 'New Full Name', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.full_name).toBe('New Full Name'); + }); + + it('应该成功更新头像 URL', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + avatar_url: 'https://example.com/avatar.jpg', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.avatar_url).toBe('https://example.com/avatar.jpg'); + }); + + it('应该拒绝无效用户名(太短)', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + username: 'ab', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_USERNAME'); + }); + + it('应该拒绝无效用户名(太长)', async () => { + if (!dbAvailable) return; + + const longUsername = 'a'.repeat(31); + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + username: longUsername, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_USERNAME'); + }); + + it('应该拒绝全名过长', async () => { + if (!dbAvailable) return; + + const longName = 'a'.repeat(101); + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + full_name: longName, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('FULL_NAME_TOO_LONG'); + }); + + it('应该拒绝无效头像 URL', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + headers: { + authorization: `Bearer ${accessToken}`, + }, + payload: { + avatar_url: 'ftp://example.com/avatar.jpg', + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.error).toContain('INVALID_AVATAR_URL'); + }); + + it('应该拒绝未授权请求', async () => { + if (!dbAvailable) return; + + const response = await app.inject({ + method: 'PUT', + url: '/api/users/me', + payload: { + username: 'newusername', + }, + }); + + expect(response.statusCode).toBe(401); + }); +}); + From 62fe34ac96338a198e034894fe01021522847965 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 19:26:11 +0800 Subject: [PATCH 06/22] feat: enhance E2E testing setup by adding Node.js backend support, including Docker configuration, health checks, and environment variable management for seamless backend switching --- .github/workflows/frontend-e2e.yml | 57 +++++++++--- backend-nodejs/Dockerfile | 68 ++++++++++++++ docker/e2e/README.md | 87 ++++++++++++++--- docker/e2e/docker-compose.yml | 70 +++++++++++++- docker/e2e/start.sh | 62 +++++++++++-- frontend/web/E2E_BACKEND_SWITCH.md | 144 +++++++++++++++++++++++++++++ frontend/web/doc/e2e-testing.md | 14 ++- frontend/web/e2e/README.md | 52 ++++++++++- frontend/web/package.json | 3 + frontend/web/playwright.config.ts | 8 +- 10 files changed, 521 insertions(+), 44 deletions(-) create mode 100644 backend-nodejs/Dockerfile create mode 100644 frontend/web/E2E_BACKEND_SWITCH.md diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index f69e0ff..2286511 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -2,10 +2,11 @@ name: Frontend E2E Tests on: push: - branches: [main, develop, master] + branches: [main, develop, master, feat-node] paths: - 'frontend/web/**' - 'backend/**' + - 'backend-nodejs/**' - 'docker/e2e/**' - '.github/workflows/frontend-e2e.yml' pull_request: @@ -13,6 +14,7 @@ on: paths: - 'frontend/web/**' - 'backend/**' + - 'backend-nodejs/**' - 'docker/e2e/**' # 允许手动触发 workflow_dispatch: @@ -24,9 +26,21 @@ concurrency: jobs: e2e: - name: E2E Tests (Docker) + name: E2E Tests (${{ matrix.backend }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + matrix: + backend: [go, nodejs] + include: + - backend: go + backend_url: http://localhost:8081 + backend_name: Go Backend + container_name: go-genai-stack-backend-e2e + - backend: nodejs + backend_url: http://localhost:8082 + backend_name: Node.js Backend + container_name: go-genai-stack-backend-nodejs-e2e steps: - name: Checkout code @@ -116,6 +130,7 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: 1 run: | echo "🐳 Building and starting E2E environment..." + echo "📋 Testing against: ${{ matrix.backend_name }}" docker compose build --build-arg BUILDKIT_INLINE_CACHE=1 docker compose up -d @@ -123,8 +138,12 @@ jobs: timeout 60s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" go-genai-stack-postgres-e2e)" == "healthy" ]; do echo -n "."; sleep 2; done' echo " ✅" - echo "⏳ Waiting for Backend to be healthy..." - timeout 90s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" go-genai-stack-backend-e2e)" == "healthy" ]; do echo -n "."; sleep 2; done' + echo "⏳ Waiting for Redis to be healthy..." + timeout 30s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" go-genai-stack-redis-e2e)" == "healthy" ]; do echo -n "."; sleep 2; done' + echo " ✅" + + echo "⏳ Waiting for ${{ matrix.backend_name }} to be healthy..." + timeout 90s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }})" == "healthy" ]; do echo -n "."; sleep 2; done' echo " ✅" echo "✅ All services are ready" @@ -132,23 +151,25 @@ jobs: - name: Verify backend health run: | - echo "🔍 Checking backend health..." - curl -f http://localhost:8081/health || (echo "❌ Backend health check failed" && exit 1) - echo "✅ Backend is healthy" + echo "🔍 Checking ${{ matrix.backend_name }} health..." + curl -f ${{ matrix.backend_url }}/health || (echo "❌ ${{ matrix.backend_name }} health check failed" && exit 1) + echo "✅ ${{ matrix.backend_name }} is healthy" - name: Run E2E tests working-directory: frontend/web env: CI: true + E2E_BACKEND_URL: ${{ matrix.backend_url }} run: | - echo "🧪 Running E2E tests..." + echo "🧪 Running E2E tests against ${{ matrix.backend_name }}..." + echo "📡 Backend URL: ${{ matrix.backend_url }}" pnpm e2e:ci - name: Upload Playwright Report if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-${{ matrix.backend }} path: frontend/web/playwright-report/ retention-days: 7 @@ -156,22 +177,23 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.backend }} path: frontend/web/test-results/ retention-days: 7 - name: E2E Test Summary if: always() run: | - echo "## 🎭 E2E Test Results" >> $GITHUB_STEP_SUMMARY + echo "## 🎭 E2E Test Results (${{ matrix.backend_name }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ -f frontend/web/test-results/results.json ]; then - echo "✅ E2E tests completed" >> $GITHUB_STEP_SUMMARY + echo "✅ E2E tests completed for ${{ matrix.backend_name }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "📊 Backend: ${{ matrix.backend_url }}" >> $GITHUB_STEP_SUMMARY echo "📊 [View Playwright Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY else - echo "⚠️ No test results found" >> $GITHUB_STEP_SUMMARY + echo "⚠️ No test results found for ${{ matrix.backend_name }}" >> $GITHUB_STEP_SUMMARY fi - name: Show Docker logs on failure @@ -183,13 +205,18 @@ jobs: echo "" echo "🔍 Health Status:" docker inspect --format='{{.Name}}: {{.State.Health.Status}}' go-genai-stack-postgres-e2e || echo "postgres-e2e: not found" + docker inspect --format='{{.Name}}: {{.State.Health.Status}}' go-genai-stack-redis-e2e || echo "redis-e2e: not found" docker inspect --format='{{.Name}}: {{.State.Health.Status}}' go-genai-stack-backend-e2e || echo "backend-e2e: not found" + docker inspect --format='{{.Name}}: {{.State.Health.Status}}' go-genai-stack-backend-nodejs-e2e || echo "backend-nodejs-e2e: not found" echo "" echo "📋 Postgres Logs:" docker compose logs --tail=50 postgres-e2e echo "" - echo "📋 Backend Logs:" - docker compose logs --tail=100 backend-e2e + echo "📋 Redis Logs:" + docker compose logs --tail=50 redis-e2e + echo "" + echo "📋 ${{ matrix.backend_name }} Logs:" + docker compose logs --tail=100 ${{ matrix.container_name }} - name: Stop E2E environment if: always() diff --git a/backend-nodejs/Dockerfile b/backend-nodejs/Dockerfile new file mode 100644 index 0000000..0ef901a --- /dev/null +++ b/backend-nodejs/Dockerfile @@ -0,0 +1,68 @@ +# ============================================ +# Go-GenAI-Stack Node.js 后端 Dockerfile +# ============================================ +# 多阶段构建,确保镜像体积小且安全 +# +# 构建方式: +# docker build -t go-genai-stack-nodejs:latest -f backend-nodejs/Dockerfile . +# +# 运行方式: +# docker run -p 8080:8080 --env-file .env go-genai-stack-nodejs:latest +# ============================================ + +# ============================================ +# Stage 1: Builder - 安装依赖并编译 TypeScript +# ============================================ +FROM node:22-alpine AS builder + +# 安装 pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# 设置工作目录 +WORKDIR /build + +# 复制 package.json 和 pnpm-lock.yaml(利用 Docker 缓存) +COPY backend-nodejs/package.json backend-nodejs/pnpm-lock.yaml* ./ + +# 安装依赖(这层会被缓存,除非 package.json 改变) +RUN pnpm install --frozen-lockfile + +# 复制源代码 +COPY backend-nodejs/ . + +# 编译 TypeScript +RUN pnpm build + +# ============================================ +# Stage 2: Runtime - 最小化运行时镜像 +# ============================================ +FROM node:22-alpine + +# 安装 pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# 创建非 root 用户 +RUN addgroup -g 1000 nodejs && \ + adduser -D -u 1000 -G nodejs nodejs + +# 设置工作目录 +WORKDIR /app + +# 从 builder 复制依赖和编译后的代码 +COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules +COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist +COPY --from=builder --chown=nodejs:nodejs /build/package.json ./ + +# 切换到非 root 用户 +USER nodejs + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# 启动应用 +CMD ["node", "dist/cmd/server/main.js"] + diff --git a/docker/e2e/README.md b/docker/e2e/README.md index 5e6d362..ec3c44a 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -53,7 +53,7 @@ pnpm e2e:ui # UI 模式(推荐) ### docker-compose.yml -包含两个服务: +包含四个服务: #### 1. postgres-e2e(测试数据库) @@ -66,14 +66,31 @@ pnpm e2e:ui # UI 模式(推荐) 1. 自动加载 Schema (`backend/database/schema.sql`) 2. 自动加载测试数据 (`seed-data.sql`) -#### 2. backend-e2e(后端服务) +#### 2. redis-e2e(Redis 缓存) + +- **镜像**: redis:7-alpine +- **端口**: 6381(避免与开发环境冲突) +- **数据卷**: redis-e2e-data +- **用途**: Node.js 后端需要 Redis 支持 + +#### 3. backend-e2e(Go 后端服务) - **构建**: backend/Dockerfile - **端口**: 8081(映射到容器的 8080) - **数据库**: postgres-e2e:5432 - **JWT Secret**: e2e-test-secret-key-for-testing-only - **环境**: test -- **健康检查**: /ping 端点 +- **健康检查**: /health 端点 + +#### 4. backend-nodejs-e2e(Node.js 后端服务) + +- **构建**: backend-nodejs/Dockerfile +- **端口**: 8082(映射到容器的 8080) +- **数据库**: postgres-e2e:5432 +- **Redis**: redis-e2e:6379 +- **JWT Secret**: e2e-test-secret-key-for-testing-only +- **环境**: test +- **健康检查**: /health 端点 --- @@ -129,11 +146,13 @@ E2E 环境使用两阶段初始化: 📋 Service Information: ┌─────────────────────────────────────────────┐ - │ Service │ URL / Connection │ - ├───────────┼─────────────────────────────────┤ - │ Postgres │ localhost:5433 │ - │ Backend │ http://localhost:8081 │ - │ Frontend │ http://localhost:5173 (Host) │ + │ Service │ URL / Connection │ + ├─────────────────┼───────────────────────────┤ + │ Postgres │ localhost:5433 │ + │ Redis │ localhost:6381 │ + │ Go Backend │ http://localhost:8081 │ + │ Node.js Backend │ http://localhost:8082 │ + │ Frontend │ http://localhost:5173 │ └─────────────────────────────────────────────┘ 👤 Test User Credentials: @@ -160,7 +179,9 @@ E2E 环境使用两阶段初始化: | 服务 | 容器端口 | 主机端口 | 说明 | |------|---------|---------|------| | Postgres | 5432 | 5433 | 避免与开发环境冲突 | -| Backend | 8080 | 8081 | 避免与开发环境冲突 | +| Redis | 6379 | 6381 | 避免与开发环境冲突 | +| Go Backend | 8080 | 8081 | 避免与开发环境冲突 | +| Node.js Backend | 8080 | 8082 | 避免与开发环境冲突 | | Frontend | - | 5173 | 在 Host 运行 | ### 网络 @@ -181,7 +202,9 @@ cd docker/e2e && docker compose logs -f # 查看特定服务日志 cd docker/e2e && docker compose logs -f postgres-e2e +cd docker/e2e && docker compose logs -f redis-e2e cd docker/e2e && docker compose logs -f backend-e2e +cd docker/e2e && docker compose logs -f backend-nodejs-e2e ``` ### 检查服务状态 @@ -196,15 +219,20 @@ cd docker/e2e && docker compose ps # 进入 Postgres 容器 docker exec -it go-genai-stack-postgres-e2e psql -U postgres -d go_genai_stack_e2e -# 进入 Backend 容器 +# 进入 Go Backend 容器 docker exec -it go-genai-stack-backend-e2e sh + +# 进入 Node.js Backend 容器 +docker exec -it go-genai-stack-backend-nodejs-e2e sh ``` ### 手动测试后端 +#### Go Backend (端口 8081) + ```bash # 健康检查 -curl http://localhost:8081/ping +curl http://localhost:8081/health # 登录测试 curl -X POST http://localhost:8081/api/v1/auth/login \ @@ -212,6 +240,18 @@ curl -X POST http://localhost:8081/api/v1/auth/login \ -d '{"email":"e2e-test@example.com","password":"Test123456!"}' ``` +#### Node.js Backend (端口 8082) + +```bash +# 健康检查 +curl http://localhost:8082/health + +# 登录测试 +curl -X POST http://localhost:8082/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"e2e-test@example.com","password":"Test123456!"}' +``` + ### 常见问题 #### 问题 1:端口已被占用 @@ -222,7 +262,9 @@ curl -X POST http://localhost:8081/api/v1/auth/login \ ```bash # 检查端口占用 lsof -i :5433 +lsof -i :6381 lsof -i :8081 +lsof -i :8082 # 停止占用端口的服务或修改 docker/e2e/docker-compose.yml 中的端口映射 ``` @@ -285,7 +327,11 @@ docker image prune -f 编辑 `docker/e2e/docker-compose.yml` 中的环境变量,然后重启: ```bash +# 重启 Go Backend cd docker/e2e && docker compose restart backend-e2e + +# 重启 Node.js Backend +cd docker/e2e && docker compose restart backend-nodejs-e2e ``` ### 使用自定义环境变量 @@ -293,8 +339,12 @@ cd docker/e2e && docker compose restart backend-e2e 创建 `.env.e2e` 文件(在项目根目录): ```bash +# Go Backend DATABASE_URL=postgres://postgres:postgres@localhost:5433/go_genai_stack_e2e?sslmode=disable BACKEND_URL=http://localhost:8081 + +# Node.js Backend +NODEJS_BACKEND_URL=http://localhost:8082 ``` --- @@ -307,6 +357,19 @@ BACKEND_URL=http://localhost:8081 --- +--- + +## 🎯 选择后端 + +E2E 环境同时提供 Go 和 Node.js 两个后端实现: + +- **Go Backend**: `http://localhost:8081` - 使用 `backend/Dockerfile` 构建 +- **Node.js Backend**: `http://localhost:8082` - 使用 `backend-nodejs/Dockerfile` 构建 + +前端 E2E 测试可以根据需要选择使用哪个后端。两个后端共享同一个数据库和测试数据。 + +--- + **维护者**: AI Assistant -**最后更新**: 2025-11-27 +**最后更新**: 2025-01-XX diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 7a5ea5f..887d3bc 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -32,7 +32,26 @@ services: networks: - e2e-network - # 后端服务器 + # Redis 测试缓存(Node.js 后端需要) + redis-e2e: + image: redis:7-alpine + container_name: go-genai-stack-redis-e2e + ports: + - "6381:6379" # 使用不同端口避免与开发环境冲突 + volumes: + # 数据持久化(可选,用于调试) + - redis-e2e-data:/data + command: redis-server --appendonly yes --protected-mode no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 10 + start_period: 5s + networks: + - e2e-network + + # Go 后端服务器 backend-e2e: build: context: ../.. @@ -72,10 +91,59 @@ services: start_period: 15s restart: unless-stopped + # Node.js 后端服务器 + backend-nodejs-e2e: + build: + context: ../.. + dockerfile: backend-nodejs/Dockerfile + container_name: go-genai-stack-backend-nodejs-e2e + environment: + # 数据库配置 + DATABASE_HOST: postgres-e2e + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: postgres + DATABASE_NAME: go_genai_stack_e2e + DATABASE_SSL_MODE: disable + + # Redis 配置 + REDIS_HOST: redis-e2e + REDIS_PORT: 6379 + REDIS_PASSWORD: "" + + # JWT 配置 + JWT_SECRET: e2e-test-secret-key-for-testing-only + JWT_ACCESS_TOKEN_EXPIRY: 15m + JWT_REFRESH_TOKEN_EXPIRY: 7d + JWT_ISSUER: go-genai-stack-e2e + + # 服务器配置 + SERVER_PORT: 8080 + SERVER_HOST: 0.0.0.0 + ports: + - "8082:8080" # 映射到 8082 避免与开发环境冲突 + depends_on: + postgres-e2e: + condition: service_healthy + redis-e2e: + condition: service_healthy + networks: + - e2e-network + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + volumes: postgres-e2e-data: driver: local name: go-genai-stack-postgres-e2e-data + redis-e2e-data: + driver: local + name: go-genai-stack-redis-e2e-data networks: e2e-network: diff --git a/docker/e2e/start.sh b/docker/e2e/start.sh index 3a1b8d1..1285729 100755 --- a/docker/e2e/start.sh +++ b/docker/e2e/start.sh @@ -74,8 +74,30 @@ if [ $ELAPSED -ge $TIMEOUT ]; then exit 1 fi -# 等待 Backend -echo -n " - Backend: " +# 等待 Redis +echo -n " - Redis: " +TIMEOUT=30 +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' go-genai-stack-redis-e2e 2>/dev/null || echo "starting") + if [ "$HEALTH_STATUS" = "healthy" ]; then + echo -e "${GREEN}✓ Ready${NC}" + break + fi + echo -n "." + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +if [ $ELAPSED -ge $TIMEOUT ]; then + echo -e "${RED}✗ Timeout${NC}" + echo "Redis failed to start. Check logs:" + $DOCKER_COMPOSE logs redis-e2e + exit 1 +fi + +# 等待 Go Backend +echo -n " - Go Backend: " TIMEOUT=90 ELAPSED=0 while [ $ELAPSED -lt $TIMEOUT ]; do @@ -91,21 +113,45 @@ done if [ $ELAPSED -ge $TIMEOUT ]; then echo -e "${RED}✗ Timeout${NC}" - echo "Backend failed to start. Check logs:" + echo "Go Backend failed to start. Check logs:" $DOCKER_COMPOSE logs backend-e2e exit 1 fi +# 等待 Node.js Backend +echo -n " - Node.js Backend: " +TIMEOUT=90 +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' go-genai-stack-backend-nodejs-e2e 2>/dev/null || echo "starting") + if [ "$HEALTH_STATUS" = "healthy" ]; then + echo -e "${GREEN}✓ Ready${NC}" + break + fi + echo -n "." + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +if [ $ELAPSED -ge $TIMEOUT ]; then + echo -e "${RED}✗ Timeout${NC}" + echo "Node.js Backend failed to start. Check logs:" + $DOCKER_COMPOSE logs backend-nodejs-e2e + exit 1 +fi + echo "" echo -e "${GREEN}✅ E2E Test Environment is Ready!${NC}" echo "" echo -e "${BLUE}📋 Service Information:${NC}" echo " ┌─────────────────────────────────────────────┐" -echo " │ Service │ URL / Connection │" -echo " ├───────────┼─────────────────────────────────┤" -echo " │ Postgres │ localhost:5433 │" -echo " │ Backend │ http://localhost:8081 │" -echo " │ Frontend │ http://localhost:5173 (Host) │" +echo " │ Service │ URL / Connection │" +echo " ├─────────────────┼───────────────────────────┤" +echo " │ Postgres │ localhost:5433 │" +echo " │ Redis │ localhost:6381 │" +echo " │ Go Backend │ http://localhost:8081 │" +echo " │ Node.js Backend │ http://localhost:8082 │" +echo " │ Frontend │ http://localhost:5173 │" echo " └─────────────────────────────────────────────┘" echo "" echo -e "${BLUE}👤 Test User Credentials:${NC}" diff --git a/frontend/web/E2E_BACKEND_SWITCH.md b/frontend/web/E2E_BACKEND_SWITCH.md new file mode 100644 index 0000000..f765f32 --- /dev/null +++ b/frontend/web/E2E_BACKEND_SWITCH.md @@ -0,0 +1,144 @@ +# E2E 测试后端切换指南 + +本文档说明如何在 E2E 测试中切换后端实现(Go 或 Node.js)。 + +--- + +## 🎯 快速开始 + +### 方式 1:使用 npm 脚本(推荐)⭐ + +```bash +cd frontend/web + +# 测试 Go 后端(默认) +pnpm e2e # 命令行模式 +pnpm e2e:ui # UI 模式(推荐) + +# 测试 Node.js 后端 +pnpm e2e:nodejs # 命令行模式 +pnpm e2e:nodejs:ui # UI 模式(推荐) + +# 一键运行(包含环境启动和清理) +pnpm e2e:all # Go 后端 +pnpm e2e:nodejs:all # Node.js 后端 +``` + +### 方式 2:使用环境变量 + +```bash +# 测试 Go 后端(默认) +E2E_BACKEND_URL=http://localhost:8081 pnpm e2e + +# 测试 Node.js 后端 +E2E_BACKEND_URL=http://localhost:8082 pnpm e2e +``` + +--- + +## 📋 后端端口映射 + +| 后端实现 | 端口 | 说明 | +|---------|------|------| +| Go Backend | 8081 | 默认后端 | +| Node.js Backend | 8082 | Node.js 实现 | + +--- + +## 🔧 配置说明 + +### playwright.config.ts + +Playwright 配置已更新,支持通过 `E2E_BACKEND_URL` 环境变量切换后端: + +```typescript +webServer: { + command: 'pnpm dev', + port: 5173, + env: { + // 支持通过 E2E_BACKEND_URL 环境变量切换后端 + VITE_API_BASE_URL: process.env.E2E_BACKEND_URL || 'http://localhost:8081', + }, +} +``` + +### package.json 脚本 + +新增的脚本: + +- `e2e:nodejs` - 测试 Node.js 后端(命令行模式) +- `e2e:nodejs:ui` - 测试 Node.js 后端(UI 模式) +- `e2e:nodejs:all` - 一键运行(启动环境 → 测试 → 清理) + +--- + +## 🚀 完整流程示例 + +### 测试 Go 后端 + +```bash +# 1. 启动 E2E 环境(包含 Go 和 Node.js 两个后端) +cd frontend/web +pnpm e2e:setup + +# 2. 运行测试(Go 后端) +pnpm e2e:ui + +# 3. 停止环境 +pnpm e2e:teardown +``` + +### 测试 Node.js 后端 + +```bash +# 1. 启动 E2E 环境(包含 Go 和 Node.js 两个后端) +cd frontend/web +pnpm e2e:setup + +# 2. 运行测试(Node.js 后端) +pnpm e2e:nodejs:ui + +# 3. 停止环境 +pnpm e2e:teardown +``` + +--- + +## 📝 注意事项 + +1. **环境启动**:`pnpm e2e:setup` 会同时启动 Go 和 Node.js 两个后端,但测试时只会使用其中一个。 + +2. **端口冲突**:确保端口 8081 和 8082 没有被其他服务占用。 + +3. **测试数据**:两个后端共享同一个数据库(Postgres),使用相同的测试数据。 + +4. **Redis 依赖**:Node.js 后端需要 Redis,但 Go 后端不需要。E2E 环境已包含 Redis 服务。 + +--- + +## 🔍 验证后端切换 + +### 检查 Go 后端 + +```bash +curl http://localhost:8081/health +``` + +### 检查 Node.js 后端 + +```bash +curl http://localhost:8082/health +``` + +--- + +## 📚 相关文档 + +- [E2E 测试文档](./e2e/README.md) +- [E2E 测试指南](./doc/e2e-testing.md) +- [Docker E2E 环境](../../docker/e2e/README.md) + +--- + +**最后更新**: 2025-01-XX + diff --git a/frontend/web/doc/e2e-testing.md b/frontend/web/doc/e2e-testing.md index 5f0d4fa..0e2c976 100644 --- a/frontend/web/doc/e2e-testing.md +++ b/frontend/web/doc/e2e-testing.md @@ -31,22 +31,30 @@ ```bash cd frontend/web -# 启动环境 → 运行测试 → 清理环境 +# 测试 Go 后端(默认) pnpm e2e:all + +# 测试 Node.js 后端 +pnpm e2e:nodejs:all ``` ### 分步运行 ```bash -# 1. 启动 E2E 环境(Docker) +# 1. 启动 E2E 环境(Docker,包含 Go 和 Node.js 两个后端) pnpm e2e:setup -# 2. 运行测试 +# 2. 运行测试(选择后端) +# Go 后端(默认) pnpm e2e # 命令行模式 pnpm e2e:ui # UI 模式(推荐)⭐ pnpm e2e:headed # 有头模式(显示浏览器) pnpm e2e:debug # 调试模式 +# Node.js 后端 +pnpm e2e:nodejs # 命令行模式 +pnpm e2e:nodejs:ui # UI 模式(推荐)⭐ + # 3. 停止环境 pnpm e2e:teardown # 保留数据 pnpm e2e:clean # 完全清理 diff --git a/frontend/web/e2e/README.md b/frontend/web/e2e/README.md index 1146005..a29f068 100644 --- a/frontend/web/e2e/README.md +++ b/frontend/web/e2e/README.md @@ -35,17 +35,24 @@ pnpm e2e:setup 这会启动: - ✅ Postgres 数据库(localhost:5433) -- ✅ 后端服务器(http://localhost:8081) +- ✅ Redis 缓存(localhost:6381) +- ✅ Go 后端服务器(http://localhost:8081) +- ✅ Node.js 后端服务器(http://localhost:8082) - ✅ 预置测试用户和数据 #### 3. 运行 E2E 测试 ```bash # 在 frontend/web 目录 +# 测试 Go 后端(默认) pnpm e2e # 运行所有测试 pnpm e2e:ui # UI 模式(推荐,可视化调试)⭐ pnpm e2e:headed # 有头模式(显示浏览器) pnpm e2e:debug # 调试模式 + +# 测试 Node.js 后端 +pnpm e2e:nodejs # 运行所有测试 +pnpm e2e:nodejs:ui # UI 模式(推荐)⭐ ``` #### 4. 停止 E2E 环境 @@ -120,10 +127,49 @@ e2e/ ### 环境变量 -创建 `.env.e2e` 文件: +#### 选择后端实现 + +E2E 测试支持两种后端实现: + +1. **Go 后端**(默认):`http://localhost:8081` +2. **Node.js 后端**:`http://localhost:8082` + +#### 方式 1:使用 npm 脚本(推荐) ```bash -VITE_API_BASE_URL=http://localhost:8080 +# 测试 Go 后端(默认) +pnpm e2e # 命令行模式 +pnpm e2e:ui # UI 模式 + +# 测试 Node.js 后端 +pnpm e2e:nodejs # 命令行模式 +pnpm e2e:nodejs:ui # UI 模式 + +# 一键运行(包含环境启动和清理) +pnpm e2e:all # Go 后端 +pnpm e2e:nodejs:all # Node.js 后端 +``` + +#### 方式 2:使用环境变量 + +```bash +# 测试 Go 后端(默认) +E2E_BACKEND_URL=http://localhost:8081 pnpm e2e + +# 测试 Node.js 后端 +E2E_BACKEND_URL=http://localhost:8082 pnpm e2e +``` + +#### 方式 3:创建 `.env.e2e` 文件(可选) + +在 `frontend/web/` 目录下创建 `.env.e2e` 文件: + +```bash +# 选择后端(二选一) +E2E_BACKEND_URL=http://localhost:8081 # Go 后端 +# E2E_BACKEND_URL=http://localhost:8082 # Node.js 后端 + +# 前端地址(通常不需要修改) E2E_BASE_URL=http://localhost:5173 ``` diff --git a/frontend/web/package.json b/frontend/web/package.json index 8b14dec..f9030c5 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -30,6 +30,9 @@ "e2e:teardown": "../../docker/e2e/stop.sh", "e2e:clean": "../../docker/e2e/stop.sh --clean", "e2e:all": "pnpm e2e:setup && pnpm e2e && pnpm e2e:teardown", + "e2e:nodejs": "E2E_BACKEND_URL=http://localhost:8082 playwright test", + "e2e:nodejs:ui": "E2E_BACKEND_URL=http://localhost:8082 playwright test --ui", + "e2e:nodejs:all": "pnpm e2e:setup && pnpm e2e:nodejs && pnpm e2e:teardown", "playwright:install": "playwright install --with-deps", "debug:setup": "../../docker/frontend-debug/start.sh", "debug:teardown": "../../docker/frontend-debug/stop.sh", diff --git a/frontend/web/playwright.config.ts b/frontend/web/playwright.config.ts index 51efedc..2a5701a 100644 --- a/frontend/web/playwright.config.ts +++ b/frontend/web/playwright.config.ts @@ -72,7 +72,10 @@ export default defineConfig({ ], // 开发服务器配置 - // 注意:后端服务由 Docker 提供(http://localhost:8081) + // 注意:后端服务由 Docker 提供 + // 可以通过 E2E_BACKEND_URL 环境变量选择后端: + // - 默认: http://localhost:8081 (Go 后端) + // - Node.js: http://localhost:8082 (Node.js 后端) webServer: { command: 'pnpm dev', port: 5173, @@ -80,7 +83,8 @@ export default defineConfig({ timeout: 120000, env: { // 指向 Docker 中的后端服务 - VITE_API_BASE_URL: 'http://localhost:8081', + // 支持通过 E2E_BACKEND_URL 环境变量切换后端 + VITE_API_BASE_URL: process.env.E2E_BACKEND_URL || 'http://localhost:8081', }, }, }) From 544c8e71b23971bf8cf48da1634ab15102619dc5 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 19:44:55 +0800 Subject: [PATCH 07/22] fix: update Dockerfile to use Node.js built-in user for improved compatibility and security, ensuring proper ownership of copied files --- backend-nodejs/Dockerfile | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend-nodejs/Dockerfile b/backend-nodejs/Dockerfile index 0ef901a..bcc91cb 100644 --- a/backend-nodejs/Dockerfile +++ b/backend-nodejs/Dockerfile @@ -41,20 +41,23 @@ FROM node:22-alpine # 安装 pnpm RUN corepack enable && corepack prepare pnpm@latest --activate -# 创建非 root 用户 -RUN addgroup -g 1000 nodejs && \ - adduser -D -u 1000 -G nodejs nodejs +# 使用 Node.js 镜像自带的 node 用户(如果不存在则创建) +# Node.js 官方镜像通常已经包含 node 用户,但为了兼容性,我们检查并创建 +RUN if ! id -u node > /dev/null 2>&1; then \ + addgroup -S node && \ + adduser -S node -G node; \ + fi # 设置工作目录 WORKDIR /app # 从 builder 复制依赖和编译后的代码 -COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules -COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist -COPY --from=builder --chown=nodejs:nodejs /build/package.json ./ +COPY --from=builder --chown=node:node /build/node_modules ./node_modules +COPY --from=builder --chown=node:node /build/dist ./dist +COPY --from=builder --chown=node:node /build/package.json ./ # 切换到非 root 用户 -USER nodejs +USER node # 暴露端口 EXPOSE 8080 From 89d8e59fb976c5302305ec421aad8534b782d1f4 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 20:06:00 +0800 Subject: [PATCH 08/22] fix: update server port configuration to prioritize SERVER_PORT environment variable, ensuring consistent behavior across different environments --- .../domains/auth/service/jwt_service.ts | 18 ++++++++++-------- backend-nodejs/infrastructure/config/config.ts | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/backend-nodejs/domains/auth/service/jwt_service.ts b/backend-nodejs/domains/auth/service/jwt_service.ts index 992eda2..549daad 100644 --- a/backend-nodejs/domains/auth/service/jwt_service.ts +++ b/backend-nodejs/domains/auth/service/jwt_service.ts @@ -3,7 +3,7 @@ * 负责 JWT Token 的生成和验证 */ -import * as jwt from 'jsonwebtoken'; +import jwt, { type JwtPayload } from 'jsonwebtoken'; export type TokenType = 'access' | 'refresh'; @@ -91,7 +91,7 @@ export class JWTService { try { const decoded = jwt.verify(tokenString, this.config.secret, { algorithms: ['HS256'], - }) as jwt.JwtPayload; + }) as JwtPayload; // 验证 Issuer if (decoded.iss !== this.config.issuer) { @@ -109,12 +109,14 @@ export class JWTService { }; return payload; - } catch (error: any) { - if (error.name === 'TokenExpiredError') { - throw new Error('INVALID_TOKEN: Token 已过期'); - } - if (error.name === 'JsonWebTokenError') { - throw new Error('INVALID_TOKEN: Token 验证失败'); + } catch (error) { + if (error instanceof Error) { + if (error.name === 'TokenExpiredError') { + throw new Error('INVALID_TOKEN: Token 已过期'); + } + if (error.name === 'JsonWebTokenError') { + throw new Error('INVALID_TOKEN: Token 验证失败'); + } } throw error; } diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts index 407c241..01c7d56 100644 --- a/backend-nodejs/infrastructure/config/config.ts +++ b/backend-nodejs/infrastructure/config/config.ts @@ -42,7 +42,7 @@ export function loadConfig(): Config { return { server: { host: process.env.SERVER_HOST || '0.0.0.0', - port: parseInt(process.env.PORT || '8081', 10), + port: parseInt(process.env.SERVER_PORT || process.env.PORT || '8080', 10), env: process.env.NODE_ENV || 'development', }, database: { From ea8b6ab05e451943d0ed9e6f534eb3929c0a4abd Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 22:08:38 +0800 Subject: [PATCH 09/22] feat: add comprehensive optimization documentation for backend-nodejs, detailing additional, further, and completed optimizations to enhance code quality and maintainability --- backend-nodejs/cmd/server/main.ts | 54 +---- backend-nodejs/domains/auth/errors/errors.ts | 42 ---- .../domains/auth/handlers/login.handler.ts | 35 +-- .../auth/handlers/refresh_token.handler.ts | 38 +--- .../domains/auth/handlers/register.handler.ts | 39 ++-- backend-nodejs/domains/auth/http/dto/auth.ts | 34 +-- backend-nodejs/domains/auth/http/router.ts | 47 +++- .../domains/auth/service/auth_service.test.ts | 13 +- .../domains/auth/service/auth_service.ts | 20 +- .../domains/auth/service/jwt_service.ts | 11 +- backend-nodejs/domains/task/errors/errors.ts | 69 ------ .../task/handlers/complete_task.handler.ts | 40 +--- .../domains/task/handlers/converters.ts | 5 +- .../task/handlers/create_task.handler.ts | 59 ++--- .../task/handlers/delete_task.handler.ts | 37 +--- .../domains/task/handlers/get_task.handler.ts | 37 +--- .../task/handlers/list_tasks.handler.ts | 48 ++-- .../task/handlers/update_task.handler.ts | 42 +--- backend-nodejs/domains/task/http/dto/task.ts | 60 ++--- backend-nodejs/domains/task/http/router.ts | 84 ++++++- backend-nodejs/domains/task/model/task.ts | 31 +-- .../domains/task/repository/interface.ts | 13 +- .../domains/task/repository/task_repo.ts | 207 +++++++++++------- .../domains/task/service/task_service.ts | 46 ++-- backend-nodejs/domains/user/errors/errors.ts | 48 ---- .../user/handlers/change_password.handler.ts | 38 +--- .../user/handlers/get_user_profile.handler.ts | 43 ++-- .../handlers/update_user_profile.handler.ts | 38 +--- backend-nodejs/domains/user/http/dto/user.ts | 24 +- backend-nodejs/domains/user/http/router.ts | 22 +- backend-nodejs/domains/user/model/user.ts | 15 +- .../domains/user/repository/interface.ts | 17 +- .../domains/user/repository/user_repo.ts | 43 ++-- .../domains/user/service/user_service.ts | 18 +- .../infrastructure/bootstrap/dependencies.ts | 130 +++++++++++ .../infrastructure/bootstrap/server.ts | 32 ++- .../infrastructure/config/config.ts | 154 +++++++------ .../infrastructure/middleware/types.ts | 14 ++ .../persistence/postgres/transaction.ts | 70 ++++++ .../infrastructure/testing/test_setup.ts | 157 +++++++++++++ backend-nodejs/shared/errors/errors.ts | 171 +++++++++++++++ backend-nodejs/shared/types/context.ts | 60 +++++ backend-nodejs/vitest.setup.ts | 3 + 43 files changed, 1327 insertions(+), 881 deletions(-) delete mode 100644 backend-nodejs/domains/auth/errors/errors.ts delete mode 100644 backend-nodejs/domains/task/errors/errors.ts delete mode 100644 backend-nodejs/domains/user/errors/errors.ts create mode 100644 backend-nodejs/infrastructure/bootstrap/dependencies.ts create mode 100644 backend-nodejs/infrastructure/middleware/types.ts create mode 100644 backend-nodejs/infrastructure/persistence/postgres/transaction.ts create mode 100644 backend-nodejs/infrastructure/testing/test_setup.ts create mode 100644 backend-nodejs/shared/errors/errors.ts create mode 100644 backend-nodejs/shared/types/context.ts diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index 847fa6d..aa6adbe 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -18,17 +18,8 @@ import { registerRoutes, registerDomainRoutes, } from '../../infrastructure/bootstrap/server.js'; +import { initDependencies } from '../../infrastructure/bootstrap/dependencies.js'; import type { RedisClientType } from 'redis'; -import { TaskRepositoryImpl } from '../../domains/task/repository/task_repo.js'; -import { TaskService } from '../../domains/task/service/task_service.js'; -import type { HandlerDependencies as TaskHandlerDependencies } from '../../domains/task/handlers/dependencies.js'; -import { UserRepositoryImpl } from '../../domains/user/repository/user_repo.js'; -import { UserService } from '../../domains/user/service/user_service.js'; -import type { HandlerDependencies as UserHandlerDependencies } from '../../domains/user/handlers/dependencies.js'; -import { JWTService } from '../../domains/auth/service/jwt_service.js'; -import { AuthService } from '../../domains/auth/service/auth_service.js'; -import type { HandlerDependencies as AuthHandlerDependencies } from '../../domains/auth/handlers/dependencies.js'; -import { createAuthMiddleware } from '../../infrastructure/middleware/auth.js'; async function main() { console.log('\n🚀 Starting Go-GenAI-Stack Backend (Node.js)...\n'); @@ -89,45 +80,18 @@ async function main() { console.log('🛣️ Registering routes...'); registerRoutes(fastify, db, redis); - // 7. 初始化领域服务 - console.log('🏗️ Initializing domain services...'); - - // Task 领域 - const taskRepo = new TaskRepositoryImpl(db); - const taskService = new TaskService(taskRepo); - const taskHandlerDeps: TaskHandlerDependencies = { - taskService, - }; - - // User 领域 - const userRepo = new UserRepositoryImpl(db); - const userService = new UserService(userRepo); - const userHandlerDeps: UserHandlerDependencies = { - userService, - }; - - // Auth 领域 - const jwtService = new JWTService({ - secret: config.jwt.secret, - accessTokenExpiry: config.jwt.accessTokenExpiry, - refreshTokenExpiry: config.jwt.refreshTokenExpiry, - issuer: config.jwt.issuer, - }); - const authService = new AuthService(userRepo, jwtService); - const authHandlerDeps: AuthHandlerDependencies = { - authService, - }; - - // 创建认证中间件 - const authMiddleware = createAuthMiddleware(jwtService); + // 7. 初始化依赖注入容器 + console.log('🏗️ Initializing dependency injection container...'); + const container = initDependencies(config, db, redis); + console.log('✅ Dependencies initialized'); // 8. 注册领域路由 console.log('📚 Registering domain routes...'); await registerDomainRoutes(fastify, { - task: taskHandlerDeps, - user: userHandlerDeps, - auth: authHandlerDeps, - }, authMiddleware); + task: container.taskHandlerDeps, + user: container.userHandlerDeps, + auth: container.authHandlerDeps, + }, container.authMiddleware); // 9. 启动服务器 const address = `http://${config.server.host}:${config.server.port}`; diff --git a/backend-nodejs/domains/auth/errors/errors.ts b/backend-nodejs/domains/auth/errors/errors.ts deleted file mode 100644 index 80e2731..0000000 --- a/backend-nodejs/domains/auth/errors/errors.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Auth 领域错误定义 - * 遵循 "ERROR_CODE: message" 格式 - */ - -export class AuthError extends Error { - constructor( - public code: string, - message: string - ) { - super(message); - this.name = 'AuthError'; - } -} - -// 错误定义 -export const AuthErrors = { - INVALID_CREDENTIALS: new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误'), - INVALID_TOKEN: new AuthError('INVALID_TOKEN', 'Token 无效或已过期'), - INVALID_REFRESH_TOKEN: new AuthError('INVALID_REFRESH_TOKEN', 'Refresh Token 无效或已过期'), - INVALID_TOKEN_TYPE: new AuthError('INVALID_TOKEN_TYPE', 'Token 类型无效'), - INVALID_SIGNING_METHOD: new AuthError('INVALID_SIGNING_METHOD', '签名算法无效'), - INVALID_ISSUER: new AuthError('INVALID_ISSUER', 'Issuer 不匹配'), -}; - -/** - * 解析错误码 - */ -export function parseErrorCode(error: unknown): string { - if (error instanceof AuthError) { - return error.code; - } - if (error instanceof Error) { - // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) - const match = error.message.match(/^([A-Z_]+):/); - if (match) { - return match[1]; - } - } - return 'INTERNAL_ERROR'; -} - diff --git a/backend-nodejs/domains/auth/handlers/login.handler.ts b/backend-nodejs/domains/auth/handlers/login.handler.ts index 94852ba..b49a9a9 100644 --- a/backend-nodejs/domains/auth/handlers/login.handler.ts +++ b/backend-nodejs/domains/auth/handlers/login.handler.ts @@ -9,37 +9,22 @@ import { toLoginInput, toLoginResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * Login Handler + * HTTP 适配层:处理用户登录的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function loginHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: LoginRequest }>, reply: FastifyReply ): Promise { - try { - const body = req.body; - - const input = toLoginInput(body); - const output = await deps.authService.login(req, input); - - reply.code(200).send(toLoginResponse(output)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '登录失败', - }); - } -} + const input = toLoginInput(req.body); + const ctx = createContextFromRequest({ ...req }); + const output = await deps.authService.login(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'INVALID_CREDENTIALS') { - return 401; - } - if (errorCode === 'USER_BANNED' || errorCode === 'USER_INACTIVE') { - return 403; - } - return 500; + reply.code(200).send(toLoginResponse(output)); } diff --git a/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts b/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts index 23c7aaa..33e4aff 100644 --- a/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts +++ b/backend-nodejs/domains/auth/handlers/refresh_token.handler.ts @@ -9,40 +9,22 @@ import { toRefreshTokenInput, toRefreshTokenResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * RefreshToken Handler + * HTTP 适配层:处理刷新 Token 的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function refreshTokenHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: RefreshTokenRequest }>, reply: FastifyReply ): Promise { - try { - const body = req.body; - - const input = toRefreshTokenInput(body); - const output = await deps.authService.refreshToken(req, input); - - reply.code(200).send(toRefreshTokenResponse(output)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '刷新 Token 失败', - }); - } -} + const input = toRefreshTokenInput(req.body); + const ctx = createContextFromRequest({ ...req }); + const output = await deps.authService.refreshToken(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'INVALID_REFRESH_TOKEN' || errorCode === 'INVALID_TOKEN') { - return 401; - } - if (errorCode === 'USER_NOT_FOUND') { - return 404; - } - if (errorCode === 'USER_BANNED' || errorCode === 'USER_INACTIVE') { - return 403; - } - return 500; + reply.code(200).send(toRefreshTokenResponse(output)); } diff --git a/backend-nodejs/domains/auth/handlers/register.handler.ts b/backend-nodejs/domains/auth/handlers/register.handler.ts index 9cd7b2d..ae8c14e 100644 --- a/backend-nodejs/domains/auth/handlers/register.handler.ts +++ b/backend-nodejs/domains/auth/handlers/register.handler.ts @@ -10,39 +10,28 @@ import { toRegisterInput, toRegisterResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * Register Handler + * HTTP 适配层:处理用户注册的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function registerHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: RegisterRequest }>, reply: FastifyReply ): Promise { - try { - // 1. 解析 HTTP 请求 - const body = req.body; - - // 2. 转换为 Domain Input - const input = toRegisterInput(body); + // 1. 转换为 Domain Input + const input = toRegisterInput(req.body); - // 3. 调用 Domain Service - const output = await deps.authService.register(req, input); + // 2. 创建请求上下文 + const ctx = createContextFromRequest({ ...req }); - // 4. 转换为 HTTP 响应 - reply.code(201).send(toRegisterResponse(output)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '注册失败', - }); - } -} + // 3. 调用 Domain Service(错误会自动向上抛出,由全局错误处理中间件处理) + const output = await deps.authService.register(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'EMAIL_ALREADY_EXISTS' || errorCode === 'USERNAME_ALREADY_EXISTS' || errorCode === 'INVALID_EMAIL' || errorCode === 'WEAK_PASSWORD' || errorCode === 'INVALID_USERNAME') { - return 400; - } - return 500; + // 3. 转换为 HTTP 响应 + reply.code(201).send(toRegisterResponse(output)); } diff --git a/backend-nodejs/domains/auth/http/dto/auth.ts b/backend-nodejs/domains/auth/http/dto/auth.ts index 1d7b668..d20f181 100644 --- a/backend-nodejs/domains/auth/http/dto/auth.ts +++ b/backend-nodejs/domains/auth/http/dto/auth.ts @@ -3,16 +3,20 @@ * 定义 HTTP 请求和响应的数据结构 */ +import { z } from 'zod'; + // ======================================== // Register // ======================================== -export interface RegisterRequest { - email: string; - password: string; - username?: string; - full_name?: string; -} +export const RegisterRequestSchema = z.object({ + email: z.string().email('邮箱格式无效').min(1, '邮箱不能为空'), + password: z.string().min(8, '密码至少 8 个字符').max(128, '密码最多 128 个字符'), + username: z.string().min(3, '用户名至少 3 个字符').max(30, '用户名最多 30 个字符').regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线').optional(), + full_name: z.string().max(100, '全名最多 100 个字符').optional(), +}); + +export type RegisterRequest = z.infer; export interface RegisterResponse { user_id: string; @@ -26,10 +30,12 @@ export interface RegisterResponse { // Login // ======================================== -export interface LoginRequest { - email: string; - password: string; -} +export const LoginRequestSchema = z.object({ + email: z.string().email('邮箱格式无效').min(1, '邮箱不能为空'), + password: z.string().min(1, '密码不能为空'), +}); + +export type LoginRequest = z.infer; export interface LoginResponse { user_id: string; @@ -43,9 +49,11 @@ export interface LoginResponse { // RefreshToken // ======================================== -export interface RefreshTokenRequest { - refresh_token: string; -} +export const RefreshTokenRequestSchema = z.object({ + refresh_token: z.string().min(1, 'Refresh Token 不能为空'), +}); + +export type RefreshTokenRequest = z.infer; export interface RefreshTokenResponse { access_token: string; diff --git a/backend-nodejs/domains/auth/http/router.ts b/backend-nodejs/domains/auth/http/router.ts index dc24354..4917a1d 100644 --- a/backend-nodejs/domains/auth/http/router.ts +++ b/backend-nodejs/domains/auth/http/router.ts @@ -6,6 +6,11 @@ import type { FastifyInstance } from 'fastify'; import type { HandlerDependencies } from '../handlers/dependencies.js'; import type { RegisterRequest, LoginRequest, RefreshTokenRequest } from './dto/auth.js'; +import { + RegisterRequestSchema, + LoginRequestSchema, + RefreshTokenRequestSchema, +} from './dto/auth.js'; import { registerHandler } from '../handlers/register.handler.js'; import { loginHandler } from '../handlers/login.handler.js'; import { refreshTokenHandler } from '../handlers/refresh_token.handler.js'; @@ -18,18 +23,42 @@ export function registerAuthRoutes( deps: HandlerDependencies ): void { // POST /api/auth/register - 用户注册 - app.post<{ Body: RegisterRequest }>('/api/auth/register', async (req, reply) => { - await registerHandler(deps, req, reply); - }); + app.post<{ Body: RegisterRequest }>( + '/api/auth/register', + { + schema: { + body: RegisterRequestSchema, + }, + }, + async (req, reply) => { + await registerHandler(deps, req, reply); + } + ); // POST /api/auth/login - 用户登录 - app.post<{ Body: LoginRequest }>('/api/auth/login', async (req, reply) => { - await loginHandler(deps, req, reply); - }); + app.post<{ Body: LoginRequest }>( + '/api/auth/login', + { + schema: { + body: LoginRequestSchema, + }, + }, + async (req, reply) => { + await loginHandler(deps, req, reply); + } + ); // POST /api/auth/refresh - 刷新 Token - app.post<{ Body: RefreshTokenRequest }>('/api/auth/refresh', async (req, reply) => { - await refreshTokenHandler(deps, req, reply); - }); + app.post<{ Body: RefreshTokenRequest }>( + '/api/auth/refresh', + { + schema: { + body: RefreshTokenRequestSchema, + }, + }, + async (req, reply) => { + await refreshTokenHandler(deps, req, reply); + } + ); } diff --git a/backend-nodejs/domains/auth/service/auth_service.test.ts b/backend-nodejs/domains/auth/service/auth_service.test.ts index c0b617e..3289e4b 100644 --- a/backend-nodejs/domains/auth/service/auth_service.test.ts +++ b/backend-nodejs/domains/auth/service/auth_service.test.ts @@ -8,6 +8,7 @@ import { AuthService } from './auth_service.js'; import { JWTService } from './jwt_service.js'; import type { UserRepository } from '../../user/repository/interface.js'; import { User } from '../../user/model/user.js'; +import type { RequestContext } from '../../../shared/types/context.js'; // Mock UserRepository class MockUserRepository implements UserRepository { @@ -15,7 +16,7 @@ class MockUserRepository implements UserRepository { private emails: Set = new Set(); private usernames: Set = new Set(); - async create(_ctx: unknown, user: User): Promise { + async create(_ctx: RequestContext, user: User): Promise { this.users.set(user.id, user); this.emails.add(user.email.toLowerCase()); if (user.username) { @@ -23,11 +24,11 @@ class MockUserRepository implements UserRepository { } } - async getById(_ctx: unknown, userId: string): Promise { + async getById(_ctx: RequestContext, userId: string): Promise { return this.users.get(userId) || null; } - async getByEmail(_ctx: unknown, email: string): Promise { + async getByEmail(_ctx: RequestContext, email: string): Promise { const normalizedEmail = email.toLowerCase(); for (const user of this.users.values()) { if (user.email.toLowerCase() === normalizedEmail) { @@ -37,18 +38,18 @@ class MockUserRepository implements UserRepository { return null; } - async update(_ctx: unknown, user: User): Promise { + async update(_ctx: RequestContext, user: User): Promise { this.users.set(user.id, user); if (user.username) { this.usernames.add(user.username); } } - async existsByEmail(_ctx: unknown, email: string): Promise { + async existsByEmail(_ctx: RequestContext, email: string): Promise { return this.emails.has(email.toLowerCase()); } - async existsByUsername(_ctx: unknown, username: string): Promise { + async existsByUsername(_ctx: RequestContext, username: string): Promise { return this.usernames.has(username); } } diff --git a/backend-nodejs/domains/auth/service/auth_service.ts b/backend-nodejs/domains/auth/service/auth_service.ts index 42d74be..9cb2497 100644 --- a/backend-nodejs/domains/auth/service/auth_service.ts +++ b/backend-nodejs/domains/auth/service/auth_service.ts @@ -6,6 +6,8 @@ import type { UserRepository } from '../../user/repository/interface.js'; import { User } from '../../user/model/user.js'; import type { JWTService } from './jwt_service.js'; +import { createError } from '../../../shared/errors/errors.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export interface RegisterInput { email: string; @@ -57,20 +59,20 @@ export class AuthService { /** * 用户注册 */ - async register(ctx: unknown, input: RegisterInput): Promise { + async register(ctx: RequestContext, input: RegisterInput): Promise { // Step 1: 验证输入(由 Model 层的验证逻辑处理) // Step 2: 检查邮箱是否已被注册 const emailExists = await this.userRepo.existsByEmail(ctx, input.email); if (emailExists) { - throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + throw createError('EMAIL_ALREADY_EXISTS', '邮箱已被占用'); } // Step 3: 检查用户名是否已被占用(如果提供) if (input.username) { const usernameExists = await this.userRepo.existsByUsername(ctx, input.username); if (usernameExists) { - throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + throw createError('VALIDATION_ERROR', '用户名已被占用'); } } @@ -107,21 +109,21 @@ export class AuthService { /** * 用户登录 */ - async login(ctx: unknown, input: LoginInput): Promise { + async login(ctx: RequestContext, input: LoginInput): Promise { // Step 1: 验证输入(由 Model 层的验证逻辑处理) // Step 2: 根据邮箱获取用户 const user = await this.userRepo.getByEmail(ctx, input.email); if (!user) { // 统一返回错误消息,不泄露是邮箱还是密码错误 - throw new Error('INVALID_CREDENTIALS: 邮箱或密码错误'); + throw createError('INVALID_CREDENTIALS', '邮箱或密码错误'); } // Step 3: 验证密码 const isValid = await user.verifyPassword(input.password); if (!isValid) { // 统一返回错误消息 - throw new Error('INVALID_CREDENTIALS: 邮箱或密码错误'); + throw createError('INVALID_CREDENTIALS', '邮箱或密码错误'); } // Step 4: 检查用户状态 @@ -162,19 +164,19 @@ export class AuthService { /** * 刷新 Token */ - async refreshToken(ctx: unknown, input: RefreshTokenInput): Promise { + async refreshToken(ctx: RequestContext, input: RefreshTokenInput): Promise { // Step 1: 验证 Refresh Token let claims; try { claims = this.jwtService.verifyRefreshToken(input.refreshToken); } catch (error) { - throw new Error('INVALID_REFRESH_TOKEN: Refresh Token 无效或已过期'); + throw createError('REFRESH_TOKEN_INVALID', 'Refresh Token 无效或已过期'); } // Step 2: 获取用户信息 const user = await this.userRepo.getById(ctx, claims.user_id); if (!user) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } // Step 3: 检查用户状态 diff --git a/backend-nodejs/domains/auth/service/jwt_service.ts b/backend-nodejs/domains/auth/service/jwt_service.ts index 549daad..adfcaf9 100644 --- a/backend-nodejs/domains/auth/service/jwt_service.ts +++ b/backend-nodejs/domains/auth/service/jwt_service.ts @@ -4,6 +4,7 @@ */ import jwt, { type JwtPayload } from 'jsonwebtoken'; +import { createError } from '../../../shared/errors/errors.js'; export type TokenType = 'access' | 'refresh'; @@ -95,7 +96,7 @@ export class JWTService { // 验证 Issuer if (decoded.iss !== this.config.issuer) { - throw new Error('INVALID_ISSUER: Issuer 不匹配'); + throw createError('VALIDATION_ERROR', 'Issuer 不匹配'); } // 转换为 JWTPayload @@ -112,10 +113,10 @@ export class JWTService { } catch (error) { if (error instanceof Error) { if (error.name === 'TokenExpiredError') { - throw new Error('INVALID_TOKEN: Token 已过期'); + throw createError('TOKEN_EXPIRED', 'Token 已过期'); } if (error.name === 'JsonWebTokenError') { - throw new Error('INVALID_TOKEN: Token 验证失败'); + throw createError('INVALID_TOKEN', 'Token 验证失败'); } } throw error; @@ -129,7 +130,7 @@ export class JWTService { const payload = this.verifyToken(tokenString); if (payload.type !== 'access') { - throw new Error('INVALID_TOKEN_TYPE: Token 类型必须是 access'); + throw createError('VALIDATION_ERROR', 'Token 类型必须是 access'); } return payload; @@ -142,7 +143,7 @@ export class JWTService { const payload = this.verifyToken(tokenString); if (payload.type !== 'refresh') { - throw new Error('INVALID_TOKEN_TYPE: Token 类型必须是 refresh'); + throw createError('VALIDATION_ERROR', 'Token 类型必须是 refresh'); } return payload; diff --git a/backend-nodejs/domains/task/errors/errors.ts b/backend-nodejs/domains/task/errors/errors.ts deleted file mode 100644 index 0ce9367..0000000 --- a/backend-nodejs/domains/task/errors/errors.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Task 领域错误定义 - * 遵循 "ERROR_CODE: message" 格式 - */ - -export class TaskError extends Error { - constructor( - public code: string, - message: string - ) { - super(message); - this.name = 'TaskError'; - } -} - -// 错误定义 -export const TaskErrors = { - TASK_TITLE_EMPTY: new TaskError( - 'TASK_TITLE_EMPTY', - '任务标题不能为空' - ), - TASK_NOT_FOUND: new TaskError('TASK_NOT_FOUND', '任务不存在'), - TASK_ALREADY_COMPLETED: new TaskError( - 'TASK_ALREADY_COMPLETED', - '任务已完成,不能再次完成' - ), - INVALID_PRIORITY: new TaskError( - 'INVALID_PRIORITY', - '优先级无效,必须是 low, medium 或 high' - ), - INVALID_DUE_DATE: new TaskError( - 'INVALID_DUE_DATE', - '截止日期不能早于创建日期' - ), - TOO_MANY_TAGS: new TaskError('TOO_MANY_TAGS', '标签过多,最多 10 个'), - TAG_NAME_EMPTY: new TaskError('TAG_NAME_EMPTY', '标签名不能为空'), - DUPLICATE_TAG: new TaskError('DUPLICATE_TAG', '标签重复'), - USER_ID_REQUIRED: new TaskError( - 'USER_ID_REQUIRED', - '用户 ID 不能为空' - ), - UNAUTHORIZED_ACCESS: new TaskError( - 'UNAUTHORIZED_ACCESS', - '无权访问此任务' - ), - CREATION_FAILED: new TaskError('CREATION_FAILED', '创建任务失败'), - UPDATE_FAILED: new TaskError('UPDATE_FAILED', '更新任务失败'), - DELETION_FAILED: new TaskError('DELETION_FAILED', '删除任务失败'), - COMPLETION_FAILED: new TaskError('COMPLETION_FAILED', '完成任务失败'), - QUERY_FAILED: new TaskError('QUERY_FAILED', '查询失败'), -}; - -/** - * 解析错误码 - */ -export function parseErrorCode(error: unknown): string { - if (error instanceof TaskError) { - return error.code; - } - if (error instanceof Error) { - // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) - const match = error.message.match(/^([A-Z_]+):/); - if (match) { - return match[1]; - } - } - return 'INTERNAL_ERROR'; -} - diff --git a/backend-nodejs/domains/task/handlers/complete_task.handler.ts b/backend-nodejs/domains/task/handlers/complete_task.handler.ts index 09b3194..3d4a14b 100644 --- a/backend-nodejs/domains/task/handlers/complete_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/complete_task.handler.ts @@ -8,42 +8,26 @@ import { toCompleteTaskInput, toCompleteTaskResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * CompleteTask Handler + * HTTP 适配层:处理完成任务的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function completeTaskHandler( deps: HandlerDependencies, req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); - const taskId = req.params.id; + const userId = requireUserId(req); + const taskId = req.params.id; - const input = toCompleteTaskInput(userId, taskId); - const output = await deps.taskService.completeTask(req, input); - - reply.code(200).send(toCompleteTaskResponse(output.task)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '完成任务失败', - }); - } -} + const input = toCompleteTaskInput(userId, taskId); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.taskService.completeTask(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'TASK_ALREADY_COMPLETED') { - return 400; - } - if (errorCode === 'TASK_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED_ACCESS') { - return 403; - } - return 500; + reply.code(200).send(toCompleteTaskResponse(output.task)); } diff --git a/backend-nodejs/domains/task/handlers/converters.ts b/backend-nodejs/domains/task/handlers/converters.ts index edfd59f..4481d12 100644 --- a/backend-nodejs/domains/task/handlers/converters.ts +++ b/backend-nodejs/domains/task/handlers/converters.ts @@ -4,6 +4,7 @@ */ import type { Task } from '../model/task.js'; +import { createError } from '../../../shared/errors/errors.js'; import type { CreateTaskRequest, CreateTaskResponse, @@ -46,7 +47,7 @@ export function toCreateTaskInput( if (req.due_date) { const dueDate = new Date(req.due_date); if (isNaN(dueDate.getTime())) { - throw new Error('INVALID_DUE_DATE: 截止日期格式无效'); + throw createError('VALIDATION_ERROR', '截止日期格式无效'); } input.dueDate = dueDate; } @@ -89,7 +90,7 @@ export function toUpdateTaskInput( if (req.due_date !== undefined) { const dueDate = new Date(req.due_date); if (isNaN(dueDate.getTime())) { - throw new Error('INVALID_DUE_DATE: 截止日期格式无效'); + throw createError('VALIDATION_ERROR', '截止日期格式无效'); } input.dueDate = dueDate; } diff --git a/backend-nodejs/domains/task/handlers/create_task.handler.ts b/backend-nodejs/domains/task/handlers/create_task.handler.ts index 043bb69..2dabe37 100644 --- a/backend-nodejs/domains/task/handlers/create_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/create_task.handler.ts @@ -10,58 +10,31 @@ import { toCreateTaskInput, toCreateTaskResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * CreateTask Handler + * HTTP 适配层:处理创建任务的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function createTaskHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: CreateTaskRequest }>, reply: FastifyReply ): Promise { - try { - // 1. 获取用户 ID(从 JWT Token 中提取) - const userId = requireUserId(req); - - // 2. 解析 HTTP 请求 - const body = req.body; + // 1. 获取用户 ID(从 JWT Token 中提取) + const userId = requireUserId(req); - // 3. 转换为 Domain Input - const input = toCreateTaskInput(userId, body); + // 2. 转换为 Domain Input + const input = toCreateTaskInput(userId, req.body); - // 4. 调用 Domain Service - const output = await deps.taskService.createTask(req, input); + // 3. 创建请求上下文 + const ctx = createContextFromRequest({ userId, ...req }); - // 5. 转换为 HTTP 响应 - reply.code(200).send(toCreateTaskResponse(output.task)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '创建任务失败', - }); - } -} + // 4. 调用 Domain Service(错误会自动向上抛出,由全局错误处理中间件处理) + const output = await deps.taskService.createTask(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'UNAUTHORIZED') { - return 401; - } - if (errorCode === 'TASK_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED_ACCESS') { - return 403; - } - if ( - errorCode.startsWith('TASK_') || - errorCode === 'INVALID_PRIORITY' || - errorCode === 'INVALID_DUE_DATE' || - errorCode === 'TOO_MANY_TAGS' || - errorCode === 'DUPLICATE_TAG' || - errorCode === 'TAG_NAME_EMPTY' - ) { - return 400; - } - return 500; + // 4. 转换为 HTTP 响应 + reply.code(200).send(toCreateTaskResponse(output.task)); } diff --git a/backend-nodejs/domains/task/handlers/delete_task.handler.ts b/backend-nodejs/domains/task/handlers/delete_task.handler.ts index e30847f..1f63083 100644 --- a/backend-nodejs/domains/task/handlers/delete_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/delete_task.handler.ts @@ -8,39 +8,26 @@ import { toDeleteTaskInput, toDeleteTaskResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * DeleteTask Handler + * HTTP 适配层:处理删除任务的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function deleteTaskHandler( deps: HandlerDependencies, req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); - const taskId = req.params.id; + const userId = requireUserId(req); + const taskId = req.params.id; - const input = toDeleteTaskInput(userId, taskId); - const output = await deps.taskService.deleteTask(req, input); - - reply.code(200).send(toDeleteTaskResponse(output.success, output.deletedAt)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '删除任务失败', - }); - } -} + const input = toDeleteTaskInput(userId, taskId); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.taskService.deleteTask(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'TASK_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED_ACCESS') { - return 403; - } - return 500; + reply.code(200).send(toDeleteTaskResponse(output.success, output.deletedAt)); } diff --git a/backend-nodejs/domains/task/handlers/get_task.handler.ts b/backend-nodejs/domains/task/handlers/get_task.handler.ts index ac3789d..2eda007 100644 --- a/backend-nodejs/domains/task/handlers/get_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/get_task.handler.ts @@ -8,39 +8,26 @@ import { toGetTaskInput, toGetTaskResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * GetTask Handler + * HTTP 适配层:处理获取任务详情的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function getTaskHandler( deps: HandlerDependencies, req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); - const taskId = req.params.id; + const userId = requireUserId(req); + const taskId = req.params.id; - const input = toGetTaskInput(userId, taskId); - const output = await deps.taskService.getTask(req, input); - - reply.code(200).send(toGetTaskResponse(output.task)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '获取任务失败', - }); - } -} + const input = toGetTaskInput(userId, taskId); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.taskService.getTask(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'TASK_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED_ACCESS') { - return 403; - } - return 500; + reply.code(200).send(toGetTaskResponse(output.task)); } diff --git a/backend-nodejs/domains/task/handlers/list_tasks.handler.ts b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts index 5204f8a..1ddeda0 100644 --- a/backend-nodejs/domains/task/handlers/list_tasks.handler.ts +++ b/backend-nodejs/domains/task/handlers/list_tasks.handler.ts @@ -9,43 +9,33 @@ import { toListTasksInput, toListTasksResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * ListTasks Handler + * HTTP 适配层:处理列出任务的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function listTasksHandler( deps: HandlerDependencies, req: FastifyRequest<{ Querystring: ListTasksQuery }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); + const userId = requireUserId(req); - const input = toListTasksInput(userId, req.query); - const output = await deps.taskService.listTasks(req, input); - - reply.code(200).send( - toListTasksResponse( - output.tasks, - output.totalCount, - output.page, - output.limit, - output.hasMore - ) - ); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '查询任务失败', - }); - } -} + const input = toListTasksInput(userId, req.query); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.taskService.listTasks(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'INVALID_FILTER' || errorCode === 'INVALID_PAGINATION') { - return 400; - } - return 500; + reply.code(200).send( + toListTasksResponse( + output.tasks, + output.totalCount, + output.page, + output.limit, + output.hasMore + ) + ); } diff --git a/backend-nodejs/domains/task/handlers/update_task.handler.ts b/backend-nodejs/domains/task/handlers/update_task.handler.ts index 494f934..c088005 100644 --- a/backend-nodejs/domains/task/handlers/update_task.handler.ts +++ b/backend-nodejs/domains/task/handlers/update_task.handler.ts @@ -9,9 +9,14 @@ import { toUpdateTaskInput, toUpdateTaskResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * UpdateTask Handler + * HTTP 适配层:处理更新任务的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function updateTaskHandler( deps: HandlerDependencies, req: FastifyRequest<{ @@ -20,36 +25,13 @@ export async function updateTaskHandler( }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); - const taskId = req.params.id; + const userId = requireUserId(req); + const taskId = req.params.id; - const input = toUpdateTaskInput(userId, taskId, req.body); - const output = await deps.taskService.updateTask(req, input); - - reply.code(200).send(toUpdateTaskResponse(output.task)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '更新任务失败', - }); - } -} + const input = toUpdateTaskInput(userId, taskId, req.body); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.taskService.updateTask(ctx, input); -function getStatusCode(errorCode: string): number { - // 先检查特定错误码 - if (errorCode === 'TASK_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED_ACCESS') { - return 403; - } - // 再检查通用错误码 - if (errorCode.startsWith('TASK_') || errorCode === 'INVALID_PRIORITY' || errorCode === 'INVALID_DUE_DATE') { - return 400; - } - return 500; + reply.code(200).send(toUpdateTaskResponse(output.task)); } diff --git a/backend-nodejs/domains/task/http/dto/task.ts b/backend-nodejs/domains/task/http/dto/task.ts index aac4022..e261d42 100644 --- a/backend-nodejs/domains/task/http/dto/task.ts +++ b/backend-nodejs/domains/task/http/dto/task.ts @@ -3,17 +3,21 @@ * 定义 HTTP 请求和响应的数据结构 */ +import { z } from 'zod'; + // ======================================== // CreateTask // ======================================== -export interface CreateTaskRequest { - title: string; - description?: string; - priority?: 'low' | 'medium' | 'high'; - due_date?: string; // ISO 8601 - tags?: string[]; -} +export const CreateTaskRequestSchema = z.object({ + title: z.string().min(1, '任务标题不能为空').max(200, '任务标题过长,最大 200 字符'), + description: z.string().max(5000, '描述过长,最大 5000 字符').optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + due_date: z.string().datetime().optional(), + tags: z.array(z.string()).max(10, '标签过多,最多 10 个').optional(), +}); + +export type CreateTaskRequest = z.infer; export interface CreateTaskResponse { task_id: string; @@ -26,13 +30,15 @@ export interface CreateTaskResponse { // UpdateTask // ======================================== -export interface UpdateTaskRequest { - title?: string; - description?: string; - priority?: 'low' | 'medium' | 'high'; - due_date?: string; // ISO 8601 - tags?: string[]; -} +export const UpdateTaskRequestSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(5000).optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + due_date: z.string().datetime().optional(), + tags: z.array(z.string()).max(10).optional(), +}); + +export type UpdateTaskRequest = z.infer; export interface UpdateTaskResponse { task_id: string; @@ -81,18 +87,20 @@ export interface GetTaskResponse { // ListTasks // ======================================== -export interface ListTasksQuery { - status?: 'pending' | 'in_progress' | 'completed'; - priority?: 'low' | 'medium' | 'high'; - tag?: string; - due_date_from?: string; // ISO 8601 date - due_date_to?: string; // ISO 8601 date - keyword?: string; - sort_by?: 'created_at' | 'due_date' | 'priority'; - sort_order?: 'asc' | 'desc'; - page?: number; - limit?: number; -} +export const ListTasksQuerySchema = z.object({ + status: z.enum(['pending', 'in_progress', 'completed']).optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + tag: z.string().optional(), + due_date_from: z.string().datetime().optional(), + due_date_to: z.string().datetime().optional(), + keyword: z.string().optional(), + sort_by: z.enum(['created_at', 'due_date', 'priority']).optional(), + sort_order: z.enum(['asc', 'desc']).optional(), + page: z.coerce.number().int().positive().default(1).optional(), + limit: z.coerce.number().int().positive().max(100).default(20).optional(), +}); + +export type ListTasksQuery = z.infer; export interface TaskItem { task_id: string; diff --git a/backend-nodejs/domains/task/http/router.ts b/backend-nodejs/domains/task/http/router.ts index d8bff79..256fa40 100644 --- a/backend-nodejs/domains/task/http/router.ts +++ b/backend-nodejs/domains/task/http/router.ts @@ -6,6 +6,11 @@ import type { FastifyInstance } from 'fastify'; import type { HandlerDependencies } from '../handlers/dependencies.js'; import type { CreateTaskRequest, UpdateTaskRequest, ListTasksQuery } from './dto/task.js'; +import { + CreateTaskRequestSchema, + UpdateTaskRequestSchema, + ListTasksQuerySchema, +} from './dto/task.js'; import { createTaskHandler } from '../handlers/create_task.handler.js'; import { updateTaskHandler } from '../handlers/update_task.handler.js'; import { completeTaskHandler } from '../handlers/complete_task.handler.js'; @@ -25,54 +30,109 @@ export function registerTaskRoutes( // POST /api/tasks - 创建任务(需要认证) app.post<{ Body: CreateTaskRequest }>( '/api/tasks', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + body: CreateTaskRequestSchema, + }, + }, async (req, reply) => { - await createTaskHandler(deps, req as any, reply); + await createTaskHandler(deps, req, reply); } ); // GET /api/tasks - 列出任务(需要认证) app.get<{ Querystring: ListTasksQuery }>( '/api/tasks', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + querystring: ListTasksQuerySchema, + }, + }, async (req, reply) => { - await listTasksHandler(deps, req as any, reply); + await listTasksHandler(deps, req, reply); } ); // GET /api/tasks/:id - 获取任务详情(需要认证) app.get<{ Params: { id: string } }>( '/api/tasks/:id', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + }, async (req, reply) => { - await getTaskHandler(deps, req as any, reply); + await getTaskHandler(deps, req, reply); } ); // PUT /api/tasks/:id - 更新任务(需要认证) app.put<{ Params: { id: string }; Body: UpdateTaskRequest }>( '/api/tasks/:id', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + body: UpdateTaskRequestSchema, + }, + }, async (req, reply) => { - await updateTaskHandler(deps, req as any, reply); + await updateTaskHandler(deps, req, reply); } ); // POST /api/tasks/:id/complete - 完成任务(需要认证) app.post<{ Params: { id: string } }>( '/api/tasks/:id/complete', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + }, async (req, reply) => { - await completeTaskHandler(deps, req as any, reply); + await completeTaskHandler(deps, req, reply); } ); // DELETE /api/tasks/:id - 删除任务(需要认证) app.delete<{ Params: { id: string } }>( '/api/tasks/:id', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + }, async (req, reply) => { - await deleteTaskHandler(deps, req as any, reply); + await deleteTaskHandler(deps, req, reply); } ); } diff --git a/backend-nodejs/domains/task/model/task.ts b/backend-nodejs/domains/task/model/task.ts index c1103ad..d6db40c 100644 --- a/backend-nodejs/domains/task/model/task.ts +++ b/backend-nodejs/domains/task/model/task.ts @@ -4,6 +4,7 @@ */ import { randomUUID } from 'crypto'; +import { createError } from '../../../shared/errors/errors.js'; // 任务状态 export type TaskStatus = 'pending' | 'in_progress' | 'completed'; @@ -79,19 +80,19 @@ export class Task { priority: Priority = Priorities.Medium ): Task { if (!userId || userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } if (!title || title.trim().length === 0) { - throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + throw createError('TASK_TITLE_EMPTY', '任务标题不能为空'); } if (title.length > 200) { - throw new Error('TASK_TITLE_TOO_LONG: 标题过长,最大 200 字符'); + throw createError('VALIDATION_ERROR', '标题过长,最大 200 字符'); } if (description.length > 5000) { - throw new Error('TASK_DESCRIPTION_TOO_LONG: 描述过长,最大 5000 字符'); + throw createError('VALIDATION_ERROR', '描述过长,最大 5000 字符'); } if (!isValidPriority(priority)) { - throw new Error('INVALID_PRIORITY: 优先级无效'); + throw createError('INVALID_PRIORITY', '优先级无效'); } const now = new Date(); @@ -115,29 +116,29 @@ export class Task { */ update(title?: string, description?: string, priority?: Priority): void { if (this.status === TaskStatuses.Completed) { - throw new Error('TASK_ALREADY_COMPLETED: 已完成的任务不能更新'); + throw createError('TASK_ALREADY_COMPLETED', '已完成的任务不能更新'); } if (title !== undefined) { if (!title || title.trim().length === 0) { - throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + throw createError('TASK_TITLE_EMPTY', '任务标题不能为空'); } if (title.length > 200) { - throw new Error('TASK_TITLE_TOO_LONG: 标题过长,最大 200 字符'); + throw createError('VALIDATION_ERROR', '标题过长,最大 200 字符'); } this.title = title.trim(); } if (description !== undefined) { if (description.length > 5000) { - throw new Error('TASK_DESCRIPTION_TOO_LONG: 描述过长,最大 5000 字符'); + throw createError('VALIDATION_ERROR', '描述过长,最大 5000 字符'); } this.description = description; } if (priority !== undefined) { if (!isValidPriority(priority)) { - throw new Error('INVALID_PRIORITY: 优先级无效'); + throw createError('INVALID_PRIORITY', '优先级无效'); } this.priority = priority; } @@ -150,7 +151,7 @@ export class Task { */ setDueDate(dueDate: Date): void { if (dueDate < this.createdAt) { - throw new Error('INVALID_DUE_DATE: 截止日期不能早于创建日期'); + throw createError('VALIDATION_ERROR', '截止日期不能早于创建日期'); } this.dueDate = dueDate; this.updatedAt = new Date(); @@ -161,7 +162,7 @@ export class Task { */ complete(): void { if (this.status === TaskStatuses.Completed) { - throw new Error('TASK_ALREADY_COMPLETED: 任务已完成,不能再次完成'); + throw createError('TASK_ALREADY_COMPLETED', '任务已完成,不能再次完成'); } this.status = TaskStatuses.Completed; this.completedAt = new Date(); @@ -173,14 +174,14 @@ export class Task { */ addTag(tag: Tag): void { if (!tag.name || tag.name.trim().length === 0) { - throw new Error('TAG_NAME_EMPTY: 标签名不能为空'); + throw createError('VALIDATION_ERROR', '标签名不能为空'); } if (this.tags.length >= 10) { - throw new Error('TOO_MANY_TAGS: 标签过多,最多 10 个'); + throw createError('VALIDATION_ERROR', '标签过多,最多 10 个'); } // 检查重复 if (this.tags.some((t) => t.name === tag.name)) { - throw new Error('DUPLICATE_TAG: 标签重复'); + throw createError('VALIDATION_ERROR', '标签重复'); } this.tags.push(tag); this.updatedAt = new Date(); diff --git a/backend-nodejs/domains/task/repository/interface.ts b/backend-nodejs/domains/task/repository/interface.ts index db2868b..24bc186 100644 --- a/backend-nodejs/domains/task/repository/interface.ts +++ b/backend-nodejs/domains/task/repository/interface.ts @@ -4,6 +4,7 @@ */ import type { Task } from '../model/task.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export interface TaskFilter { // 筛选条件 @@ -31,31 +32,31 @@ export interface TaskRepository { /** * 创建任务 */ - create(ctx: unknown, task: Task): Promise; + create(ctx: RequestContext, task: Task): Promise; /** * 根据 ID 查找任务 */ - findById(ctx: unknown, taskId: string): Promise; + findById(ctx: RequestContext, taskId: string): Promise; /** * 更新任务 */ - update(ctx: unknown, task: Task): Promise; + update(ctx: RequestContext, task: Task): Promise; /** * 删除任务 */ - delete(ctx: unknown, taskId: string): Promise; + delete(ctx: RequestContext, taskId: string): Promise; /** * 列出任务 */ - list(ctx: unknown, filter: TaskFilter): Promise<{ tasks: Task[]; total: number }>; + list(ctx: RequestContext, filter: TaskFilter): Promise<{ tasks: Task[]; total: number }>; /** * 检查任务是否存在 */ - exists(ctx: unknown, taskId: string): Promise; + exists(ctx: RequestContext, taskId: string): Promise; } diff --git a/backend-nodejs/domains/task/repository/task_repo.ts b/backend-nodejs/domains/task/repository/task_repo.ts index 8899eb9..490f440 100644 --- a/backend-nodejs/domains/task/repository/task_repo.ts +++ b/backend-nodejs/domains/task/repository/task_repo.ts @@ -3,39 +3,45 @@ * 使用 Kysely 进行类型安全的数据库操作 */ -import type { Kysely } from 'kysely'; +import type { Kysely, Transaction } from 'kysely'; import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; import { Task } from '../model/task.js'; import type { TaskRepository, TaskFilter } from './interface.js'; +import { createError } from '../../../shared/errors/errors.js'; +import { withTransaction } from '../../../infrastructure/persistence/postgres/transaction.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export class TaskRepositoryImpl implements TaskRepository { constructor(private db: Kysely) {} - async create(_ctx: unknown, task: Task): Promise { - // 插入任务 - await this.db - .insertInto('tasks') - .values({ - id: task.id, - user_id: task.userId, - title: task.title, - description: task.description, - status: task.status, - priority: task.priority, - due_date: task.dueDate, - created_at: task.createdAt, - updated_at: task.updatedAt, - completed_at: task.completedAt, - }) - .execute(); - - // 保存标签 - if (task.tags.length > 0) { - await this.saveTags(task.id, task.tags); - } + async create(_ctx: RequestContext, task: Task): Promise { + // 在事务中执行:插入任务和保存标签 + await withTransaction(this.db, async (trx) => { + // 插入任务 + await trx + .insertInto('tasks') + .values({ + id: task.id, + user_id: task.userId, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + due_date: task.dueDate, + created_at: task.createdAt, + updated_at: task.updatedAt, + completed_at: task.completedAt, + }) + .execute(); + + // 保存标签(在同一事务中) + if (task.tags.length > 0) { + await this.saveTagsInTransaction(trx, task.id, task.tags); + } + }); } - async findById(_ctx: unknown, taskId: string): Promise { + async findById(_ctx: RequestContext, taskId: string): Promise { const taskRow = await this.db .selectFrom('tasks') .selectAll() @@ -52,47 +58,50 @@ export class TaskRepositoryImpl implements TaskRepository { return this.toDomainModel(taskRow, tags); } - async update(_ctx: unknown, task: Task): Promise { - const result = await this.db - .updateTable('tasks') - .set({ - title: task.title, - description: task.description, - status: task.status, - priority: task.priority, - due_date: task.dueDate, - updated_at: task.updatedAt, - completed_at: task.completedAt, - }) - .where('id', '=', task.id) - .execute(); - - if (result.length === 0) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); - } + async update(_ctx: RequestContext, task: Task): Promise { + // 在事务中执行:更新任务和标签 + await withTransaction(this.db, async (trx) => { + const result = await trx + .updateTable('tasks') + .set({ + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + due_date: task.dueDate, + updated_at: task.updatedAt, + completed_at: task.completedAt, + }) + .where('id', '=', task.id) + .execute(); + + if (result.length === 0) { + throw createError('TASK_NOT_FOUND', '任务不存在'); + } - // 更新标签(先删除旧的,再插入新的) - await this.deleteTags(task.id); - if (task.tags.length > 0) { - await this.saveTags(task.id, task.tags); - } + // 更新标签(先删除旧的,再插入新的,在同一事务中) + await this.deleteTagsInTransaction(trx, task.id); + if (task.tags.length > 0) { + await this.saveTagsInTransaction(trx, task.id, task.tags); + } + }); } - async delete(_ctx: unknown, taskId: string): Promise { + async delete(_ctx: RequestContext, taskId: string): Promise { const result = await this.db .deleteFrom('tasks') .where('id', '=', taskId) .execute(); if (result.length === 0) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); + throw createError('TASK_NOT_FOUND', '任务不存在'); } // 标签会通过外键级联删除 } async list( - _ctx: unknown, + _ctx: RequestContext, filter: TaskFilter ): Promise<{ tasks: Task[]; total: number }> { // 构建查询 @@ -154,17 +163,32 @@ export class TaskRepositoryImpl implements TaskRepository { // 执行查询 const taskRows = await query.selectAll().execute(); - // 加载标签并转换为领域模型 - const tasks: Task[] = []; - for (const row of taskRows) { - const tags = await this.loadTags(row.id); - tasks.push(this.toDomainModel(row, tags)); + // 批量加载所有标签(修复 N+1 查询问题) + const taskIds = taskRows.map((row) => row.id); + const allTags = await this.loadTagsBatch(taskIds); + + // 构建标签映射 + const tagsMap = new Map>(); + for (const tag of allTags) { + if (!tagsMap.has(tag.task_id)) { + tagsMap.set(tag.task_id, []); + } + tagsMap.get(tag.task_id)!.push({ + name: tag.tag_name, + color: tag.tag_color || '#808080', + }); } + // 转换为领域模型 + const tasks = taskRows.map((row) => { + const tags = tagsMap.get(row.id) || []; + return this.toDomainModel(row, tags); + }); + return { tasks, total }; } - async exists(_ctx: unknown, taskId: string): Promise { + async exists(_ctx: RequestContext, taskId: string): Promise { const result = await this.db .selectFrom('tasks') .select((eb) => eb.fn.count('id').as('count')) @@ -179,49 +203,64 @@ export class TaskRepositoryImpl implements TaskRepository { // ============================================ /** - * 保存标签 + * 保存标签(在事务中) */ - private async saveTags(taskId: string, tags: Array<{ name: string; color: string }>): Promise { + private async saveTagsInTransaction( + trx: Transaction, + taskId: string, + tags: Array<{ name: string; color: string }> + ): Promise { if (tags.length === 0) { return; } - // 检查并插入标签(避免重复) - for (const tag of tags) { - const existing = await this.db - .selectFrom('task_tags') - .select('task_id') - .where('task_id', '=', taskId) - .where('tag_name', '=', tag.name) - .executeTakeFirst(); - - if (!existing) { - // 不存在,插入 - await this.db - .insertInto('task_tags') - .values({ - task_id: taskId, - tag_name: tag.name, - tag_color: tag.color || '#808080', - }) - .execute(); - } - // 已存在,跳过 - } + // 批量插入标签(使用 INSERT ... ON CONFLICT DO NOTHING 避免重复) + const tagValues = tags.map((tag) => ({ + task_id: taskId, + tag_name: tag.name, + tag_color: tag.color || '#808080', + })); + + await trx + .insertInto('task_tags') + .values(tagValues) + .onConflict((oc) => oc + .columns(['task_id', 'tag_name']) + .doNothing() + ) + .execute(); } /** - * 删除标签 + * 删除标签(在事务中) */ - private async deleteTags(taskId: string): Promise { - await this.db + private async deleteTagsInTransaction(trx: Transaction, taskId: string): Promise { + await trx .deleteFrom('task_tags') .where('task_id', '=', taskId) .execute(); } + + /** + * 批量加载标签(修复 N+1 查询问题) + */ + private async loadTagsBatch( + taskIds: string[] + ): Promise> { + if (taskIds.length === 0) { + return []; + } + + return await this.db + .selectFrom('task_tags') + .select(['task_id', 'tag_name', 'tag_color']) + .where('task_id', 'in', taskIds) + .execute(); + } + /** - * 加载标签 + * 加载单个任务的标签(保留用于兼容) */ private async loadTags(taskId: string): Promise> { const tagRows = await this.db diff --git a/backend-nodejs/domains/task/service/task_service.ts b/backend-nodejs/domains/task/service/task_service.ts index 20b8271..ebb6e7c 100644 --- a/backend-nodejs/domains/task/service/task_service.ts +++ b/backend-nodejs/domains/task/service/task_service.ts @@ -5,6 +5,8 @@ import { Task } from '../model/task.js'; import type { TaskRepository, TaskFilter } from '../repository/interface.js'; +import { createError } from '../../../shared/errors/errors.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export interface CreateTaskInput { userId: string; @@ -82,13 +84,13 @@ export class TaskService { /** * 创建任务 */ - async createTask(ctx: unknown, input: CreateTaskInput): Promise { + async createTask(ctx: RequestContext, input: CreateTaskInput): Promise { // Step 1: ValidateInput if (!input.userId || input.userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } if (!input.title || input.title.trim().length === 0) { - throw new Error('TASK_TITLE_EMPTY: 任务标题不能为空'); + throw createError('TASK_TITLE_EMPTY', '任务标题不能为空'); } // Step 2 & 3: CreateTaskEntity @@ -102,7 +104,7 @@ export class TaskService { // 添加标签 if (input.tags && input.tags.length > 10) { - throw new Error('TOO_MANY_TAGS: 标签过多,最多 10 个'); + throw createError('VALIDATION_ERROR', '标签过多,最多 10 个'); } if (input.tags) { @@ -126,26 +128,26 @@ export class TaskService { /** * 更新任务 */ - async updateTask(ctx: unknown, input: UpdateTaskInput): Promise { + async updateTask(ctx: RequestContext, input: UpdateTaskInput): Promise { // Step 1: ValidateUserID if (!input.userId || input.userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } // Step 2: GetTask const task = await this.taskRepo.findById(ctx, input.taskId); if (!task) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); + throw createError('TASK_NOT_FOUND', '任务不存在'); } // Step 2.1: CheckOwnership if (task.userId !== input.userId) { - throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + throw createError('UNAUTHORIZED', '无权访问此任务'); } // Step 3: CheckIfCompleted if (task.status === 'completed') { - throw new Error('TASK_ALREADY_COMPLETED: 已完成的任务不能更新'); + throw createError('TASK_ALREADY_COMPLETED', '已完成的任务不能更新'); } // Step 4: UpdateTaskFields @@ -185,21 +187,21 @@ export class TaskService { /** * 完成任务 */ - async completeTask(ctx: unknown, input: CompleteTaskInput): Promise { + async completeTask(ctx: RequestContext, input: CompleteTaskInput): Promise { // Step 1: ValidateUserID if (!input.userId || input.userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } // Step 2: GetTask const task = await this.taskRepo.findById(ctx, input.taskId); if (!task) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); + throw createError('TASK_NOT_FOUND', '任务不存在'); } // Step 3: CheckOwnership if (task.userId !== input.userId) { - throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + throw createError('UNAUTHORIZED', '无权访问此任务'); } // Step 4 & 5: CheckStatus & MarkAsCompleted @@ -215,21 +217,21 @@ export class TaskService { /** * 删除任务 */ - async deleteTask(ctx: unknown, input: DeleteTaskInput): Promise { + async deleteTask(ctx: RequestContext, input: DeleteTaskInput): Promise { // Step 1: ValidateUserID if (!input.userId || input.userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } // Step 2: GetTask const task = await this.taskRepo.findById(ctx, input.taskId); if (!task) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); + throw createError('TASK_NOT_FOUND', '任务不存在'); } // Step 3: CheckOwnership if (task.userId !== input.userId) { - throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + throw createError('UNAUTHORIZED', '无权访问此任务'); } // Step 4: DeleteTaskRecord @@ -245,21 +247,21 @@ export class TaskService { /** * 获取任务详情 */ - async getTask(ctx: unknown, input: GetTaskInput): Promise { + async getTask(ctx: RequestContext, input: GetTaskInput): Promise { // Step 1: ValidateUserID if (!input.userId || input.userId.trim().length === 0) { - throw new Error('USER_ID_REQUIRED: 用户 ID 不能为空'); + throw createError('VALIDATION_ERROR', '用户 ID 不能为空'); } // Step 2: GetTask const task = await this.taskRepo.findById(ctx, input.taskId); if (!task) { - throw new Error('TASK_NOT_FOUND: 任务不存在'); + throw createError('TASK_NOT_FOUND', '任务不存在'); } // Step 3: CheckOwnership if (task.userId !== input.userId) { - throw new Error('UNAUTHORIZED_ACCESS: 无权访问此任务'); + throw createError('UNAUTHORIZED', '无权访问此任务'); } return { task }; @@ -268,7 +270,7 @@ export class TaskService { /** * 列出任务 */ - async listTasks(ctx: unknown, input: ListTasksInput): Promise { + async listTasks(ctx: RequestContext, input: ListTasksInput): Promise { // Step 2 & 3: QueryTasks + CountTotalTasks const { tasks, total } = await this.taskRepo.list(ctx, input.filter); diff --git a/backend-nodejs/domains/user/errors/errors.ts b/backend-nodejs/domains/user/errors/errors.ts deleted file mode 100644 index 9924dde..0000000 --- a/backend-nodejs/domains/user/errors/errors.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * User 领域错误定义 - * 遵循 "ERROR_CODE: message" 格式 - */ - -export class UserError extends Error { - constructor( - public code: string, - message: string - ) { - super(message); - this.name = 'UserError'; - } -} - -// 错误定义 -export const UserErrors = { - INVALID_EMAIL: new UserError('INVALID_EMAIL', '邮箱格式无效'), - EMAIL_ALREADY_EXISTS: new UserError('EMAIL_ALREADY_EXISTS', '邮箱已被占用'), - USERNAME_ALREADY_EXISTS: new UserError('USERNAME_ALREADY_EXISTS', '用户名已被占用'), - INVALID_USERNAME: new UserError('INVALID_USERNAME', '用户名格式无效(3-30 字符,仅字母数字下划线)'), - WEAK_PASSWORD: new UserError('WEAK_PASSWORD', '密码强度不足(至少 8 字符)'), - PASSWORD_TOO_LONG: new UserError('PASSWORD_TOO_LONG', '密码过长(最多 128 字符)'), - INVALID_PASSWORD: new UserError('INVALID_PASSWORD', '密码错误'), - USER_NOT_FOUND: new UserError('USER_NOT_FOUND', '用户不存在'), - USER_BANNED: new UserError('USER_BANNED', '用户已被禁用'), - USER_INACTIVE: new UserError('USER_INACTIVE', '用户未激活'), - FULL_NAME_TOO_LONG: new UserError('FULL_NAME_TOO_LONG', '全名过长(最多 100 字符)'), - INVALID_AVATAR_URL: new UserError('INVALID_AVATAR_URL', '头像 URL 格式无效'), -}; - -/** - * 解析错误码 - */ -export function parseErrorCode(error: unknown): string { - if (error instanceof UserError) { - return error.code; - } - if (error instanceof Error) { - // 尝试从错误消息中提取错误码(格式:ERROR_CODE: message) - const match = error.message.match(/^([A-Z_]+):/); - if (match) { - return match[1]; - } - } - return 'INTERNAL_ERROR'; -} - diff --git a/backend-nodejs/domains/user/handlers/change_password.handler.ts b/backend-nodejs/domains/user/handlers/change_password.handler.ts index 9949b74..93790d1 100644 --- a/backend-nodejs/domains/user/handlers/change_password.handler.ts +++ b/backend-nodejs/domains/user/handlers/change_password.handler.ts @@ -9,41 +9,25 @@ import { toChangePasswordInput, toChangePasswordResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * ChangePassword Handler + * HTTP 适配层:处理修改密码的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function changePasswordHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: ChangePasswordRequest }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); + const userId = requireUserId(req); - const input = toChangePasswordInput(userId, req.body); - const output = await deps.userService.changePassword(req, input); - - reply.code(200).send(toChangePasswordResponse(output.success, output.message)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '修改密码失败', - }); - } -} + const input = toChangePasswordInput(userId, req.body); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.userService.changePassword(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'USER_NOT_FOUND') { - return 404; - } - if (errorCode === 'INVALID_PASSWORD' || errorCode === 'WEAK_PASSWORD' || errorCode === 'PASSWORD_TOO_LONG') { - return 400; - } - if (errorCode === 'UNAUTHORIZED') { - return 401; - } - return 500; + reply.code(200).send(toChangePasswordResponse(output.success, output.message)); } diff --git a/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts b/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts index 5dc5092..4b63262 100644 --- a/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts +++ b/backend-nodejs/domains/user/handlers/get_user_profile.handler.ts @@ -9,43 +9,32 @@ import { toGetUserProfileInput, toGetUserProfileResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * GetUserProfile Handler + * HTTP 适配层:处理获取用户资料的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function getUserProfileHandler( deps: HandlerDependencies, req: FastifyRequest, reply: FastifyReply ): Promise { - try { - // 1. 获取用户 ID(从 JWT Token 中提取) - const userId = requireUserId(req); + // 1. 获取用户 ID(从 JWT Token 中提取) + const userId = requireUserId(req); - // 2. 转换为 Domain Input - const input = toGetUserProfileInput(userId); + // 2. 转换为 Domain Input + const input = toGetUserProfileInput(userId); - // 3. 调用 Domain Service - const output = await deps.userService.getUserProfile(req, input); + // 3. 创建请求上下文 + const ctx = createContextFromRequest({ userId, ...req }); - // 4. 转换为 HTTP 响应 - reply.code(200).send(toGetUserProfileResponse(output.user)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '获取用户资料失败', - }); - } -} + // 4. 调用 Domain Service(错误会自动向上抛出,由全局错误处理中间件处理) + const output = await deps.userService.getUserProfile(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'USER_NOT_FOUND') { - return 404; - } - if (errorCode === 'UNAUTHORIZED') { - return 401; - } - return 500; + // 4. 转换为 HTTP 响应 + reply.code(200).send(toGetUserProfileResponse(output.user)); } diff --git a/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts b/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts index b7c17bd..e64d2e2 100644 --- a/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts +++ b/backend-nodejs/domains/user/handlers/update_user_profile.handler.ts @@ -9,41 +9,25 @@ import { toUpdateUserProfileInput, toUpdateUserProfileResponse, } from './converters.js'; -import { parseErrorCode } from '../errors/errors.js'; import { requireUserId } from '../../../infrastructure/middleware/auth.js'; +import { createContextFromRequest } from '../../../shared/types/context.js'; +/** + * UpdateUserProfile Handler + * HTTP 适配层:处理更新用户资料的 HTTP 请求 + * 错误由全局错误处理中间件统一处理 + */ export async function updateUserProfileHandler( deps: HandlerDependencies, req: FastifyRequest<{ Body: UpdateUserProfileRequest }>, reply: FastifyReply ): Promise { - try { - const userId = requireUserId(req); + const userId = requireUserId(req); - const input = toUpdateUserProfileInput(userId, req.body); - const output = await deps.userService.updateUserProfile(req, input); - - reply.code(200).send(toUpdateUserProfileResponse(output.user)); - } catch (error) { - const errorCode = parseErrorCode(error); - const statusCode = getStatusCode(errorCode); - reply.code(statusCode).send({ - error: errorCode, - message: error instanceof Error ? error.message : '更新用户资料失败', - }); - } -} + const input = toUpdateUserProfileInput(userId, req.body); + const ctx = createContextFromRequest({ userId, ...req }); + const output = await deps.userService.updateUserProfile(ctx, input); -function getStatusCode(errorCode: string): number { - if (errorCode === 'USER_NOT_FOUND') { - return 404; - } - if (errorCode === 'USERNAME_ALREADY_EXISTS' || errorCode === 'INVALID_USERNAME' || errorCode === 'FULL_NAME_TOO_LONG' || errorCode === 'INVALID_AVATAR_URL') { - return 400; - } - if (errorCode === 'UNAUTHORIZED') { - return 401; - } - return 500; + reply.code(200).send(toUpdateUserProfileResponse(output.user)); } diff --git a/backend-nodejs/domains/user/http/dto/user.ts b/backend-nodejs/domains/user/http/dto/user.ts index decbab5..7a244c8 100644 --- a/backend-nodejs/domains/user/http/dto/user.ts +++ b/backend-nodejs/domains/user/http/dto/user.ts @@ -3,6 +3,8 @@ * 定义 HTTP 请求和响应的数据结构 */ +import { z } from 'zod'; + // ======================================== // GetUserProfile // ======================================== @@ -24,11 +26,13 @@ export interface GetUserProfileResponse { // UpdateUserProfile // ======================================== -export interface UpdateUserProfileRequest { - username?: string; - full_name?: string; - avatar_url?: string; -} +export const UpdateUserProfileRequestSchema = z.object({ + username: z.string().min(3, '用户名至少 3 个字符').max(30, '用户名最多 30 个字符').regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线').optional(), + full_name: z.string().max(100, '全名最多 100 个字符').optional(), + avatar_url: z.string().url('头像 URL 格式无效').optional(), +}); + +export type UpdateUserProfileRequest = z.infer; export interface UpdateUserProfileResponse { user_id: string; @@ -42,10 +46,12 @@ export interface UpdateUserProfileResponse { // ChangePassword // ======================================== -export interface ChangePasswordRequest { - old_password: string; - new_password: string; -} +export const ChangePasswordRequestSchema = z.object({ + old_password: z.string().min(1, '旧密码不能为空'), + new_password: z.string().min(8, '新密码至少 8 个字符').max(128, '新密码最多 128 个字符'), +}); + +export type ChangePasswordRequest = z.infer; export interface ChangePasswordResponse { success: boolean; diff --git a/backend-nodejs/domains/user/http/router.ts b/backend-nodejs/domains/user/http/router.ts index bd9f6b3..977912b 100644 --- a/backend-nodejs/domains/user/http/router.ts +++ b/backend-nodejs/domains/user/http/router.ts @@ -6,6 +6,10 @@ import type { FastifyInstance } from 'fastify'; import type { HandlerDependencies } from '../handlers/dependencies.js'; import type { UpdateUserProfileRequest, ChangePasswordRequest } from './dto/user.js'; +import { + UpdateUserProfileRequestSchema, + ChangePasswordRequestSchema, +} from './dto/user.js'; import { getUserProfileHandler } from '../handlers/get_user_profile.handler.js'; import { updateUserProfileHandler } from '../handlers/update_user_profile.handler.js'; import { changePasswordHandler } from '../handlers/change_password.handler.js'; @@ -31,18 +35,28 @@ export function registerUserRoutes( // PUT /api/users/me - 更新用户资料(需要认证) app.put<{ Body: UpdateUserProfileRequest }>( '/api/users/me', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + body: UpdateUserProfileRequestSchema, + }, + }, async (req, reply) => { - await updateUserProfileHandler(deps, req as any, reply); + await updateUserProfileHandler(deps, req, reply); } ); // POST /api/users/me/change-password - 修改密码(需要认证) app.post<{ Body: ChangePasswordRequest }>( '/api/users/me/change-password', - { preHandler: authMiddleware }, + { + preHandler: authMiddleware, + schema: { + body: ChangePasswordRequestSchema, + }, + }, async (req, reply) => { - await changePasswordHandler(deps, req as any, reply); + await changePasswordHandler(deps, req, reply); } ); } diff --git a/backend-nodejs/domains/user/model/user.ts b/backend-nodejs/domains/user/model/user.ts index a0f3813..c09b7ca 100644 --- a/backend-nodejs/domains/user/model/user.ts +++ b/backend-nodejs/domains/user/model/user.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'; import * as bcrypt from 'bcryptjs'; +import { createError } from '../../../shared/errors/errors.js'; // 用户状态 export type UserStatus = 'active' | 'inactive' | 'banned'; @@ -62,7 +63,7 @@ export class User { // 验证邮箱 const normalizedEmail = normalizeEmail(email); if (!isValidEmail(normalizedEmail)) { - throw new Error('INVALID_EMAIL: 邮箱格式无效'); + throw createError('INVALID_EMAIL', '邮箱格式无效'); } // 验证密码 @@ -117,7 +118,7 @@ export class User { // 验证用户名(如果提供) if (username !== undefined && username !== '') { if (!isValidUsername(username)) { - throw new Error('INVALID_USERNAME: 用户名格式无效(3-30 字符,仅字母数字下划线)'); + throw createError('VALIDATION_ERROR', '用户名格式无效(3-30 字符,仅字母数字下划线)'); } this.username = username; } @@ -125,7 +126,7 @@ export class User { // 验证全名 if (fullName !== undefined && fullName !== '') { if (fullName.length > 100) { - throw new Error('FULL_NAME_TOO_LONG: 全名过长(最多 100 字符)'); + throw createError('VALIDATION_ERROR', '全名过长(最多 100 字符)'); } this.fullName = fullName; } @@ -133,7 +134,7 @@ export class User { // 验证头像 URL(如果提供) if (avatarURL !== undefined && avatarURL !== '') { if (!avatarURL.startsWith('http://') && !avatarURL.startsWith('https://')) { - throw new Error('INVALID_AVATAR_URL: 头像 URL 格式无效'); + throw createError('VALIDATION_ERROR', '头像 URL 格式无效'); } this.avatarURL = avatarURL; } @@ -179,7 +180,7 @@ export class User { */ canLogin(): void { if (this.status === UserStatuses.Banned) { - throw new Error('USER_BANNED: 用户已被禁用'); + throw createError('UNAUTHORIZED', '用户已被禁用'); } // 注意:我们允许 Inactive 用户登录,但可能限制某些功能 } @@ -217,10 +218,10 @@ function isValidUsername(username: string): boolean { */ function validatePassword(password: string): void { if (password.length < 8) { - throw new Error('WEAK_PASSWORD: 密码强度不足(至少 8 字符)'); + throw createError('PASSWORD_TOO_SHORT', '密码强度不足(至少 8 字符)'); } if (password.length > 128) { - throw new Error('PASSWORD_TOO_LONG: 密码过长(最多 128 字符)'); + throw createError('VALIDATION_ERROR', '密码过长(最多 128 字符)'); } } diff --git a/backend-nodejs/domains/user/repository/interface.ts b/backend-nodejs/domains/user/repository/interface.ts index 4ae1b1b..431774c 100644 --- a/backend-nodejs/domains/user/repository/interface.ts +++ b/backend-nodejs/domains/user/repository/interface.ts @@ -4,6 +4,7 @@ */ import type { User } from '../model/user.js'; +import type { RequestContext } from '../../../shared/types/context.js'; /** * UserRepository 用户仓储接口 @@ -12,41 +13,41 @@ export interface UserRepository { /** * 创建用户 */ - create(ctx: unknown, user: User): Promise; + create(ctx: RequestContext, user: User): Promise; /** * 根据 ID 获取用户 */ - getById(ctx: unknown, userId: string): Promise; + getById(ctx: RequestContext, userId: string): Promise; /** * 根据邮箱获取用户 */ - getByEmail(ctx: unknown, email: string): Promise; + getByEmail(ctx: RequestContext, email: string): Promise; /** * 根据用户名获取用户 */ - getByUsername(ctx: unknown, username: string): Promise; + getByUsername(ctx: RequestContext, username: string): Promise; /** * 更新用户信息 */ - update(ctx: unknown, user: User): Promise; + update(ctx: RequestContext, user: User): Promise; /** * 删除用户 */ - delete(ctx: unknown, userId: string): Promise; + delete(ctx: RequestContext, userId: string): Promise; /** * 检查邮箱是否存在 */ - existsByEmail(ctx: unknown, email: string): Promise; + existsByEmail(ctx: RequestContext, email: string): Promise; /** * 检查用户名是否存在 */ - existsByUsername(ctx: unknown, username: string): Promise; + existsByUsername(ctx: RequestContext, username: string): Promise; } diff --git a/backend-nodejs/domains/user/repository/user_repo.ts b/backend-nodejs/domains/user/repository/user_repo.ts index ed0cda0..7467a78 100644 --- a/backend-nodejs/domains/user/repository/user_repo.ts +++ b/backend-nodejs/domains/user/repository/user_repo.ts @@ -7,11 +7,13 @@ import type { Kysely } from 'kysely'; import type { Database } from '../../../infrastructure/persistence/postgres/database.js'; import { User } from '../model/user.js'; import type { UserRepository } from './interface.js'; +import { createError } from '../../../shared/errors/errors.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export class UserRepositoryImpl implements UserRepository { constructor(private db: Kysely) {} - async create(_ctx: unknown, user: User): Promise { + async create(_ctx: RequestContext, user: User): Promise { try { await this.db .insertInto('users') @@ -34,17 +36,17 @@ export class UserRepositoryImpl implements UserRepository { if (error.code === '23505') { // unique_violation if (error.constraint === 'users_email_key' || error.constraint === 'idx_users_email') { - throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + throw createError('EMAIL_ALREADY_EXISTS', '邮箱已被占用'); } if (error.constraint === 'users_username_key' || error.constraint === 'idx_users_username') { - throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + throw createError('VALIDATION_ERROR', '用户名已被占用'); } } - throw new Error(`创建用户失败: ${error.message}`); + throw createError('INTERNAL_SERVER_ERROR', `创建用户失败: ${error.message}`); } } - async getById(_ctx: unknown, userId: string): Promise { + async getById(_ctx: RequestContext, userId: string): Promise { const userRow = await this.db .selectFrom('users') .selectAll() @@ -58,7 +60,7 @@ export class UserRepositoryImpl implements UserRepository { return this.toDomainModel(userRow); } - async getByEmail(_ctx: unknown, email: string): Promise { + async getByEmail(_ctx: RequestContext, email: string): Promise { const normalizedEmail = email.toLowerCase().trim(); const userRow = await this.db .selectFrom('users') @@ -73,7 +75,7 @@ export class UserRepositoryImpl implements UserRepository { return this.toDomainModel(userRow); } - async getByUsername(_ctx: unknown, username: string): Promise { + async getByUsername(_ctx: RequestContext, username: string): Promise { const userRow = await this.db .selectFrom('users') .selectAll() @@ -87,7 +89,7 @@ export class UserRepositoryImpl implements UserRepository { return this.toDomainModel(userRow); } - async update(_ctx: unknown, user: User): Promise { + async update(_ctx: RequestContext, user: User): Promise { try { const result = await this.db .updateTable('users') @@ -106,37 +108,39 @@ export class UserRepositoryImpl implements UserRepository { .execute(); if (result.length === 0) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } } catch (error: any) { + // 如果是 DomainError,直接抛出 + if (error.name === 'DomainError') { + throw error; + } + // 检查是否是唯一性约束冲突 if (error.code === '23505') { if (error.constraint === 'users_email_key' || error.constraint === 'idx_users_email') { - throw new Error('EMAIL_ALREADY_EXISTS: 邮箱已被占用'); + throw createError('EMAIL_ALREADY_EXISTS', '邮箱已被占用'); } if (error.constraint === 'users_username_key' || error.constraint === 'idx_users_username') { - throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + throw createError('VALIDATION_ERROR', '用户名已被占用'); } } - if (error.message.includes('USER_NOT_FOUND')) { - throw error; - } - throw new Error(`更新用户失败: ${error.message}`); + throw createError('INTERNAL_SERVER_ERROR', `更新用户失败: ${error.message}`); } } - async delete(_ctx: unknown, userId: string): Promise { + async delete(_ctx: RequestContext, userId: string): Promise { const result = await this.db .deleteFrom('users') .where('id', '=', userId) .execute(); if (result.length === 0) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } } - async existsByEmail(_ctx: unknown, email: string): Promise { + async existsByEmail(_ctx: RequestContext, email: string): Promise { const normalizedEmail = email.toLowerCase().trim(); const result = await this.db .selectFrom('users') @@ -147,7 +151,7 @@ export class UserRepositoryImpl implements UserRepository { return Number(result?.count || 0) > 0; } - async existsByUsername(_ctx: unknown, username: string): Promise { + async existsByUsername(_ctx: RequestContext, username: string): Promise { const result = await this.db .selectFrom('users') .select((eb) => eb.fn.count('id').as('count')) @@ -181,3 +185,4 @@ export class UserRepositoryImpl implements UserRepository { } } + diff --git a/backend-nodejs/domains/user/service/user_service.ts b/backend-nodejs/domains/user/service/user_service.ts index dbee0d8..c5dfc9f 100644 --- a/backend-nodejs/domains/user/service/user_service.ts +++ b/backend-nodejs/domains/user/service/user_service.ts @@ -5,6 +5,8 @@ import type { User } from '../model/user.js'; import type { UserRepository } from '../repository/interface.js'; +import { createError } from '../../../shared/errors/errors.js'; +import type { RequestContext } from '../../../shared/types/context.js'; export interface GetUserProfileInput { userId: string; @@ -45,11 +47,11 @@ export class UserService { /** * 获取用户资料 */ - async getUserProfile(ctx: unknown, input: GetUserProfileInput): Promise { + async getUserProfile(ctx: RequestContext, input: GetUserProfileInput): Promise { // Step 1: 从数据库获取用户 const user = await this.userRepo.getById(ctx, input.userId); if (!user) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } // Step 2: 返回用户信息(不包含敏感字段) @@ -60,7 +62,7 @@ export class UserService { * 更新用户资料 */ async updateUserProfile( - ctx: unknown, + ctx: RequestContext, input: UpdateUserProfileInput ): Promise { // Step 1: 验证输入(由 Model 层的验证逻辑处理) @@ -68,14 +70,14 @@ export class UserService { // Step 2: 获取用户 const user = await this.userRepo.getById(ctx, input.userId); if (!user) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } // Step 3: 检查用户名是否已被占用(如果修改了用户名) if (input.username && input.username !== user.username) { const exists = await this.userRepo.existsByUsername(ctx, input.username); if (exists) { - throw new Error('USERNAME_ALREADY_EXISTS: 用户名已被占用'); + throw createError('VALIDATION_ERROR', '用户名已被占用'); } } @@ -99,7 +101,7 @@ export class UserService { * 修改密码 */ async changePassword( - ctx: unknown, + ctx: RequestContext, input: ChangePasswordInput ): Promise { // Step 1: 验证输入(密码强度由 Model 层验证) @@ -107,13 +109,13 @@ export class UserService { // Step 2: 获取用户 const user = await this.userRepo.getById(ctx, input.userId); if (!user) { - throw new Error('USER_NOT_FOUND: 用户不存在'); + throw createError('USER_NOT_FOUND', '用户不存在'); } // Step 3: 验证旧密码 const isValid = await user.verifyPassword(input.oldPassword); if (!isValid) { - throw new Error('INVALID_PASSWORD: 密码错误'); + throw createError('VALIDATION_ERROR', '密码错误'); } // Step 4 & 5: 更新密码(包含哈希) diff --git a/backend-nodejs/infrastructure/bootstrap/dependencies.ts b/backend-nodejs/infrastructure/bootstrap/dependencies.ts new file mode 100644 index 0000000..9cb5ac4 --- /dev/null +++ b/backend-nodejs/infrastructure/bootstrap/dependencies.ts @@ -0,0 +1,130 @@ +/** + * 依赖注入容器 + * 统一管理所有领域依赖,遵循 DDD 三层架构 + * + * 依赖注入顺序(从内到外): + * 1. Repository Layer(基础设施层):数据访问 + * 2. Domain Service Layer(领域层):业务逻辑 + * 3. Handler Dependencies(Handler 层):HTTP 适配 + * + * 遵循依赖注入原则:外层依赖内层,内层不依赖外层 + */ + +import type { Kysely } from 'kysely'; +import type { RedisClientType } from 'redis'; +import type { Database } from '../persistence/postgres/database.js'; +import type { Config } from '../config/config.js'; + +// Repository 实现 +import { TaskRepositoryImpl } from '../../domains/task/repository/task_repo.js'; +import { UserRepositoryImpl } from '../../domains/user/repository/user_repo.js'; + +// Service 层 +import { TaskService } from '../../domains/task/service/task_service.js'; +import { UserService } from '../../domains/user/service/user_service.js'; +import { JWTService } from '../../domains/auth/service/jwt_service.js'; +import { AuthService } from '../../domains/auth/service/auth_service.js'; + +// Handler Dependencies +import type { HandlerDependencies as TaskHandlerDependencies } from '../../domains/task/handlers/dependencies.js'; +import type { HandlerDependencies as UserHandlerDependencies } from '../../domains/user/handlers/dependencies.js'; +import type { HandlerDependencies as AuthHandlerDependencies } from '../../domains/auth/handlers/dependencies.js'; + +// Middleware +import { createAuthMiddleware } from '../middleware/auth.js'; + +/** + * 应用依赖容器 + * 包含所有领域的 Handler Dependencies 和 Middleware + */ +export interface AppContainer { + // Auth 领域 + authHandlerDeps: AuthHandlerDependencies; + authMiddleware: ReturnType; + + // User 领域 + userHandlerDeps: UserHandlerDependencies; + + // Task 领域 + taskHandlerDeps: TaskHandlerDependencies; +} + +/** + * 初始化应用依赖(DDD 三层架构) + * + * @param config 应用配置 + * @param db 数据库连接(Kysely) + * @param _redis Redis 连接(可选,保留用于未来扩展) + * @returns 应用依赖容器 + */ +export function initDependencies( + config: Config, + db: Kysely, + _redis: RedisClientType | null = null +): AppContainer { + // ============================================ + // Repository Layer(基础设施层):数据访问 + // ============================================ + const userRepo = new UserRepositoryImpl(db); + const taskRepo = new TaskRepositoryImpl(db); + + // ============================================ + // Domain Service Layer(领域层):业务逻辑 + // ============================================ + + // JWT Service(用于 Token 生成和验证) + const jwtService = new JWTService({ + secret: config.jwt.secret, + accessTokenExpiry: config.jwt.accessTokenExpiry, + refreshTokenExpiry: config.jwt.refreshTokenExpiry, + issuer: config.jwt.issuer, + }); + + // Auth Service(依赖 User Repository) + const authService = new AuthService(userRepo, jwtService); + + // User Service + const userService = new UserService(userRepo); + + // Task Service + const taskService = new TaskService(taskRepo); + + // ============================================ + // Handler Dependencies(Handler 层):HTTP 适配 + // ============================================ + const authHandlerDeps: AuthHandlerDependencies = { + authService, + }; + + const userHandlerDeps: UserHandlerDependencies = { + userService, + }; + + const taskHandlerDeps: TaskHandlerDependencies = { + taskService, + }; + + // ============================================ + // Middleware + // ============================================ + const authMiddleware = createAuthMiddleware(jwtService); + + // ============================================ + // Extension point: 其他领域依赖注入 + // ============================================ + // 示例:添加 LLM 领域 + // + // const llmRepo = new LLMRepositoryImpl(db); + // const llmService = new LLMService(llmRepo); + // const llmHandlerDeps: LLMHandlerDependencies = { + // llmService, + // }; + + return { + authHandlerDeps, + authMiddleware, + userHandlerDeps, + taskHandlerDeps, + }; +} + diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index b245227..86f0cfc 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -10,6 +10,8 @@ import type { Kysely } from 'kysely'; import type { Database } from '../persistence/postgres/database.js'; import type { RedisClientType } from 'redis'; import { checkHealth } from '../monitoring/health/health.js'; +import type { AuthMiddleware } from '../middleware/types.js'; +import { isDomainError } from '../../shared/errors/errors.js'; /** * 创建 Fastify 服务器 @@ -51,21 +53,33 @@ export async function registerMiddleware(fastify: FastifyInstance): Promise { - const statusCode = error.statusCode || 500; - const message = error.message || 'Internal Server Error'; - + // Error handling (统一错误处理) + fastify.setErrorHandler((error: Error, request: FastifyRequest, reply: FastifyReply) => { request.log.error({ err: error, url: request.url, method: request.method, }, 'Request error'); - reply.code(statusCode).send({ + // 处理领域错误 + if (isDomainError(error)) { + return reply.code(error.statusCode).send({ + error: { + code: error.code, + message: error.message, + }, + }); + } + + // 处理其他错误(包含 statusCode 的 HTTP 错误) + const httpError = error as Error & { statusCode?: number }; + const statusCode = httpError.statusCode || 500; + const message = httpError.message || 'Internal Server Error'; + + return reply.code(statusCode).send({ error: { - message, - statusCode, + code: 'INTERNAL_SERVER_ERROR', + message: statusCode === 500 ? 'Internal server error' : message, }, }); }); @@ -114,7 +128,7 @@ export function registerRoutes( export async function registerDomainRoutes( fastify: FastifyInstance, handlerDeps: Record, - authMiddleware: unknown + authMiddleware: AuthMiddleware ): Promise { // 动态导入并注册 Task 路由 if (handlerDeps.task) { diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts index 01c7d56..6ffa5e5 100644 --- a/backend-nodejs/infrastructure/config/config.ts +++ b/backend-nodejs/infrastructure/config/config.ts @@ -1,76 +1,100 @@ /** * 配置管理模块 - * 从环境变量读取配置,提供类型安全的配置对象 + * 从环境变量读取配置,使用 Zod 进行验证,提供类型安全的配置对象 */ -export interface Config { - server: { - host: string; - port: number; - env: string; - }; - database: { - host: string; - port: number; - user: string; - password: string; - database: string; - sslMode: string; - maxConnections: number; - idleTimeout: number; - connectionTimeout: number; - }; - redis: { - host: string; - port: number; - password: string; - db: number; - }; - jwt: { - secret: string; - accessTokenExpiry: number; // 秒 - refreshTokenExpiry: number; // 秒 - issuer: string; - }; -} +import { z } from 'zod'; + +/** + * 配置 Schema(使用 Zod 进行验证) + */ +const ConfigSchema = z.object({ + server: z.object({ + host: z.string().default('0.0.0.0'), + port: z.coerce.number().int().positive().default(8080), + env: z.enum(['development', 'production', 'test']).default('development'), + }), + database: z.object({ + host: z.string().default('localhost'), + port: z.coerce.number().int().positive().default(5432), + user: z.string().min(1, 'Database user is required'), + password: z.string().min(1, 'Database password is required'), + database: z.string().min(1, 'Database name is required'), + sslMode: z.enum(['disable', 'require', 'verify-ca', 'verify-full']).default('disable'), + maxConnections: z.coerce.number().int().positive().default(25), + idleTimeout: z.coerce.number().int().nonnegative().default(10000), + connectionTimeout: z.coerce.number().int().nonnegative().default(2000), + }), + redis: z.object({ + host: z.string().default('localhost'), + port: z.coerce.number().int().positive().default(6379), + password: z.string().default(''), + db: z.coerce.number().int().nonnegative().default(0), + }), + jwt: z.object({ + secret: z.string().min(32, 'JWT secret must be at least 32 characters'), + accessTokenExpiry: z.coerce.number().int().positive().default(3600), // 1 小时 + refreshTokenExpiry: z.coerce.number().int().positive().default(604800), // 7 天 + issuer: z.string().default('go-genai-stack'), + }), +}); + +/** + * 配置类型(从 Schema 自动推断) + */ +export type Config = z.infer; /** * 加载配置 - * 从环境变量读取配置,提供默认值 + * 从环境变量读取配置,使用 Zod 进行验证 + * + * @throws {Error} 配置验证失败时抛出错误 */ export function loadConfig(): Config { - return { - server: { - host: process.env.SERVER_HOST || '0.0.0.0', - port: parseInt(process.env.SERVER_PORT || process.env.PORT || '8080', 10), - env: process.env.NODE_ENV || 'development', - }, - database: { - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5435', 10), - user: process.env.DATABASE_USER || 'postgres', - password: process.env.DATABASE_PASSWORD || 'postgres', - database: process.env.DATABASE_NAME || 'go_genai_stack_backend_debug', - sslMode: process.env.DATABASE_SSL_MODE || 'disable', - maxConnections: parseInt(process.env.DATABASE_MAX_CONNECTIONS || '25', 10), - idleTimeout: parseInt(process.env.DATABASE_IDLE_TIMEOUT || '10000', 10), - connectionTimeout: parseInt( - process.env.DATABASE_CONNECTION_TIMEOUT || '2000', - 10 - ), - }, - 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), - }, - jwt: { - secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', - accessTokenExpiry: parseInt(process.env.JWT_ACCESS_TOKEN_EXPIRY || '3600', 10), // 1 小时 - refreshTokenExpiry: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRY || '604800', 10), // 7 天 - issuer: process.env.JWT_ISSUER || 'go-genai-stack', - }, - }; + try { + const rawConfig = { + server: { + host: process.env.SERVER_HOST, + port: process.env.SERVER_PORT || process.env.PORT, + env: process.env.NODE_ENV, + }, + database: { + host: process.env.DATABASE_HOST, + port: process.env.DATABASE_PORT, + user: process.env.DATABASE_USER, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + sslMode: process.env.DATABASE_SSL_MODE, + maxConnections: process.env.DATABASE_MAX_CONNECTIONS, + idleTimeout: process.env.DATABASE_IDLE_TIMEOUT, + connectionTimeout: process.env.DATABASE_CONNECTION_TIMEOUT, + }, + redis: { + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, + password: process.env.REDIS_PASSWORD, + db: process.env.REDIS_DB, + }, + jwt: { + secret: process.env.JWT_SECRET, + accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY, + refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY, + issuer: process.env.JWT_ISSUER, + }, + }; + + return ConfigSchema.parse(rawConfig); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('❌ Configuration validation failed:'); + error.errors.forEach((err) => { + const path = err.path.join('.'); + console.error(` ${path}: ${err.message}`); + }); + console.error('\nPlease check your environment variables and .env file.'); + process.exit(1); + } + throw error; + } } diff --git a/backend-nodejs/infrastructure/middleware/types.ts b/backend-nodejs/infrastructure/middleware/types.ts new file mode 100644 index 0000000..cb6d1be --- /dev/null +++ b/backend-nodejs/infrastructure/middleware/types.ts @@ -0,0 +1,14 @@ +/** + * 中间件类型定义 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; + +/** + * 认证中间件类型 + */ +export type AuthMiddleware = ( + request: FastifyRequest, + reply: FastifyReply +) => Promise; + diff --git a/backend-nodejs/infrastructure/persistence/postgres/transaction.ts b/backend-nodejs/infrastructure/persistence/postgres/transaction.ts new file mode 100644 index 0000000..9cbd7cd --- /dev/null +++ b/backend-nodejs/infrastructure/persistence/postgres/transaction.ts @@ -0,0 +1,70 @@ +/** + * 事务管理工具 + * 提供事务执行辅助函数,自动处理事务的开始、提交和回滚 + * 参考 Go 后端的 WithTransaction 实现 + */ + +import type { Kysely, Transaction } from 'kysely'; +import type { Database } from './database.js'; + +/** + * 事务函数类型 + * 在事务中执行的业务逻辑函数 + * 如果返回错误,事务会自动回滚 + * 如果返回正常,事务会自动提交 + */ +export type TransactionFunction = (trx: Transaction) => Promise; + +/** + * 在事务中执行函数 + * 自动处理事务的开始、提交和回滚 + * + * @param db 数据库连接 + * @param fn 在事务中执行的函数 + * @returns 函数执行结果 + * + * @example + * ```typescript + * await withTransaction(db, async (trx) => { + * await trx.insertInto('tasks').values({...}).execute(); + * await trx.insertInto('task_tags').values({...}).execute(); + * // 如果任何操作失败,事务会自动回滚 + * }); + * ``` + */ +export async function withTransaction( + db: Kysely, + fn: TransactionFunction +): Promise { + return await db.transaction().execute(async (trx) => { + return await fn(trx); + }); +} + +/** + * 在只读事务中执行函数 + * 适用于需要一致性读取但不修改数据的场景 + * + * @param db 数据库连接 + * @param fn 在事务中执行的函数 + * @returns 函数执行结果 + * + * @example + * ```typescript + * await withReadOnlyTransaction(db, async (trx) => { + * const tasks = await trx.selectFrom('tasks').selectAll().execute(); + * // 只读操作,不会修改数据 + * }); + * ``` + */ +export async function withReadOnlyTransaction( + db: Kysely, + fn: TransactionFunction +): Promise { + // Kysely 的事务默认是读写的,只读事务需要在数据库层面配置 + // 这里使用普通事务,但可以通过注释说明这是只读操作 + return await db.transaction().execute(async (trx) => { + return await fn(trx); + }); +} + diff --git a/backend-nodejs/infrastructure/testing/test_setup.ts b/backend-nodejs/infrastructure/testing/test_setup.ts new file mode 100644 index 0000000..aca1f2f --- /dev/null +++ b/backend-nodejs/infrastructure/testing/test_setup.ts @@ -0,0 +1,157 @@ +/** + * 统一测试基础设施 + * 提供测试数据库连接管理和通用测试辅助函数 + */ + +import type { Kysely } from 'kysely'; +import type { Database } from '../persistence/postgres/database.js'; +import { createDatabaseConnection } from '../persistence/postgres/connection.js'; +import { loadConfig } from '../config/config.js'; +import bcrypt from 'bcryptjs'; + +// ========== 测试数据库连接管理 ========== + +let testDb: Kysely | null = null; + +/** + * 获取测试数据库连接 + * 使用单例模式,避免重复创建连接 + */ +export async function getTestDatabase(): Promise> { + if (!testDb) { + const config = loadConfig(); + + // 使用测试数据库配置(从环境变量读取,默认使用主数据库) + const testDbConfig = { + host: process.env.TEST_DATABASE_HOST || config.database.host, + port: parseInt(process.env.TEST_DATABASE_PORT || String(config.database.port), 10), + user: process.env.TEST_DATABASE_USER || config.database.user, + password: process.env.TEST_DATABASE_PASSWORD || config.database.password, + database: process.env.TEST_DATABASE_NAME || config.database.database, + sslMode: config.database.sslMode, + maxConnections: 10, // 测试环境使用较少的连接数 + idleTimeout: config.database.idleTimeout, + connectionTimeout: config.database.connectionTimeout, + }; + + testDb = createDatabaseConnection(testDbConfig); + } + return testDb; +} + +/** + * 清理测试数据库连接 + */ +export async function cleanupTestDatabase(): Promise { + if (testDb) { + await testDb.destroy(); + testDb = null; + } +} + +// ========== 测试常量 ========== + +export const TEST_USER_ID = '00000000-0000-0000-0000-000000000001'; +export const TEST_USER_EMAIL = 'test@example.com'; +export const TEST_USER_PASSWORD = 'test-password-123'; +export const TEST_USER_USERNAME = 'testuser'; +export const TEST_USER_FULL_NAME = 'Test User'; + +export const TEST_TASK_ID = '00000000-0000-0000-0000-000000000002'; +export const TEST_TASK_TITLE = 'Test Task'; +export const TEST_TASK_DESCRIPTION = 'Test Description'; + +// ========== 通用测试辅助函数 ========== + +/** + * 确保测试用户存在 + * 如果用户不存在,则创建它 + */ +export async function ensureTestUser(db: Kysely): Promise { + const existingUser = await db + .selectFrom('users') + .select('id') + .where('id', '=', TEST_USER_ID) + .executeTakeFirst(); + + if (!existingUser) { + const passwordHash = await bcrypt.hash(TEST_USER_PASSWORD, 10); + await db + .insertInto('users') + .values({ + id: TEST_USER_ID, + email: TEST_USER_EMAIL, + username: TEST_USER_USERNAME, + password_hash: passwordHash, + full_name: TEST_USER_FULL_NAME, + avatar_url: null, + status: 'active', + email_verified: true, + created_at: new Date(), + updated_at: new Date(), + last_login_at: null, + }) + .execute(); + } +} + +/** + * 清理测试数据 + * 删除测试用户和所有相关数据 + */ +export async function cleanupTestData(db: Kysely): Promise { + // 删除测试任务 + await db + .deleteFrom('task_tags') + .where('task_id', 'in', (eb) => + eb + .selectFrom('tasks') + .select('id') + .where('user_id', '=', TEST_USER_ID) + ) + .execute(); + + await db + .deleteFrom('tasks') + .where('user_id', '=', TEST_USER_ID) + .execute(); + + // 删除测试用户 + await db + .deleteFrom('users') + .where('id', '=', TEST_USER_ID) + .execute(); +} + +/** + * 清理所有测试数据(更彻底) + */ +export async function cleanupAllTestData(db: Kysely): Promise { + // 删除所有任务标签 + await db.deleteFrom('task_tags').execute(); + + // 删除所有任务 + await db.deleteFrom('tasks').execute(); + + // 删除所有用户(除了系统用户) + await db + .deleteFrom('users') + .where('id', '!=', '00000000-0000-0000-0000-000000000000') + .execute(); +} + +/** + * 重置测试数据库(清空所有表) + * 注意:仅在测试环境中使用,生产环境禁用 + */ +export async function resetTestDatabase(db: Kysely): Promise { + if (process.env.NODE_ENV === 'production') { + throw new Error('resetTestDatabase should not be called in production'); + } + + // 按依赖顺序删除数据 + await db.deleteFrom('task_tags').execute(); + await db.deleteFrom('tasks').execute(); + await db.deleteFrom('users').execute(); +} + diff --git a/backend-nodejs/shared/errors/errors.ts b/backend-nodejs/shared/errors/errors.ts new file mode 100644 index 0000000..aeffe88 --- /dev/null +++ b/backend-nodejs/shared/errors/errors.ts @@ -0,0 +1,171 @@ +/** + * 统一错误处理机制 + * 定义领域错误类型和错误码,与 Go 后端保持一致 + */ + +/** + * 领域错误类 + * 包含错误码、消息和 HTTP 状态码 + */ +export class DomainError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly statusCode: number = 400 + ) { + super(message); + this.name = 'DomainError'; + // 保持错误堆栈 + if (Error.captureStackTrace) { + Error.captureStackTrace(this, DomainError); + } + } +} + +/** + * 错误码定义 + * 与 Go 后端的错误码保持一致 + */ +export const ErrorCodes = { + // Auth 错误 + UNAUTHORIZED: 'UNAUTHORIZED', + INVALID_TOKEN: 'INVALID_TOKEN', + INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', + TOKEN_EXPIRED: 'TOKEN_EXPIRED', + REFRESH_TOKEN_INVALID: 'REFRESH_TOKEN_INVALID', + + // User 错误 + USER_NOT_FOUND: 'USER_NOT_FOUND', + EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS', + PASSWORD_TOO_SHORT: 'PASSWORD_TOO_SHORT', + INVALID_EMAIL: 'INVALID_EMAIL', + + // Task 错误 + TASK_NOT_FOUND: 'TASK_NOT_FOUND', + TASK_TITLE_EMPTY: 'TASK_TITLE_EMPTY', + TASK_ALREADY_COMPLETED: 'TASK_ALREADY_COMPLETED', + TASK_ALREADY_DELETED: 'TASK_ALREADY_DELETED', + INVALID_PRIORITY: 'INVALID_PRIORITY', + INVALID_STATUS: 'INVALID_STATUS', + + // 通用错误 + INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + NOT_FOUND: 'NOT_FOUND', +} as const; + +/** + * 错误码类型 + */ +export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]; + +/** + * 默认 HTTP 状态码映射 + */ +const DEFAULT_STATUS_CODES: Record = { + // Auth 错误 + UNAUTHORIZED: 401, + INVALID_TOKEN: 401, + INVALID_CREDENTIALS: 401, + TOKEN_EXPIRED: 401, + REFRESH_TOKEN_INVALID: 401, + + // User 错误 + USER_NOT_FOUND: 404, + EMAIL_ALREADY_EXISTS: 409, + PASSWORD_TOO_SHORT: 400, + INVALID_EMAIL: 400, + + // Task 错误 + TASK_NOT_FOUND: 404, + TASK_TITLE_EMPTY: 400, + TASK_ALREADY_COMPLETED: 400, + TASK_ALREADY_DELETED: 400, + INVALID_PRIORITY: 400, + INVALID_STATUS: 400, + + // 通用错误 + INTERNAL_SERVER_ERROR: 500, + VALIDATION_ERROR: 400, + NOT_FOUND: 404, +}; + +/** + * 创建领域错误 + * + * @param code 错误码(ErrorCodes 中的键) + * @param message 错误消息 + * @param statusCode 可选的 HTTP 状态码(如果不提供,使用默认值) + * @returns DomainError 实例 + * + * @example + * ```typescript + * throw createError('TASK_NOT_FOUND', 'Task with id 123 not found'); + * throw createError('UNAUTHORIZED', 'Invalid credentials', 401); + * ``` + */ +export function createError( + code: keyof typeof ErrorCodes, + message: string, + statusCode?: number +): DomainError { + const errorCode = ErrorCodes[code]; + const httpStatusCode = statusCode || DEFAULT_STATUS_CODES[errorCode] || 400; + + return new DomainError(errorCode, message, httpStatusCode); +} + +/** + * 检查错误是否为 DomainError + */ +export function isDomainError(error: unknown): error is DomainError { + return error instanceof DomainError; +} + +/** + * 从错误消息解析错误码和消息 + * 支持格式:ERROR_CODE: message + * + * @example + * ```typescript + * parseError('TASK_NOT_FOUND: Task with id 123 not found') + * // => { code: 'TASK_NOT_FOUND', message: 'Task with id 123 not found' } + * ``` + */ +export function parseError(errorMessage: string): { code: string; message: string } { + const colonIndex = errorMessage.indexOf(':'); + if (colonIndex === -1) { + return { code: 'INTERNAL_SERVER_ERROR', message: errorMessage }; + } + + const code = errorMessage.substring(0, colonIndex).trim(); + const message = errorMessage.substring(colonIndex + 1).trim(); + + return { code, message }; +} + +/** + * 从错误消息创建 DomainError + * 支持格式:ERROR_CODE: message + * + * @example + * ```typescript + * throw fromErrorMessage('TASK_NOT_FOUND: Task with id 123 not found'); + * ``` + */ +export function fromErrorMessage(errorMessage: string): DomainError { + const { code, message } = parseError(errorMessage); + + // 检查是否是已知的错误码 + const knownCode = Object.keys(ErrorCodes).find( + (key) => ErrorCodes[key as keyof typeof ErrorCodes] === code + ) as keyof typeof ErrorCodes | undefined; + + if (knownCode) { + return createError(knownCode, message); + } + + // 未知错误码,使用默认状态码 + return new DomainError(code, message, DEFAULT_STATUS_CODES[code] || 400); +} + diff --git a/backend-nodejs/shared/types/context.ts b/backend-nodejs/shared/types/context.ts new file mode 100644 index 0000000..fad0201 --- /dev/null +++ b/backend-nodejs/shared/types/context.ts @@ -0,0 +1,60 @@ +/** + * 请求上下文类型 + * 用于在 Handler、Service、Repository 层之间传递请求相关信息 + */ + +/** + * 请求上下文接口 + * 包含请求追踪、用户信息等上下文数据 + */ +export interface RequestContext { + /** + * 请求 ID(每个请求唯一) + * 用于日志追踪和问题排查 + */ + requestId?: string; + + /** + * 用户 ID(从 JWT Token 中提取) + * 用于权限检查和业务逻辑 + */ + userId?: string; + + /** + * 追踪 ID(用于分布式追踪) + * 跨服务请求时保持相同的 TraceID + */ + traceId?: string; + + /** + * 扩展字段(用于未来扩展) + * 可以添加 IP、UserAgent 等信息 + */ + [key: string]: unknown; +} + +/** + * 创建空的请求上下文 + * 用于测试或不需要上下文信息的场景 + */ +export function createEmptyContext(): RequestContext { + return {}; +} + +/** + * 从 FastifyRequest 创建请求上下文 + * 提取请求 ID、用户 ID 等信息 + */ +export function createContextFromRequest(req: { + requestId?: string; + userId?: string; + traceId?: string; + [key: string]: unknown; +}): RequestContext { + return { + requestId: req.requestId, + userId: req.userId, + traceId: req.traceId, + }; +} + diff --git a/backend-nodejs/vitest.setup.ts b/backend-nodejs/vitest.setup.ts index 926a2c5..48b3f89 100644 --- a/backend-nodejs/vitest.setup.ts +++ b/backend-nodejs/vitest.setup.ts @@ -1,6 +1,9 @@ /** * Vitest 测试环境设置 * 在所有测试运行前执行,加载环境变量 + * + * 注意:数据库连接的清理应该在各个测试文件中使用 afterAll 钩子 + * 参考:infrastructure/testing/test_setup.ts */ // 加载 .env 文件(如果存在) From 3f9a15c033e3cd204cd73e17a1f626aae7e1f8d9 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 23:18:46 +0800 Subject: [PATCH 10/22] feat: implement monitoring and security features in Node.js backend, including request tracing, metrics monitoring, and API rate limiting with Redis support --- backend-nodejs/README.md | 63 ++++++++ backend-nodejs/cmd/server/main.ts | 2 +- backend-nodejs/domains/auth/http/router.ts | 24 +++- .../infrastructure/bootstrap/server.ts | 27 +++- .../infrastructure/middleware/auth.ts | 3 + .../infrastructure/middleware/metrics.ts | 57 ++++++++ .../infrastructure/middleware/ratelimit.ts | 135 +++++++++++++++++ .../infrastructure/middleware/tracing.ts | 43 ++++++ .../monitoring/metrics/metrics.ts | 52 +++++++ backend-nodejs/package.json | 1 + backend-nodejs/pnpm-lock.yaml | 30 ++++ backend-nodejs/scripts/test-monitoring.sh | 136 ++++++++++++++++++ backend-nodejs/shared/types/context.ts | 2 +- 13 files changed, 567 insertions(+), 8 deletions(-) create mode 100644 backend-nodejs/infrastructure/middleware/metrics.ts create mode 100644 backend-nodejs/infrastructure/middleware/ratelimit.ts create mode 100644 backend-nodejs/infrastructure/middleware/tracing.ts create mode 100644 backend-nodejs/infrastructure/monitoring/metrics/metrics.ts create mode 100755 backend-nodejs/scripts/test-monitoring.sh diff --git a/backend-nodejs/README.md b/backend-nodejs/README.md index 88c66e4..8758c84 100644 --- a/backend-nodejs/README.md +++ b/backend-nodejs/README.md @@ -13,6 +13,7 @@ - [项目结构](#-项目结构) - [架构设计](#-架构设计) - [开发指南](#-开发指南) +- [监控和安全](#-监控和安全) - [与 Go 后端的关系](#-与-go-后端的关系) --- @@ -515,6 +516,68 @@ pnpm test:watch --- +## 📊 监控和安全 + +### 功能概览 + +系统已集成以下监控和安全功能: + +- ✅ **请求追踪**:自动生成 TraceID/RequestID,支持分布式追踪 +- ✅ **Metrics 监控**:Prometheus 格式指标,支持 QPS、延迟、错误率监控 +- ✅ **API 限流**:基于 Redis 的限流保护,防止恶意请求 + +### 快速使用 + +#### 1. 请求追踪(自动启用) + +```bash +# 发送请求,自动获得追踪信息 +curl -v http://localhost:8081/api/tasks + +# 响应头包含: +# X-Trace-Id: 550e8400-e29b-41d4-a716-446655440000 +# X-Request-Id: 660e8400-e29b-41d4-a716-446655440001 +``` + +#### 2. Metrics 监控 + +```bash +# 访问 Metrics 端点 +curl http://localhost:8081/metrics + +# 集成 Prometheus(prometheus.yml) +scrape_configs: + - job_name: 'backend-nodejs' + static_configs: + - targets: ['localhost:8081'] +``` + +#### 3. API 限流 + +```bash +# 测试登录限流(每分钟最多 5 次) +for i in {1..6}; do + curl -X POST http://localhost:8081/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"wrong"}' +done +# 第 6 次会返回 429 Too Many Requests +``` + +### 详细文档 + +- 📖 [完整使用文档](docs/MONITORING_AND_SECURITY.md) - 详细的使用说明和配置 +- 🚀 [快速参考](docs/QUICK_START_MONITORING.md) - 快速上手指南 +- 💡 [使用示例](docs/USAGE_EXAMPLES.md) - 实际使用场景和代码示例 +- 🧪 [测试脚本](scripts/test-monitoring.sh) - 自动化测试脚本 + +### 已应用的限流策略 + +- **登录接口**:每分钟最多 5 次(防止暴力破解) +- **注册接口**:每小时最多 3 次(防止批量注册) + +--- + ## 🔗 与 Go 后端的关系 ### 共享资源 diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index aa6adbe..9ef884a 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -91,7 +91,7 @@ async function main() { task: container.taskHandlerDeps, user: container.userHandlerDeps, auth: container.authHandlerDeps, - }, container.authMiddleware); + }, container.authMiddleware, redis); // 9. 启动服务器 const address = `http://${config.server.host}:${config.server.port}`; diff --git a/backend-nodejs/domains/auth/http/router.ts b/backend-nodejs/domains/auth/http/router.ts index 4917a1d..0b96dc1 100644 --- a/backend-nodejs/domains/auth/http/router.ts +++ b/backend-nodejs/domains/auth/http/router.ts @@ -4,6 +4,7 @@ */ import type { FastifyInstance } from 'fastify'; +import type { RedisClientType } from 'redis'; import type { HandlerDependencies } from '../handlers/dependencies.js'; import type { RegisterRequest, LoginRequest, RefreshTokenRequest } from './dto/auth.js'; import { @@ -14,18 +15,32 @@ import { import { registerHandler } from '../handlers/register.handler.js'; import { loginHandler } from '../handlers/login.handler.js'; import { refreshTokenHandler } from '../handlers/refresh_token.handler.js'; +import { + createLoginRateLimitMiddleware, + createRegisterRateLimitMiddleware, +} from '../../../infrastructure/middleware/ratelimit.js'; /** * 注册 Auth 路由 + * + * @param app Fastify 实例 + * @param deps Handler 依赖 + * @param redis Redis 客户端(可选,用于限流) */ export function registerAuthRoutes( app: FastifyInstance, - deps: HandlerDependencies + deps: HandlerDependencies, + redis: RedisClientType | null = null ): void { - // POST /api/auth/register - 用户注册 + // 创建限流中间件 + const loginRateLimit = createLoginRateLimitMiddleware(redis); + const registerRateLimit = createRegisterRateLimitMiddleware(redis); + + // POST /api/auth/register - 用户注册(限流:每小时最多 3 次) app.post<{ Body: RegisterRequest }>( '/api/auth/register', { + preHandler: [registerRateLimit], schema: { body: RegisterRequestSchema, }, @@ -35,10 +50,11 @@ export function registerAuthRoutes( } ); - // POST /api/auth/login - 用户登录 + // POST /api/auth/login - 用户登录(限流:每分钟最多 5 次) app.post<{ Body: LoginRequest }>( '/api/auth/login', { + preHandler: [loginRateLimit], schema: { body: LoginRequestSchema, }, @@ -48,7 +64,7 @@ export function registerAuthRoutes( } ); - // POST /api/auth/refresh - 刷新 Token + // POST /api/auth/refresh - 刷新 Token(不需要限流,因为需要有效的 refresh token) app.post<{ Body: RefreshTokenRequest }>( '/api/auth/refresh', { diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index 86f0cfc..6bba794 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -12,6 +12,9 @@ import type { RedisClientType } from 'redis'; import { checkHealth } from '../monitoring/health/health.js'; import type { AuthMiddleware } from '../middleware/types.js'; import { isDomainError } from '../../shared/errors/errors.js'; +import { tracingMiddleware } from '../middleware/tracing.js'; +import { metricsMiddleware, metricsResponseHook } from '../middleware/metrics.js'; +import { register } from '../monitoring/metrics/metrics.js'; /** * 创建 Fastify 服务器 @@ -40,11 +43,20 @@ export function createServer(config: Config): FastifyInstance { * 注册全局中间件 */ export async function registerMiddleware(fastify: FastifyInstance): Promise { + // 请求追踪(必须在最前面,确保所有请求都有 TraceID/RequestID) + fastify.addHook('onRequest', tracingMiddleware); + // CORS await fastify.register(cors, { origin: true, }); + // Metrics 中间件(记录请求开始时间) + fastify.addHook('onRequest', metricsMiddleware); + + // Metrics 响应 Hook(记录请求指标) + fastify.addHook('onResponse', metricsResponseHook); + // Security headers (手动实现,因为 @fastify/helmet 不支持 Fastify 5) fastify.addHook('onRequest', async (_request: FastifyRequest, reply: FastifyReply) => { reply.header('X-Content-Type-Options', 'nosniff'); @@ -111,6 +123,12 @@ export function registerRoutes( return reply.code(statusCode).send(health); }); + // Prometheus Metrics 端点 + fastify.get('/metrics', async (_request: unknown, reply) => { + reply.type('text/plain'); + return register.metrics(); + }); + // 根路径 fastify.get('/', async (_request: unknown, reply) => { return reply.send({ @@ -128,7 +146,8 @@ export function registerRoutes( export async function registerDomainRoutes( fastify: FastifyInstance, handlerDeps: Record, - authMiddleware: AuthMiddleware + authMiddleware: AuthMiddleware, + redis: RedisClientType | null = null ): Promise { // 动态导入并注册 Task 路由 if (handlerDeps.task) { @@ -153,7 +172,11 @@ export async function registerDomainRoutes( // 动态导入并注册 Auth 路由(不需要认证中间件) if (handlerDeps.auth) { const authRouter = await import('../../domains/auth/http/router.js'); - authRouter.registerAuthRoutes(fastify, handlerDeps.auth as Parameters[1]); + authRouter.registerAuthRoutes( + fastify, + handlerDeps.auth as Parameters[1], + redis + ); } } diff --git a/backend-nodejs/infrastructure/middleware/auth.ts b/backend-nodejs/infrastructure/middleware/auth.ts index 1fb801b..2c6e115 100644 --- a/backend-nodejs/infrastructure/middleware/auth.ts +++ b/backend-nodejs/infrastructure/middleware/auth.ts @@ -7,10 +7,13 @@ import type { FastifyRequest, FastifyReply } from 'fastify'; import type { JWTService } from '../../domains/auth/service/jwt_service.js'; // 扩展 FastifyRequest 类型,添加 user_id 和 email +// 注意:traceId 和 requestId 在 tracing.ts 中定义 declare module 'fastify' { interface FastifyRequest { userId?: string; email?: string; + traceId?: string; + requestId?: string; } } diff --git a/backend-nodejs/infrastructure/middleware/metrics.ts b/backend-nodejs/infrastructure/middleware/metrics.ts new file mode 100644 index 0000000..f0e4938 --- /dev/null +++ b/backend-nodejs/infrastructure/middleware/metrics.ts @@ -0,0 +1,57 @@ +/** + * Metrics 中间件 + * 记录 HTTP 请求的指标(QPS、延迟、错误率) + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { httpRequestCounter, httpRequestDuration } from '../monitoring/metrics/metrics.js'; + +/** + * Metrics 中间件 + * 记录每个请求的指标 + * 使用 Fastify 的 onRequest hook 记录开始时间,onResponse hook 记录指标 + */ +export async function metricsMiddleware( + request: FastifyRequest, + _reply: FastifyReply +): Promise { + // 在请求对象上存储开始时间 + (request as any).metricsStartTime = Date.now(); +} + +/** + * Metrics 响应 Hook + * 在响应发送后记录指标 + */ +export async function metricsResponseHook( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const startTime = (request as any).metricsStartTime; + if (!startTime) { + return; // 如果没有开始时间,跳过 + } + + const duration = (Date.now() - startTime) / 1000; // 转换为秒 + const method = request.method; + // 获取路由路径(如果没有 routerPath,使用 URL 路径) + const route = (request as any).routerPath || request.url.split('?')[0]; // 移除查询参数 + const statusCode = reply.statusCode; + + // 记录请求计数 + httpRequestCounter.inc({ + method, + route, + status: statusCode.toString(), + }); + + // 记录请求延迟 + httpRequestDuration.observe( + { + method, + route, + }, + duration + ); +} + diff --git a/backend-nodejs/infrastructure/middleware/ratelimit.ts b/backend-nodejs/infrastructure/middleware/ratelimit.ts new file mode 100644 index 0000000..ea41379 --- /dev/null +++ b/backend-nodejs/infrastructure/middleware/ratelimit.ts @@ -0,0 +1,135 @@ +/** + * 限流中间件 + * 基于 Redis 的 API 限流保护,防止恶意请求和暴力破解 + */ + +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { RedisClientType } from 'redis'; +import { createError } from '../../shared/errors/errors.js'; + +/** + * 限流配置选项 + */ +export interface RateLimitOptions { + /** + * 时间窗口(毫秒) + * 例如:60000 表示 1 分钟 + */ + windowMs: number; + + /** + * 最大请求数 + * 在时间窗口内允许的最大请求数 + */ + max: number; + + /** + * 自定义 key 生成器 + * 默认使用 IP + 路径 + */ + keyGenerator?: (request: FastifyRequest) => string; + + /** + * 跳过限流的条件 + * 返回 true 时跳过限流检查 + */ + skip?: (request: FastifyRequest) => boolean; +} + +/** + * 创建限流中间件 + * + * @param redis Redis 客户端(如果为 null,则跳过限流) + * @param options 限流配置 + * @returns 限流中间件函数 + * + * @example + * ```typescript + * const loginRateLimit = createRateLimitMiddleware(redis, { + * windowMs: 60 * 1000, // 1 分钟 + * max: 5, // 最多 5 次 + * keyGenerator: (req) => `ratelimit:login:${req.ip}`, + * }); + * ``` + */ +export function createRateLimitMiddleware( + redis: RedisClientType | null, + options: RateLimitOptions +) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + // Redis 不可用时,跳过限流 + if (!redis) { + return; + } + + // 检查是否需要跳过限流 + if (options.skip && options.skip(request)) { + return; + } + + // 生成限流 key + const keyGenerator = options.keyGenerator || ((req) => { + // 默认:IP + 路径 + const route = (req as any).routerPath || req.url.split('?')[0]; + return `ratelimit:${req.ip}:${route}`; + }); + + const key = keyGenerator(request); + + // 增加计数器 + const current = await redis.incr(key); + + // 如果是第一次请求,设置过期时间 + if (current === 1) { + await redis.expire(key, Math.ceil(options.windowMs / 1000)); + } + + // 添加响应头 + reply.header('X-RateLimit-Limit', options.max.toString()); + reply.header('X-RateLimit-Remaining', Math.max(0, options.max - current).toString()); + reply.header('X-RateLimit-Reset', new Date(Date.now() + options.windowMs).toISOString()); + + // 超过限制,返回 429 + if (current > options.max) { + reply.header('Retry-After', Math.ceil(options.windowMs / 1000).toString()); + throw createError('VALIDATION_ERROR', '请求过于频繁,请稍后再试', 429); + } + }; +} + +/** + * 创建登录限流中间件 + * 同一 IP 每分钟最多 5 次登录尝试 + */ +export function createLoginRateLimitMiddleware(redis: RedisClientType | null) { + return createRateLimitMiddleware(redis, { + windowMs: 60 * 1000, // 1 分钟 + max: 5, // 最多 5 次 + keyGenerator: (req) => `ratelimit:login:${req.ip}`, + }); +} + +/** + * 创建注册限流中间件 + * 同一 IP 每小时最多 3 次注册 + */ +export function createRegisterRateLimitMiddleware(redis: RedisClientType | null) { + return createRateLimitMiddleware(redis, { + windowMs: 60 * 60 * 1000, // 1 小时 + max: 3, // 最多 3 次 + keyGenerator: (req) => `ratelimit:register:${req.ip}`, + }); +} + +/** + * 创建通用 API 限流中间件 + * 同一 IP 每分钟最多 100 次请求 + */ +export function createApiRateLimitMiddleware(redis: RedisClientType | null) { + return createRateLimitMiddleware(redis, { + windowMs: 60 * 1000, // 1 分钟 + max: 100, // 最多 100 次 + keyGenerator: (req) => `ratelimit:api:${req.ip}`, + }); +} + diff --git a/backend-nodejs/infrastructure/middleware/tracing.ts b/backend-nodejs/infrastructure/middleware/tracing.ts new file mode 100644 index 0000000..0bc14b2 --- /dev/null +++ b/backend-nodejs/infrastructure/middleware/tracing.ts @@ -0,0 +1,43 @@ +/** + * 请求追踪中间件 + * 自动生成和传播 TraceID/RequestID,支持分布式追踪 + */ + +import { randomUUID } from 'crypto'; +import type { FastifyRequest, FastifyReply } from 'fastify'; + +// 扩展 FastifyRequest 类型,添加追踪信息 +declare module 'fastify' { + interface FastifyRequest { + traceId?: string; + requestId?: string; + } +} + +/** + * 请求追踪中间件 + * 为每个请求生成 TraceID 和 RequestID,并添加到日志上下文和响应头 + */ +export function tracingMiddleware( + request: FastifyRequest, + reply: FastifyReply, + done: () => void +): void { + // 从请求头获取或生成 TraceID(支持分布式追踪) + const traceId = (request.headers['x-trace-id'] as string) || randomUUID(); + const requestId = randomUUID(); + + // 添加到请求对象 + request.traceId = traceId; + request.requestId = requestId; + + // 添加到响应头(便于客户端追踪) + reply.header('X-Trace-Id', traceId); + reply.header('X-Request-Id', requestId); + + // 添加到日志上下文(所有后续日志都会包含这些字段) + request.log = request.log.child({ traceId, requestId }); + + done(); +} + diff --git a/backend-nodejs/infrastructure/monitoring/metrics/metrics.ts b/backend-nodejs/infrastructure/monitoring/metrics/metrics.ts new file mode 100644 index 0000000..cb5fe92 --- /dev/null +++ b/backend-nodejs/infrastructure/monitoring/metrics/metrics.ts @@ -0,0 +1,52 @@ +/** + * Prometheus Metrics 定义 + * 收集 HTTP 请求和数据库查询的指标 + */ + +import { Registry, Counter, Histogram } from 'prom-client'; + +// 创建全局 Registry +const register = new Registry(); + +// 收集默认指标(CPU、内存等) +register.setDefaultLabels({ + app: 'go-genai-stack-nodejs', +}); + +// HTTP 请求计数器 +export const httpRequestCounter = new Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [register], +}); + +// HTTP 请求延迟直方图 +export const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route'], + buckets: [0.1, 0.5, 1, 2, 5, 10], // 延迟桶(秒) + registers: [register], +}); + +// 数据库查询延迟直方图 +export const dbQueryDuration = new Histogram({ + name: 'db_query_duration_seconds', + help: 'Duration of database queries in seconds', + labelNames: ['operation', 'table'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2], // 延迟桶(秒) + registers: [register], +}); + +// 数据库查询计数器 +export const dbQueryCounter = new Counter({ + name: 'db_queries_total', + help: 'Total number of database queries', + labelNames: ['operation', 'table', 'status'], + registers: [register], +}); + +// 导出 Registry(用于 /metrics 端点) +export { register }; + diff --git a/backend-nodejs/package.json b/backend-nodejs/package.json index 93429dc..1bb32ef 100644 --- a/backend-nodejs/package.json +++ b/backend-nodejs/package.json @@ -40,6 +40,7 @@ "jsonwebtoken": "^9.0.3", "kysely": "^0.27.2", "pg": "^8.11.3", + "prom-client": "^15.1.3", "redis": "^4.6.13", "supertest": "^7.1.4", "zod": "^3.23.8" diff --git a/backend-nodejs/pnpm-lock.yaml b/backend-nodejs/pnpm-lock.yaml index 2bd4936..9ebe672 100644 --- a/backend-nodejs/pnpm-lock.yaml +++ b/backend-nodejs/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: pg: specifier: ^8.11.3 version: 8.16.3 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 redis: specifier: ^4.6.13 version: 4.7.1 @@ -463,6 +466,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -812,6 +819,9 @@ packages: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1577,6 +1587,10 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -1753,6 +1767,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2163,6 +2180,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@opentelemetry/api@1.9.0': {} + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -2501,6 +2520,8 @@ snapshots: bcryptjs@3.0.3: {} + bintrees@1.0.2: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3339,6 +3360,11 @@ snapshots: process@0.11.10: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.0 + tdigest: 0.1.2 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -3538,6 +3564,10 @@ snapshots: dependencies: has-flag: 4.0.0 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + text-table@0.2.0: {} thread-stream@3.1.0: diff --git a/backend-nodejs/scripts/test-monitoring.sh b/backend-nodejs/scripts/test-monitoring.sh new file mode 100755 index 0000000..6984055 --- /dev/null +++ b/backend-nodejs/scripts/test-monitoring.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# 监控和安全功能测试脚本 +# 演示请求追踪、Metrics 监控和 API 限流的使用 + +BASE_URL="${BASE_URL:-http://localhost:8081}" + +echo "==========================================" +echo "监控和安全功能测试" +echo "==========================================" +echo "" + +# 颜色定义 +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# 1. 测试请求追踪 +echo -e "${BLUE}1. 测试请求追踪(TraceID/RequestID)${NC}" +echo "----------------------------------------" +echo "发送请求并查看响应头中的追踪信息:" +echo "" + +RESPONSE=$(curl -s -i -X GET "$BASE_URL/health") +TRACE_ID=$(echo "$RESPONSE" | grep -i "X-Trace-Id" | cut -d' ' -f2 | tr -d '\r') +REQUEST_ID=$(echo "$RESPONSE" | grep -i "X-Request-Id" | cut -d' ' -f2 | tr -d '\r') + +echo -e "${GREEN}✓ TraceID:${NC} $TRACE_ID" +echo -e "${GREEN}✓ RequestID:${NC} $REQUEST_ID" +echo "" + +# 测试分布式追踪(传递 TraceID) +echo "测试分布式追踪(传递自定义 TraceID):" +CUSTOM_TRACE_ID="my-custom-trace-123" +RESPONSE2=$(curl -s -i -H "X-Trace-Id: $CUSTOM_TRACE_ID" -X GET "$BASE_URL/health") +RECEIVED_TRACE_ID=$(echo "$RESPONSE2" | grep -i "X-Trace-Id" | cut -d' ' -f2 | tr -d '\r') + +if [ "$RECEIVED_TRACE_ID" = "$CUSTOM_TRACE_ID" ]; then + echo -e "${GREEN}✓ 分布式追踪成功:使用传入的 TraceID${NC}" +else + echo -e "${YELLOW}⚠ TraceID 不匹配(可能生成了新的)${NC}" +fi +echo "" + +# 2. 测试 Metrics 监控 +echo -e "${BLUE}2. 测试 Metrics 监控${NC}" +echo "----------------------------------------" +echo "访问 /metrics 端点:" +echo "" + +METRICS=$(curl -s "$BASE_URL/metrics") +if [ -n "$METRICS" ]; then + echo -e "${GREEN}✓ Metrics 端点可用${NC}" + echo "" + echo "指标示例(前 10 行):" + echo "$METRICS" | head -10 + echo "..." + echo "" + + # 统计指标数量 + METRIC_COUNT=$(echo "$METRICS" | grep -c "^[^#]" || echo "0") + echo -e "${GREEN}✓ 共 $METRIC_COUNT 个指标${NC}" +else + echo -e "${RED}✗ Metrics 端点不可用${NC}" +fi +echo "" + +# 3. 测试 API 限流 +echo -e "${BLUE}3. 测试 API 限流${NC}" +echo "----------------------------------------" +echo "测试登录接口限流(每分钟最多 5 次):" +echo "" + +SUCCESS_COUNT=0 +RATE_LIMITED_COUNT=0 + +for i in {1..7}; do + echo -n "请求 $i: " + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"wrong"}') + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + REMAINING=$(echo "$RESPONSE" | grep -i "X-RateLimit-Remaining" | cut -d' ' -f2 | tr -d '\r' || echo "") + + if [ "$HTTP_CODE" = "401" ]; then + echo -e "${GREEN}✓ 401 (正常,密码错误)${NC} - 剩余: $REMAINING" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + elif [ "$HTTP_CODE" = "429" ]; then + echo -e "${YELLOW}⚠ 429 (限流触发)${NC} - 剩余: $REMAINING" + RATE_LIMITED_COUNT=$((RATE_LIMITED_COUNT + 1)) + else + echo -e "${RED}✗ $HTTP_CODE (意外状态码)${NC}" + fi + + sleep 0.5 +done + +echo "" +echo "结果统计:" +echo -e " ${GREEN}正常请求:${NC} $SUCCESS_COUNT" +echo -e " ${YELLOW}被限流:${NC} $RATE_LIMITED_COUNT" +echo "" + +# 4. 查看限流响应头 +echo -e "${BLUE}4. 查看限流响应头${NC}" +echo "----------------------------------------" +echo "发送一个登录请求并查看响应头:" +echo "" + +curl -s -i -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"wrong"}' | \ + grep -i "X-RateLimit\|Retry-After" | \ + sed 's/^/ /' + +echo "" + +# 5. 总结 +echo "==========================================" +echo -e "${GREEN}测试完成!${NC}" +echo "==========================================" +echo "" +echo "📚 详细文档:" +echo " - docs/MONITORING_AND_SECURITY.md (完整文档)" +echo " - docs/QUICK_START_MONITORING.md (快速参考)" +echo "" +echo "🔧 下一步:" +echo " 1. 配置 Prometheus 抓取 /metrics" +echo " 2. 在 Grafana 中创建监控 Dashboard" +echo " 3. 根据实际需求调整限流策略" +echo "" + diff --git a/backend-nodejs/shared/types/context.ts b/backend-nodejs/shared/types/context.ts index fad0201..2743efc 100644 --- a/backend-nodejs/shared/types/context.ts +++ b/backend-nodejs/shared/types/context.ts @@ -43,7 +43,7 @@ export function createEmptyContext(): RequestContext { /** * 从 FastifyRequest 创建请求上下文 - * 提取请求 ID、用户 ID 等信息 + * 提取请求 ID、用户 ID、追踪 ID 等信息 */ export function createContextFromRequest(req: { requestId?: string; From 4901be393c19ffd02d43834efe2d4cd53a71a7cd Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sat, 20 Dec 2025 23:54:55 +0800 Subject: [PATCH 11/22] feat: update E2E testing configuration for Node.js backend, enhancing log retrieval based on backend type and improving Redis memory management settings in Docker --- .github/workflows/frontend-e2e.yml | 6 +- backend-nodejs/cmd/server/main.ts | 10 +- .../infrastructure/bootstrap/server.ts | 8 +- .../infrastructure/config/config.ts | 11 +- backend-nodejs/package.json | 3 +- backend-nodejs/pnpm-lock.yaml | 66 +++- docker/e2e/README.md | 317 +++++------------- docker/e2e/docker-compose.yml | 16 +- 8 files changed, 178 insertions(+), 259 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index 2286511..288c2e5 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -216,7 +216,11 @@ jobs: docker compose logs --tail=50 redis-e2e echo "" echo "📋 ${{ matrix.backend_name }} Logs:" - docker compose logs --tail=100 ${{ matrix.container_name }} + if [ "${{ matrix.backend }}" == "nodejs" ]; then + docker compose logs --tail=100 backend-nodejs-e2e || echo "Could not retrieve logs for backend-nodejs-e2e" + else + docker compose logs --tail=100 backend-e2e || echo "Could not retrieve logs for backend-e2e" + fi - name: Stop E2E environment if: always() diff --git a/backend-nodejs/cmd/server/main.ts b/backend-nodejs/cmd/server/main.ts index 9ef884a..06dad7e 100644 --- a/backend-nodejs/cmd/server/main.ts +++ b/backend-nodejs/cmd/server/main.ts @@ -39,12 +39,18 @@ async function main() { let db: ReturnType; try { db = createDatabaseConnection(config.database); - // 测试连接 - // await db.selectFrom('users').select('id').limit(1).execute(); + // 测试连接(实际执行查询以确保连接可用) + await db.selectFrom('users').select('id').limit(1).execute(); console.log('✅ Database connected'); } catch (error) { console.error('❌ Failed to connect to database:', error); console.error(' Make sure PostgreSQL is running and schema is applied'); + console.error(' Connection details:', { + host: config.database.host, + port: config.database.port, + database: config.database.database, + user: config.database.user, + }); process.exit(1); } diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index 6bba794..f9266a3 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -5,6 +5,7 @@ import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyReply } from 'fastify'; import cors from '@fastify/cors'; +import { serializerCompiler, validatorCompiler, type ZodTypeProvider } from 'fastify-type-provider-zod'; import type { Config } from '../config/config.js'; import type { Kysely } from 'kysely'; import type { Database } from '../persistence/postgres/database.js'; @@ -18,6 +19,7 @@ import { register } from '../monitoring/metrics/metrics.js'; /** * 创建 Fastify 服务器 + * 注册 Zod type provider 以支持直接使用 Zod schema */ export function createServer(config: Config): FastifyInstance { const fastify = Fastify({ @@ -34,7 +36,11 @@ export function createServer(config: Config): FastifyInstance { } : undefined, }, - }); + }).withTypeProvider(); + + // 注册 Zod validator 和 serializer + fastify.setValidatorCompiler(validatorCompiler); + fastify.setSerializerCompiler(serializerCompiler); return fastify; } diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts index 6ffa5e5..a89c50a 100644 --- a/backend-nodejs/infrastructure/config/config.ts +++ b/backend-nodejs/infrastructure/config/config.ts @@ -32,7 +32,10 @@ const ConfigSchema = z.object({ db: z.coerce.number().int().nonnegative().default(0), }), jwt: z.object({ - secret: z.string().min(32, 'JWT secret must be at least 32 characters'), + secret: z.string().min(1, 'JWT secret is required').refine( + (val) => val.length >= 32 || process.env.NODE_ENV === 'test', + { message: 'JWT secret must be at least 32 characters (except in test mode)' } + ), accessTokenExpiry: z.coerce.number().int().positive().default(3600), // 1 小时 refreshTokenExpiry: z.coerce.number().int().positive().default(604800), // 7 天 issuer: z.string().default('go-genai-stack'), @@ -87,9 +90,9 @@ export function loadConfig(): Config { } catch (error) { if (error instanceof z.ZodError) { console.error('❌ Configuration validation failed:'); - error.errors.forEach((err) => { - const path = err.path.join('.'); - console.error(` ${path}: ${err.message}`); + error.issues.forEach((issue) => { + const path = issue.path.join('.'); + console.error(` ${path}: ${issue.message}`); }); console.error('\nPlease check your environment variables and .env file.'); process.exit(1); diff --git a/backend-nodejs/package.json b/backend-nodejs/package.json index 1bb32ef..86903ba 100644 --- a/backend-nodejs/package.json +++ b/backend-nodejs/package.json @@ -30,6 +30,7 @@ "dependencies": { "@fastify/cors": "^10.0.1", "@fastify/rate-limit": "^9.1.0", + "fastify-type-provider-zod": "^6.0.0", "@types/bcryptjs": "^3.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/supertest": "^6.0.3", @@ -43,7 +44,7 @@ "prom-client": "^15.1.3", "redis": "^4.6.13", "supertest": "^7.1.4", - "zod": "^3.23.8" + "zod": "^4.1.5" }, "devDependencies": { "@types/node": "^22.5.0", diff --git a/backend-nodejs/pnpm-lock.yaml b/backend-nodejs/pnpm-lock.yaml index 9ebe672..772ccea 100644 --- a/backend-nodejs/pnpm-lock.yaml +++ b/backend-nodejs/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: fastify: specifier: ^5.0.0 version: 5.6.2 + fastify-type-provider-zod: + specifier: ^6.0.0 + version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.2.1) jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -54,8 +57,8 @@ importers: specifier: ^7.1.4 version: 7.1.4 zod: - specifier: ^3.23.8 - version: 3.25.76 + specifier: ^4.1.5 + version: 4.2.1 devDependencies: '@types/node': specifier: ^22.5.0 @@ -426,6 +429,9 @@ packages: '@fastify/rate-limit@9.1.0': resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + '@fastify/swagger@9.6.1': + resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1075,6 +1081,14 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-type-provider-zod@6.1.0: + resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.5.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + fastify@5.6.2: resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} @@ -1273,6 +1287,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1433,6 +1451,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1929,6 +1950,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1937,8 +1963,8 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} snapshots: @@ -2146,6 +2172,16 @@ snapshots: fastify-plugin: 4.5.1 toad-cache: 3.7.0 + '@fastify/swagger@9.6.1': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -2851,6 +2887,14 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.2.1): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.6.1 + fastify: 5.6.2 + openapi-types: 12.1.3 + zod: 4.2.1 + fastify@5.6.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -3053,6 +3097,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -3203,6 +3255,8 @@ snapshots: dependencies: mimic-fn: 4.0.0 + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3703,8 +3757,10 @@ snapshots: yallist@4.0.0: {} + yaml@2.8.2: {} + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} - zod@3.25.76: {} + zod@4.2.1: {} diff --git a/docker/e2e/README.md b/docker/e2e/README.md index ec3c44a..13427a0 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -72,6 +72,12 @@ pnpm e2e:ui # UI 模式(推荐) - **端口**: 6381(避免与开发环境冲突) - **数据卷**: redis-e2e-data - **用途**: Node.js 后端需要 Redis 支持 +- **配置**: + - 禁用 RDB 持久化(`--save ""`) + - 禁用 AOF 持久化(`--appendonly no`) + - 最大内存:256MB + - 淘汰策略:allkeys-lru +- **注意**: 已配置以减少内存 overcommit 警告 #### 3. backend-e2e(Go 后端服务) @@ -89,287 +95,114 @@ pnpm e2e:ui # UI 模式(推荐) - **数据库**: postgres-e2e:5432 - **Redis**: redis-e2e:6379 - **JWT Secret**: e2e-test-secret-key-for-testing-only -- **环境**: test - **健康检查**: /health 端点 --- -## 📊 测试数据 - -### 数据加载机制 - -E2E 环境使用两阶段初始化: - -1. **Schema(统一管理)** - - 来源:`backend/database/schema.sql` - - 内容:表结构、触发器、函数 - - 自动加载为 `01-schema.sql` - -2. **测试数据(环境独立)** - - 来源:`seed-data.sql`(本目录下) - - 内容:E2E 测试专用数据 - - 自动加载为 `02-seed-data.sql` - -### 测试数据内容 - -自动创建: - -1. **表结构** - - users(用户表) - - tasks(任务表) - - task_tags(任务标签表) - -2. **测试用户** - - Email: `e2e-test@example.com` - - Password: `Test123456!` - - 自动创建,预验证 - -3. **示例任务** - - 一个预置任务用于测试列表 - ---- +## 🔧 故障排查 -## 🔧 脚本说明 - -### start.sh - -**功能**: -- ✅ 检查 Docker 是否运行 -- ✅ 启动 Docker Compose 服务 -- ✅ 等待服务健康检查通过 -- ✅ 显示服务信息和测试凭据 - -**输出示例**: +### Redis 内存 Overcommit 警告 +如果看到以下警告: ``` -✅ E2E Test Environment is Ready! - -📋 Service Information: - ┌─────────────────────────────────────────────┐ - │ Service │ URL / Connection │ - ├─────────────────┼───────────────────────────┤ - │ Postgres │ localhost:5433 │ - │ Redis │ localhost:6381 │ - │ Go Backend │ http://localhost:8081 │ - │ Node.js Backend │ http://localhost:8082 │ - │ Frontend │ http://localhost:5173 │ - └─────────────────────────────────────────────┘ - -👤 Test User Credentials: - Email: e2e-test@example.com - Password: Test123456! +WARNING Memory overcommit must be enabled! ``` -### stop.sh +**原因**: +- Redis 在低内存条件下进行后台保存或复制时可能会失败 +- 需要启用 `vm.overcommit_memory = 1` -**功能**: -- ✅ 停止所有服务 -- ✅ 可选:清理数据卷 - -**参数**: -- 无参数:停止服务但保留数据 -- `--clean`:停止服务并删除所有数据 - ---- +**解决方案**: +1. **E2E 测试环境**(已配置): + - 禁用 RDB 持久化(`--save ""`) + - 禁用 AOF 持久化(`--appendonly no`) + - 这样可以避免触发后台保存操作 -## 🌐 网络配置 +2. **生产环境**: + - 在宿主机上配置 `vm.overcommit_memory = 1` + - 或在 Docker Compose 中使用 `sysctls`(需要特权模式) -### 端口映射 - -| 服务 | 容器端口 | 主机端口 | 说明 | -|------|---------|---------|------| -| Postgres | 5432 | 5433 | 避免与开发环境冲突 | -| Redis | 6379 | 6381 | 避免与开发环境冲突 | -| Go Backend | 8080 | 8081 | 避免与开发环境冲突 | -| Node.js Backend | 8080 | 8082 | 避免与开发环境冲突 | -| Frontend | - | 5173 | 在 Host 运行 | - -### 网络 - -- **网络名**: go-genai-stack-e2e-network -- **驱动**: bridge -- **内部通信**: 服务间通过容器名访问 - ---- - -## 🔍 调试和故障排查 - -### 查看日志 - -```bash -# 查看所有服务日志 -cd docker/e2e && docker compose logs -f - -# 查看特定服务日志 -cd docker/e2e && docker compose logs -f postgres-e2e -cd docker/e2e && docker compose logs -f redis-e2e -cd docker/e2e && docker compose logs -f backend-e2e -cd docker/e2e && docker compose logs -f backend-nodejs-e2e -``` - -### 检查服务状态 - -```bash -cd docker/e2e && docker compose ps +**当前配置**(E2E 测试): +```yaml +command: > + redis-server + --save "" + --appendonly no + --protected-mode no + --maxmemory 256mb + --maxmemory-policy allkeys-lru ``` -### 进入容器 - -```bash -# 进入 Postgres 容器 -docker exec -it go-genai-stack-postgres-e2e psql -U postgres -d go_genai_stack_e2e - -# 进入 Go Backend 容器 -docker exec -it go-genai-stack-backend-e2e sh - -# 进入 Node.js Backend 容器 -docker exec -it go-genai-stack-backend-nodejs-e2e sh -``` - -### 手动测试后端 - -#### Go Backend (端口 8081) - -```bash -# 健康检查 -curl http://localhost:8081/health - -# 登录测试 -curl -X POST http://localhost:8081/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"e2e-test@example.com","password":"Test123456!"}' -``` +这个配置会: +- ✅ 禁用持久化(E2E 测试不需要) +- ✅ 减少内存 overcommit 警告 +- ✅ 设置内存限制和淘汰策略 -#### Node.js Backend (端口 8082) +### 服务启动失败 +**检查服务状态**: ```bash -# 健康检查 -curl http://localhost:8082/health - -# 登录测试 -curl -X POST http://localhost:8082/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"e2e-test@example.com","password":"Test123456!"}' +cd docker/e2e +docker compose ps ``` -### 常见问题 - -#### 问题 1:端口已被占用 - -**错误**:`port is already allocated` - -**解决**: +**查看日志**: ```bash -# 检查端口占用 -lsof -i :5433 -lsof -i :6381 -lsof -i :8081 -lsof -i :8082 +# 查看所有服务日志 +docker compose logs -# 停止占用端口的服务或修改 docker/e2e/docker-compose.yml 中的端口映射 +# 查看特定服务日志 +docker compose logs postgres-e2e +docker compose logs redis-e2e +docker compose logs backend-e2e +docker compose logs backend-nodejs-e2e ``` -#### 问题 2:服务启动失败 - -**解决**: +**检查健康状态**: ```bash -# 查看日志 -cd docker/e2e && docker compose logs - -# 重新构建并启动 -cd docker/e2e && docker compose up -d --build +docker inspect --format='{{.State.Health.Status}}' go-genai-stack-postgres-e2e +docker inspect --format='{{.State.Health.Status}}' go-genai-stack-redis-e2e +docker inspect --format='{{.State.Health.Status}}' go-genai-stack-backend-e2e +docker inspect --format='{{.State.Health.Status}}' go-genai-stack-backend-nodejs-e2e ``` -#### 问题 3:数据库连接失败 - -**解决**: -```bash -# 检查 Postgres 健康状态 -cd docker/e2e && docker compose ps postgres-e2e +### 端口冲突 -# 手动连接测试 -docker exec -it go-genai-stack-postgres-e2e psql -U postgres -d go_genai_stack_e2e -c "SELECT 1" -``` +如果遇到端口冲突: +- **PostgreSQL**: 默认使用 5433(开发环境使用 5432) +- **Redis**: 默认使用 6381(开发环境使用 6379) +- **Go Backend**: 默认使用 8081(开发环境使用 8080) +- **Node.js Backend**: 默认使用 8082(开发环境使用 8081) --- -## 🧹 清理 +## 📊 健康检查 -### 完全清理 +所有服务都配置了健康检查: -```bash -# 停止服务并删除数据卷 -./docker/e2e/stop.sh --clean - -# 或使用 Docker Compose -cd docker/e2e && docker compose down -v - -# 清理未使用的镜像 -docker image prune -f -``` +| 服务 | 健康检查命令 | 间隔 | 超时 | 重试 | +|------|------------|------|------|------| +| postgres-e2e | `pg_isready -U postgres -d go_genai_stack_e2e` | 3s | 3s | 10 | +| redis-e2e | `redis-cli ping` | 3s | 3s | 10 | +| backend-e2e | `wget --quiet --tries=1 --spider http://localhost:8080/health` | 5s | 3s | 10 | +| backend-nodejs-e2e | `node -e "require('http').get('http://localhost:8080/health', ...)"` | 5s | 3s | 10 | --- -## ⚙️ 高级配置 - -### 修改测试数据 - -编辑 `seed-data.sql` 文件,然后: - -```bash -# 重新创建环境 -./docker/e2e/stop.sh --clean -./docker/e2e/start.sh -``` +## 🔄 CI/CD 集成 -### 修改后端配置 +在 GitHub Actions 中,E2E 测试会自动: +1. 启动所有服务 +2. 等待服务健康检查通过 +3. 运行 E2E 测试 +4. 清理环境 -编辑 `docker/e2e/docker-compose.yml` 中的环境变量,然后重启: - -```bash -# 重启 Go Backend -cd docker/e2e && docker compose restart backend-e2e - -# 重启 Node.js Backend -cd docker/e2e && docker compose restart backend-nodejs-e2e -``` - -### 使用自定义环境变量 - -创建 `.env.e2e` 文件(在项目根目录): - -```bash -# Go Backend -DATABASE_URL=postgres://postgres:postgres@localhost:5433/go_genai_stack_e2e?sslmode=disable -BACKEND_URL=http://localhost:8081 - -# Node.js Backend -NODEJS_BACKEND_URL=http://localhost:8082 -``` +**相关文件**: +- `.github/workflows/frontend-e2e.yml` - E2E 测试工作流 --- ## 📚 相关文档 -- [E2E 测试文档](../../frontend/web/e2e/README.md) -- [E2E 测试方案](../../docs/FRONTEND_E2E_PLAN.md) -- [E2E 完成报告](../../docs/FRONTEND_E2E_COMPLETE.md) - ---- - ---- - -## 🎯 选择后端 - -E2E 环境同时提供 Go 和 Node.js 两个后端实现: - -- **Go Backend**: `http://localhost:8081` - 使用 `backend/Dockerfile` 构建 -- **Node.js Backend**: `http://localhost:8082` - 使用 `backend-nodejs/Dockerfile` 构建 - -前端 E2E 测试可以根据需要选择使用哪个后端。两个后端共享同一个数据库和测试数据。 - ---- - -**维护者**: AI Assistant -**最后更新**: 2025-01-XX - +- [E2E 测试指南](../../frontend/web/doc/e2e-testing.md) +- [Docker 部署指南](../../docs/Guides/docker-deployment.md) diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 887d3bc..2321a23 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -41,7 +41,17 @@ services: volumes: # 数据持久化(可选,用于调试) - redis-e2e-data:/data - command: redis-server --appendonly yes --protected-mode no + # 配置 Redis 以减少内存 overcommit 警告 + # --save "" 禁用 RDB 持久化(E2E 测试不需要持久化) + # --appendonly yes 启用 AOF(如果需要持久化) + # 或者使用 --save "" --appendonly no 完全禁用持久化(推荐用于 E2E 测试) + command: > + redis-server + --save "" + --appendonly no + --protected-mode no + --maxmemory 256mb + --maxmemory-policy allkeys-lru healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 3s @@ -111,8 +121,8 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: "" - # JWT 配置 - JWT_SECRET: e2e-test-secret-key-for-testing-only + # JWT 配置(E2E 测试环境,secret 长度至少 32 字符) + JWT_SECRET: e2e-test-secret-key-for-testing-only-32chars JWT_ACCESS_TOKEN_EXPIRY: 15m JWT_REFRESH_TOKEN_EXPIRY: 7d JWT_ISSUER: go-genai-stack-e2e From d25a573dfb84189dfb7db6e7dd36b5018062d7fa Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 00:23:54 +0800 Subject: [PATCH 12/22] fix: update E2E Docker configuration by adding NODE_ENV variable and enhancing health check parameters for improved reliability in testing environment --- docker/e2e/docker-compose.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 2321a23..b904bd9 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -128,6 +128,7 @@ services: JWT_ISSUER: go-genai-stack-e2e # 服务器配置 + NODE_ENV: test SERVER_PORT: 8080 SERVER_HOST: 0.0.0.0 ports: @@ -142,9 +143,9 @@ services: healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] interval: 5s - timeout: 3s - retries: 10 - start_period: 15s + timeout: 5s + retries: 20 + start_period: 30s restart: unless-stopped volumes: From da7d744744d5938acb9b2af6667412ef553c0774 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 01:16:21 +0800 Subject: [PATCH 13/22] fix: enhance health check mechanism in E2E workflow for improved reliability and add Redis connection timeout and retry strategy to prevent hanging connections --- .github/workflows/frontend-e2e.yml | 24 +++++++++++++-- .../persistence/redis/connection.ts | 29 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index 288c2e5..ac1d9b4 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -143,8 +143,28 @@ jobs: echo " ✅" echo "⏳ Waiting for ${{ matrix.backend_name }} to be healthy..." - timeout 90s bash -c 'until [ "$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }})" == "healthy" ]; do echo -n "."; sleep 2; done' - echo " ✅" + timeout 120s bash -c ' + max_attempts=60 + attempt=0 + while [ $attempt -lt $max_attempts ]; do + status=$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "not-found") + if [ "$status" == "healthy" ]; then + echo " ✅" + exit 0 + fi + if [ "$status" == "not-found" ]; then + echo " ❌ Container not found" + exit 1 + fi + echo -n "." + sleep 2 + attempt=$((attempt + 1)) + done + echo " ❌ Health check timeout after $((max_attempts * 2)) seconds" + echo "Container status: $status" + docker logs --tail=50 ${{ matrix.container_name }} || true + exit 1 + ' echo "✅ All services are ready" docker compose ps diff --git a/backend-nodejs/infrastructure/persistence/redis/connection.ts b/backend-nodejs/infrastructure/persistence/redis/connection.ts index baad276..2732d8f 100644 --- a/backend-nodejs/infrastructure/persistence/redis/connection.ts +++ b/backend-nodejs/infrastructure/persistence/redis/connection.ts @@ -17,6 +17,16 @@ export function createRedisConnection( socket: { host: config.host, port: config.port, + // 设置连接超时,避免连接挂起 + connectTimeout: 5000, // 5 秒连接超时 + reconnectStrategy: (retries) => { + // 最多重试 3 次 + if (retries > 3) { + return new Error('Redis connection failed after 3 retries'); + } + // 指数退避:100ms, 200ms, 400ms + return Math.min(100 * Math.pow(2, retries), 1000); + }, }, password: config.password || undefined, database: config.db, @@ -32,11 +42,28 @@ export function createRedisConnection( /** * 连接 Redis + * 设置超时以避免连接挂起 */ export async function connectRedis( client: RedisClientType ): Promise { - await client.connect(); + // 使用 Promise.race 设置连接超时 + let timeoutId: NodeJS.Timeout | null = null; + const connectPromise = client.connect(); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Redis connection timeout after 10 seconds')); + }, 10000); // 10 秒超时 + }); + + try { + await Promise.race([connectPromise, timeoutPromise]); + } finally { + // 清理超时定时器 + if (timeoutId) { + clearTimeout(timeoutId); + } + } } /** From ad9475afe7574d66abe0ed381577e45e5d2509be Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 01:34:24 +0800 Subject: [PATCH 14/22] fix: improve health check logic in Docker configuration for backend-nodejs and E2E setup, enhancing error handling and reliability --- backend-nodejs/Dockerfile | 2 +- docker/e2e/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend-nodejs/Dockerfile b/backend-nodejs/Dockerfile index bcc91cb..0eda93c 100644 --- a/backend-nodejs/Dockerfile +++ b/backend-nodejs/Dockerfile @@ -64,7 +64,7 @@ EXPOSE 8080 # 健康检查 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + CMD node -e "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {console.error('Health check error:', e);process.exit(1)});r.on('data', () => {});r.on('end', () => {process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {console.error('Request error:', e);process.exit(1)})" # 启动应用 CMD ["node", "dist/cmd/server/main.js"] diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index b904bd9..6cea1d3 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -141,7 +141,7 @@ services: networks: - e2e-network healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {console.error('Health check error:', e);process.exit(1)});r.on('data', () => {});r.on('end', () => {process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {console.error('Request error:', e);process.exit(1)})"] interval: 5s timeout: 5s retries: 20 From e8dfeb40ee0fa9ec1f08962bd969020ab99cf8ab Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 01:41:17 +0800 Subject: [PATCH 15/22] fix: improve health check logic in E2E workflow by increasing timeout and enhancing status reporting for better reliability and error handling --- .github/workflows/frontend-e2e.yml | 37 ++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index ac1d9b4..3967d55 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -143,26 +143,49 @@ jobs: echo " ✅" echo "⏳ Waiting for ${{ matrix.backend_name }} to be healthy..." - timeout 120s bash -c ' - max_attempts=60 + timeout 180s bash -c ' + max_attempts=90 attempt=0 while [ $attempt -lt $max_attempts ]; do - status=$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "not-found") + # 检查容器是否存在 + if ! docker ps --format "{{.Names}}" | grep -q "^${{ matrix.container_name }}$"; then + echo " ❌ Container ${{ matrix.container_name }} not found" + exit 1 + fi + + # 获取健康状态 + status=$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "starting") + if [ "$status" == "healthy" ]; then echo " ✅" exit 0 fi - if [ "$status" == "not-found" ]; then - echo " ❌ Container not found" - exit 1 + + # 每 10 次尝试输出一次状态信息 + if [ $((attempt % 10)) -eq 0 ] && [ $attempt -gt 0 ]; then + echo "" + echo " Attempt $attempt/$max_attempts, Status: $status" + # 如果是 unhealthy,显示最近的 health check 日志 + if [ "$status" == "unhealthy" ]; then + echo " Recent health check logs:" + docker inspect --format="{{range .State.Health.Log}}{{.Output}}{{end}}" ${{ matrix.container_name }} 2>/dev/null | tail -3 || true + fi fi + echo -n "." sleep 2 attempt=$((attempt + 1)) done + echo "" echo " ❌ Health check timeout after $((max_attempts * 2)) seconds" - echo "Container status: $status" + echo "Container: ${{ matrix.container_name }}" + echo "Final status: $(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "unknown")" + echo "" + echo "Container logs (last 50 lines):" docker logs --tail=50 ${{ matrix.container_name }} || true + echo "" + echo "Health check history:" + docker inspect --format="{{range .State.Health.Log}}{{.Output}}{{end}}" ${{ matrix.container_name }} 2>/dev/null || true exit 1 ' From 239d48de220a0c762701d77ef4fe7e0179134c2c Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 01:51:52 +0800 Subject: [PATCH 16/22] fix: enhance health check logic in Docker and E2E configurations by improving error reporting and adding detailed health check outputs for better diagnostics --- .github/workflows/frontend-e2e.yml | 17 +++++++++++------ backend-nodejs/Dockerfile | 2 +- docker/e2e/docker-compose.yml | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index 3967d55..04335d5 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -165,10 +165,12 @@ jobs: if [ $((attempt % 10)) -eq 0 ] && [ $attempt -gt 0 ]; then echo "" echo " Attempt $attempt/$max_attempts, Status: $status" - # 如果是 unhealthy,显示最近的 health check 日志 - if [ "$status" == "unhealthy" ]; then - echo " Recent health check logs:" - docker inspect --format="{{range .State.Health.Log}}{{.Output}}{{end}}" ${{ matrix.container_name }} 2>/dev/null | tail -3 || true + # 如果是 unhealthy,显示详细的 health check 信息 + if [ "$status" == "unhealthy" ] || [ "$status" == "starting" ]; then + echo " Health check details:" + docker inspect ${{ matrix.container_name }} --format='{{json .State.Health.Log}}' 2>/dev/null | python3 -c "import sys, json; logs = json.load(sys.stdin); last = logs[-1] if logs else {}; print(f\"ExitCode: {last.get('ExitCode', 'N/A')}\"); print(f\"Output: {last.get('Output', 'N/A')}\")" 2>/dev/null || docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null | tail -1 || true + echo " Container state:" + docker inspect ${{ matrix.container_name }} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true fi fi @@ -184,8 +186,11 @@ jobs: echo "Container logs (last 50 lines):" docker logs --tail=50 ${{ matrix.container_name }} || true echo "" - echo "Health check history:" - docker inspect --format="{{range .State.Health.Log}}{{.Output}}{{end}}" ${{ matrix.container_name }} 2>/dev/null || true + echo "Health check history (last 5 attempts):" + docker inspect ${{ matrix.container_name }} --format='{{json .State.Health.Log}}' 2>/dev/null | python3 -c "import sys, json; logs = json.load(sys.stdin); [print(f\"[{log.get('Start', 'N/A')}] ExitCode: {log.get('ExitCode', 'N/A')}\\nOutput: {log.get('Output', 'N/A')}\\n---\") for log in logs[-5:]]" 2>/dev/null || docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null || true + echo "" + echo "Testing health endpoint directly:" + docker exec ${{ matrix.container_name }} node -e "require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})" 2>&1 || true exit 1 ' diff --git a/backend-nodejs/Dockerfile b/backend-nodejs/Dockerfile index 0eda93c..5ce8731 100644 --- a/backend-nodejs/Dockerfile +++ b/backend-nodejs/Dockerfile @@ -64,7 +64,7 @@ EXPOSE 8080 # 健康检查 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {console.error('Health check error:', e);process.exit(1)});r.on('data', () => {});r.on('end', () => {process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {console.error('Request error:', e);process.exit(1)})" + CMD node -e "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {process.stderr.write('Health check response error: ' + e.message + '\\n');process.exit(1)});r.on('data', () => {});r.on('end', () => {if (r.statusCode !== 200) {process.stderr.write('Health check failed: status code ' + r.statusCode + '\\n');}process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {process.stderr.write('Health check request error: ' + e.message + '\\n');process.exit(1)})" # 启动应用 CMD ["node", "dist/cmd/server/main.js"] diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 6cea1d3..16ed3d7 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -141,7 +141,7 @@ services: networks: - e2e-network healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {console.error('Health check error:', e);process.exit(1)});r.on('data', () => {});r.on('end', () => {process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {console.error('Request error:', e);process.exit(1)})"] + test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => {r.on('error', (e) => {process.stderr.write('Health check response error: ' + e.message + '\\n');process.exit(1)});r.on('data', () => {});r.on('end', () => {if (r.statusCode !== 200) {process.stderr.write('Health check failed: status code ' + r.statusCode + '\\n');}process.exit(r.statusCode === 200 ? 0 : 1)})}).on('error', (e) => {process.stderr.write('Health check request error: ' + e.message + '\\n');process.exit(1)})"] interval: 5s timeout: 5s retries: 20 From f2c4e9f235550dcacda94af0f3af88be6add1924 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 01:57:32 +0800 Subject: [PATCH 17/22] fix: enhance health check output formatting in E2E workflow for better diagnostics and error reporting, and add time string parsing utility in backend configuration for flexible token expiry settings --- .github/workflows/frontend-e2e.yml | 5 +- .../infrastructure/config/config.ts | 58 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index 04335d5..e406ab6 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -168,7 +168,8 @@ jobs: # 如果是 unhealthy,显示详细的 health check 信息 if [ "$status" == "unhealthy" ] || [ "$status" == "starting" ]; then echo " Health check details:" - docker inspect ${{ matrix.container_name }} --format='{{json .State.Health.Log}}' 2>/dev/null | python3 -c "import sys, json; logs = json.load(sys.stdin); last = logs[-1] if logs else {}; print(f\"ExitCode: {last.get('ExitCode', 'N/A')}\"); print(f\"Output: {last.get('Output', 'N/A')}\")" 2>/dev/null || docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null | tail -1 || true + # 获取最后一次 health check 的详细信息 + docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.ExitCode}}|{{.Output}}{{end}}' 2>/dev/null | tail -1 | awk -F'|' '{print "ExitCode: " $1; print "Output: " $2}' || true echo " Container state:" docker inspect ${{ matrix.container_name }} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true fi @@ -187,7 +188,7 @@ jobs: docker logs --tail=50 ${{ matrix.container_name }} || true echo "" echo "Health check history (last 5 attempts):" - docker inspect ${{ matrix.container_name }} --format='{{json .State.Health.Log}}' 2>/dev/null | python3 -c "import sys, json; logs = json.load(sys.stdin); [print(f\"[{log.get('Start', 'N/A')}] ExitCode: {log.get('ExitCode', 'N/A')}\\nOutput: {log.get('Output', 'N/A')}\\n---\") for log in logs[-5:]]" 2>/dev/null || docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null || true + docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}[{{.Start}}] ExitCode: {{.ExitCode}} Output: {{.Output}}{{end}}' 2>/dev/null | tail -5 || true echo "" echo "Testing health endpoint directly:" docker exec ${{ matrix.container_name }} node -e "require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})" 2>&1 || true diff --git a/backend-nodejs/infrastructure/config/config.ts b/backend-nodejs/infrastructure/config/config.ts index a89c50a..03ff3a7 100644 --- a/backend-nodejs/infrastructure/config/config.ts +++ b/backend-nodejs/infrastructure/config/config.ts @@ -5,6 +5,44 @@ import { z } from 'zod'; +/** + * 解析时间字符串为秒数 + * 支持格式:15m, 1h, 7d, 3600 (纯数字表示秒) + */ +function parseTimeToSeconds(timeStr: string | undefined): number { + if (!timeStr) { + return 0; + } + + // 如果是纯数字,直接返回 + const numOnly = /^\d+$/.test(timeStr); + if (numOnly) { + return parseInt(timeStr, 10); + } + + // 解析带单位的时间字符串 + const match = timeStr.match(/^(\d+)([smhd])$/i); + if (!match) { + throw new Error(`Invalid time format: ${timeStr}. Expected format: 15m, 1h, 7d, or 3600`); + } + + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 3600; + case 'd': + return value * 86400; + default: + throw new Error(`Unknown time unit: ${unit}`); + } +} + /** * 配置 Schema(使用 Zod 进行验证) */ @@ -36,8 +74,24 @@ const ConfigSchema = z.object({ (val) => val.length >= 32 || process.env.NODE_ENV === 'test', { message: 'JWT secret must be at least 32 characters (except in test mode)' } ), - accessTokenExpiry: z.coerce.number().int().positive().default(3600), // 1 小时 - refreshTokenExpiry: z.coerce.number().int().positive().default(604800), // 7 天 + accessTokenExpiry: z.preprocess( + (val) => { + if (typeof val === 'string') { + return parseTimeToSeconds(val); + } + return typeof val === 'number' ? val : parseTimeToSeconds(String(val)); + }, + z.number().int().positive() + ).default(3600), // 1 小时(默认) + refreshTokenExpiry: z.preprocess( + (val) => { + if (typeof val === 'string') { + return parseTimeToSeconds(val); + } + return typeof val === 'number' ? val : parseTimeToSeconds(String(val)); + }, + z.number().int().positive() + ).default(604800), // 7 天(默认) issuer: z.string().default('go-genai-stack'), }), }); From 445e7ec1a18918afda2daef2cfbbba66e1c8de89 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 02:04:07 +0800 Subject: [PATCH 18/22] fix: refine health check logic in E2E workflow by improving output formatting and enhancing error reporting for better diagnostics and reliability --- .github/workflows/frontend-e2e.yml | 78 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index e406ab6..b838de7 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -143,57 +143,55 @@ jobs: echo " ✅" echo "⏳ Waiting for ${{ matrix.backend_name }} to be healthy..." - timeout 180s bash -c ' + CONTAINER_NAME="${{ matrix.container_name }}" + timeout 180s bash -c " max_attempts=90 attempt=0 - while [ $attempt -lt $max_attempts ]; do - # 检查容器是否存在 - if ! docker ps --format "{{.Names}}" | grep -q "^${{ matrix.container_name }}$"; then - echo " ❌ Container ${{ matrix.container_name }} not found" + while [ \$attempt -lt \$max_attempts ]; do + if ! docker ps --format '{{.Names}}' | grep -q \"^\${CONTAINER_NAME}\$\"; then + echo \" ❌ Container \${CONTAINER_NAME} not found\" exit 1 fi - - # 获取健康状态 - status=$(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "starting") - - if [ "$status" == "healthy" ]; then - echo " ✅" + status=\$(docker inspect --format='{{.State.Health.Status}}' \${CONTAINER_NAME} 2>/dev/null || echo 'starting') + if [ \"\$status\" == 'healthy' ]; then + echo \" ✅\" exit 0 fi - - # 每 10 次尝试输出一次状态信息 - if [ $((attempt % 10)) -eq 0 ] && [ $attempt -gt 0 ]; then - echo "" - echo " Attempt $attempt/$max_attempts, Status: $status" - # 如果是 unhealthy,显示详细的 health check 信息 - if [ "$status" == "unhealthy" ] || [ "$status" == "starting" ]; then - echo " Health check details:" - # 获取最后一次 health check 的详细信息 - docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}{{.ExitCode}}|{{.Output}}{{end}}' 2>/dev/null | tail -1 | awk -F'|' '{print "ExitCode: " $1; print "Output: " $2}' || true - echo " Container state:" - docker inspect ${{ matrix.container_name }} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true + if [ \$((attempt % 10)) -eq 0 ] && [ \$attempt -gt 0 ]; then + echo \"\" + echo \" Attempt \$attempt/\$max_attempts, Status: \$status\" + if [ \"\$status\" == 'unhealthy' ] || [ \"\$status\" == 'starting' ]; then + echo \" Health check details:\" + health_log=\$(docker inspect \${CONTAINER_NAME} --format='{{range .State.Health.Log}}{{.ExitCode}}|{{.Output}}{{end}}' 2>/dev/null | tail -1) + if [ -n \"\$health_log\" ]; then + exit_code=\$(echo \"\$health_log\" | cut -d'|' -f1) + output=\$(echo \"\$health_log\" | cut -d'|' -f2-) + echo \" ExitCode: \${exit_code}\" + echo \" Output: \${output}\" + fi + echo \" Container state:\" + docker inspect \${CONTAINER_NAME} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true fi fi - - echo -n "." + echo -n \".\" sleep 2 - attempt=$((attempt + 1)) + attempt=\$((attempt + 1)) done - echo "" - echo " ❌ Health check timeout after $((max_attempts * 2)) seconds" - echo "Container: ${{ matrix.container_name }}" - echo "Final status: $(docker inspect --format="{{.State.Health.Status}}" ${{ matrix.container_name }} 2>/dev/null || echo "unknown")" - echo "" - echo "Container logs (last 50 lines):" - docker logs --tail=50 ${{ matrix.container_name }} || true - echo "" - echo "Health check history (last 5 attempts):" - docker inspect ${{ matrix.container_name }} --format='{{range .State.Health.Log}}[{{.Start}}] ExitCode: {{.ExitCode}} Output: {{.Output}}{{end}}' 2>/dev/null | tail -5 || true - echo "" - echo "Testing health endpoint directly:" - docker exec ${{ matrix.container_name }} node -e "require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})" 2>&1 || true + echo \"\" + echo \" ❌ Health check timeout after \$((max_attempts * 2)) seconds\" + echo \"Container: \${CONTAINER_NAME}\" + echo \"Final status: \$(docker inspect --format='{{.State.Health.Status}}' \${CONTAINER_NAME} 2>/dev/null || echo 'unknown')\" + echo \"\" + echo \"Container logs (last 50 lines):\" + docker logs --tail=50 \${CONTAINER_NAME} || true + echo \"\" + echo \"Health check history (last 5 attempts):\" + docker inspect \${CONTAINER_NAME} --format='{{range .State.Health.Log}}[{{.Start}}] ExitCode: {{.ExitCode}} Output: {{.Output}}{{end}}' 2>/dev/null | tail -5 || true + echo \"\" + echo \"Testing health endpoint directly:\" + docker exec \${CONTAINER_NAME} node -e \"require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})\" 2>&1 || true exit 1 - ' + " echo "✅ All services are ready" docker compose ps From ee46242fd05ee6e6499334201fe1c8484d1baa8a Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 02:09:40 +0800 Subject: [PATCH 19/22] fix: streamline health check logic in E2E workflow by standardizing container name variable usage for improved clarity and consistency in error reporting --- .github/workflows/frontend-e2e.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index b838de7..8af8f4e 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -143,16 +143,16 @@ jobs: echo " ✅" echo "⏳ Waiting for ${{ matrix.backend_name }} to be healthy..." - CONTAINER_NAME="${{ matrix.container_name }}" timeout 180s bash -c " max_attempts=90 attempt=0 + container_name='${{ matrix.container_name }}' while [ \$attempt -lt \$max_attempts ]; do - if ! docker ps --format '{{.Names}}' | grep -q \"^\${CONTAINER_NAME}\$\"; then - echo \" ❌ Container \${CONTAINER_NAME} not found\" + if ! docker ps --format '{{.Names}}' | grep -q \"^\${container_name}\$\"; then + echo \" ❌ Container \${container_name} not found\" exit 1 fi - status=\$(docker inspect --format='{{.State.Health.Status}}' \${CONTAINER_NAME} 2>/dev/null || echo 'starting') + status=\$(docker inspect --format='{{.State.Health.Status}}' \${container_name} 2>/dev/null || echo 'starting') if [ \"\$status\" == 'healthy' ]; then echo \" ✅\" exit 0 @@ -162,7 +162,7 @@ jobs: echo \" Attempt \$attempt/\$max_attempts, Status: \$status\" if [ \"\$status\" == 'unhealthy' ] || [ \"\$status\" == 'starting' ]; then echo \" Health check details:\" - health_log=\$(docker inspect \${CONTAINER_NAME} --format='{{range .State.Health.Log}}{{.ExitCode}}|{{.Output}}{{end}}' 2>/dev/null | tail -1) + health_log=\$(docker inspect \${container_name} --format='{{range .State.Health.Log}}{{.ExitCode}}|{{.Output}}{{end}}' 2>/dev/null | tail -1) if [ -n \"\$health_log\" ]; then exit_code=\$(echo \"\$health_log\" | cut -d'|' -f1) output=\$(echo \"\$health_log\" | cut -d'|' -f2-) @@ -170,7 +170,7 @@ jobs: echo \" Output: \${output}\" fi echo \" Container state:\" - docker inspect \${CONTAINER_NAME} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true + docker inspect \${container_name} --format='Status: {{.State.Status}}, Health: {{.State.Health.Status}}, Started: {{.State.StartedAt}}' 2>/dev/null || true fi fi echo -n \".\" @@ -179,17 +179,17 @@ jobs: done echo \"\" echo \" ❌ Health check timeout after \$((max_attempts * 2)) seconds\" - echo \"Container: \${CONTAINER_NAME}\" - echo \"Final status: \$(docker inspect --format='{{.State.Health.Status}}' \${CONTAINER_NAME} 2>/dev/null || echo 'unknown')\" + echo \"Container: \${container_name}\" + echo \"Final status: \$(docker inspect --format='{{.State.Health.Status}}' \${container_name} 2>/dev/null || echo 'unknown')\" echo \"\" echo \"Container logs (last 50 lines):\" - docker logs --tail=50 \${CONTAINER_NAME} || true + docker logs --tail=50 \${container_name} || true echo \"\" echo \"Health check history (last 5 attempts):\" - docker inspect \${CONTAINER_NAME} --format='{{range .State.Health.Log}}[{{.Start}}] ExitCode: {{.ExitCode}} Output: {{.Output}}{{end}}' 2>/dev/null | tail -5 || true + docker inspect \${container_name} --format='{{range .State.Health.Log}}[{{.Start}}] ExitCode: {{.ExitCode}} Output: {{.Output}}{{end}}' 2>/dev/null | tail -5 || true echo \"\" echo \"Testing health endpoint directly:\" - docker exec \${CONTAINER_NAME} node -e \"require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})\" 2>&1 || true + docker exec \${container_name} node -e \"require('http').get('http://localhost:8080/health', (r) => {let d='';r.on('data',x=>d+=x);r.on('end',()=>{console.log('Status:',r.statusCode);console.log('Response:',d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error('Error:',e.message);process.exit(1)})\" 2>&1 || true exit 1 " From b70643e240f6585174cd5bffee9918abd95d1b4a Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 11:29:24 +0800 Subject: [PATCH 20/22] fix: update error messages in auth and user models to provide localized feedback in Chinese, enhancing user experience and clarity in validation responses --- .../domains/auth/service/auth_service.test.ts | 22 +++++++-------- .../domains/auth/service/jwt_service.test.ts | 10 +++---- .../domains/task/handlers/converters.ts | 5 +++- backend-nodejs/domains/task/http/dto/task.ts | 10 +++++++ backend-nodejs/domains/task/http/router.ts | 11 ++++++++ .../domains/task/model/task.test.ts | 28 +++++++++---------- .../domains/user/model/user.test.ts | 24 ++++++++-------- backend-nodejs/domains/user/model/user.ts | 2 +- .../infrastructure/middleware/ratelimit.ts | 6 ++++ backend-nodejs/shared/errors/errors.ts | 2 ++ docker/e2e/docker-compose.yml | 1 + .../src/features/auth/stores/auth.store.ts | 12 ++++++-- 12 files changed, 87 insertions(+), 46 deletions(-) diff --git a/backend-nodejs/domains/auth/service/auth_service.test.ts b/backend-nodejs/domains/auth/service/auth_service.test.ts index 3289e4b..5960c9f 100644 --- a/backend-nodejs/domains/auth/service/auth_service.test.ts +++ b/backend-nodejs/domains/auth/service/auth_service.test.ts @@ -103,7 +103,7 @@ describe('AuthService', () => { password: 'password123', }; - await expect(authService.register({}, input)).rejects.toThrow('EMAIL_ALREADY_EXISTS'); + await expect(authService.register({}, input)).rejects.toThrow('邮箱已被占用'); }); it('应该拒绝重复用户名', async () => { @@ -117,7 +117,7 @@ describe('AuthService', () => { username: 'takenusername', }; - await expect(authService.register({}, input)).rejects.toThrow('USERNAME_ALREADY_EXISTS'); + await expect(authService.register({}, input)).rejects.toThrow('用户名已被占用'); }); it('应该拒绝无效邮箱', async () => { @@ -126,7 +126,7 @@ describe('AuthService', () => { password: 'password123', }; - await expect(authService.register({}, input)).rejects.toThrow('INVALID_EMAIL'); + await expect(authService.register({}, input)).rejects.toThrow('邮箱格式无效'); }); it('应该拒绝弱密码', async () => { @@ -135,7 +135,7 @@ describe('AuthService', () => { password: 'short', }; - await expect(authService.register({}, input)).rejects.toThrow('WEAK_PASSWORD'); + await expect(authService.register({}, input)).rejects.toThrow('密码强度不足'); }); }); @@ -170,7 +170,7 @@ describe('AuthService', () => { password: 'wrong-password', }; - await expect(authService.login({}, input)).rejects.toThrow('INVALID_CREDENTIALS'); + await expect(authService.login({}, input)).rejects.toThrow('邮箱或密码错误'); }); it('应该拒绝不存在的邮箱', async () => { @@ -179,7 +179,7 @@ describe('AuthService', () => { password: 'password123', }; - await expect(authService.login({}, input)).rejects.toThrow('INVALID_CREDENTIALS'); + await expect(authService.login({}, input)).rejects.toThrow('邮箱或密码错误'); }); it('应该拒绝禁用用户登录', async () => { @@ -192,7 +192,7 @@ describe('AuthService', () => { password: 'password123', }; - await expect(authService.login({}, input)).rejects.toThrow('USER_BANNED'); + await expect(authService.login({}, input)).rejects.toThrow('用户已被禁用'); }); it('应该允许未激活用户登录', async () => { @@ -236,7 +236,7 @@ describe('AuthService', () => { refreshToken: 'invalid-token', }; - await expect(authService.refreshToken({}, input)).rejects.toThrow('INVALID_REFRESH_TOKEN'); + await expect(authService.refreshToken({}, input)).rejects.toThrow('Refresh Token 无效或已过期'); }); it('应该拒绝 Access Token 作为 Refresh Token', async () => { @@ -249,7 +249,7 @@ describe('AuthService', () => { refreshToken: accessToken, }; - await expect(authService.refreshToken({}, input)).rejects.toThrow('INVALID_REFRESH_TOKEN'); + await expect(authService.refreshToken({}, input)).rejects.toThrow('Refresh Token 无效或已过期'); }); it('应该拒绝不存在的用户', async () => { @@ -260,7 +260,7 @@ describe('AuthService', () => { refreshToken, }; - await expect(authService.refreshToken({}, input)).rejects.toThrow('USER_NOT_FOUND'); + await expect(authService.refreshToken({}, input)).rejects.toThrow('用户不存在'); }); it('应该拒绝禁用用户的 Refresh Token', async () => { @@ -274,7 +274,7 @@ describe('AuthService', () => { refreshToken, }; - await expect(authService.refreshToken({}, input)).rejects.toThrow('USER_BANNED'); + await expect(authService.refreshToken({}, input)).rejects.toThrow('用户已被禁用'); }); }); }); diff --git a/backend-nodejs/domains/auth/service/jwt_service.test.ts b/backend-nodejs/domains/auth/service/jwt_service.test.ts index eae989f..3cd4c83 100644 --- a/backend-nodejs/domains/auth/service/jwt_service.test.ts +++ b/backend-nodejs/domains/auth/service/jwt_service.test.ts @@ -92,7 +92,7 @@ describe('JWTService', () => { expect(() => { jwtService.verifyToken(invalidToken); - }).toThrow('INVALID_TOKEN'); + }).toThrow('Token 验证失败'); }); it('应该拒绝错误 Secret 签名的 Token', () => { @@ -104,7 +104,7 @@ describe('JWTService', () => { expect(() => { jwtService.verifyToken(token); - }).toThrow('INVALID_TOKEN'); + }).toThrow('Token 验证失败'); }); it('应该拒绝错误 Issuer 的 Token', () => { @@ -116,7 +116,7 @@ describe('JWTService', () => { expect(() => { jwtService.verifyToken(token); - }).toThrow('INVALID_ISSUER'); + }).toThrow('Issuer 不匹配'); }); }); @@ -134,7 +134,7 @@ describe('JWTService', () => { expect(() => { jwtService.verifyAccessToken(token); - }).toThrow('INVALID_TOKEN_TYPE'); + }).toThrow('Token 类型必须是 access'); }); }); @@ -152,7 +152,7 @@ describe('JWTService', () => { expect(() => { jwtService.verifyRefreshToken(token); - }).toThrow('INVALID_TOKEN_TYPE'); + }).toThrow('Token 类型必须是 refresh'); }); }); diff --git a/backend-nodejs/domains/task/handlers/converters.ts b/backend-nodejs/domains/task/handlers/converters.ts index 4481d12..0f04c13 100644 --- a/backend-nodejs/domains/task/handlers/converters.ts +++ b/backend-nodejs/domains/task/handlers/converters.ts @@ -125,10 +125,13 @@ export function toCompleteTaskInput( } export function toCompleteTaskResponse(task: Task): CompleteTaskResponse { + if (!task.completedAt) { + throw createError('INTERNAL_SERVER_ERROR', '任务完成时间未设置'); + } return { task_id: task.id, status: task.status, - completed_at: task.completedAt!.toISOString(), + completed_at: task.completedAt.toISOString(), }; } diff --git a/backend-nodejs/domains/task/http/dto/task.ts b/backend-nodejs/domains/task/http/dto/task.ts index e261d42..a32b0c4 100644 --- a/backend-nodejs/domains/task/http/dto/task.ts +++ b/backend-nodejs/domains/task/http/dto/task.ts @@ -47,6 +47,16 @@ export interface UpdateTaskResponse { updated_at: string; // ISO 8601 } +// ======================================== +// Params Schemas (for route params) +// ======================================== + +export const TaskParamsSchema = z.object({ + id: z.string().min(1, '任务 ID 不能为空'), +}); + +export type TaskParams = z.infer; + // ======================================== // CompleteTask // ======================================== diff --git a/backend-nodejs/domains/task/http/router.ts b/backend-nodejs/domains/task/http/router.ts index 256fa40..2f07cc2 100644 --- a/backend-nodejs/domains/task/http/router.ts +++ b/backend-nodejs/domains/task/http/router.ts @@ -10,6 +10,7 @@ import { CreateTaskRequestSchema, UpdateTaskRequestSchema, ListTasksQuerySchema, + TaskParamsSchema, } from './dto/task.js'; import { createTaskHandler } from '../handlers/create_task.handler.js'; import { updateTaskHandler } from '../handlers/update_task.handler.js'; @@ -92,6 +93,16 @@ export function registerTaskRoutes( }, }, async (req, reply) => { + // 手动验证 params(因为 fastify-type-provider-zod 对 params 的支持有限) + const paramsResult = TaskParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: '任务 ID 格式无效', + }, + }); + } await updateTaskHandler(deps, req, reply); } ); diff --git a/backend-nodejs/domains/task/model/task.test.ts b/backend-nodejs/domains/task/model/task.test.ts index 9ad7112..5d46dfc 100644 --- a/backend-nodejs/domains/task/model/task.test.ts +++ b/backend-nodejs/domains/task/model/task.test.ts @@ -25,27 +25,27 @@ describe('Task Model', () => { it('应该拒绝空标题', () => { expect(() => { Task.create('user-123', '', 'Description', 'medium'); - }).toThrow('TASK_TITLE_EMPTY'); + }).toThrow('任务标题不能为空'); }); it('应该拒绝标题过长', () => { const longTitle = 'a'.repeat(201); expect(() => { Task.create('user-123', longTitle, 'Description', 'medium'); - }).toThrow('TASK_TITLE_TOO_LONG'); + }).toThrow('标题过长,最大 200 字符'); }); it('应该拒绝描述过长', () => { const longDescription = 'a'.repeat(5001); expect(() => { Task.create('user-123', 'Title', longDescription, 'medium'); - }).toThrow('TASK_DESCRIPTION_TOO_LONG'); + }).toThrow('描述过长,最大 5000 字符'); }); it('应该拒绝无效优先级', () => { expect(() => { Task.create('user-123', 'Title', 'Description', 'invalid' as any); - }).toThrow('INVALID_PRIORITY'); + }).toThrow('优先级无效'); }); it('应该接受空描述', () => { @@ -96,7 +96,7 @@ describe('Task Model', () => { expect(() => { task.update('New Title', 'New Desc', 'high'); - }).toThrow('TASK_ALREADY_COMPLETED'); + }).toThrow('已完成的任务不能更新'); }); it('应该拒绝更新标题为空', () => { @@ -104,7 +104,7 @@ describe('Task Model', () => { expect(() => { task.update('', 'Desc', 'medium'); - }).toThrow('TASK_TITLE_EMPTY'); + }).toThrow('任务标题不能为空'); }); it('应该拒绝更新标题过长', () => { @@ -113,7 +113,7 @@ describe('Task Model', () => { expect(() => { task.update(longTitle, 'Desc', 'medium'); - }).toThrow('TASK_TITLE_TOO_LONG'); + }).toThrow('标题过长,最大 200 字符'); }); it('应该拒绝更新描述过长', () => { @@ -122,7 +122,7 @@ describe('Task Model', () => { expect(() => { task.update('Test', longDescription, 'medium'); - }).toThrow('TASK_DESCRIPTION_TOO_LONG'); + }).toThrow('描述过长,最大 5000 字符'); }); it('应该拒绝无效优先级', () => { @@ -130,7 +130,7 @@ describe('Task Model', () => { expect(() => { task.update('Test', 'Desc', 'invalid' as any); - }).toThrow('INVALID_PRIORITY'); + }).toThrow('优先级无效'); }); }); @@ -164,7 +164,7 @@ describe('Task Model', () => { expect(() => { task.complete(); - }).toThrow('TASK_ALREADY_COMPLETED'); + }).toThrow('任务已完成,不能再次完成'); }); }); @@ -189,7 +189,7 @@ describe('Task Model', () => { expect(() => { task.setDueDate(pastDate); - }).toThrow('INVALID_DUE_DATE'); + }).toThrow('截止日期不能早于创建日期'); }); }); @@ -213,7 +213,7 @@ describe('Task Model', () => { expect(() => { task.addTag({ name: '', color: '#ff0000' }); - }).toThrow('TAG_NAME_EMPTY'); + }).toThrow('标签名不能为空'); }); it('应该拒绝添加重复标签', () => { @@ -222,7 +222,7 @@ describe('Task Model', () => { expect(() => { task.addTag({ name: 'test', color: '#00ff00' }); - }).toThrow('DUPLICATE_TAG'); + }).toThrow('标签重复'); }); it('应该拒绝添加过多标签', () => { @@ -236,7 +236,7 @@ describe('Task Model', () => { // 尝试添加第 11 个标签 expect(() => { task.addTag({ name: 'tag11', color: '#ff0000' }); - }).toThrow('TOO_MANY_TAGS'); + }).toThrow('标签过多,最多 10 个'); }); it('应该添加多个不同标签', () => { diff --git a/backend-nodejs/domains/user/model/user.test.ts b/backend-nodejs/domains/user/model/user.test.ts index ef3b3ea..388d0a9 100644 --- a/backend-nodejs/domains/user/model/user.test.ts +++ b/backend-nodejs/domains/user/model/user.test.ts @@ -31,20 +31,20 @@ describe('User Model', () => { }); it('应该拒绝无效邮箱格式', async () => { - await expect(User.create('invalid-email', 'password123')).rejects.toThrow('INVALID_EMAIL'); + await expect(User.create('invalid-email', 'password123')).rejects.toThrow('邮箱格式无效'); }); it('应该拒绝空邮箱', async () => { - await expect(User.create('', 'password123')).rejects.toThrow('INVALID_EMAIL'); + await expect(User.create('', 'password123')).rejects.toThrow('邮箱格式无效'); }); it('应该拒绝弱密码(少于 8 字符)', async () => { - await expect(User.create('test@example.com', 'short')).rejects.toThrow('WEAK_PASSWORD'); + await expect(User.create('test@example.com', 'short')).rejects.toThrow('密码强度不足'); }); it('应该拒绝过长密码(超过 128 字符)', async () => { const longPassword = 'a'.repeat(129); - await expect(User.create('test@example.com', longPassword)).rejects.toThrow('PASSWORD_TOO_LONG'); + await expect(User.create('test@example.com', longPassword)).rejects.toThrow('密码过长'); }); it('应该接受最小长度密码(8 字符)', async () => { @@ -93,13 +93,13 @@ describe('User Model', () => { it('应该拒绝弱密码', async () => { const user = await User.create('test@example.com', 'old-password'); - await expect(user.updatePassword('short')).rejects.toThrow('WEAK_PASSWORD'); + await expect(user.updatePassword('short')).rejects.toThrow('密码强度不足'); }); it('应该拒绝过长密码', async () => { const user = await User.create('test@example.com', 'old-password'); const longPassword = 'a'.repeat(129); - await expect(user.updatePassword(longPassword)).rejects.toThrow('PASSWORD_TOO_LONG'); + await expect(user.updatePassword(longPassword)).rejects.toThrow('密码过长'); }); }); @@ -134,7 +134,7 @@ describe('User Model', () => { const user = await User.create('test@example.com', 'password123'); expect(() => { user.updateProfile('ab'); - }).toThrow('INVALID_USERNAME'); + }).toThrow('用户名格式无效'); }); it('应该拒绝无效用户名(太长)', async () => { @@ -142,14 +142,14 @@ describe('User Model', () => { const longUsername = 'a'.repeat(31); expect(() => { user.updateProfile(longUsername); - }).toThrow('INVALID_USERNAME'); + }).toThrow('用户名格式无效'); }); it('应该拒绝无效用户名(包含特殊字符)', async () => { const user = await User.create('test@example.com', 'password123'); expect(() => { user.updateProfile('user-name'); - }).toThrow('INVALID_USERNAME'); + }).toThrow('用户名格式无效'); }); it('应该接受有效用户名(3-30 字符,字母数字下划线)', async () => { @@ -163,14 +163,14 @@ describe('User Model', () => { const longName = 'a'.repeat(101); expect(() => { user.updateProfile(undefined, longName); - }).toThrow('FULL_NAME_TOO_LONG'); + }).toThrow('全名过长'); }); it('应该拒绝无效头像 URL(非 HTTP/HTTPS)', async () => { const user = await User.create('test@example.com', 'password123'); expect(() => { user.updateProfile(undefined, undefined, 'ftp://example.com/avatar.jpg'); - }).toThrow('INVALID_AVATAR_URL'); + }).toThrow('头像 URL 格式无效'); }); it('应该接受空字符串(不更新)', async () => { @@ -322,7 +322,7 @@ describe('User Model', () => { expect(() => { user.canLogin(); - }).toThrow('USER_BANNED'); + }).toThrow('用户已被禁用'); }); }); }); diff --git a/backend-nodejs/domains/user/model/user.ts b/backend-nodejs/domains/user/model/user.ts index c09b7ca..63eeb7d 100644 --- a/backend-nodejs/domains/user/model/user.ts +++ b/backend-nodejs/domains/user/model/user.ts @@ -180,7 +180,7 @@ export class User { */ canLogin(): void { if (this.status === UserStatuses.Banned) { - throw createError('UNAUTHORIZED', '用户已被禁用'); + throw createError('USER_BANNED', '用户已被禁用'); } // 注意:我们允许 Inactive 用户登录,但可能限制某些功能 } diff --git a/backend-nodejs/infrastructure/middleware/ratelimit.ts b/backend-nodejs/infrastructure/middleware/ratelimit.ts index ea41379..5c78971 100644 --- a/backend-nodejs/infrastructure/middleware/ratelimit.ts +++ b/backend-nodejs/infrastructure/middleware/ratelimit.ts @@ -57,6 +57,12 @@ export function createRateLimitMiddleware( options: RateLimitOptions ) { return async (request: FastifyRequest, reply: FastifyReply): Promise => { + // E2E 测试环境或 NODE_ENV=test 时,跳过限流 + const isTestEnv = process.env.NODE_ENV === 'test' || process.env.E2E_TEST === 'true'; + if (isTestEnv) { + return; + } + // Redis 不可用时,跳过限流 if (!redis) { return; diff --git a/backend-nodejs/shared/errors/errors.ts b/backend-nodejs/shared/errors/errors.ts index aeffe88..2bfe634 100644 --- a/backend-nodejs/shared/errors/errors.ts +++ b/backend-nodejs/shared/errors/errors.ts @@ -36,6 +36,7 @@ export const ErrorCodes = { // User 错误 USER_NOT_FOUND: 'USER_NOT_FOUND', + USER_BANNED: 'USER_BANNED', EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS', PASSWORD_TOO_SHORT: 'PASSWORD_TOO_SHORT', INVALID_EMAIL: 'INVALID_EMAIL', @@ -72,6 +73,7 @@ const DEFAULT_STATUS_CODES: Record = { // User 错误 USER_NOT_FOUND: 404, + USER_BANNED: 403, EMAIL_ALREADY_EXISTS: 409, PASSWORD_TOO_SHORT: 400, INVALID_EMAIL: 400, diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml index 16ed3d7..0935409 100644 --- a/docker/e2e/docker-compose.yml +++ b/docker/e2e/docker-compose.yml @@ -129,6 +129,7 @@ services: # 服务器配置 NODE_ENV: test + E2E_TEST: "true" # 标记为 E2E 测试环境,禁用 rate limiting SERVER_PORT: 8080 SERVER_HOST: 0.0.0.0 ports: diff --git a/frontend/web/src/features/auth/stores/auth.store.ts b/frontend/web/src/features/auth/stores/auth.store.ts index 605dcb2..176e614 100644 --- a/frontend/web/src/features/auth/stores/auth.store.ts +++ b/frontend/web/src/features/auth/stores/auth.store.ts @@ -68,7 +68,11 @@ export const useAuthStore = create()( isLoading: false, }) } catch (error: any) { - const errorMessage = error.response?.data?.message || '登录失败' + // 支持两种错误格式: + // 1. backend-nodejs: {error: {code, message}} + // 2. backend-go: {message} + const errorMessage = + error.response?.data?.error?.message || error.response?.data?.message || '登录失败' set({ error: errorMessage, isLoading: false }) throw error } @@ -105,7 +109,11 @@ export const useAuthStore = create()( email: user.email, }) } catch (error: any) { - const errorMessage = error.response?.data?.message || '注册失败' + // 支持两种错误格式: + // 1. backend-nodejs: {error: {code, message}} + // 2. backend-go: {message} + const errorMessage = + error.response?.data?.error?.message || error.response?.data?.message || '注册失败' set({ error: errorMessage, isLoading: false }) throw error } From cbc871c187c423754b584d6deb797c2246300217 Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 14:57:46 +0800 Subject: [PATCH 21/22] fix: enhance server validation logic by customizing Zod validator compiler to skip params validation, ensuring improved handling of body and querystring validations; update E2E start script to support --no-cache flag for fresh builds --- .../infrastructure/bootstrap/server.ts | 15 +++++++++++++-- docker/e2e/start.sh | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend-nodejs/infrastructure/bootstrap/server.ts b/backend-nodejs/infrastructure/bootstrap/server.ts index f9266a3..949949f 100644 --- a/backend-nodejs/infrastructure/bootstrap/server.ts +++ b/backend-nodejs/infrastructure/bootstrap/server.ts @@ -38,8 +38,19 @@ export function createServer(config: Config): FastifyInstance { }, }).withTypeProvider(); - // 注册 Zod validator 和 serializer - fastify.setValidatorCompiler(validatorCompiler); + // 注册自定义 validator compiler + // 跳过 params 验证(因为 fastify-type-provider-zod 对 params 的支持有限) + // 只验证 body 和 querystring + fastify.setValidatorCompiler((opts: { schema: unknown; httpPart?: string }) => { + // 如果是 params 验证,跳过(返回一个总是成功的验证器) + if (opts.httpPart === 'params') { + return () => ({ value: true }); + } + // 对于 body 和 querystring,使用 Zod validator + return validatorCompiler(opts as Parameters[0]); + }); + + // 注册 Zod serializer fastify.setSerializerCompiler(serializerCompiler); return fastify; diff --git a/docker/e2e/start.sh b/docker/e2e/start.sh index 1285729..97eebb0 100755 --- a/docker/e2e/start.sh +++ b/docker/e2e/start.sh @@ -2,7 +2,8 @@ # E2E 测试环境启动脚本 # 用途:启动 Postgres 和 Backend E2E 测试环境 -# 使用:./docker/e2e/start.sh +# 使用:./docker/e2e/start.sh [--no-cache] +# --no-cache: 强制完全重新构建镜像(不使用缓存) set -e @@ -13,6 +14,13 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color +# 检查是否使用 --no-cache 参数 +NO_CACHE_FLAG="" +if [ "$1" = "--no-cache" ]; then + NO_CACHE_FLAG="--no-cache" + echo -e "${YELLOW}⚠️ Using --no-cache flag (slower but ensures fresh build)${NC}" +fi + # 脚本目录 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -44,7 +52,14 @@ if docker ps --filter "name=postgres-e2e" --filter "status=running" | grep -q "p exit 0 fi -# 启动 Docker Compose +# 构建并启动 Docker Compose(确保使用最新代码) +echo -e "${BLUE}🔨 Building Docker images (with latest code)...${NC}" +if [ -n "$NO_CACHE_FLAG" ]; then + $DOCKER_COMPOSE build $NO_CACHE_FLAG backend-e2e backend-nodejs-e2e +else + $DOCKER_COMPOSE build --build-arg BUILDKIT_INLINE_CACHE=1 backend-e2e backend-nodejs-e2e +fi + echo -e "${BLUE}📦 Starting Docker containers...${NC}" $DOCKER_COMPOSE up -d From d24d77dcf8b6c6b1c3d422d99e76b5f8da73c9ce Mon Sep 17 00:00:00 2001 From: sunguangbiao <2991552132@qq.com> Date: Sun, 21 Dec 2025 15:13:44 +0800 Subject: [PATCH 22/22] fix: update task route validation logic to manually validate parameters in handlers, improving error handling and ensuring proper feedback for invalid task ID formats --- backend-nodejs/domains/task/http/router.ts | 72 ++++++++++++---------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/backend-nodejs/domains/task/http/router.ts b/backend-nodejs/domains/task/http/router.ts index 2f07cc2..1ed9b38 100644 --- a/backend-nodejs/domains/task/http/router.ts +++ b/backend-nodejs/domains/task/http/router.ts @@ -61,17 +61,20 @@ export function registerTaskRoutes( '/api/tasks/:id', { preHandler: authMiddleware, - schema: { - params: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, - }, + // 不定义 params schema,避免 fastify-type-provider-zod 尝试用 Zod 验证 + // 改为在 handler 中手动验证 }, async (req, reply) => { + // 手动验证 params(因为 fastify-type-provider-zod 对 params 的支持有限) + const paramsResult = TaskParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: '任务 ID 格式无效', + }, + }); + } await getTaskHandler(deps, req, reply); } ); @@ -82,13 +85,8 @@ export function registerTaskRoutes( { preHandler: authMiddleware, schema: { - params: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, + // 不定义 params schema,避免 fastify-type-provider-zod 尝试用 Zod 验证 + // 改为在 handler 中手动验证 body: UpdateTaskRequestSchema, }, }, @@ -112,17 +110,20 @@ export function registerTaskRoutes( '/api/tasks/:id/complete', { preHandler: authMiddleware, - schema: { - params: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, - }, + // 不定义 params schema,避免 fastify-type-provider-zod 尝试用 Zod 验证 + // 改为在 handler 中手动验证 }, async (req, reply) => { + // 手动验证 params(因为 fastify-type-provider-zod 对 params 的支持有限) + const paramsResult = TaskParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: '任务 ID 格式无效', + }, + }); + } await completeTaskHandler(deps, req, reply); } ); @@ -132,17 +133,20 @@ export function registerTaskRoutes( '/api/tasks/:id', { preHandler: authMiddleware, - schema: { - params: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, - }, + // 不定义 params schema,避免 fastify-type-provider-zod 尝试用 Zod 验证 + // 改为在 handler 中手动验证 }, async (req, reply) => { + // 手动验证 params(因为 fastify-type-provider-zod 对 params 的支持有限) + const paramsResult = TaskParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: '任务 ID 格式无效', + }, + }); + } await deleteTaskHandler(deps, req, reply); } );