diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ac314d8..0000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# 使用官方 Node.js 镜像 -FROM node:lts-alpine - -# 设置工作目录 -WORKDIR /app - -# 设置构建参数 -ARG NPM_REGISTRY=https://registry.npmmirror.com - -# 设置 npm 和 pnpm 镜像源 -RUN npm config set registry ${NPM_REGISTRY} && \ - npm install -g pnpm && \ - pnpm config set registry ${NPM_REGISTRY} - -# 复制 package.json -COPY package.json pnpm-workspace.yaml ./ - -# 安装依赖 -RUN pnpm install - -# 复制源代码 -COPY . . - -# 构建应用 -RUN pnpm run build - -# 暴露端口 -EXPOSE 3000 - -# 启动应用 -CMD ["pnpm", "run", "start:prod"] \ No newline at end of file diff --git a/README.md b/README.md index e3663f1..1768a65 100644 --- a/README.md +++ b/README.md @@ -124,29 +124,48 @@ pnpm run dev ### 第二步:熟悉项目架构 🏗️ +**📁 项目文件结构总览** + ``` -项目根目录/ -├── src/ # 源代码目录 -│ ├── business/ # 业务功能模块(按功能组织) -│ │ ├── auth/ # 🔐 用户认证模块 -│ │ ├── user-mgmt/ # 👥 用户管理模块 -│ │ ├── admin/ # 🛡️ 管理员模块 -│ │ ├── security/ # 🔒 安全模块 -│ │ └── shared/ # 🔗 共享组件 -│ ├── core/ # 核心技术服务 -│ │ ├── db/ # 数据库层(支持MySQL/内存双模式) -│ │ ├── redis/ # Redis缓存服务(支持真实Redis/文件存储) -│ │ ├── login_core/ # 登录核心服务 -│ │ ├── admin_core/ # 管理员核心服务 -│ │ └── utils/ # 工具服务(邮件、验证码、日志) -│ ├── app.module.ts # 应用主模块 -│ └── main.ts # 应用入口 -├── client/ # 前端管理界面 -├── docs/ # 项目文档 -├── test/ # 测试文件 -├── redis-data/ # Redis文件存储数据 -├── logs/ # 日志文件 -└── 配置文件 # .env, package.json, tsconfig.json等 +whale-town-end/ # 🐋 项目根目录 +├── 📂 src/ # 源代码目录 +│ ├── 📂 business/ # 🎯 业务功能模块(按功能组织) +│ │ ├── 📂 auth/ # 🔐 用户认证模块 +│ │ ├── 📂 user-mgmt/ # 👥 用户管理模块 +│ │ ├── 📂 admin/ # 🛡️ 管理员模块 +│ │ ├── 📂 security/ # 🔒 安全防护模块 +│ │ ├── 📂 zulip/ # 💬 Zulip集成模块 +│ │ └── 📂 shared/ # 🔗 共享业务组件 +│ ├── 📂 core/ # ⚙️ 核心技术服务 +│ │ ├── 📂 db/ # 🗄️ 数据库层(MySQL/内存双模式) +│ │ ├── 📂 redis/ # 🔴 Redis缓存(真实Redis/文件存储) +│ │ ├── 📂 login_core/ # 🔑 登录核心服务 +│ │ ├── 📂 admin_core/ # 👑 管理员核心服务 +│ │ ├── 📂 zulip/ # 💬 Zulip核心服务 +│ │ └── 📂 utils/ # 🛠️ 工具服务(邮件、验证码、日志) +│ ├── 📄 app.module.ts # 🏠 应用主模块 +│ └── 📄 main.ts # 🚀 应用入口点 +├── 📂 client/ # 🎨 前端管理界面 +│ ├── 📂 src/ # 前端源码 +│ ├── 📂 dist/ # 前端构建产物 +│ ├── 📄 package.json # 前端依赖配置 +│ └── 📄 vite.config.ts # Vite构建配置 +├── 📂 docs/ # 📚 项目文档中心 +│ ├── 📂 api/ # 🔌 API接口文档 +│ ├── 📂 development/ # 💻 开发指南 +│ ├── 📂 deployment/ # 🚀 部署文档 +│ ├── 📄 ARCHITECTURE.md # 🏗️ 架构设计文档 +│ └── 📄 README.md # 📖 文档导航中心 +├── 📂 test/ # 🧪 测试文件目录 +├── 📂 config/ # ⚙️ 配置文件目录 +├── 📂 logs/ # 📝 日志文件存储 +├── 📂 redis-data/ # 💾 Redis文件存储数据 +├── 📂 dist/ # 📦 后端构建产物 +├── 📄 .env # 🔧 环境变量配置 +├── 📄 package.json # 📋 项目依赖配置 +├── 📄 docker-compose.yml # 🐳 Docker编排配置 +├── 📄 Dockerfile # 🐳 Docker镜像配置 +└── 📄 README.md # 📖 项目主文档(当前文件) ``` **架构特点:** diff --git a/deploy.sh.example b/deploy.sh.example deleted file mode 100644 index 7e97101..0000000 --- a/deploy.sh.example +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# 部署脚本模板 - 用于 Gitea Webhook 自动部署 -# 复制此文件为 deploy.sh 并根据服务器环境修改配置 -set -e - -echo "开始部署 Pixel Game Server..." - -# 项目路径(根据你的服务器实际路径修改) -PROJECT_PATH="/var/www/pixel-game-server" -BACKUP_PATH="/var/backups/pixel-game-server" - -# 创建备份 -echo "创建备份..." -mkdir -p $BACKUP_PATH -cp -r $PROJECT_PATH $BACKUP_PATH/backup-$(date +%Y%m%d-%H%M%S) - -# 进入项目目录 -cd $PROJECT_PATH - -# 拉取最新代码 -echo "拉取最新代码..." -git pull origin main - -# 安装/更新依赖 -echo "安装依赖..." -pnpm install --frozen-lockfile - -# 构建项目 -echo "构建项目..." -pnpm run build - -# 重启服务 -echo "重启服务..." -if command -v pm2 &> /dev/null; then - # 使用 PM2 - pm2 restart pixel-game-server || pm2 start dist/main.js --name pixel-game-server -elif command -v docker-compose &> /dev/null; then - # 使用 Docker Compose - docker-compose down - docker-compose up -d --build -else - # 使用 systemd - sudo systemctl restart pixel-game-server -fi - -echo "部署完成!" - -# 清理旧备份(保留最近5个) -find $BACKUP_PATH -maxdepth 1 -type d -name "backup-*" | sort -r | tail -n +6 | xargs rm -rf - -echo "服务状态检查..." -sleep 5 -curl -f http://localhost:3000/health || echo "警告:服务健康检查失败" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 957b459..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: '3.8' - -services: - app: - build: . - ports: - - "3000:3000" - environment: - - NODE_ENV=production - - DB_HOST=mysql - - DB_PORT=3306 - - DB_USERNAME=pixel_game - - DB_PASSWORD=your_password - - DB_NAME=pixel_game_db - depends_on: - - mysql - restart: unless-stopped - volumes: - - ./logs:/app/logs - - mysql: - image: mysql:8.0 - environment: - - MYSQL_ROOT_PASSWORD=root_password - - MYSQL_DATABASE=pixel_game_db - - MYSQL_USER=pixel_game - - MYSQL_PASSWORD=your_password - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./init.sql:/docker-entrypoint-initdb.d/init.sql - restart: unless-stopped - -volumes: - mysql_data: \ No newline at end of file diff --git a/docs/API_STATUS_CODES.md b/docs/API_STATUS_CODES.md deleted file mode 100644 index 1042acb..0000000 --- a/docs/API_STATUS_CODES.md +++ /dev/null @@ -1,257 +0,0 @@ -# API 状态码说明 - -## 📊 概述 - -本文档说明了项目中使用的 HTTP 状态码,特别是针对邮件发送功能的特殊状态码处理。 - -## 🔢 标准状态码 - -| 状态码 | 含义 | 使用场景 | -|--------|------|----------| -| 200 | OK | 请求成功 | -| 201 | Created | 资源创建成功(如用户注册) | -| 400 | Bad Request | 请求参数错误 | -| 401 | Unauthorized | 未授权(如密码错误) | -| 403 | Forbidden | 权限不足 | -| 404 | Not Found | 资源不存在 | -| 409 | Conflict | 资源冲突(如用户名已存在) | -| 429 | Too Many Requests | 请求频率过高 | -| 500 | Internal Server Error | 服务器内部错误 | - -## 🎯 特殊状态码 - -### 206 Partial Content - 测试模式 - -**使用场景:** 邮件发送功能在测试模式下使用 - -**含义:** 请求部分成功,但未完全达到预期效果 - -**具体应用:** -- 验证码已生成,但邮件未真实发送 -- 功能正常工作,但处于测试/开发模式 -- 用户可以获得验证码进行测试,但需要知道这不是真实发送 - -**响应示例:** - -```json -{ - "success": false, - "data": { - "verification_code": "123456", - "is_test_mode": true - }, - "message": "⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。", - "error_code": "TEST_MODE_ONLY" -} -``` - -## 📧 邮件发送接口状态码 - -### 发送邮箱验证码 - POST /auth/send-email-verification - -| 状态码 | 场景 | 响应 | -|--------|------|------| -| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收邮件" }` | -| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` | -| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` | -| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` | - -### 发送密码重置验证码 - POST /auth/forgot-password - -| 状态码 | 场景 | 响应 | -|--------|------|------| -| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收" }` | -| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` | -| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` | -| 404 | 用户不存在 | `{ "success": false, "message": "用户不存在" }` | - -### 重新发送邮箱验证码 - POST /auth/resend-email-verification - -| 状态码 | 场景 | 响应 | -|--------|------|------| -| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已重新发送,请查收邮件" }` | -| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` | -| 400 | 邮箱已验证或用户不存在 | `{ "success": false, "message": "邮箱已验证,无需重复验证" }` | -| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` | - -## 🔄 模式切换 - -### 测试模式 → 真实发送模式 - -**配置前(测试模式):** -```bash -curl -X POST http://localhost:3000/auth/send-email-verification \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com"}' - -# 响应:206 Partial Content -{ - "success": false, - "data": { - "verification_code": "123456", - "is_test_mode": true - }, - "message": "⚠️ 测试模式:验证码已生成但未真实发送...", - "error_code": "TEST_MODE_ONLY" -} -``` - -**配置后(真实发送模式):** -```bash -# 同样的请求 -curl -X POST http://localhost:3000/auth/send-email-verification \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com"}' - -# 响应:200 OK -{ - "success": true, - "data": { - "is_test_mode": false - }, - "message": "验证码已发送,请查收邮件" -} -``` - -## 💡 前端处理建议 - -### JavaScript 示例 - -```javascript -async function sendEmailVerification(email) { - try { - const response = await fetch('/auth/send-email-verification', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email }), - }); - - const data = await response.json(); - - if (response.status === 200) { - // 真实发送成功 - showSuccess('验证码已发送,请查收邮件'); - } else if (response.status === 206) { - // 测试模式 - showWarning(`测试模式:验证码是 ${data.data.verification_code}`); - showInfo('请配置邮件服务以启用真实发送'); - } else { - // 其他错误 - showError(data.message); - } - } catch (error) { - showError('网络错误,请稍后重试'); - } -} -``` - -### React 示例 - -```jsx -const handleSendVerification = async (email) => { - try { - const response = await fetch('/auth/send-email-verification', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }); - - const data = await response.json(); - - switch (response.status) { - case 200: - setMessage({ type: 'success', text: '验证码已发送,请查收邮件' }); - break; - case 206: - setMessage({ - type: 'warning', - text: `测试模式:验证码是 ${data.data.verification_code}` - }); - setShowConfigTip(true); - break; - case 400: - setMessage({ type: 'error', text: data.message }); - break; - case 429: - setMessage({ type: 'error', text: '发送频率过高,请稍后重试' }); - break; - default: - setMessage({ type: 'error', text: '发送失败,请稍后重试' }); - } - } catch (error) { - setMessage({ type: 'error', text: '网络错误,请稍后重试' }); - } -}; -``` - -## 🎨 UI 展示建议 - -### 测试模式提示 - -```html - -
- ✅ 验证码已发送,请查收邮件 -
- - -
- ⚠️ 测试模式:验证码是 123456 -
- 请配置邮件服务以启用真实发送 -
- - -
- ❌ 发送失败:邮箱格式错误 -
-``` - -## 📝 开发建议 - -### 1. 状态码检查 - -```javascript -// 推荐:明确检查状态码 -if (response.status === 206) { - // 处理测试模式 -} else if (response.status === 200) { - // 处理真实发送 -} - -// 不推荐:只检查 success 字段 -if (data.success) { - // 可能遗漏测试模式的情况 -} -``` - -### 2. 错误处理 - -```javascript -// 推荐:根据 error_code 进行精确处理 -switch (data.error_code) { - case 'TEST_MODE_ONLY': - handleTestMode(data); - break; - case 'SEND_CODE_FAILED': - handleSendFailure(data); - break; - default: - handleGenericError(data); -} -``` - -### 3. 用户体验 - -- **测试模式**:清晰提示用户当前处于测试模式 -- **配置引导**:提供配置邮件服务的链接或说明 -- **验证码显示**:在测试模式下直接显示验证码 -- **状态区分**:用不同的颜色和图标区分不同状态 - -## 🔗 相关文档 - -- [邮件服务配置指南](./EMAIL_CONFIGURATION.md) -- [快速启动指南](./QUICK_START.md) -- [API 文档](./api/README.md) \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1b471e..3b858e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,187 +1,773 @@ -# 🏗️ 项目架构设计 +# 🏗️ Whale Town 项目架构设计 -## 整体架构 +> 基于业务功能模块化的现代化后端架构,支持双模式运行,开发测试零依赖,生产部署高性能。 -Whale Town 采用分层架构设计,确保代码的可维护性和可扩展性。 +## 📋 目录 + +- [🎯 架构概述](#-架构概述) +- [📁 目录结构详解](#-目录结构详解) +- [🏗️ 分层架构设计](#️-分层架构设计) +- [🔄 双模式架构](#-双模式架构) +- [📦 模块依赖关系](#-模块依赖关系) +- [🚀 扩展指南](#-扩展指南) + +--- + +## 🎯 架构概述 + +Whale Town 采用**业务功能模块化架构**,将代码按业务功能而非技术组件组织,确保高内聚、低耦合的设计原则。 + +### 🌟 核心设计理念 + +- **业务驱动** - 按业务功能组织代码,而非技术分层 +- **双模式支持** - 开发测试零依赖,生产部署高性能 +- **清晰分层** - 业务层 → 核心层 → 数据层,职责明确 +- **模块化设计** - 每个模块独立完整,可单独测试和部署 +- **配置驱动** - 通过环境变量控制运行模式和行为 + +### 🛠️ 技术栈 + +#### 后端技术栈 +- **框架**: NestJS 11.x (基于Express) +- **语言**: TypeScript 5.x +- **数据库**: MySQL + TypeORM (生产) / 内存数据库 (开发) +- **缓存**: Redis + IORedis (生产) / 文件存储 (开发) +- **认证**: JWT + bcrypt +- **验证**: class-validator + class-transformer +- **文档**: Swagger/OpenAPI +- **测试**: Jest + Supertest +- **日志**: Pino + nestjs-pino +- **WebSocket**: Socket.IO +- **邮件**: Nodemailer +- **集成**: Zulip API + +#### 前端技术栈 +- **框架**: React 18.x +- **构建工具**: Vite 7.x +- **UI库**: Ant Design 5.x +- **路由**: React Router DOM 6.x +- **语言**: TypeScript 5.x + +### 📊 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🌐 API接口层 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 🔗 REST API │ │ 🔌 WebSocket │ │ 📄 Swagger UI │ │ +│ │ (HTTP接口) │ │ (实时通信) │ │ (API文档) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ⬇️ +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 业务功能模块层 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 🔐 用户认证 │ │ 👥 用户管理 │ │ 🛡️ 管理员 │ │ +│ │ (auth) │ │ (user_mgmt) │ │ (admin) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 💬 Zulip集成 │ │ 🔗 共享组件 │ │ │ │ +│ │ (zulip) │ │ (shared) │ │ │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ⬇️ +┌─────────────────────────────────────────────────────────────────┐ +│ ⚙️ 核心技术服务层 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 🔑 登录核心 │ │ 👑 管理员核心 │ │ 💬 Zulip核心 │ │ +│ │ (auth_core) │ │ (admin_core) │ │ (zulip) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 🛡️ 安全核心 │ │ 🛠️ 工具服务 │ │ 📧 邮件服务 │ │ +│ │ (security_core)│ │ (utils) │ │ (email) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ⬇️ +┌─────────────────────────────────────────────────────────────────┐ +│ 🗄️ 数据存储层 │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 🗃️ 数据库 │ │ 🔴 Redis缓存 │ │ 📁 文件存储 │ │ +│ │ (MySQL/内存) │ │ (Redis/文件) │ │ (logs/data) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 目录结构详解 + +### 🎯 业务功能模块 (`src/business/`) + +> **设计原则**: 按业务功能组织,每个模块包含完整的业务逻辑 + +``` +src/business/ +├── 📂 auth/ # 🔐 用户认证模块 +│ ├── 📄 auth.module.ts # 模块定义 +│ ├── 📂 controllers/ # 控制器 +│ │ └── 📄 login.controller.ts # 登录接口控制器 +│ ├── 📂 services/ # 业务服务 +│ │ ├── 📄 login.service.ts # 登录业务逻辑 +│ │ └── 📄 login.service.spec.ts # 登录服务测试 +│ ├── 📂 dto/ # 数据传输对象 +│ │ ├── 📄 login.dto.ts # 登录请求DTO +│ │ └── 📄 login_response.dto.ts # 登录响应DTO +│ └── 📂 guards/ # 权限守卫(预留) +│ +├── 📂 user-mgmt/ # 👥 用户管理模块 +│ ├── 📄 user-mgmt.module.ts # 模块定义 +│ ├── 📂 controllers/ # 控制器 +│ │ └── 📄 user-status.controller.ts # 用户状态管理接口 +│ ├── 📂 services/ # 业务服务 +│ │ └── 📄 user-management.service.ts # 用户管理逻辑 +│ ├── 📂 dto/ # 数据传输对象 +│ │ ├── 📄 user-status.dto.ts # 用户状态DTO +│ │ └── 📄 user-status-response.dto.ts # 状态响应DTO +│ ├── 📂 enums/ # 枚举定义 +│ │ └── 📄 user-status.enum.ts # 用户状态枚举 +│ └── 📂 tests/ # 测试文件(预留) +│ +├── 📂 admin/ # 🛡️ 管理员模块 +│ ├── 📄 admin.controller.ts # 管理员接口 +│ ├── 📄 admin.service.ts # 管理员业务逻辑 +│ ├── 📄 admin.module.ts # 模块定义 +│ ├── 📄 admin.service.spec.ts # 管理员服务测试 +│ ├── 📂 dto/ # 数据传输对象 +│ └── 📂 guards/ # 权限守卫 +│ +├── 📂 zulip/ # 💬 Zulip集成模块 +│ ├── 📄 zulip.service.ts # Zulip业务服务 +│ ├── 📄 zulip_websocket.gateway.ts # WebSocket网关 +│ ├── 📄 zulip.module.ts # 模块定义 +│ ├── 📂 interfaces/ # 接口定义 +│ └── 📂 services/ # 子服务 +│ ├── 📄 message_filter.service.ts # 消息过滤 +│ └── 📄 session_cleanup.service.ts # 会话清理 +│ +└── 📂 shared/ # 🔗 共享业务组件 + ├── 📂 dto/ # 共享数据传输对象 + └── 📄 index.ts # 导出文件 +``` + +### ⚙️ 核心技术服务 (`src/core/`) + +> **设计原则**: 提供技术基础设施,支持业务模块运行 + +``` +src/core/ +├── 📂 db/ # 🗄️ 数据库层 +│ └── 📂 users/ # 用户数据服务 +│ ├── 📄 users.service.ts # MySQL数据库实现 +│ ├── 📄 users_memory.service.ts # 内存数据库实现 +│ ├── 📄 users.dto.ts # 用户数据传输对象 +│ ├── 📄 users.entity.ts # 用户实体定义 +│ ├── 📄 users.module.ts # 用户数据模块 +│ └── 📄 users.service.spec.ts # 用户服务测试 +│ +├── 📂 redis/ # 🔴 Redis缓存层 +│ ├── 📄 redis.module.ts # Redis模块 +│ ├── 📄 real-redis.service.ts # Redis真实实现 +│ ├── 📄 file-redis.service.ts # 文件存储实现 +│ └── 📄 redis.interface.ts # Redis服务接口 +│ +├── 📂 login_core/ # 🔑 登录核心服务 +│ ├── 📄 login_core.service.ts # 登录核心逻辑 +│ ├── 📄 login_core.module.ts # 模块定义 +│ └── 📄 login_core.service.spec.ts # 登录核心测试 +│ +├── 📂 admin_core/ # 👑 管理员核心服务 +│ ├── 📄 admin_core.service.ts # 管理员核心逻辑 +│ ├── 📄 admin_core.module.ts # 模块定义 +│ └── 📄 admin_core.service.spec.ts # 管理员核心测试 +│ +├── 📂 zulip/ # 💬 Zulip核心服务 +│ ├── 📄 zulip-core.module.ts # Zulip核心模块 +│ ├── 📂 config/ # 配置文件 +│ ├── 📂 interfaces/ # 接口定义 +│ ├── 📂 services/ # 核心服务 +│ ├── 📂 types/ # 类型定义 +│ └── 📄 index.ts # 导出文件 +│ +├── 📂 security_core/ # 🛡️ 安全核心模块 +│ ├── 📄 security_core.module.ts # 安全模块定义 +│ ├── 📂 guards/ # 安全守卫 +│ │ └── 📄 throttle.guard.ts # 频率限制守卫 +│ ├── 📂 interceptors/ # 拦截器 +│ │ └── 📄 timeout.interceptor.ts # 超时拦截器 +│ ├── 📂 middleware/ # 中间件 +│ │ ├── 📄 maintenance.middleware.ts # 维护模式中间件 +│ │ └── 📄 content_type.middleware.ts # 内容类型中间件 +│ └── 📂 decorators/ # 装饰器 +│ ├── 📄 throttle.decorator.ts # 频率限制装饰器 +│ └── 📄 timeout.decorator.ts # 超时装饰器 +│ +└── 📂 utils/ # 🛠️ 工具服务 + ├── 📂 email/ # 📧 邮件服务 + │ ├── 📄 email.service.ts # 邮件发送服务 + │ ├── 📄 email.module.ts # 邮件模块 + │ └── 📄 email.service.spec.ts # 邮件服务测试 + ├── 📂 verification/ # 🔢 验证码服务 + │ ├── 📄 verification.service.ts # 验证码生成验证 + │ ├── 📄 verification.module.ts # 验证码模块 + │ └── 📄 verification.service.spec.ts # 验证码服务测试 + └── 📂 logger/ # 📝 日志服务 + ├── 📄 logger.service.ts # 日志记录服务 + ├── 📄 logger.module.ts # 日志模块 + ├── 📄 logger.config.ts # 日志配置 + └── 📄 log_management.service.ts # 日志管理服务 +``` + +### 🎨 前端管理界面 (`client/`) + +> **设计原则**: 独立的前端项目,提供管理员后台功能,基于React + Vite + Ant Design + +``` +client/ +├── 📂 src/ # 前端源码 +│ ├── 📂 app/ # 应用组件 +│ │ ├── 📄 App.tsx # 应用主组件 +│ │ └── 📄 AdminLayout.tsx # 管理员布局组件 +│ ├── 📂 pages/ # 页面组件 +│ │ ├── 📄 LoginPage.tsx # 登录页面 +│ │ ├── 📄 UsersPage.tsx # 用户管理页面 +│ │ └── 📄 LogsPage.tsx # 日志管理页面 +│ ├── 📂 lib/ # 工具库 +│ │ ├── 📄 api.ts # API客户端 +│ │ └── 📄 adminAuth.ts # 管理员认证服务 +│ └── 📄 main.tsx # 应用入口 +├── 📂 dist/ # 构建产物 +├── 📄 package.json # 前端依赖 +├── 📄 vite.config.ts # Vite配置 +└── 📄 tsconfig.json # TypeScript配置 +``` + +### 📚 文档中心 (`docs/`) + +> **设计原则**: 完整的项目文档,支持开发者快速上手 + +``` +docs/ +├── 📄 README.md # 📖 文档导航中心 +├── 📄 ARCHITECTURE.md # 🏗️ 架构设计文档 +├── 📄 CONTRIBUTORS.md # 🤝 贡献者指南 +│ +├── 📂 api/ # 🔌 API接口文档 +│ ├── 📄 README.md # API文档使用指南 +│ └── 📄 api-documentation.md # 完整API接口文档 +│ +├── 📂 development/ # 💻 开发指南 +│ ├── 📄 backend_development_guide.md # 后端开发规范 +│ ├── 📄 git_commit_guide.md # Git提交规范 +│ ├── 📄 AI辅助开发规范指南.md # AI辅助开发指南 +│ └── 📄 TESTING.md # 测试指南 +│ +└── 📂 deployment/ # 🚀 部署文档 + └── 📄 DEPLOYMENT.md # 生产环境部署指南 +``` + +### 🧪 测试文件 (`test/`) + +> **设计原则**: 完整的测试覆盖,确保代码质量 + +``` +test/ +├── 📂 unit/ # 单元测试 +├── 📂 integration/ # 集成测试 +├── 📂 e2e/ # 端到端测试 +└── 📂 fixtures/ # 测试数据 +``` + +### ⚙️ 配置文件 + +> **设计原则**: 清晰的配置管理,支持多环境部署 + +``` +项目根目录/ +├── 📄 .env # 🔧 环境变量配置 +├── 📄 .env.example # 🔧 环境变量示例 +├── 📄 .env.production.example # 🔧 生产环境示例 +├── 📄 package.json # 📋 后端项目依赖配置 +├── 📄 pnpm-workspace.yaml # 📦 pnpm工作空间配置 +├── 📄 tsconfig.json # 📘 TypeScript配置 +├── 📄 jest.config.js # 🧪 Jest测试配置 +├── 📄 nest-cli.json # 🏠 NestJS CLI配置 +└── 📄 ecosystem.config.js # 🚀 PM2进程管理配置 + +client/ +├── 📄 package.json # 📋 前端项目依赖配置 +├── 📄 vite.config.ts # ⚡ Vite构建配置 +└── 📄 tsconfig.json # 📘 前端TypeScript配置 +``` + +--- + +## 🏗️ 分层架构设计 + +### 📊 架构分层说明 ``` ┌─────────────────────────────────────────────────────────────┐ -│ API 层 │ +│ 🌐 表现层 (Presentation) │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ HTTP API │ │ WebSocket │ │ GraphQL │ │ -│ │ (REST) │ │ (Socket.IO) │ │ (预留) │ │ +│ │ Controllers │ │ WebSocket │ │ Swagger UI │ │ +│ │ (HTTP接口) │ │ Gateways │ │ (API文档) │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────────┘ - │ + ⬇️ ┌─────────────────────────────────────────────────────────────┐ -│ 业务逻辑层 │ +│ 🎯 业务层 (Business) │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ 用户认证 │ │ 游戏逻辑 │ │ 社交功能 │ │ -│ │ (Login) │ │ (Game) │ │ (Social) │ │ +│ │ Auth Module │ │ UserMgmt │ │ Admin Module │ │ +│ │ (用户认证) │ │ Module │ │ (管理员) │ │ +│ │ │ │ (用户管理) │ │ │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Security Module │ │ Zulip Module │ │ Shared Module │ │ +│ │ (安全防护) │ │ (Zulip集成) │ │ (共享组件) │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────────┘ - │ + ⬇️ ┌─────────────────────────────────────────────────────────────┐ -│ 核心服务层 │ +│ ⚙️ 服务层 (Service) │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ 邮件服务 │ │ 验证码服务 │ │ 日志服务 │ │ -│ │ (Email) │ │ (Verification)│ │ (Logger) │ │ +│ │ Login Core │ │ Admin Core │ │ Zulip Core │ │ +│ │ (登录核心) │ │ (管理员核心) │ │ (Zulip核心) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Email Service │ │ Verification │ │ Logger Service │ │ +│ │ (邮件服务) │ │ Service │ │ (日志服务) │ │ +│ │ │ │ (验证码服务) │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────────┘ - │ + ⬇️ ┌─────────────────────────────────────────────────────────────┐ -│ 数据访问层 │ +│ 🗄️ 数据层 (Data) │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ 用户数据 │ │ Redis缓存 │ │ 文件存储 │ │ -│ │ (Users) │ │ (Cache) │ │ (Files) │ │ +│ │ Users Service │ │ Redis Service │ │ File Storage │ │ +│ │ (用户数据) │ │ (缓存服务) │ │ (文件存储) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ MySQL/Memory │ │ Redis/File │ │ Logs/Data │ │ +│ │ (数据库) │ │ (缓存实现) │ │ (日志数据) │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` -## 模块依赖关系 +### 🔄 数据流向 + +#### 用户登录流程示例 ``` -AppModule -├── ConfigModule (全局配置) -├── LoggerModule (日志系统) -├── RedisModule (缓存服务) -├── UsersModule (用户管理) -│ ├── UsersService (数据库模式) -│ └── UsersMemoryService (内存模式) -├── EmailModule (邮件服务) -├── VerificationModule (验证码服务) -├── LoginCoreModule (登录核心) -└── LoginModule (登录业务) +1. 📱 用户请求 → LoginController.login() +2. 🔍 参数验证 → class-validator装饰器 +3. 🎯 业务逻辑 → LoginService.login() +4. ⚙️ 核心服务 → LoginCoreService.validateUser() +5. 📧 发送验证码 → VerificationService.generate() +6. 💾 存储数据 → UsersService.findByEmail() + RedisService.set() +7. 📝 记录日志 → LoggerService.log() +8. ✅ 返回响应 → 用户收到登录结果 ``` -## 数据流向 +#### 管理员操作流程示例 -### 用户注册流程 ``` -1. 用户请求 → LoginController -2. 参数验证 → LoginService -3. 发送验证码 → LoginCoreService -4. 生成验证码 → VerificationService -5. 发送邮件 → EmailService -6. 存储验证码 → RedisService -7. 返回响应 → 用户 +1. 🛡️ 管理员请求 → AdminController.resetUserPassword() +2. 🔐 权限验证 → AdminGuard.canActivate() +3. 🎯 业务逻辑 → AdminService.resetPassword() +4. ⚙️ 核心服务 → AdminCoreService.resetUserPassword() +5. 🔑 密码加密 → bcrypt.hash() +6. 💾 更新数据 → UsersService.update() +7. 📧 通知用户 → EmailService.sendPasswordReset() +8. 📝 审计日志 → LoggerService.audit() +9. ✅ 返回响应 → 管理员收到操作结果 ``` -### 双模式架构 +--- -项目支持开发测试模式和生产部署模式的无缝切换: +## 🔄 双模式架构 -#### 开发测试模式 -- **数据库**: 内存存储 (UsersMemoryService) -- **缓存**: 文件存储 (FileRedisService) -- **邮件**: 控制台输出 (测试模式) -- **优势**: 无需外部依赖,快速启动测试 +### 🎯 设计目标 -#### 生产部署模式 -- **数据库**: MySQL (UsersService + TypeORM) -- **缓存**: Redis (RealRedisService + IORedis) -- **邮件**: SMTP服务器 (生产模式) -- **优势**: 高性能,高可用,数据持久化 +- **开发测试**: 零依赖快速启动,无需安装MySQL、Redis等外部服务 +- **生产部署**: 高性能、高可用,支持集群和负载均衡 -## 设计原则 +### 📊 模式对比 -### 1. 单一职责原则 -每个模块只负责一个特定的功能领域: -- `LoginModule`: 只处理登录相关业务 -- `EmailModule`: 只处理邮件发送 -- `VerificationModule`: 只处理验证码逻辑 +| 功能模块 | 🧪 开发测试模式 | 🚀 生产部署模式 | +|----------|----------------|----------------| +| **数据库** | 内存存储 (UsersMemoryService) | MySQL (UsersService + TypeORM) | +| **缓存** | 文件存储 (FileRedisService) | Redis (RealRedisService + IORedis) | +| **邮件** | 控制台输出 (测试模式) | SMTP服务器 (生产模式) | +| **日志** | 控制台 + 文件 | 结构化日志 + 日志轮转 | +| **配置** | `.env` 默认配置 | 环境变量 + 配置中心 | -### 2. 依赖注入 -使用NestJS的依赖注入系统: -- 接口抽象: `IRedisService`, `IUsersService` -- 实现切换: 根据配置自动选择实现类 -- 测试友好: 易于Mock和单元测试 +### ⚙️ 模式切换配置 -### 3. 配置驱动 -通过环境变量控制行为: -- `USE_FILE_REDIS`: 选择Redis实现 -- `DB_HOST`: 数据库连接配置 -- `EMAIL_HOST`: 邮件服务配置 +#### 开发测试模式 (.env) +```bash +# 数据存储模式 +USE_FILE_REDIS=true # 使用文件存储代替Redis +NODE_ENV=development # 开发环境 -### 4. 错误处理 -统一的错误处理机制: -- HTTP异常: `BadRequestException`, `UnauthorizedException` -- 业务异常: 自定义异常类 -- 日志记录: 结构化错误日志 +# 数据库配置(注释掉,使用内存数据库) +# DB_HOST=localhost +# DB_USERNAME=root +# DB_PASSWORD=password -## 扩展指南 +# 邮件配置(注释掉,使用测试模式) +# EMAIL_HOST=smtp.gmail.com +# EMAIL_USER=your_email@gmail.com +# EMAIL_PASS=your_password +``` -### 添加新的业务模块 +#### 生产部署模式 (.env.production) +```bash +# 数据存储模式 +USE_FILE_REDIS=false # 使用真实Redis +NODE_ENV=production # 生产环境 -1. **创建业务模块** - ```bash - nest g module business/game - nest g controller business/game - nest g service business/game - ``` +# 数据库配置 +DB_HOST=your_mysql_host +DB_PORT=3306 +DB_USERNAME=your_username +DB_PASSWORD=your_password +DB_DATABASE=whale_town -2. **创建核心服务** - ```bash - nest g module core/game_core - nest g service core/game_core - ``` +# Redis配置 +REDIS_HOST=your_redis_host +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password -3. **添加数据模型** - ```bash - nest g module core/db/games - nest g service core/db/games - ``` +# 邮件配置 +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USER=your_email@gmail.com +EMAIL_PASS=your_app_password +``` -4. **更新主模块** - 在 `app.module.ts` 中导入新模块 +### 🔧 实现机制 -### 添加新的工具服务 +#### 依赖注入切换 +```typescript +// redis.module.ts +@Module({ + providers: [ + { + provide: 'IRedisService', + useFactory: (configService: ConfigService) => { + const useFileRedis = configService.get('USE_FILE_REDIS'); + return useFileRedis + ? new FileRedisService() + : new RealRedisService(configService); + }, + inject: [ConfigService], + }, + ], +}) +export class RedisModule {} +``` -1. **创建工具模块** - ```bash - nest g module core/utils/notification - nest g service core/utils/notification - ``` +#### 配置驱动服务选择 +```typescript +// users.module.ts +@Module({ + providers: [ + { + provide: 'IUsersService', + useFactory: (configService: ConfigService) => { + const dbHost = configService.get('DB_HOST'); + return dbHost + ? new UsersService() + : new UsersMemoryService(); + }, + inject: [ConfigService], + }, + ], +}) +export class UsersModule {} +``` -2. **实现服务接口** - 定义抽象接口和具体实现 +--- -3. **添加配置支持** - 在环境变量中添加相关配置 +## 📦 模块依赖关系 -4. **编写测试用例** - 确保功能正确性和代码覆盖率 +### 🏗️ 模块依赖图 -## 性能优化 +``` +AppModule (应用主模块) +├── 📊 ConfigModule (全局配置) +├── 📝 LoggerModule (日志系统) +├── 🔴 RedisModule (缓存服务) +│ ├── RealRedisService (真实Redis) +│ └── FileRedisService (文件存储) +├── 🗄️ UsersModule (用户数据) +│ ├── UsersService (MySQL数据库) +│ └── UsersMemoryService (内存数据库) +├── 📧 EmailModule (邮件服务) +├── 🔢 VerificationModule (验证码服务) +├── 🔑 LoginCoreModule (登录核心) +├── 👑 AdminCoreModule (管理员核心) +├── 💬 ZulipCoreModule (Zulip核心) +├── 🔒 SecurityCoreModule (安全核心) +│ +├── 🎯 业务功能模块 +│ ├── 🔐 AuthModule (用户认证) +│ │ └── 依赖: LoginCoreModule, EmailModule, VerificationModule, SecurityCoreModule +│ ├── 👥 UserMgmtModule (用户管理) +│ │ └── 依赖: UsersModule, LoggerModule, SecurityCoreModule +│ ├── 🛡️ AdminModule (管理员) +│ │ └── 依赖: AdminCoreModule, UsersModule, SecurityCoreModule +│ ├── 💬 ZulipModule (Zulip集成) +│ │ └── 依赖: ZulipCoreModule, RedisModule +│ └── 🔗 SharedModule (共享组件) +``` -### 1. 缓存策略 -- **Redis缓存**: 验证码、会话信息 +### 🔄 模块交互流程 + +#### 用户认证流程 +``` +AuthController → LoginService → LoginCoreService + ↓ +EmailService ← VerificationService ← RedisService + ↓ + UsersService +``` + +#### 管理员操作流程 +``` +AdminController → AdminService → AdminCoreService + ↓ +LoggerService ← UsersService ← RedisService +``` + +#### 安全防护流程 +``` +SecurityGuard → RedisService (频率限制) + → LoggerService (审计日志) + → ConfigService (维护模式) +``` + +--- + +## 🚀 扩展指南 + +### 📝 添加新的业务模块 + +#### 1. 创建业务模块结构 +```bash +# 创建模块目录 +mkdir -p src/business/game/{dto,enums,guards,interfaces} + +# 生成NestJS模块文件 +nest g module business/game +nest g controller business/game +nest g service business/game +``` + +#### 2. 实现业务逻辑 +```typescript +// src/business/game/game.module.ts +@Module({ + imports: [ + GameCoreModule, # 依赖核心服务 + UsersModule, # 依赖用户数据 + RedisModule, # 依赖缓存服务 + ], + controllers: [GameController], + providers: [GameService], + exports: [GameService], +}) +export class GameModule {} +``` + +#### 3. 创建对应的核心服务 +```bash +# 创建核心服务 +mkdir -p src/core/game_core +nest g module core/game_core +nest g service core/game_core +``` + +#### 4. 更新主模块 +```typescript +// src/app.module.ts +@Module({ + imports: [ + // ... 其他模块 + GameModule, # 添加新的业务模块 + ], +}) +export class AppModule {} +``` + +### 🛠️ 添加新的工具服务 + +#### 1. 创建工具服务 +```bash +mkdir -p src/core/utils/notification +nest g module core/utils/notification +nest g service core/utils/notification +``` + +#### 2. 定义服务接口 +```typescript +// src/core/utils/notification/notification.interface.ts +export interface INotificationService { + sendPush(userId: string, message: string): Promise; + sendSMS(phone: string, message: string): Promise; +} +``` + +#### 3. 实现服务 +```typescript +// src/core/utils/notification/notification.service.ts +@Injectable() +export class NotificationService implements INotificationService { + async sendPush(userId: string, message: string): Promise { + // 实现推送通知逻辑 + } + + async sendSMS(phone: string, message: string): Promise { + // 实现短信发送逻辑 + } +} +``` + +#### 4. 配置依赖注入 +```typescript +// src/core/utils/notification/notification.module.ts +@Module({ + providers: [ + { + provide: 'INotificationService', + useClass: NotificationService, + }, + ], + exports: ['INotificationService'], +}) +export class NotificationModule {} +``` + +### 🔌 添加新的API接口 + +#### 1. 定义DTO +```typescript +// src/business/game/dto/create-game.dto.ts +export class CreateGameDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + description?: string; +} +``` + +#### 2. 实现Controller +```typescript +// src/business/game/game.controller.ts +@Controller('game') +@ApiTags('游戏管理') +export class GameController { + constructor(private readonly gameService: GameService) {} + + @Post() + @ApiOperation({ summary: '创建游戏' }) + async createGame(@Body() createGameDto: CreateGameDto) { + return this.gameService.create(createGameDto); + } +} +``` + +#### 3. 实现Service +```typescript +// src/business/game/game.service.ts +@Injectable() +export class GameService { + constructor( + @Inject('IGameCoreService') + private readonly gameCoreService: IGameCoreService, + ) {} + + async create(createGameDto: CreateGameDto) { + return this.gameCoreService.createGame(createGameDto); + } +} +``` + +#### 4. 添加测试用例 +```typescript +// src/business/game/game.service.spec.ts +describe('GameService', () => { + let service: GameService; + let gameCoreService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GameService, + { + provide: 'IGameCoreService', + useValue: { + createGame: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(GameService); + gameCoreService = module.get('IGameCoreService'); + }); + + it('should create game', async () => { + const createGameDto = { name: 'Test Game' }; + const expectedResult = { id: 1, ...createGameDto }; + + gameCoreService.createGame.mockResolvedValue(expectedResult); + + const result = await service.create(createGameDto); + + expect(result).toEqual(expectedResult); + expect(gameCoreService.createGame).toHaveBeenCalledWith(createGameDto); + }); +}); +``` + +### 📊 性能优化建议 + +#### 1. 缓存策略 +- **Redis缓存**: 用户会话、验证码、频繁查询数据 - **内存缓存**: 配置信息、静态数据 - **CDN缓存**: 静态资源文件 -### 2. 数据库优化 -- **连接池**: 复用数据库连接 -- **索引优化**: 关键字段建立索引 -- **查询优化**: 避免N+1查询问题 +#### 2. 数据库优化 +- **连接池**: 复用数据库连接,减少连接开销 +- **索引优化**: 为查询字段建立合适的索引 +- **查询优化**: 避免N+1查询,使用JOIN优化关联查询 -### 3. 日志优化 -- **异步日志**: 使用Pino的异步写入 -- **日志分级**: 生产环境只记录必要日志 +#### 3. 日志优化 +- **异步日志**: 使用Pino的异步写入功能 +- **日志分级**: 生产环境只记录ERROR和WARN级别 - **日志轮转**: 自动清理过期日志文件 -## 安全考虑 +### 🔒 安全加固建议 -### 1. 数据验证 -- **输入验证**: class-validator装饰器 -- **类型检查**: TypeScript静态类型 -- **SQL注入**: TypeORM参数化查询 +#### 1. 数据验证 +- **输入验证**: 使用class-validator进行严格验证 +- **类型检查**: TypeScript静态类型检查 +- **SQL注入防护**: TypeORM参数化查询 -### 2. 认证授权 -- **密码加密**: bcrypt哈希算法 -- **会话管理**: Redis存储会话信息 -- **权限控制**: 基于角色的访问控制 +#### 2. 认证授权 +- **密码安全**: bcrypt加密,强密码策略 +- **会话管理**: JWT + Redis会话存储 +- **权限控制**: 基于角色的访问控制(RBAC) -### 3. 通信安全 +#### 3. 通信安全 - **HTTPS**: 生产环境强制HTTPS -- **CORS**: 跨域请求控制 -- **Rate Limiting**: API请求频率限制 \ No newline at end of file +- **CORS**: 严格的跨域请求控制 +- **Rate Limiting**: API请求频率限制 + +--- + +**🏗️ 通过清晰的架构设计,Whale Town 实现了高内聚、低耦合的模块化架构,支持快速开发和灵活部署!** \ No newline at end of file diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index d36f7fb..9b35a49 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -9,18 +9,22 @@ **moyin** - 主要维护者 - Gitea: [@moyin](https://gitea.xinghangee.icu/moyin) - Email: xinghang_a@proton.me -- 提交数: **66 commits** +- 提交数: **112 commits** - 主要贡献: - 🚀 项目架构设计与初始化 - 🔐 完整用户认证系统实现 - 📧 邮箱验证系统设计与开发 - 🗄️ Redis缓存服务(文件存储+真实Redis双模式) - 📝 完整的API文档系统(Swagger UI + OpenAPI) - - 🧪 测试框架搭建与114个测试用例编写 + - 🧪 测试框架搭建与507个测试用例编写 - 📊 高性能日志系统集成(Pino) - 🔧 项目配置优化与部署方案 - 🐛 验证码TTL重置关键问题修复 - 📚 完整的项目文档体系建设 + - 🏗️ **Zulip模块架构重构** - 业务功能模块化架构设计与实现 + - 📖 **架构文档重写** - 详细的架构设计文档和开发者指南 + - 🔄 **验证码冷却时间优化** - 自动清除机制设计与实现 + - 📋 **文档清理优化** - 项目文档结构化整理和维护体系建立 ### 🌟 核心开发者 @@ -28,18 +32,21 @@ - Gitea: [@ANGJustinl](https://gitea.xinghangee.icu/ANGJustinl) - GitHub: [@ANGJustinl](https://github.com/ANGJustinl) - Email: 96008766+ANGJustinl@users.noreply.github.com -- 提交数: **2 commits** +- 提交数: **7 commits** - 主要贡献: - 🔄 邮箱验证流程重构与优化 - 💾 基于内存的用户服务实现 - 🛠️ API响应处理改进 - 🧪 测试用例完善与错误修复 - 📚 系统架构优化 + - 💬 **Zulip集成系统** - 完整的Zulip实时通信系统开发 + - 🔧 **E2E测试修复** - Zulip集成的端到端测试优化 + - 🎯 **验证码登录测试** - 验证码登录功能测试用例编写 **jianuo** - 核心开发者 - Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo) - Email: 32106500027@e.gzhu.edu.cn -- 提交数: **6 commits** +- 提交数: **11 commits** - 主要贡献: - 🎛️ **管理员后台系统** - 完整的前后端管理界面开发 - 📊 **日志管理功能** - 运行时日志查看与下载系统 @@ -48,14 +55,42 @@ - ⚙️ **TypeScript配置优化** - Node16模块解析配置 - 🐳 **Docker部署优化** - 容器化部署问题修复 - 📖 **技术栈文档更新** - 项目技术栈说明完善 + - 🔧 **项目配置优化** - 构建和开发环境配置改进 ## 贡献统计 | 贡献者 | 提交数 | 主要领域 | 贡献占比 | |--------|--------|----------|----------| -| moyin | 66 | 架构设计、核心功能、文档、测试 | 88% | -| jianuo | 6 | 管理员后台、日志系统、部署优化 | 8% | -| angjustinl | 2 | 功能优化、测试、重构 | 3% | +| moyin | 112 | 架构设计、核心功能、文档、测试、Zulip重构 | 86% | +| jianuo | 11 | 管理员后台、日志系统、部署优化、配置管理 | 8% | +| angjustinl | 7 | Zulip集成、功能优化、测试、重构 | 5% | + +## 🌟 最新重要贡献 + +### 🏗️ Zulip模块架构重构 (2025年12月31日) +**主要贡献者**: moyin, angjustinl + +这是项目历史上最重要的架构重构之一: + +- **架构重构**: 实现业务功能模块化架构,将Zulip模块按照业务层和核心层进行清晰分离 +- **代码迁移**: 36个文件的重构和迁移,涉及2773行代码的新增和125行的删除 +- **依赖注入**: 通过接口抽象实现业务层与核心层的完全解耦 +- **测试完善**: 所有507个测试用例通过,确保重构的安全性 + +### 📚 项目文档体系优化 (2025年12月31日) +**主要贡献者**: moyin + +- **架构文档重写**: `docs/ARCHITECTURE.md` 从简单架构图扩展为800+行的完整架构设计文档 +- **README优化**: 采用总分结构设计,详细的文件结构总览 +- **文档清理**: 新增 `docs/DOCUMENT_CLEANUP.md` 记录文档维护过程 +- **开发者体验**: 建立完整的文档导航体系,提升开发者上手体验 + +### 💬 Zulip集成系统 (2025年12月25日) +**主要贡献者**: angjustinl + +- **完整集成**: 实现与Zulip的完整集成,支持实时通信功能 +- **WebSocket支持**: 建立稳定的WebSocket连接和消息处理机制 +- **测试覆盖**: 完善的E2E测试确保集成功能的稳定性 ## 项目里程碑 @@ -72,6 +107,13 @@ - **12月20日**: jianuo完善日志管理功能 - **12月21日**: jianuo添加管理员后台单元测试 - **12月22日**: 管理员后台功能合并到主分支 +- **12月25日**: angjustinl开发完整的Zulip集成系统 +- **12月25日**: 实现验证码冷却时间自动清除机制 +- **12月25日**: 完成邮箱冲突检测优化v1.1.1 +- **12月25日**: 升级项目版本到v1.1.0 +- **12月31日**: **重大架构重构** - 完成Zulip模块业务功能模块化架构重构 +- **12月31日**: **文档体系优化** - 项目文档结构化整理和架构文档重写 +- **12月31日**: **测试覆盖完善** - 所有507个测试用例通过,测试覆盖率达到新高 ## 如何成为贡献者 diff --git a/docs/README.md b/docs/README.md index c471c23..33ba129 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ ### 📋 **项目管理** - [贡献指南](CONTRIBUTORS.md) - 如何参与项目贡献 -- [文档清理说明](DOCUMENT_CLEANUP.md) - 文档维护记录 +- [文档清理说明](DOCUMENT_CLEANUP.md) - 文档维护和优化记录 ## 🏗️ **文档结构说明** diff --git a/docs/development/ab164782cdc17e22f9bdf443c7e1e96c.png b/docs/development/ab164782cdc17e22f9bdf443c7e1e96c.png new file mode 100644 index 0000000..5678dae Binary files /dev/null and b/docs/development/ab164782cdc17e22f9bdf443c7e1e96c.png differ diff --git a/docs/development/git_commit_guide.md b/docs/development/git_commit_guide.md index 484726a..c05d6db 100644 --- a/docs/development/git_commit_guide.md +++ b/docs/development/git_commit_guide.md @@ -1,3 +1,7 @@ + + +![alt text](ab164782cdc17e22f9bdf443c7e1e96c.png) + # Git 提交规范 本文档定义了项目的 Git 提交信息格式规范,以保持提交历史的清晰和一致性。 diff --git a/docs/systems/zulip/api.md b/docs/systems/zulip/api.md index 86e3548..35af5b0 100644 --- a/docs/systems/zulip/api.md +++ b/docs/systems/zulip/api.md @@ -5,7 +5,7 @@ ### 连接地址 ``` -ws://localhost:3000/game +wss://localhost:3000/game ``` ### 连接参数 diff --git a/docs/systems/zulip/quick_tests/test-registered-user.js b/docs/systems/zulip/quick_tests/test-registered-user.js new file mode 100644 index 0000000..b2e0b1c --- /dev/null +++ b/docs/systems/zulip/quick_tests/test-registered-user.js @@ -0,0 +1,232 @@ +/** + * 测试新注册用户的Zulip账号功能 + * + * 功能: + * 1. 验证新注册用户可以通过游戏服务器登录 + * 2. 验证Zulip账号已正确创建和关联 + * 3. 验证用户可以通过WebSocket发送消息到Zulip + * 4. 验证用户可以接收来自Zulip的消息 + * + * 使用方法: + * node docs/systems/zulip/quick_tests/test-registered-user.js + */ + +const io = require('socket.io-client'); +const axios = require('axios'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 步骤1: 登录游戏服务器获取token + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 昵称: ${response.data.data.user.nickname}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + +/** + * 步骤2: 通过WebSocket连接并测试Zulip集成 + */ +async function testZulipIntegration(userInfo) { + console.log('\n📡 步骤 2: 测试 Zulip 集成'); + console.log(` 连接到: ${GAME_SERVER}/game`); + + return new Promise((resolve, reject) => { + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); + + let testStep = 0; + let testResults = { + connected: false, + loggedIn: false, + messageSent: false, + messageReceived: false + }; + + // 连接成功 + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + testResults.connected = true; + testStep = 1; + + // 发送登录消息 + const loginMessage = { + type: 'login', + token: userInfo.token + }; + + console.log('📤 发送登录消息...'); + socket.emit('login', loginMessage); + }); + + // 登录成功 + socket.on('login_success', (data) => { + console.log('✅ 登录成功'); + console.log(` 会话ID: ${data.sessionId}`); + console.log(` 用户ID: ${data.userId}`); + console.log(` 用户名: ${data.username}`); + console.log(` 当前地图: ${data.currentMap}`); + testResults.loggedIn = true; + testStep = 2; + + // 等待Zulip客户端初始化 + console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...'); + setTimeout(() => { + const chatMessage = { + t: 'chat', + content: `🎮 【注册用户测试】来自 ${userInfo.username} 的消息!\n` + + `时间: ${new Date().toLocaleString()}\n` + + `这是通过新注册账号发送的测试消息。`, + scope: 'local' + }; + + console.log('📤 发送测试消息到 Zulip...'); + console.log(` 内容: ${chatMessage.content.split('\n')[0]}`); + socket.emit('chat', chatMessage); + }, 3000); + }); + + // 消息发送成功 + socket.on('chat_sent', (data) => { + console.log('✅ 消息发送成功'); + console.log(` 消息ID: ${data.id || '未知'}`); + testResults.messageSent = true; + testStep = 3; + + // 等待一段时间接收消息 + setTimeout(() => { + console.log('\n📊 测试完成,断开连接...'); + socket.disconnect(); + }, 5000); + }); + + // 接收到消息 + socket.on('chat_render', (data) => { + console.log('📨 收到来自 Zulip 的消息:'); + console.log(` 发送者: ${data.from}`); + console.log(` 内容: ${data.txt}`); + console.log(` Stream: ${data.stream || '未知'}`); + console.log(` Topic: ${data.topic || '未知'}`); + testResults.messageReceived = true; + }); + + // 错误处理 + socket.on('error', (error) => { + console.error('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + // 连接断开 + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + resolve(testResults); + }); + + // 连接错误 + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + reject(error); + }); + + // 超时保护 + setTimeout(() => { + if (socket.connected) { + socket.disconnect(); + } + }, 15000); + }); +} + +/** + * 打印测试结果 + */ +function printTestResults(results) { + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + + const checks = [ + { name: 'WebSocket 连接', passed: results.connected }, + { name: '游戏服务器登录', passed: results.loggedIn }, + { name: '发送消息到 Zulip', passed: results.messageSent }, + { name: '接收 Zulip 消息', passed: results.messageReceived } + ]; + + checks.forEach(check => { + const icon = check.passed ? '✅' : '❌'; + console.log(`${icon} ${check.name}: ${check.passed ? '通过' : '失败'}`); + }); + + const passedCount = checks.filter(c => c.passed).length; + const totalCount = checks.length; + + console.log('='.repeat(60)); + console.log(`总计: ${passedCount}/${totalCount} 项测试通过`); + + if (passedCount === totalCount) { + console.log('\n🎉 所有测试通过!Zulip账号创建和集成功能正常!'); + console.log('💡 提示: 请访问 https://zulip.xinghangee.icu/ 查看发送的消息'); + } else { + console.log('\n⚠️ 部分测试失败,请检查日志'); + } + console.log('='.repeat(60)); +} + +/** + * 主测试流程 + */ +async function runTest() { + console.log('🚀 开始测试新注册用户的 Zulip 集成功能'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录 + const userInfo = await loginToGameServer(); + + // 步骤2: 测试Zulip集成 + const results = await testZulipIntegration(userInfo); + + // 打印结果 + printTestResults(results); + + process.exit(results.connected && results.loggedIn && results.messageSent ? 0 : 1); + } catch (error) { + console.error('\n❌ 测试失败:', error.message); + process.exit(1); + } +} + +// 运行测试 +runTest(); diff --git a/nest-cli.json b/nest-cli.json index f9aa683..98676bf 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,12 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [ + { + "include": "../config/**/*", + "outDir": "./dist" + } + ] } } diff --git a/src/app.module.ts b/src/app.module.ts index 858b3cd..9835288 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,12 +8,13 @@ import { LoggerModule } from './core/utils/logger/logger.module'; import { UsersModule } from './core/db/users/users.module'; import { LoginCoreModule } from './core/login_core/login_core.module'; import { AuthModule } from './business/auth/auth.module'; +import { ZulipModule } from './business/zulip/zulip.module'; import { RedisModule } from './core/redis/redis.module'; import { AdminModule } from './business/admin/admin.module'; import { UserMgmtModule } from './business/user-mgmt/user-mgmt.module'; -import { SecurityModule } from './business/security/security.module'; -import { MaintenanceMiddleware } from './business/security/middleware/maintenance.middleware'; -import { ContentTypeMiddleware } from './business/security/middleware/content-type.middleware'; +import { SecurityCoreModule } from './core/security_core/security_core.module'; +import { MaintenanceMiddleware } from './core/security_core/middleware/maintenance.middleware'; +import { ContentTypeMiddleware } from './core/security_core/middleware/content_type.middleware'; /** * 检查数据库配置是否完整 by angjustinl 2025-12-17 @@ -67,9 +68,10 @@ function isDatabaseConfigured(): boolean { isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(), LoginCoreModule, AuthModule, + ZulipModule, UserMgmtModule, AdminModule, - SecurityModule, + SecurityCoreModule, ], controllers: [AppController], providers: [ diff --git a/src/business/admin/admin.controller.ts b/src/business/admin/admin.controller.ts index cdf2b16..ba9088c 100644 --- a/src/business/admin/admin.controller.ts +++ b/src/business/admin/admin.controller.ts @@ -25,7 +25,7 @@ import { AdminUserResponseDto, AdminRuntimeLogsResponseDto } from './dto/admin-response.dto'; -import { Throttle, ThrottlePresets } from '../security/decorators/throttle.decorator'; +import { Throttle, ThrottlePresets } from '../../core/security_core/decorators/throttle.decorator'; import type { Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 28e8065..60e8e66 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -16,11 +16,19 @@ import { Module } from '@nestjs/common'; import { LoginController } from './controllers/login.controller'; import { LoginService } from './services/login.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; +import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; @Module({ - imports: [LoginCoreModule], + imports: [ + LoginCoreModule, + ZulipCoreModule, + ZulipAccountsModule.forRoot(), + ], controllers: [LoginController], - providers: [LoginService], + providers: [ + LoginService, + ], exports: [LoginService], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/controllers/login.controller.ts b/src/business/auth/controllers/login.controller.ts index 37d4a7c..c1d94af 100644 --- a/src/business/auth/controllers/login.controller.ts +++ b/src/business/auth/controllers/login.controller.ts @@ -33,8 +33,8 @@ import { TestModeEmailVerificationResponseDto, SuccessEmailVerificationResponseDto } from '../dto/login_response.dto'; -import { Throttle, ThrottlePresets } from '../../security/decorators/throttle.decorator'; -import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator'; +import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator'; +import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator'; @ApiTags('auth') @Controller('auth') diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index a0f6503..a9bcc0c 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -16,9 +16,12 @@ * @since 2025-12-17 */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service'; import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; /** * 登录响应数据接口 @@ -65,6 +68,10 @@ export class LoginService { constructor( private readonly loginCoreService: LoginCoreService, + private readonly zulipAccountService: ZulipAccountService, + @Inject('ZulipAccountsRepository') + private readonly zulipAccountsRepository: ZulipAccountsRepository, + private readonly apiKeySecurityService: ApiKeySecurityService, ) {} /** @@ -116,36 +123,106 @@ export class LoginService { * @returns 注册响应 */ async register(registerRequest: RegisterRequest): Promise> { + const startTime = Date.now(); + try { this.logger.log(`用户注册尝试: ${registerRequest.username}`); - // 调用核心服务进行注册 + // 1. 初始化Zulip管理员客户端 + await this.initializeZulipAdminClient(); + + // 2. 调用核心服务进行注册 const authResult = await this.loginCoreService.register(registerRequest); - // 生成访问令牌 + // 3. 创建Zulip账号(使用相同的邮箱和密码) + let zulipAccountCreated = false; + try { + if (registerRequest.email && registerRequest.password) { + await this.createZulipAccountForUser(authResult.user, registerRequest.password); + zulipAccountCreated = true; + + this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + email: registerRequest.email, + }); + } else { + this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, { + operation: 'register', + username: registerRequest.username, + hasEmail: !!registerRequest.email, + hasPassword: !!registerRequest.password, + }); + } + } catch (zulipError) { + const err = zulipError as Error; + this.logger.error(`Zulip账号创建失败,回滚用户注册`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + zulipError: err.message, + }, err.stack); + + // 回滚游戏用户注册 + try { + await this.loginCoreService.deleteUser(authResult.user.id); + this.logger.log(`用户注册回滚成功: ${registerRequest.username}`); + } catch (rollbackError) { + const rollbackErr = rollbackError as Error; + this.logger.error(`用户注册回滚失败`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + rollbackError: rollbackErr.message, + }, rollbackErr.stack); + } + + // 抛出原始错误 + throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); + } + + // 4. 生成访问令牌 const accessToken = this.generateAccessToken(authResult.user); - // 格式化响应数据 + // 5. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), access_token: accessToken, is_new_user: true, - message: '注册成功' + message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功' }; - this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + const duration = Date.now() - startTime; + + this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + username: authResult.user.username, + zulipAccountCreated, + duration, + timestamp: new Date().toISOString(), + }); return { success: true, data: response, - message: '注册成功' + message: response.message }; } catch (error) { - this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error)); + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error(`用户注册失败: ${registerRequest.username}`, { + operation: 'register', + username: registerRequest.username, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); return { success: false, - message: error instanceof Error ? error.message : '注册失败', + message: err.message || '注册失败', error_code: 'REGISTER_FAILED' }; } @@ -592,4 +669,171 @@ export class LoginService { }; } } + + /** + * 初始化Zulip管理员客户端 + * + * 功能描述: + * 使用环境变量中的管理员凭证初始化Zulip客户端 + * + * 业务逻辑: + * 1. 从环境变量获取管理员配置 + * 2. 验证配置完整性 + * 3. 初始化ZulipAccountService的管理员客户端 + * + * @throws Error 当配置缺失或初始化失败时 + * @private + */ + private async initializeZulipAdminClient(): Promise { + try { + // 从环境变量获取管理员配置 + const adminConfig = { + realm: process.env.ZULIP_SERVER_URL || '', + username: process.env.ZULIP_BOT_EMAIL || '', + apiKey: process.env.ZULIP_BOT_API_KEY || '', + }; + + // 验证配置完整性 + if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) { + throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'); + } + + // 初始化管理员客户端 + const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); + + if (!initialized) { + throw new Error('Zulip管理员客户端初始化失败'); + } + + this.logger.log('Zulip管理员客户端初始化成功', { + operation: 'initializeZulipAdminClient', + realm: adminConfig.realm, + adminEmail: adminConfig.username, + }); + + } catch (error) { + const err = error as Error; + this.logger.error('Zulip管理员客户端初始化失败', { + operation: 'initializeZulipAdminClient', + error: err.message, + }, err.stack); + throw error; + } + } + + /** + * 为用户创建Zulip账号 + * + * 功能描述: + * 为新注册的游戏用户创建对应的Zulip账号并建立关联 + * + * 业务逻辑: + * 1. 使用相同的邮箱和密码创建Zulip账号 + * 2. 加密存储API Key + * 3. 在数据库中建立关联关系 + * 4. 处理创建失败的情况 + * + * @param gameUser 游戏用户信息 + * @param password 用户密码(明文) + * @throws Error 当Zulip账号创建失败时 + * @private + */ + private async createZulipAccountForUser(gameUser: Users, password: string): Promise { + const startTime = Date.now(); + + this.logger.log('开始为用户创建Zulip账号', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + nickname: gameUser.nickname, + }); + + try { + // 1. 检查是否已存在Zulip账号关联 + const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id); + if (existingAccount) { + this.logger.warn('用户已存在Zulip账号关联,跳过创建', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + existingZulipUserId: existingAccount.zulipUserId, + }); + return; + } + + // 2. 创建Zulip账号 + const createResult = await this.zulipAccountService.createZulipAccount({ + email: gameUser.email, + fullName: gameUser.nickname, + password: password, + }); + + if (!createResult.success) { + throw new Error(createResult.error || 'Zulip账号创建失败'); + } + + // 3. 存储API Key + if (createResult.apiKey) { + await this.apiKeySecurityService.storeApiKey( + gameUser.id.toString(), + createResult.apiKey + ); + } + + // 4. 在数据库中创建关联记录 + await this.zulipAccountsRepository.create({ + gameUserId: gameUser.id, + zulipUserId: createResult.userId!, + zulipEmail: createResult.email!, + zulipFullName: gameUser.nickname, + zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中 + status: 'active', + }); + + // 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话) + if (createResult.apiKey) { + await this.zulipAccountService.linkGameAccount( + gameUser.id.toString(), + createResult.userId!, + createResult.email!, + createResult.apiKey + ); + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建和关联成功', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + zulipUserId: createResult.userId, + zulipEmail: createResult.email, + hasApiKey: !!createResult.apiKey, + duration, + }); + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('为用户创建Zulip账号失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + error: err.message, + duration, + }, err.stack); + + // 清理可能创建的部分数据 + try { + await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id); + } catch (cleanupError) { + this.logger.warn('清理Zulip账号关联数据失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + cleanupError: (cleanupError as Error).message, + }); + } + + throw error; + } + } } \ No newline at end of file diff --git a/src/business/auth/services/login.service.zulip-account.spec.ts b/src/business/auth/services/login.service.zulip-account.spec.ts new file mode 100644 index 0000000..4011b47 --- /dev/null +++ b/src/business/auth/services/login.service.zulip-account.spec.ts @@ -0,0 +1,520 @@ +/** + * LoginService Zulip账号创建属性测试 + * + * 功能描述: + * - 测试用户注册时Zulip账号创建的一致性 + * - 验证账号关联和数据完整性 + * - 测试失败回滚机制 + * + * 属性测试: + * - 属性 13: Zulip账号创建一致性 + * - 验证需求: 账号创建成功率和数据一致性 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as fc from 'fast-check'; +import { LoginService } from './login.service'; +import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccounts } from '../../../core/db/zulip_accounts/zulip_accounts.entity'; + +describe('LoginService - Zulip账号创建属性测试', () => { + let loginService: LoginService; + let loginCoreService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let zulipAccountsRepository: jest.Mocked; + let apiKeySecurityService: jest.Mocked; + + // 测试用的模拟数据生成器 + const validEmailArb = fc.string({ minLength: 5, maxLength: 50 }) + .filter(s => s.includes('@') && s.includes('.')) + .map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`); + + const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 }) + .filter(s => /^[a-zA-Z0-9_]+$/.test(s)); + + const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 }) + .filter(s => s.trim().length > 0); + + const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 }) + .filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s)); + + const registerRequestArb = fc.record({ + username: validUsernameArb, + email: validEmailArb, + nickname: validNicknameArb, + password: validPasswordArb, + }); + + beforeEach(async () => { + // 创建模拟服务 + const mockLoginCoreService = { + register: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsRepository = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginService, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsRepository', + useValue: mockZulipAccountsRepository, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, + ], + }).compile(); + + loginService = module.get(LoginService); + loginCoreService = module.get(LoginCoreService); + zulipAccountService = module.get(ZulipAccountService); + zulipAccountsRepository = module.get('ZulipAccountsRepository'); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // 设置环境变量模拟 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }); + + afterEach(() => { + jest.clearAllMocks(); + // 清理环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + }); + + /** + * 属性 13: Zulip账号创建一致性 + * + * 验证需求: 账号创建成功率和数据一致性 + * + * 测试内容: + * 1. 成功注册时,游戏账号和Zulip账号都应该被创建 + * 2. 账号关联信息应该正确存储 + * 3. Zulip账号创建失败时,游戏账号应该被回滚 + * 4. 数据一致性:邮箱、昵称等信息应该保持一致 + */ + describe('属性 13: Zulip账号创建一致性', () => { + it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + const mockZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey, + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.user.email).toBe(registerRequest.email); + expect(result.data?.user.nickname).toBe(registerRequest.nickname); + expect(result.data?.is_new_user).toBe(true); + + // 验证Zulip管理员客户端初始化 + expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({ + realm: 'https://test.zulip.com', + username: 'bot@test.zulip.com', + apiKey: 'test_api_key_123', + }); + + // 验证游戏用户注册 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证API Key存储 + expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.apiKey + ); + + // 验证账号关联创建 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }); + + // 验证内存关联 + expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.userId, + mockZulipResult.email, + mockZulipResult.apiKey + ); + }), + { numRuns: 100 } + ); + }); + + it('应该在Zulip账号创建失败时回滚游戏账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 - Zulip账号创建失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: false, + error: 'Zulip服务器连接失败', + errorCode: 'CONNECTION_FAILED', + }); + loginCoreService.deleteUser.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip账号创建失败'); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建尝试 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证游戏用户被回滚删除 + expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有创建账号关联 + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理已存在Zulip账号关联的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const existingZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: 12345, + zulipEmail: registerRequest.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'existing_encrypted_key', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 - 已存在Zulip账号关联 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该成功 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证检查了现有关联 + expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有尝试创建新的Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理缺少邮箱或密码的注册请求', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + username: validUsernameArb, + nickname: validNicknameArb, + email: fc.option(validEmailArb, { nil: undefined }), + password: fc.option(validPasswordArb, { nil: undefined }), + }), + async (registerRequest) => { + // 只测试缺少邮箱或密码的情况 + if (registerRequest.email && registerRequest.password) { + return; // 跳过完整数据的情况 + } + + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email || null, + nickname: registerRequest.nickname, + password_hash: registerRequest.password ? 'hashed_password' : null, + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + + // 执行注册 + const result = await loginService.register(registerRequest as RegisterRequest); + + // 验证结果 - 注册应该成功,但跳过Zulip账号创建 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息 + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }); + + it('应该正确处理Zulip管理员客户端初始化失败', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 设置模拟行为 - 管理员客户端初始化失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(false); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员客户端初始化失败'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + }), + { numRuns: 50 } + ); + }); + + it('应该正确处理环境变量缺失的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 清除环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员配置不完整'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 恢复环境变量 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }), + { numRuns: 30 } + ); + }); + }); + + /** + * 数据一致性验证测试 + * + * 验证游戏账号和Zulip账号之间的数据一致性 + */ + describe('数据一致性验证', () => { + it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + await loginService.register(registerRequest); + + // 验证Zulip账号创建时使用了正确的数据 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, // 相同的邮箱 + fullName: registerRequest.nickname, // 相同的昵称 + password: registerRequest.password, // 相同的密码 + }); + + // 验证账号关联存储了正确的数据 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: registerRequest.email, // 相同的邮箱 + zulipFullName: registerRequest.nickname, // 相同的昵称 + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }) + ); + }), + { numRuns: 100 } + ); + }); + }); +}); \ No newline at end of file diff --git a/src/business/user-mgmt/controllers/user-status.controller.ts b/src/business/user-mgmt/controllers/user-status.controller.ts index e58d197..724dcdd 100644 --- a/src/business/user-mgmt/controllers/user-status.controller.ts +++ b/src/business/user-mgmt/controllers/user-status.controller.ts @@ -20,8 +20,8 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Put, Post, UseGuard import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AdminGuard } from '../../admin/guards/admin.guard'; import { UserManagementService } from '../services/user-management.service'; -import { Throttle, ThrottlePresets } from '../../security/decorators/throttle.decorator'; -import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator'; +import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator'; +import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator'; import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto'; import { UserStatusResponseDto, BatchUserStatusResponseDto, UserStatusStatsResponseDto } from '../dto/user-status-response.dto'; diff --git a/src/business/zulip/README.md b/src/business/zulip/README.md new file mode 100644 index 0000000..3cba68c --- /dev/null +++ b/src/business/zulip/README.md @@ -0,0 +1,172 @@ +# Zulip集成业务模块 + +## 架构重构说明 + +本模块已按照项目的分层架构要求进行重构,将技术实现细节移动到核心服务层,业务逻辑保留在业务层。 + +### 重构前后对比 + +#### 重构前(❌ 违反架构原则) +``` +src/business/zulip/services/ +├── zulip_client.service.ts # 技术实现:API调用 +├── zulip_client_pool.service.ts # 技术实现:连接池管理 +├── config_manager.service.ts # 技术实现:配置管理 +├── zulip_event_processor.service.ts # 技术实现:事件处理 +├── session_manager.service.ts # ✅ 业务逻辑:会话管理 +└── message_filter.service.ts # ✅ 业务逻辑:消息过滤 +``` + +#### 重构后(✅ 符合架构原则) +``` +# 业务逻辑层 +src/business/zulip/ +├── zulip.service.ts # 业务协调服务 +├── zulip_websocket.gateway.ts # WebSocket业务网关 +└── services/ + ├── session_manager.service.ts # 会话业务逻辑 + └── message_filter.service.ts # 消息过滤业务规则 + +# 核心服务层 +src/core/zulip/ +├── interfaces/ +│ └── zulip-core.interfaces.ts # 核心服务接口定义 +├── services/ +│ ├── zulip_client.service.ts # Zulip API封装 +│ ├── zulip_client_pool.service.ts # 客户端池管理 +│ ├── config_manager.service.ts # 配置管理 +│ ├── zulip_event_processor.service.ts # 事件处理 +│ └── ... # 其他技术服务 +└── zulip-core.module.ts # 核心服务模块 +``` + +### 架构优势 + +#### 1. 单一职责原则 +- **业务层**:只关注游戏相关的业务逻辑和规则 +- **核心层**:只处理技术实现和第三方API调用 + +#### 2. 依赖注入和接口抽象 +```typescript +// 业务层通过接口依赖核心服务 +constructor( + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly zulipClientPool: IZulipClientPoolService, + @Inject('ZULIP_CONFIG_SERVICE') + private readonly configManager: IZulipConfigService, +) {} +``` + +#### 3. 易于测试和维护 +- 业务逻辑可以独立测试,不依赖具体的技术实现 +- 核心服务可以独立替换,不影响业务逻辑 +- 接口定义清晰,便于理解和维护 + +### 服务职责划分 + +#### 业务逻辑层服务 + +| 服务 | 职责 | 业务价值 | +|------|------|----------| +| `ZulipService` | 游戏登录/登出业务流程协调 | 处理玩家生命周期管理 | +| `SessionManagerService` | 游戏会话状态和上下文管理 | 维护玩家位置和聊天上下文 | +| `MessageFilterService` | 消息过滤和业务规则控制 | 实现内容审核和权限验证 | +| `ZulipWebSocketGateway` | WebSocket业务协议处理 | 游戏协议转换和路由 | + +#### 核心服务层服务 + +| 服务 | 职责 | 技术价值 | +|------|------|----------| +| `ZulipClientService` | Zulip REST API封装 | 第三方API调用抽象 | +| `ZulipClientPoolService` | 客户端连接池管理 | 资源管理和性能优化 | +| `ConfigManagerService` | 配置文件管理和热重载 | 系统配置和运维支持 | +| `ZulipEventProcessorService` | 事件队列处理和消息转换 | 异步消息处理机制 | + +### 使用示例 + +#### 业务层调用核心服务 +```typescript +@Injectable() +export class ZulipService { + constructor( + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly zulipClientPool: IZulipClientPoolService, + ) {} + + async sendChatMessage(request: ChatMessageRequest): Promise { + // 业务逻辑:验证和处理 + const session = await this.sessionManager.getSession(request.socketId); + const context = await this.sessionManager.injectContext(request.socketId); + + // 调用核心服务:技术实现 + const result = await this.zulipClientPool.sendMessage( + session.userId, + context.stream, + context.topic, + request.content, + ); + + return { success: result.success, messageId: result.messageId }; + } +} +``` + +### 迁移指南 + +如果你的代码中直接导入了已移动的服务,请按以下方式更新: + +#### 更新导入路径 +```typescript +// ❌ 旧的导入方式 +import { ZulipClientPoolService } from './services/zulip_client_pool.service'; + +// ✅ 新的导入方式(通过依赖注入) +import { IZulipClientPoolService } from '../../core/zulip/interfaces/zulip-core.interfaces'; + +constructor( + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly zulipClientPool: IZulipClientPoolService, +) {} +``` + +#### 更新模块导入 +```typescript +// ✅ 业务模块自动导入核心模块 +@Module({ + imports: [ + ZulipCoreModule, // 自动提供所有核心服务 + // ... + ], +}) +export class ZulipModule {} +``` + +### 测试策略 + +#### 业务逻辑测试 +```typescript +// 使用Mock核心服务测试业务逻辑 +const mockZulipClientPool: IZulipClientPoolService = { + sendMessage: jest.fn().mockResolvedValue({ success: true }), + // ... +}; + +const module = await Test.createTestingModule({ + providers: [ + ZulipService, + { provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockZulipClientPool }, + ], +}).compile(); +``` + +#### 核心服务测试 +```typescript +// 独立测试技术实现 +describe('ZulipClientService', () => { + it('should call Zulip API correctly', async () => { + // 测试API调用逻辑 + }); +}); +``` + +这种架构设计确保了业务逻辑与技术实现的清晰分离,提高了代码的可维护性和可测试性。 \ No newline at end of file diff --git a/src/business/zulip/services/message-filter.service.spec.ts b/src/business/zulip/services/message_filter.service.spec.ts similarity index 97% rename from src/business/zulip/services/message-filter.service.spec.ts rename to src/business/zulip/services/message_filter.service.spec.ts index 904356c..a2bf502 100644 --- a/src/business/zulip/services/message-filter.service.spec.ts +++ b/src/business/zulip/services/message_filter.service.spec.ts @@ -12,8 +12,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as fc from 'fast-check'; -import { MessageFilterService, ViolationType, ContentFilterResult } from './message-filter.service'; -import { ConfigManagerService } from './config-manager.service'; +import { MessageFilterService, ViolationType } from './message_filter.service'; +import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; import { IRedisService } from '../../../core/redis/redis.interface'; @@ -21,7 +21,7 @@ describe('MessageFilterService', () => { let service: MessageFilterService; let mockLogger: jest.Mocked; let mockRedisService: jest.Mocked; - let mockConfigManager: jest.Mocked; + let mockConfigManager: jest.Mocked; // 内存存储模拟Redis let memoryStore: Map; @@ -100,6 +100,14 @@ describe('MessageFilterService', () => { hasMap: jest.fn().mockImplementation((mapId: string) => { return ['novice_village', 'tavern', 'market'].includes(mapId); }), + getMapIdByStream: jest.fn(), + getTopicByObject: jest.fn(), + getZulipConfig: jest.fn(), + hasStream: jest.fn(), + getAllMapIds: jest.fn(), + getAllStreams: jest.fn(), + reloadConfig: jest.fn(), + validateConfig: jest.fn(), } as any; const module: TestingModule = await Test.createTestingModule({ @@ -114,7 +122,7 @@ describe('MessageFilterService', () => { useValue: mockRedisService, }, { - provide: ConfigManagerService, + provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, ], diff --git a/src/business/zulip/services/message-filter.service.ts b/src/business/zulip/services/message_filter.service.ts similarity index 96% rename from src/business/zulip/services/message-filter.service.ts rename to src/business/zulip/services/message_filter.service.ts index aad005b..54ff578 100644 --- a/src/business/zulip/services/message-filter.service.ts +++ b/src/business/zulip/services/message_filter.service.ts @@ -30,7 +30,7 @@ import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { IRedisService } from '../../../core/redis/redis.interface'; -import { ConfigManagerService } from './config-manager.service'; +import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; /** * 内容过滤结果接口 @@ -90,6 +90,28 @@ export interface SensitiveWordConfig { category?: string; } +/** + * 消息过滤服务类 + * + * 职责: + * - 实施内容审核和频率控制 + * - 敏感词过滤和权限验证 + * - 防止恶意操作和滥用 + * - 与ConfigManager集成实现位置权限验证 + * + * 主要方法: + * - filterContent(): 内容过滤,敏感词检查 + * - checkRateLimit(): 频率限制检查 + * - validatePermission(): 权限验证,防止位置欺诈 + * - validateMessage(): 综合消息验证 + * - logViolation(): 记录违规行为 + * + * 使用场景: + * - 消息发送前的内容审核 + * - 频率限制和防刷屏 + * - 权限验证和安全控制 + * - 违规行为监控和记录 + */ @Injectable() export class MessageFilterService { private readonly RATE_LIMIT_PREFIX = 'zulip:rate_limit:'; @@ -127,8 +149,8 @@ export class MessageFilterService { constructor( @Inject('REDIS_SERVICE') private readonly redisService: IRedisService, - @Inject(forwardRef(() => ConfigManagerService)) - private readonly configManager: ConfigManagerService, + @Inject('ZULIP_CONFIG_SERVICE') + private readonly configManager: IZulipConfigService, ) { this.logger.log('MessageFilterService初始化完成'); } diff --git a/src/business/zulip/services/session_cleanup.service.spec.ts b/src/business/zulip/services/session_cleanup.service.spec.ts new file mode 100644 index 0000000..3e0469d --- /dev/null +++ b/src/business/zulip/services/session_cleanup.service.spec.ts @@ -0,0 +1,650 @@ +/** + * 会话清理定时任务服务测试 + * + * 功能描述: + * - 测试SessionCleanupService的核心功能 + * - 包含属性测试验证定时清理机制 + * - 包含属性测试验证资源释放完整性 + * + * **Feature: zulip-integration, Property 13: 定时清理机制** + * **Validates: Requirements 6.1, 6.2, 6.3** + * + * **Feature: zulip-integration, Property 14: 资源释放完整性** + * **Validates: Requirements 6.4, 6.5** + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-31 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import * as fc from 'fast-check'; +import { + SessionCleanupService, + CleanupConfig, + CleanupResult +} from './session_cleanup.service'; +import { SessionManagerService } from './session_manager.service'; +import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; + +describe('SessionCleanupService', () => { + let service: SessionCleanupService; + let mockSessionManager: jest.Mocked; + let mockZulipClientPool: jest.Mocked; + + // 模拟清理结果 + const createMockCleanupResult = (overrides: Partial = {}): any => ({ + cleanedCount: 3, + zulipQueueIds: ['queue-1', 'queue-2', 'queue-3'], + duration: 150, + timestamp: new Date(), + ...overrides, + }); + + beforeEach(async () => { + jest.clearAllMocks(); + // Only use fake timers for tests that need them + // The concurrent test will use real timers for proper Promise handling + + mockSessionManager = { + cleanupExpiredSessions: jest.fn(), + getSession: jest.fn(), + destroySession: jest.fn(), + createSession: jest.fn(), + updatePlayerPosition: jest.fn(), + getSocketsInMap: jest.fn(), + injectContext: jest.fn(), + } as any; + + mockZulipClientPool = { + createUserClient: jest.fn(), + getUserClient: jest.fn(), + hasUserClient: jest.fn(), + sendMessage: jest.fn(), + registerEventQueue: jest.fn(), + deregisterEventQueue: jest.fn(), + destroyUserClient: jest.fn(), + getPoolStats: jest.fn(), + cleanupIdleClients: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionCleanupService, + { + provide: SessionManagerService, + useValue: mockSessionManager, + }, + { + provide: 'ZULIP_CLIENT_POOL_SERVICE', + useValue: mockZulipClientPool, + }, + ], + }).compile(); + + service = module.get(SessionCleanupService); + }); + + afterEach(() => { + service.stopCleanupTask(); + // Only restore timers if they were faked + if (jest.isMockFunction(setTimeout)) { + jest.useRealTimers(); + } + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('startCleanupTask - 启动清理任务', () => { + it('应该启动定时清理任务', () => { + service.startCleanupTask(); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(true); + }); + + it('应该在已启动时不重复启动', () => { + service.startCleanupTask(); + service.startCleanupTask(); // 第二次调用 + + const status = service.getStatus(); + expect(status.isEnabled).toBe(true); + }); + + it('应该立即执行一次清理', async () => { + jest.useFakeTimers(); + + mockSessionManager.cleanupExpiredSessions.mockResolvedValue( + createMockCleanupResult({ cleanedCount: 2 }) + ); + + service.startCleanupTask(); + + // 等待立即执行的清理完成 + await jest.runOnlyPendingTimersAsync(); + + expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); + + jest.useRealTimers(); + }); + }); + + describe('stopCleanupTask - 停止清理任务', () => { + it('应该停止定时清理任务', () => { + service.startCleanupTask(); + service.stopCleanupTask(); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(false); + }); + + it('应该在未启动时安全停止', () => { + service.stopCleanupTask(); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(false); + }); + }); + + describe('runCleanup - 执行清理', () => { + it('应该成功执行清理并返回结果', async () => { + const mockResult = createMockCleanupResult({ + cleanedCount: 5, + zulipQueueIds: ['queue-1', 'queue-2', 'queue-3', 'queue-4', 'queue-5'], + }); + + mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); + + const result = await service.runCleanup(); + + expect(result.success).toBe(true); + expect(result.cleanedSessions).toBe(5); + expect(result.deregisteredQueues).toBe(5); + expect(result.duration).toBeGreaterThanOrEqual(0); // 修改为 >= 0,因为测试环境可能很快 + expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); + }); + + it('应该处理清理过程中的错误', async () => { + const error = new Error('清理失败'); + mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); + + const result = await service.runCleanup(); + + expect(result.success).toBe(false); + expect(result.error).toBe('清理失败'); + expect(result.cleanedSessions).toBe(0); + expect(result.deregisteredQueues).toBe(0); + }); + + it('应该防止并发执行', async () => { + let resolveFirst: () => void; + const firstPromise = new Promise(resolve => { + resolveFirst = () => resolve(createMockCleanupResult()); + }); + + mockSessionManager.cleanupExpiredSessions.mockReturnValueOnce(firstPromise); + + // 同时启动两个清理任务 + const promise1 = service.runCleanup(); + const promise2 = service.runCleanup(); + + // 第二个应该立即返回失败 + const result2 = await promise2; + expect(result2.success).toBe(false); + expect(result2.error).toContain('正在执行中'); + + // 完成第一个任务 + resolveFirst!(); + const result1 = await promise1; + expect(result1.success).toBe(true); + }, 15000); + + it('应该记录最后一次清理结果', async () => { + const mockResult = createMockCleanupResult({ cleanedCount: 3 }); + mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); + + await service.runCleanup(); + + const lastResult = service.getLastCleanupResult(); + expect(lastResult).not.toBeNull(); + expect(lastResult!.cleanedSessions).toBe(3); + expect(lastResult!.success).toBe(true); + }); + }); + + describe('getStatus - 获取状态', () => { + it('应该返回正确的状态信息', () => { + const status = service.getStatus(); + + expect(status).toHaveProperty('isRunning'); + expect(status).toHaveProperty('isEnabled'); + expect(status).toHaveProperty('config'); + expect(status).toHaveProperty('lastResult'); + expect(typeof status.isRunning).toBe('boolean'); + expect(typeof status.isEnabled).toBe('boolean'); + }); + + it('应该反映任务启动状态', () => { + let status = service.getStatus(); + expect(status.isEnabled).toBe(false); + + service.startCleanupTask(); + status = service.getStatus(); + expect(status.isEnabled).toBe(true); + + service.stopCleanupTask(); + status = service.getStatus(); + expect(status.isEnabled).toBe(false); + }); + }); + + describe('updateConfig - 更新配置', () => { + it('应该更新清理配置', () => { + const newConfig: Partial = { + intervalMs: 10 * 60 * 1000, // 10分钟 + sessionTimeoutMinutes: 60, // 60分钟 + }; + + service.updateConfig(newConfig); + + const status = service.getStatus(); + expect(status.config.intervalMs).toBe(10 * 60 * 1000); + expect(status.config.sessionTimeoutMinutes).toBe(60); + }); + + it('应该在配置更改后重启任务', () => { + service.startCleanupTask(); + + const newConfig: Partial = { + intervalMs: 2 * 60 * 1000, // 2分钟 + }; + + service.updateConfig(newConfig); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(true); + expect(status.config.intervalMs).toBe(2 * 60 * 1000); + }); + + it('应该支持禁用清理任务', () => { + service.startCleanupTask(); + + service.updateConfig({ enabled: false }); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(false); + }); + }); + /** + * 属性测试: 定时清理机制 + * + * **Feature: zulip-integration, Property 13: 定时清理机制** + * **Validates: Requirements 6.1, 6.2, 6.3** + * + * 系统应该定期清理过期的游戏会话,释放相关资源, + * 并确保清理过程不影响正常的游戏服务 + */ + describe('Property 13: 定时清理机制', () => { + /** + * 属性: 对于任何有效的清理配置,系统应该按配置间隔执行清理 + * 验证需求 6.1: 系统应定期检查并清理过期的游戏会话 + */ + it('对于任何有效的清理配置,系统应该按配置间隔执行清理', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的清理间隔(1-10分钟) + fc.integer({ min: 1, max: 10 }).map(minutes => minutes * 60 * 1000), + // 生成有效的会话超时时间(10-120分钟) + fc.integer({ min: 10, max: 120 }), + async (intervalMs, sessionTimeoutMinutes) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + jest.useFakeTimers(); + + const config: Partial = { + intervalMs, + sessionTimeoutMinutes, + enabled: true, + }; + + // 模拟清理结果 + mockSessionManager.cleanupExpiredSessions.mockResolvedValue( + createMockCleanupResult({ cleanedCount: 2 }) + ); + + service.updateConfig(config); + service.startCleanupTask(); + + // 验证配置被正确设置 + const status = service.getStatus(); + expect(status.config.intervalMs).toBe(intervalMs); + expect(status.config.sessionTimeoutMinutes).toBe(sessionTimeoutMinutes); + expect(status.isEnabled).toBe(true); + + // 验证立即执行了一次清理 + await jest.runOnlyPendingTimersAsync(); + expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(sessionTimeoutMinutes); + + service.stopCleanupTask(); + jest.useRealTimers(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 对于任何清理操作,都应该记录清理结果和统计信息 + * 验证需求 6.2: 清理过程中系统应记录清理的会话数量和释放的资源 + */ + it('对于任何清理操作,都应该记录清理结果和统计信息', async () => { + await fc.assert( + fc.asyncProperty( + // 生成清理的会话数量 + fc.integer({ min: 0, max: 20 }), + // 生成Zulip队列ID列表 + fc.array( + fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), + { minLength: 0, maxLength: 20 } + ), + async (cleanedCount, queueIds) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + + const mockResult = createMockCleanupResult({ + cleanedCount, + zulipQueueIds: queueIds.slice(0, cleanedCount), // 确保队列数量不超过清理数量 + }); + + mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); + + const result = await service.runCleanup(); + + // 验证清理结果被正确记录 + expect(result.success).toBe(true); + expect(result.cleanedSessions).toBe(cleanedCount); + expect(result.deregisteredQueues).toBe(Math.min(queueIds.length, cleanedCount)); + expect(result.duration).toBeGreaterThanOrEqual(0); + expect(result.timestamp).toBeInstanceOf(Date); + + // 验证最后一次清理结果被保存 + const lastResult = service.getLastCleanupResult(); + expect(lastResult).not.toBeNull(); + expect(lastResult!.cleanedSessions).toBe(cleanedCount); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 清理过程中发生错误时,系统应该正确处理并记录错误信息 + * 验证需求 6.3: 清理过程中出现错误时系统应记录错误信息并继续正常服务 + */ + it('清理过程中发生错误时,系统应该正确处理并记录错误信息', async () => { + await fc.assert( + fc.asyncProperty( + // 生成各种错误消息 + fc.string({ minLength: 5, maxLength: 100 }).filter(s => s.trim().length > 0), + async (errorMessage) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + + const error = new Error(errorMessage.trim()); + mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); + + const result = await service.runCleanup(); + + // 验证错误被正确处理 + expect(result.success).toBe(false); + expect(result.error).toBe(errorMessage.trim()); + expect(result.cleanedSessions).toBe(0); + expect(result.deregisteredQueues).toBe(0); + expect(result.duration).toBeGreaterThanOrEqual(0); + + // 验证错误结果被保存 + const lastResult = service.getLastCleanupResult(); + expect(lastResult).not.toBeNull(); + expect(lastResult!.success).toBe(false); + expect(lastResult!.error).toBe(errorMessage.trim()); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 并发清理请求应该被正确处理,避免重复执行 + * 验证需求 6.1: 系统应避免同时执行多个清理任务 + */ + it('并发清理请求应该被正确处理,避免重复执行', async () => { + // 重置mock + jest.clearAllMocks(); + + // 创建一个可控的Promise,使用实际的异步行为 + let resolveCleanup: (value: any) => void; + const cleanupPromise = new Promise(resolve => { + resolveCleanup = resolve; + }); + + mockSessionManager.cleanupExpiredSessions.mockReturnValue(cleanupPromise); + + // 启动第一个清理请求(应该成功) + const promise1 = service.runCleanup(); + + // 等待一个微任务周期,确保第一个请求开始执行 + await Promise.resolve(); + + // 启动第二个和第三个清理请求(应该被拒绝) + const promise2 = service.runCleanup(); + const promise3 = service.runCleanup(); + + // 第二个和第三个请求应该立即返回失败 + const result2 = await promise2; + const result3 = await promise3; + + expect(result2.success).toBe(false); + expect(result2.error).toContain('正在执行中'); + expect(result3.success).toBe(false); + expect(result3.error).toContain('正在执行中'); + + // 完成第一个清理操作 + resolveCleanup!(createMockCleanupResult({ cleanedCount: 1 })); + const result1 = await promise1; + + expect(result1.success).toBe(true); + }, 10000); + }); + /** + * 属性测试: 资源释放完整性 + * + * **Feature: zulip-integration, Property 14: 资源释放完整性** + * **Validates: Requirements 6.4, 6.5** + * + * 清理过期会话时,系统应该完整释放所有相关资源, + * 包括Zulip事件队列、内存缓存等,确保不会造成资源泄漏 + */ + describe('Property 14: 资源释放完整性', () => { + /** + * 属性: 对于任何过期会话,清理时应该释放所有相关的Zulip资源 + * 验证需求 6.4: 清理会话时系统应注销对应的Zulip事件队列 + */ + it('对于任何过期会话,清理时应该释放所有相关的Zulip资源', async () => { + await fc.assert( + fc.asyncProperty( + // 生成过期会话数量 + fc.integer({ min: 1, max: 10 }), + // 生成每个会话对应的Zulip队列ID + fc.array( + fc.string({ minLength: 8, maxLength: 20 }).filter(s => s.trim().length > 0), + { minLength: 1, maxLength: 10 } + ), + async (sessionCount, queueIds) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + + const actualQueueIds = queueIds.slice(0, sessionCount); + const mockResult = createMockCleanupResult({ + cleanedCount: sessionCount, + zulipQueueIds: actualQueueIds, + }); + + mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); + + const result = await service.runCleanup(); + + // 验证清理成功 + expect(result.success).toBe(true); + expect(result.cleanedSessions).toBe(sessionCount); + + // 验证Zulip队列被处理(这里简化为计数验证) + expect(result.deregisteredQueues).toBe(actualQueueIds.length); + + // 验证SessionManager被调用清理过期会话 + expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 清理操作应该是原子性的,要么全部成功要么全部回滚 + * 验证需求 6.5: 清理过程应确保数据一致性,避免部分清理导致的不一致状态 + */ + it('清理操作应该是原子性的,要么全部成功要么全部回滚', async () => { + await fc.assert( + fc.asyncProperty( + // 生成是否模拟清理失败 + fc.boolean(), + // 生成会话数量 + fc.integer({ min: 1, max: 5 }), + async (shouldFail, sessionCount) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + + if (shouldFail) { + // 模拟清理失败 + const error = new Error('清理操作失败'); + mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); + } else { + // 模拟清理成功 + const mockResult = createMockCleanupResult({ + cleanedCount: sessionCount, + zulipQueueIds: Array.from({ length: sessionCount }, (_, i) => `queue-${i}`), + }); + mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); + } + + const result = await service.runCleanup(); + + if (shouldFail) { + // 失败时应该没有任何资源被释放 + expect(result.success).toBe(false); + expect(result.cleanedSessions).toBe(0); + expect(result.deregisteredQueues).toBe(0); + expect(result.error).toBeDefined(); + } else { + // 成功时所有资源都应该被正确处理 + expect(result.success).toBe(true); + expect(result.cleanedSessions).toBe(sessionCount); + expect(result.deregisteredQueues).toBe(sessionCount); + expect(result.error).toBeUndefined(); + } + + // 验证结果的一致性 + expect(result.timestamp).toBeInstanceOf(Date); + expect(result.duration).toBeGreaterThanOrEqual(0); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 清理配置更新应该正确重启清理任务而不丢失状态 + * 验证需求 6.5: 配置更新时系统应保持服务连续性 + */ + it('清理配置更新应该正确重启清理任务而不丢失状态', async () => { + await fc.assert( + fc.asyncProperty( + // 生成初始配置 + fc.record({ + intervalMs: fc.integer({ min: 1, max: 5 }).map(m => m * 60 * 1000), + sessionTimeoutMinutes: fc.integer({ min: 10, max: 60 }), + }), + // 生成新配置 + fc.record({ + intervalMs: fc.integer({ min: 1, max: 5 }).map(m => m * 60 * 1000), + sessionTimeoutMinutes: fc.integer({ min: 10, max: 60 }), + }), + async (initialConfig, newConfig) => { + // 重置mock以确保每次测试都是干净的状态 + jest.clearAllMocks(); + + // 设置初始配置并启动任务 + service.updateConfig(initialConfig); + service.startCleanupTask(); + + let status = service.getStatus(); + expect(status.isEnabled).toBe(true); + expect(status.config.intervalMs).toBe(initialConfig.intervalMs); + + // 更新配置 + service.updateConfig(newConfig); + + // 验证配置更新后任务仍在运行 + status = service.getStatus(); + expect(status.isEnabled).toBe(true); + expect(status.config.intervalMs).toBe(newConfig.intervalMs); + expect(status.config.sessionTimeoutMinutes).toBe(newConfig.sessionTimeoutMinutes); + + service.stopCleanupTask(); + } + ), + { numRuns: 30 } + ); + }, 30000); + }); + + describe('模块生命周期', () => { + it('应该在模块初始化时启动清理任务', async () => { + // 重新创建服务实例来测试模块初始化 + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionCleanupService, + { + provide: SessionManagerService, + useValue: mockSessionManager, + }, + { + provide: 'ZULIP_CLIENT_POOL_SERVICE', + useValue: mockZulipClientPool, + }, + ], + }).compile(); + + const newService = module.get(SessionCleanupService); + + // 模拟模块初始化 + await newService.onModuleInit(); + + const status = newService.getStatus(); + expect(status.isEnabled).toBe(true); + + // 清理 + await newService.onModuleDestroy(); + }); + + it('应该在模块销毁时停止清理任务', async () => { + service.startCleanupTask(); + + await service.onModuleDestroy(); + + const status = service.getStatus(); + expect(status.isEnabled).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/business/zulip/services/session-cleanup.service.ts b/src/business/zulip/services/session_cleanup.service.ts similarity index 86% rename from src/business/zulip/services/session-cleanup.service.ts rename to src/business/zulip/services/session_cleanup.service.ts index 3f5fc0c..66f1639 100644 --- a/src/business/zulip/services/session-cleanup.service.ts +++ b/src/business/zulip/services/session_cleanup.service.ts @@ -21,9 +21,9 @@ * @since 2025-12-25 */ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { SessionManagerService } from './session-manager.service'; -import { ZulipClientPoolService } from './zulip-client-pool.service'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; +import { SessionManagerService } from './session_manager.service'; +import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; /** * 清理任务配置接口 @@ -55,6 +55,28 @@ export interface CleanupResult { error?: string; } +/** + * 会话清理服务类 + * + * 职责: + * - 定时清理过期的游戏会话 + * - 释放无效的Zulip客户端资源 + * - 维护会话数据的一致性 + * - 提供会话清理统计和监控 + * + * 主要方法: + * - startCleanup(): 启动定时清理任务 + * - stopCleanup(): 停止清理任务 + * - performCleanup(): 执行一次清理操作 + * - getCleanupStats(): 获取清理统计信息 + * - updateConfig(): 更新清理配置 + * + * 使用场景: + * - 系统启动时自动开始清理任务 + * - 定期清理过期会话和资源 + * - 系统关闭时停止清理任务 + * - 监控清理效果和系统健康 + */ @Injectable() export class SessionCleanupService implements OnModuleInit, OnModuleDestroy { private cleanupInterval: NodeJS.Timeout | null = null; @@ -70,7 +92,8 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy { constructor( private readonly sessionManager: SessionManagerService, - private readonly zulipClientPool: ZulipClientPoolService, + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly zulipClientPool: IZulipClientPoolService, ) { this.logger.log('SessionCleanupService初始化完成'); } @@ -176,7 +199,8 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy { // 2. 注销对应的Zulip事件队列 let deregisteredQueues = 0; - for (const queueId of cleanupResult.zulipQueueIds) { + const queueIds = cleanupResult?.zulipQueueIds || []; + for (const queueId of queueIds) { try { // 根据queueId找到对应的用户并注销队列 // 注意:这里需要通过某种方式找到queueId对应的userId @@ -200,7 +224,7 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy { const duration = Date.now() - startTime; const result: CleanupResult = { - cleanedSessions: cleanupResult.cleanedCount, + cleanedSessions: cleanupResult?.cleanedCount || 0, deregisteredQueues, duration, timestamp: new Date(), diff --git a/src/business/zulip/services/session-manager.service.spec.ts b/src/business/zulip/services/session_manager.service.spec.ts similarity index 97% rename from src/business/zulip/services/session-manager.service.spec.ts rename to src/business/zulip/services/session_manager.service.spec.ts index 9cd3db2..fef1cce 100644 --- a/src/business/zulip/services/session-manager.service.spec.ts +++ b/src/business/zulip/services/session_manager.service.spec.ts @@ -12,8 +12,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as fc from 'fast-check'; -import { SessionManagerService, GameSession, Position } from './session-manager.service'; -import { ConfigManagerService } from './config-manager.service'; +import { SessionManagerService, GameSession, Position } from './session_manager.service'; +import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; import { IRedisService } from '../../../core/redis/redis.interface'; @@ -21,7 +21,7 @@ describe('SessionManagerService', () => { let service: SessionManagerService; let mockLogger: jest.Mocked; let mockRedisService: jest.Mocked; - let mockConfigManager: jest.Mocked; + let mockConfigManager: jest.Mocked; // 内存存储模拟Redis let memoryStore: Map; @@ -57,9 +57,15 @@ describe('SessionManagerService', () => { }; return streamMap[mapId] || 'General'; }), + getMapIdByStream: jest.fn(), getTopicByObject: jest.fn().mockReturnValue('General'), - getMapConfig: jest.fn(), - getAllMaps: jest.fn(), + getZulipConfig: jest.fn(), + hasMap: jest.fn(), + hasStream: jest.fn(), + getAllMapIds: jest.fn(), + getAllStreams: jest.fn(), + reloadConfig: jest.fn(), + validateConfig: jest.fn(), } as any; // 创建模拟Redis服务,使用内存存储 @@ -135,7 +141,7 @@ describe('SessionManagerService', () => { useValue: mockRedisService, }, { - provide: ConfigManagerService, + provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, ], diff --git a/src/business/zulip/services/session-manager.service.ts b/src/business/zulip/services/session_manager.service.ts similarity index 95% rename from src/business/zulip/services/session-manager.service.ts rename to src/business/zulip/services/session_manager.service.ts index 3db5580..5490201 100644 --- a/src/business/zulip/services/session-manager.service.ts +++ b/src/business/zulip/services/session_manager.service.ts @@ -35,8 +35,8 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { IRedisService } from '../../../core/redis/redis.interface'; -import { ConfigManagerService } from './config-manager.service'; -import { Internal, Constants } from '../interfaces/zulip.interfaces'; +import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; +import { Internal, Constants } from '../../../core/zulip/interfaces/zulip.interfaces'; /** * 游戏会话接口 - 重新导出以保持向后兼容 @@ -78,6 +78,29 @@ export interface SessionStats { newestSession?: Date; } +/** + * 会话管理服务类 + * + * 职责: + * - 维护WebSocket连接ID与Zulip队列ID的映射关系 + * - 管理玩家位置跟踪和上下文注入 + * - 提供空间过滤和会话查询功能 + * - 支持会话状态的序列化和反序列化 + * + * 主要方法: + * - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID + * - getSession(): 获取会话信息 + * - injectContext(): 上下文注入,根据位置确定Stream/Topic + * - getSocketsInMap(): 空间过滤,获取指定地图的所有Socket + * - updatePlayerPosition(): 更新玩家位置 + * - destroySession(): 销毁会话 + * + * 使用场景: + * - 玩家登录时创建会话映射 + * - 消息路由时进行上下文注入 + * - 消息分发时进行空间过滤 + * - 玩家登出时清理会话数据 + */ @Injectable() export class SessionManagerService { private readonly SESSION_PREFIX = 'zulip:session:'; @@ -91,7 +114,8 @@ export class SessionManagerService { constructor( @Inject('REDIS_SERVICE') private readonly redisService: IRedisService, - private readonly configManager: ConfigManagerService, + @Inject('ZULIP_CONFIG_SERVICE') + private readonly configManager: IZulipConfigService, ) { this.logger.log('SessionManagerService初始化完成'); } @@ -170,6 +194,9 @@ export class SessionManagerService { * @param initialMap 初始地图(可选) * @param initialPosition 初始位置(可选) * @returns Promise 创建的会话对象 + * + * @throws Error 当参数验证失败时 + * @throws Error 当Redis操作失败时 */ async createSession( socketId: string, @@ -378,6 +405,8 @@ export class SessionManagerService { * @param socketId WebSocket连接ID * @param mapId 地图ID(可选,用于覆盖当前地图) * @returns Promise 上下文信息 + * + * @throws Error 当会话不存在时 */ async injectContext(socketId: string, mapId?: string): Promise { this.logger.debug('开始上下文注入', { diff --git a/src/business/zulip/services/zulip-event-processor.service.spec.ts b/src/business/zulip/services/zulip_event_processor.service.spec.ts similarity index 97% rename from src/business/zulip/services/zulip-event-processor.service.spec.ts rename to src/business/zulip/services/zulip_event_processor.service.spec.ts index bfd3b5b..ef0cf63 100644 --- a/src/business/zulip/services/zulip-event-processor.service.spec.ts +++ b/src/business/zulip/services/zulip_event_processor.service.spec.ts @@ -24,18 +24,17 @@ import { ZulipMessage, GameMessage, MessageDistributor, -} from './zulip-event-processor.service'; -import { SessionManagerService, GameSession } from './session-manager.service'; -import { ConfigManagerService } from './config-manager.service'; -import { ZulipClientPoolService } from './zulip-client-pool.service'; +} from './zulip_event_processor.service'; +import { SessionManagerService, GameSession } from './session_manager.service'; +import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; describe('ZulipEventProcessorService', () => { let service: ZulipEventProcessorService; let mockLogger: jest.Mocked; let mockSessionManager: jest.Mocked; - let mockConfigManager: jest.Mocked; - let mockClientPool: jest.Mocked; + let mockConfigManager: jest.Mocked; + let mockClientPool: jest.Mocked; let mockDistributor: jest.Mocked; // 创建模拟Zulip消息 @@ -87,14 +86,26 @@ describe('ZulipEventProcessorService', () => { mockConfigManager = { getMapIdByStream: jest.fn(), getStreamByMap: jest.fn(), - getMapConfig: jest.fn(), + getTopicByObject: jest.fn(), + getZulipConfig: jest.fn(), + hasMap: jest.fn(), + hasStream: jest.fn(), getAllMapIds: jest.fn(), + getAllStreams: jest.fn(), + reloadConfig: jest.fn(), + validateConfig: jest.fn(), } as any; mockClientPool = { getUserClient: jest.fn(), createUserClient: jest.fn(), destroyUserClient: jest.fn(), + hasUserClient: jest.fn(), + sendMessage: jest.fn(), + registerEventQueue: jest.fn(), + deregisterEventQueue: jest.fn(), + getPoolStats: jest.fn(), + cleanupIdleClients: jest.fn(), } as any; mockDistributor = { @@ -114,11 +125,11 @@ describe('ZulipEventProcessorService', () => { useValue: mockSessionManager, }, { - provide: ConfigManagerService, + provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, { - provide: ZulipClientPoolService, + provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockClientPool, }, ], diff --git a/src/business/zulip/services/zulip-event-processor.service.ts b/src/business/zulip/services/zulip_event_processor.service.ts similarity index 96% rename from src/business/zulip/services/zulip-event-processor.service.ts rename to src/business/zulip/services/zulip_event_processor.service.ts index d3f69b7..b034c33 100644 --- a/src/business/zulip/services/zulip-event-processor.service.ts +++ b/src/business/zulip/services/zulip_event_processor.service.ts @@ -31,9 +31,8 @@ */ import { Injectable, OnModuleDestroy, Inject, forwardRef, Logger } from '@nestjs/common'; -import { SessionManagerService } from './session-manager.service'; -import { ConfigManagerService } from './config-manager.service'; -import { ZulipClientPoolService } from './zulip-client-pool.service'; +import { SessionManagerService } from './session_manager.service'; +import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; /** * Zulip消息接口 @@ -94,6 +93,28 @@ export interface EventProcessingStats { lastEventTime?: Date; } +/** + * Zulip事件处理服务类 + * + * 职责: + * - 处理从Zulip接收的事件队列消息 + * - 将Zulip消息转换为游戏协议格式 + * - 管理事件队列的生命周期 + * - 提供消息分发和路由功能 + * + * 主要方法: + * - processEvents(): 处理Zulip事件队列 + * - processMessage(): 处理单个消息事件 + * - startProcessing(): 启动事件处理 + * - stopProcessing(): 停止事件处理 + * - registerQueue(): 注册新的事件队列 + * + * 使用场景: + * - 接收Zulip服务器推送的消息 + * - 将Zulip消息转发给游戏客户端 + * - 管理多用户的事件队列 + * - 消息格式转换和过滤 + */ @Injectable() export class ZulipEventProcessorService implements OnModuleDestroy { private readonly logger = new Logger(ZulipEventProcessorService.name); @@ -109,9 +130,10 @@ export class ZulipEventProcessorService implements OnModuleDestroy { constructor( private readonly sessionManager: SessionManagerService, - private readonly configManager: ConfigManagerService, - @Inject(forwardRef(() => ZulipClientPoolService)) - private readonly clientPool: ZulipClientPoolService, + @Inject('ZULIP_CONFIG_SERVICE') + private readonly configManager: IZulipConfigService, + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly clientPool: IZulipClientPoolService, ) { this.logger.log('ZulipEventProcessorService初始化完成'); } diff --git a/src/business/zulip/zulip.module.ts b/src/business/zulip/zulip.module.ts index e587d58..13590aa 100644 --- a/src/business/zulip/zulip.module.ts +++ b/src/business/zulip/zulip.module.ts @@ -2,26 +2,32 @@ * Zulip集成业务模块 * * 功能描述: - * - 整合Zulip集成相关的控制器、服务和依赖 - * - 提供完整的Zulip集成功能模块 - * - 实现游戏与Zulip的无缝通信桥梁 - * - 支持WebSocket网关、会话管理、消息过滤等核心功能 - * - 启动时自动检查并创建所有地图对应的Zulip Streams + * - 整合Zulip集成相关的业务逻辑和控制器 + * - 提供完整的Zulip集成业务功能模块 + * - 实现游戏与Zulip的业务逻辑协调 + * - 支持WebSocket网关、会话管理、消息过滤等业务功能 * - * 核心服务: - * - ZulipService: 主协调服务,处理登录、消息发送等核心业务 + * 架构设计: + * - 业务逻辑层:处理游戏相关的业务规则和流程 + * - 核心服务层:封装技术实现细节和第三方API调用 + * - 通过依赖注入实现业务层与技术层的解耦 + * + * 业务服务: + * - ZulipService: 主协调服务,处理登录、消息发送等核心业务流程 * - ZulipWebSocketGateway: WebSocket统一网关,处理客户端连接 - * - ZulipClientPoolService: Zulip客户端池管理 - * - SessionManagerService: 会话状态管理 - * - MessageFilterService: 消息过滤和安全控制 + * - SessionManagerService: 会话状态管理和业务逻辑 + * - MessageFilterService: 消息过滤和业务规则控制 + * + * 核心服务(通过ZulipCoreModule提供): + * - ZulipClientService: Zulip REST API封装 + * - ZulipClientPoolService: 客户端池管理 * - ConfigManagerService: 配置管理和热重载 - * - StreamInitializerService: Stream初始化和自动创建 - * - ErrorHandlerService: 错误处理和服务降级 - * - MonitoringService: 系统监控和告警 - * - ApiKeySecurityService: API Key安全存储 + * - ZulipEventProcessorService: 事件处理和消息转换 + * - 其他技术支持服务 * * 依赖模块: - * - LoginModule: 用户认证和会话管理 + * - ZulipCoreModule: Zulip核心技术服务 + * - LoginCoreModule: 用户认证和会话管理 * - RedisModule: 会话状态缓存 * - LoggerModule: 日志记录服务 * @@ -29,65 +35,47 @@ * - 游戏客户端通过WebSocket连接进行实时聊天 * - 游戏内消息与Zulip社群的双向同步 * - 基于位置的聊天上下文管理 - * - 系统启动时自动初始化所有地图对应的Streams + * - 业务规则驱动的消息过滤和权限控制 * * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 + * @version 2.0.0 + * @since 2025-12-31 */ import { Module } from '@nestjs/common'; -import { ZulipWebSocketGateway } from './zulip-websocket.gateway'; +import { ZulipWebSocketGateway } from './zulip_websocket.gateway'; import { ZulipService } from './zulip.service'; -import { ZulipClientService } from './services/zulip-client.service'; -import { ZulipClientPoolService } from './services/zulip-client-pool.service'; -import { SessionManagerService } from './services/session-manager.service'; -import { SessionCleanupService } from './services/session-cleanup.service'; -import { MessageFilterService } from './services/message-filter.service'; -import { ZulipEventProcessorService } from './services/zulip-event-processor.service'; -import { ConfigManagerService } from './services/config-manager.service'; -import { ErrorHandlerService } from './services/error-handler.service'; -import { MonitoringService } from './services/monitoring.service'; -import { ApiKeySecurityService } from './services/api-key-security.service'; -import { StreamInitializerService } from './services/stream-initializer.service'; +import { SessionManagerService } from './services/session_manager.service'; +import { MessageFilterService } from './services/message_filter.service'; +import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; +import { SessionCleanupService } from './services/session_cleanup.service'; +import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; import { RedisModule } from '../../core/redis/redis.module'; import { LoggerModule } from '../../core/utils/logger/logger.module'; -import { LoginModule } from '../login/login.module'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; @Module({ imports: [ + // Zulip核心服务模块 - 提供技术实现相关的核心服务 + ZulipCoreModule, // Redis模块 - 提供会话状态缓存和数据存储 RedisModule, // 日志模块 - 提供统一的日志记录服务 LoggerModule, // 登录模块 - 提供用户认证和Token验证 - LoginModule, + LoginCoreModule, ], providers: [ // 主协调服务 - 整合各子服务,提供统一业务接口 ZulipService, - // Zulip客户端服务 - 封装Zulip REST API调用 - ZulipClientService, - // Zulip客户端池服务 - 管理用户专用Zulip客户端实例 - ZulipClientPoolService, // 会话管理服务 - 维护Socket_ID与Zulip_Queue_ID的映射关系 SessionManagerService, - // 会话清理服务 - 定时清理过期会话 - SessionCleanupService, // 消息过滤服务 - 敏感词过滤、频率限制、权限验证 MessageFilterService, // Zulip事件处理服务 - 处理Zulip事件队列消息 ZulipEventProcessorService, - // 配置管理服务 - 地图映射配置和系统配置管理 - ConfigManagerService, - // Stream初始化服务 - 启动时检查并创建所有地图对应的Streams - StreamInitializerService, - // 错误处理服务 - 错误处理、重试机制、服务降级 - ErrorHandlerService, - // 监控服务 - 系统监控、健康检查、告警 - MonitoringService, - // API Key安全服务 - API Key加密存储和安全日志 - ApiKeySecurityService, + // 会话清理服务 - 定时清理过期会话 + SessionCleanupService, // WebSocket网关 - 处理游戏客户端WebSocket连接 ZulipWebSocketGateway, ], @@ -95,26 +83,14 @@ import { LoginModule } from '../login/login.module'; exports: [ // 导出主服务供其他模块使用 ZulipService, - // 导出Zulip客户端服务 - ZulipClientService, - // 导出客户端池服务 - ZulipClientPoolService, // 导出会话管理服务 SessionManagerService, - // 导出会话清理服务 - SessionCleanupService, // 导出消息过滤服务 MessageFilterService, - // 导出配置管理服务 - ConfigManagerService, - // 导出Stream初始化服务 - StreamInitializerService, - // 导出错误处理服务 - ErrorHandlerService, - // 导出监控服务 - MonitoringService, - // 导出API Key安全服务 - ApiKeySecurityService, + // 导出事件处理服务 + ZulipEventProcessorService, + // 导出会话清理服务 + SessionCleanupService, // 导出WebSocket网关 ZulipWebSocketGateway, ], diff --git a/src/business/zulip/zulip.service.spec.ts b/src/business/zulip/zulip.service.spec.ts new file mode 100644 index 0000000..aa021ce --- /dev/null +++ b/src/business/zulip/zulip.service.spec.ts @@ -0,0 +1,1134 @@ +/** + * Zulip集成主服务测试 + * + * 功能描述: + * - 测试ZulipService的核心功能 + * - 包含属性测试验证玩家登录流程完整性 + * - 包含属性测试验证消息发送流程完整性 + * - 包含属性测试验证位置更新和上下文注入 + * + * **Feature: zulip-integration, Property 1: 玩家登录流程完整性** + * **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5** + * + * **Feature: zulip-integration, Property 3: 消息发送流程完整性** + * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** + * + * **Feature: zulip-integration, Property 6: 位置更新和上下文注入** + * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-31 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import * as fc from 'fast-check'; +import { + ZulipService, + PlayerLoginRequest, + ChatMessageRequest, + PositionUpdateRequest, + LoginResponse, + ChatMessageResponse, +} from './zulip.service'; +import { SessionManagerService, GameSession } from './services/session_manager.service'; +import { MessageFilterService } from './services/message_filter.service'; +import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; +import { + IZulipClientPoolService, + IZulipConfigService, + ZulipClientInstance, + SendMessageResult, +} from '../../core/zulip/interfaces/zulip-core.interfaces'; + +describe('ZulipService', () => { + let service: ZulipService; + let mockZulipClientPool: jest.Mocked; + let mockSessionManager: jest.Mocked; + let mockMessageFilter: jest.Mocked; + let mockEventProcessor: jest.Mocked; + let mockConfigManager: jest.Mocked; + + // 创建模拟的Zulip客户端实例 + const createMockClientInstance = (overrides: Partial = {}): ZulipClientInstance => ({ + userId: 'test-user-123', + config: { + username: 'test@example.com', + apiKey: 'test-api-key', + realm: 'https://zulip.example.com', + }, + client: {}, + queueId: 'queue-123', + lastEventId: 0, + createdAt: new Date(), + lastActivity: new Date(), + isValid: true, + ...overrides, + }); + + // 创建模拟的游戏会话 + const createMockSession = (overrides: Partial = {}): GameSession => ({ + socketId: 'socket-123', + userId: 'user-123', + username: 'TestPlayer', + zulipQueueId: 'queue-123', + currentMap: 'whale_port', + position: { x: 400, y: 300 }, + lastActivity: new Date(), + createdAt: new Date(), + ...overrides, + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + mockZulipClientPool = { + createUserClient: jest.fn(), + getUserClient: jest.fn(), + hasUserClient: jest.fn(), + sendMessage: jest.fn(), + registerEventQueue: jest.fn(), + deregisterEventQueue: jest.fn(), + destroyUserClient: jest.fn(), + getPoolStats: jest.fn(), + cleanupIdleClients: jest.fn(), + } as any; + + mockSessionManager = { + createSession: jest.fn(), + getSession: jest.fn(), + destroySession: jest.fn(), + updatePlayerPosition: jest.fn(), + getSocketsInMap: jest.fn(), + injectContext: jest.fn(), + cleanupExpiredSessions: jest.fn(), + } as any; + + mockMessageFilter = { + validateMessage: jest.fn(), + filterContent: jest.fn(), + checkRateLimit: jest.fn(), + validatePermission: jest.fn(), + logViolation: jest.fn(), + } as any; + + mockEventProcessor = { + startEventProcessing: jest.fn(), + stopEventProcessing: jest.fn(), + registerEventQueue: jest.fn(), + unregisterEventQueue: jest.fn(), + processMessageEvent: jest.fn(), + setMessageDistributor: jest.fn(), + getProcessingStats: jest.fn(), + } as any; + + mockConfigManager = { + getStreamByMap: jest.fn(), + getMapIdByStream: jest.fn(), + getTopicByObject: jest.fn(), + getZulipConfig: jest.fn(), + hasMap: jest.fn(), + hasStream: jest.fn(), + getAllMapIds: jest.fn(), + getAllStreams: jest.fn(), + reloadConfig: jest.fn(), + validateConfig: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ZulipService, + { + provide: 'ZULIP_CLIENT_POOL_SERVICE', + useValue: mockZulipClientPool, + }, + { + provide: SessionManagerService, + useValue: mockSessionManager, + }, + { + provide: MessageFilterService, + useValue: mockMessageFilter, + }, + { + provide: ZulipEventProcessorService, + useValue: mockEventProcessor, + }, + { + provide: 'ZULIP_CONFIG_SERVICE', + useValue: mockConfigManager, + }, + ], + }).compile(); + + service = module.get(ZulipService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handlePlayerLogin - 处理玩家登录', () => { + it('应该成功处理有效Token的登录请求', async () => { + const loginRequest: PlayerLoginRequest = { + token: 'valid_token_123', + socketId: 'socket-456', + }; + + const mockSession = createMockSession({ + socketId: 'socket-456', + userId: 'user_valid_to', + username: 'Player_lid_to', + }); + + mockConfigManager.getZulipConfig.mockReturnValue({ + zulipServerUrl: 'https://zulip.example.com', + }); + + mockZulipClientPool.createUserClient.mockResolvedValue( + createMockClientInstance({ + userId: 'user_valid_to', + queueId: 'queue-789', + }) + ); + + mockSessionManager.createSession.mockResolvedValue(mockSession); + + const result = await service.handlePlayerLogin(loginRequest); + + expect(result.success).toBe(true); + expect(result.userId).toBe('user_valid_to'); + expect(result.username).toBe('Player_valid'); + expect(result.currentMap).toBe('whale_port'); + expect(mockSessionManager.createSession).toHaveBeenCalled(); + }); + + it('应该拒绝无效Token的登录请求', async () => { + const loginRequest: PlayerLoginRequest = { + token: 'invalid_token', + socketId: 'socket-456', + }; + + const result = await service.handlePlayerLogin(loginRequest); + + expect(result.success).toBe(false); + expect(result.error).toBe('Token验证失败'); + expect(mockSessionManager.createSession).not.toHaveBeenCalled(); + }); + + it('应该处理空Token的情况', async () => { + const loginRequest: PlayerLoginRequest = { + token: '', + socketId: 'socket-456', + }; + + const result = await service.handlePlayerLogin(loginRequest); + + expect(result.success).toBe(false); + expect(result.error).toBe('Token不能为空'); + }); + + it('应该处理空socketId的情况', async () => { + const loginRequest: PlayerLoginRequest = { + token: 'valid_token', + socketId: '', + }; + + const result = await service.handlePlayerLogin(loginRequest); + + expect(result.success).toBe(false); + expect(result.error).toBe('socketId不能为空'); + }); + it('应该在Zulip客户端创建失败时使用本地模式', async () => { + const loginRequest: PlayerLoginRequest = { + token: 'real_user_token_with_zulip_key_123', // 有API Key的Token + socketId: 'socket-456', + }; + + const mockSession = createMockSession({ + socketId: 'socket-456', + userId: 'user_real_user_', + }); + + mockConfigManager.getZulipConfig.mockReturnValue({ + zulipServerUrl: 'https://zulip.example.com', + }); + + // 模拟Zulip客户端创建失败 + mockZulipClientPool.createUserClient.mockRejectedValue(new Error('Zulip连接失败')); + mockSessionManager.createSession.mockResolvedValue(mockSession); + + const result = await service.handlePlayerLogin(loginRequest); + + // 应该成功登录(本地模式) + expect(result.success).toBe(true); + expect(mockSessionManager.createSession).toHaveBeenCalled(); + }); + }); + + describe('handlePlayerLogout - 处理玩家登出', () => { + it('应该成功处理玩家登出', async () => { + const socketId = 'socket-123'; + const mockSession = createMockSession({ socketId, userId: 'user-123' }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockZulipClientPool.destroyUserClient.mockResolvedValue(); + mockSessionManager.destroySession.mockResolvedValue(undefined); + + await service.handlePlayerLogout(socketId); + + expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); + expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123'); + expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId); + }); + + it('应该处理会话不存在的情况', async () => { + const socketId = 'non-existent-socket'; + + mockSessionManager.getSession.mockResolvedValue(null); + + await service.handlePlayerLogout(socketId); + + expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); + expect(mockZulipClientPool.destroyUserClient).not.toHaveBeenCalled(); + expect(mockSessionManager.destroySession).not.toHaveBeenCalled(); + }); + + it('应该在Zulip客户端清理失败时继续执行会话清理', async () => { + const socketId = 'socket-123'; + const mockSession = createMockSession({ socketId, userId: 'user-123' }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockZulipClientPool.destroyUserClient.mockRejectedValue(new Error('清理失败')); + mockSessionManager.destroySession.mockResolvedValue(undefined); + + await service.handlePlayerLogout(socketId); + + expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123'); + expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId); + }); + }); + + describe('sendChatMessage - 发送聊天消息', () => { + it('应该成功发送聊天消息', async () => { + const chatRequest: ChatMessageRequest = { + socketId: 'socket-123', + content: 'Hello, world!', + scope: 'local', + }; + + const mockSession = createMockSession({ + socketId: 'socket-123', + userId: 'user-123', + currentMap: 'tavern', + }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: 'Tavern', + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: true, + filteredContent: 'Hello, world!', + }); + + mockZulipClientPool.sendMessage.mockResolvedValue({ + success: true, + messageId: 12345, + }); + + const result = await service.sendChatMessage(chatRequest); + + expect(result.success).toBe(true); + expect(result.messageId).toBe(12345); + expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith( + 'user-123', + 'Tavern', + 'General', + 'Hello, world!' + ); + }); + + it('应该拒绝会话不存在的消息发送', async () => { + const chatRequest: ChatMessageRequest = { + socketId: 'non-existent-socket', + content: 'Hello, world!', + scope: 'local', + }; + + mockSessionManager.getSession.mockResolvedValue(null); + + const result = await service.sendChatMessage(chatRequest); + + expect(result.success).toBe(false); + expect(result.error).toBe('会话不存在,请重新登录'); + expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); + }); + + it('应该拒绝未通过验证的消息', async () => { + const chatRequest: ChatMessageRequest = { + socketId: 'socket-123', + content: '敏感词内容', + scope: 'local', + }; + + const mockSession = createMockSession({ socketId: 'socket-123' }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: 'Tavern', + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: false, + reason: '消息包含敏感词', + }); + + const result = await service.sendChatMessage(chatRequest); + + expect(result.success).toBe(false); + expect(result.error).toBe('消息包含敏感词'); + expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); + }); + + it('应该在Zulip发送失败时仍返回成功(本地模式)', async () => { + const chatRequest: ChatMessageRequest = { + socketId: 'socket-123', + content: 'Hello, world!', + scope: 'local', + }; + + const mockSession = createMockSession({ socketId: 'socket-123' }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: 'Tavern', + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: true, + filteredContent: 'Hello, world!', + }); + + mockZulipClientPool.sendMessage.mockResolvedValue({ + success: false, + error: 'Zulip服务不可用', + }); + + const result = await service.sendChatMessage(chatRequest); + + expect(result.success).toBe(true); // 本地模式下仍返回成功 + }); + }); + + describe('updatePlayerPosition - 更新玩家位置', () => { + it('应该成功更新玩家位置', async () => { + const positionRequest: PositionUpdateRequest = { + socketId: 'socket-123', + x: 500, + y: 400, + mapId: 'tavern', + }; + + mockSessionManager.updatePlayerPosition.mockResolvedValue(true); + + const result = await service.updatePlayerPosition(positionRequest); + + expect(result).toBe(true); + expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith( + 'socket-123', + 'tavern', + 500, + 400 + ); + }); + + it('应该拒绝空socketId的位置更新', async () => { + const positionRequest: PositionUpdateRequest = { + socketId: '', + x: 500, + y: 400, + mapId: 'tavern', + }; + + const result = await service.updatePlayerPosition(positionRequest); + + expect(result).toBe(false); + expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); + }); + + it('应该拒绝空mapId的位置更新', async () => { + const positionRequest: PositionUpdateRequest = { + socketId: 'socket-123', + x: 500, + y: 400, + mapId: '', + }; + + const result = await service.updatePlayerPosition(positionRequest); + + expect(result).toBe(false); + expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); + }); + }); + /** + * 属性测试: 玩家登录流程完整性 + * + * **Feature: zulip-integration, Property 1: 玩家登录流程完整性** + * **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5** + * + * 对于任何有效的游戏Token,系统应该能够验证Token,创建Zulip客户端, + * 建立会话映射,并返回成功的登录响应 + */ + describe('Property 1: 玩家登录流程完整性', () => { + /** + * 属性: 对于任何有效的Token和socketId,登录应该成功并创建会话 + * 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性 + * 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key + * 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例 + */ + it('对于任何有效的Token和socketId,登录应该成功并创建会话', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的Token(不以'invalid'开头) + fc.string({ minLength: 8, maxLength: 50 }) + .filter(s => !s.startsWith('invalid') && s.trim().length > 0), + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + async (token, socketId) => { + const trimmedToken = token.trim(); + const trimmedSocketId = socketId.trim(); + + const loginRequest: PlayerLoginRequest = { + token: trimmedToken, + socketId: trimmedSocketId, + }; + + const expectedUserId = `user_${trimmedToken.substring(0, 8)}`; + const expectedUsername = `Player_${expectedUserId.substring(5, 10)}`; + + const mockSession = createMockSession({ + socketId: trimmedSocketId, + userId: expectedUserId, + username: expectedUsername, + }); + + mockConfigManager.getZulipConfig.mockReturnValue({ + zulipServerUrl: 'https://zulip.example.com', + }); + + mockSessionManager.createSession.mockResolvedValue(mockSession); + + const result = await service.handlePlayerLogin(loginRequest); + + // 验证登录成功 + expect(result.success).toBe(true); + expect(result.userId).toBe(expectedUserId); + expect(result.username).toBe(expectedUsername); + expect(result.currentMap).toBe('whale_port'); + expect(result.sessionId).toBeDefined(); + + // 验证会话创建被调用 + expect(mockSessionManager.createSession).toHaveBeenCalledWith( + trimmedSocketId, + expectedUserId, + expect.any(String), // zulipQueueId + expectedUsername, + 'whale_port', + { x: 400, y: 300 } + ); + } + ), + { numRuns: 100 } + ); + }, 60000); + + /** + * 属性: 对于任何无效的Token,登录应该失败 + * 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性 + */ + it('对于任何无效的Token,登录应该失败', async () => { + await fc.assert( + fc.asyncProperty( + // 生成无效的Token(以'invalid'开头) + fc.string({ minLength: 1, maxLength: 30 }) + .map(s => `invalid${s}`), + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + async (invalidToken, socketId) => { + const loginRequest: PlayerLoginRequest = { + token: invalidToken, + socketId: socketId.trim(), + }; + + const result = await service.handlePlayerLogin(loginRequest); + + // 验证登录失败 + expect(result.success).toBe(false); + expect(result.error).toBe('Token验证失败'); + expect(result.userId).toBeUndefined(); + expect(result.sessionId).toBeUndefined(); + + // 验证没有创建会话 + expect(mockSessionManager.createSession).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 对于空或无效的参数,登录应该返回相应的错误信息 + * 验证需求 1.1: 系统应正确处理无效的登录请求 + */ + it('对于空或无效的参数,登录应该返回相应的错误信息', async () => { + await fc.assert( + fc.asyncProperty( + // 生成可能为空或以'invalid'开头的Token + fc.oneof( + fc.constant(''), // 空字符串 + fc.constant(' '), // 只有空格 + fc.string({ minLength: 1, maxLength: 50 }).map(s => 'invalid' + s), // 以invalid开头 + ), + // 生成可能为空的socketId + fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }), + async (token, socketId) => { + // 重置mock调用历史 + jest.clearAllMocks(); + + const loginRequest: PlayerLoginRequest = { + token: token || '', + socketId: socketId || '', + }; + + const result = await service.handlePlayerLogin(loginRequest); + + // 验证登录失败 + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + if (!token || token.trim().length === 0) { + expect(result.error).toBe('Token不能为空'); + } else if (!socketId || socketId.trim().length === 0) { + expect(result.error).toBe('socketId不能为空'); + } else if (token.startsWith('invalid')) { + expect(result.error).toBe('Token验证失败'); + } + + // 验证没有创建会话 + expect(mockSessionManager.createSession).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 对于有Zulip API Key的用户,应该尝试创建Zulip客户端 + * 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key + * 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例 + */ + it('对于有Zulip API Key的用户,应该尝试创建Zulip客户端', async () => { + await fc.assert( + fc.asyncProperty( + // 生成包含特定标识的Token(表示有API Key) + fc.constantFrom( + 'real_user_token_with_zulip_key_123', + 'token_with_lCPWCPf_key', + 'token_with_W2KhXaQx_key' + ), + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + async (tokenWithApiKey, socketId) => { + const loginRequest: PlayerLoginRequest = { + token: tokenWithApiKey, + socketId: socketId.trim(), + }; + + const mockClientInstance = createMockClientInstance({ + userId: `user_${tokenWithApiKey.substring(0, 8)}`, + queueId: 'test-queue-123', + }); + + const mockSession = createMockSession({ + socketId: socketId.trim(), + zulipQueueId: 'test-queue-123', + }); + + mockConfigManager.getZulipConfig.mockReturnValue({ + zulipServerUrl: 'https://zulip.example.com', + }); + + mockZulipClientPool.createUserClient.mockResolvedValue(mockClientInstance); + mockSessionManager.createSession.mockResolvedValue(mockSession); + + const result = await service.handlePlayerLogin(loginRequest); + + // 验证登录成功 + expect(result.success).toBe(true); + + // 验证尝试创建了Zulip客户端 + expect(mockZulipClientPool.createUserClient).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + username: expect.any(String), + apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8', + realm: 'https://zulip.example.com', + }) + ); + } + ), + { numRuns: 30 } + ); + }, 30000); + }); + /** + * 属性测试: 消息发送流程完整性 + * + * **Feature: zulip-integration, Property 3: 消息发送流程完整性** + * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** + * + * 对于任何有效的聊天消息请求,系统应该进行内容过滤、权限验证、 + * 上下文注入,并成功发送到对应的Zulip Stream/Topic + */ + describe('Property 3: 消息发送流程完整性', () => { + /** + * 属性: 对于任何有效会话的消息发送请求,应该成功处理并发送 + * 验证需求 3.1: 游戏客户端发送聊天消息时系统应获取玩家当前位置 + * 验证需求 3.2: 获取位置后系统应根据位置确定目标Stream和Topic + * 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查 + */ + it('对于任何有效会话的消息发送请求,应该成功处理并发送', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + // 生成有效的消息内容 + fc.string({ minLength: 1, maxLength: 200 }) + .filter(s => s.trim().length > 0 && !/[敏感词|违禁词]/.test(s)), + // 生成地图和Stream映射 + fc.record({ + mapId: fc.constantFrom('tavern', 'novice_village', 'market'), + streamName: fc.constantFrom('Tavern', 'Novice Village', 'Market'), + }), + async (socketId, content, mapping) => { + const chatRequest: ChatMessageRequest = { + socketId: socketId.trim(), + content: content.trim(), + scope: 'local', + }; + + const mockSession = createMockSession({ + socketId: socketId.trim(), + userId: `user_${socketId.substring(0, 8)}`, + currentMap: mapping.mapId, + }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: mapping.streamName, + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: true, + filteredContent: content.trim(), + }); + + mockZulipClientPool.sendMessage.mockResolvedValue({ + success: true, + messageId: Math.floor(Math.random() * 1000000), + }); + + const result = await service.sendChatMessage(chatRequest); + + // 验证消息发送成功 + expect(result.success).toBe(true); + expect(result.messageId).toBeDefined(); + + // 验证调用了正确的方法 + expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId.trim()); + expect(mockSessionManager.injectContext).toHaveBeenCalledWith(socketId.trim()); + expect(mockMessageFilter.validateMessage).toHaveBeenCalledWith( + mockSession.userId, + content.trim(), + mapping.streamName, + mapping.mapId + ); + expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith( + mockSession.userId, + mapping.streamName, + 'General', + content.trim() + ); + } + ), + { numRuns: 100 } + ); + }, 60000); + + /** + * 属性: 对于任何不存在的会话,消息发送应该失败 + * 验证需求 3.1: 系统应验证会话的有效性 + */ + it('对于任何不存在的会话,消息发送应该失败', async () => { + await fc.assert( + fc.asyncProperty( + // 生成不存在的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0) + .map(s => `nonexistent_${s}`), + // 生成任意消息内容 + fc.string({ minLength: 1, maxLength: 200 }) + .filter(s => s.trim().length > 0), + async (nonExistentSocketId, content) => { + const chatRequest: ChatMessageRequest = { + socketId: nonExistentSocketId, + content: content.trim(), + scope: 'local', + }; + + mockSessionManager.getSession.mockResolvedValue(null); + + const result = await service.sendChatMessage(chatRequest); + + // 验证消息发送失败 + expect(result.success).toBe(false); + expect(result.error).toBe('会话不存在,请重新登录'); + + // 验证没有进行后续处理 + expect(mockMessageFilter.validateMessage).not.toHaveBeenCalled(); + expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 对于任何未通过验证的消息,发送应该失败 + * 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查 + */ + it('对于任何未通过验证的消息,发送应该失败', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + // 生成可能包含敏感词的消息内容 + fc.string({ minLength: 1, maxLength: 200 }) + .filter(s => s.trim().length > 0), + // 生成验证失败的原因 + fc.constantFrom( + '消息包含敏感词', + '发送频率过快', + '权限不足', + '消息长度超限' + ), + async (socketId, content, failureReason) => { + const chatRequest: ChatMessageRequest = { + socketId: socketId.trim(), + content: content.trim(), + scope: 'local', + }; + + const mockSession = createMockSession({ + socketId: socketId.trim(), + }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: 'Tavern', + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: false, + reason: failureReason, + }); + + const result = await service.sendChatMessage(chatRequest); + + // 验证消息发送失败 + expect(result.success).toBe(false); + expect(result.error).toBe(failureReason); + + // 验证没有发送到Zulip + expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 即使Zulip发送失败,系统也应该返回成功(本地模式) + * 验证需求 3.5: 发送消息到Zulip时系统应处理发送失败的情况 + */ + it('即使Zulip发送失败,系统也应该返回成功(本地模式)', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + // 生成有效的消息内容 + fc.string({ minLength: 1, maxLength: 200 }) + .filter(s => s.trim().length > 0), + // 生成Zulip错误信息 + fc.constantFrom( + 'Zulip服务不可用', + '网络连接超时', + 'API Key无效', + 'Stream不存在' + ), + async (socketId, content, zulipError) => { + const chatRequest: ChatMessageRequest = { + socketId: socketId.trim(), + content: content.trim(), + scope: 'local', + }; + + const mockSession = createMockSession({ + socketId: socketId.trim(), + }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + mockSessionManager.injectContext.mockResolvedValue({ + stream: 'Tavern', + topic: 'General', + }); + + mockMessageFilter.validateMessage.mockResolvedValue({ + allowed: true, + filteredContent: content.trim(), + }); + + mockZulipClientPool.sendMessage.mockResolvedValue({ + success: false, + error: zulipError, + }); + + const result = await service.sendChatMessage(chatRequest); + + // 验证本地模式下仍返回成功 + expect(result.success).toBe(true); + expect(result.messageId).toBeUndefined(); + } + ), + { numRuns: 50 } + ); + }, 30000); + }); + /** + * 属性测试: 位置更新和上下文注入 + * + * **Feature: zulip-integration, Property 6: 位置更新和上下文注入** + * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** + * + * 对于任何位置更新请求,系统应该正确更新玩家位置信息, + * 并在消息发送时根据位置进行上下文注入 + */ + describe('Property 6: 位置更新和上下文注入', () => { + /** + * 属性: 对于任何有效的位置更新请求,应该成功更新位置 + * 验证需求 4.1: 玩家移动时系统应更新玩家在游戏世界中的位置信息 + * 验证需求 4.2: 更新位置时系统应验证位置的有效性 + */ + it('对于任何有效的位置更新请求,应该成功更新位置', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + // 生成有效的坐标 + fc.record({ + x: fc.integer({ min: 0, max: 2000 }), + y: fc.integer({ min: 0, max: 2000 }), + }), + // 生成有效的地图ID + fc.constantFrom('tavern', 'novice_village', 'market', 'whale_port'), + async (socketId, position, mapId) => { + const positionRequest: PositionUpdateRequest = { + socketId: socketId.trim(), + x: position.x, + y: position.y, + mapId, + }; + + mockSessionManager.updatePlayerPosition.mockResolvedValue(true); + + const result = await service.updatePlayerPosition(positionRequest); + + // 验证位置更新成功 + expect(result).toBe(true); + + // 验证调用了正确的方法 + expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith( + socketId.trim(), + mapId, + position.x, + position.y + ); + } + ), + { numRuns: 100 } + ); + }, 60000); + + /** + * 属性: 对于任何无效的参数,位置更新应该失败 + * 验证需求 4.2: 更新位置时系统应验证位置的有效性 + */ + it('对于任何无效的参数,位置更新应该失败', async () => { + await fc.assert( + fc.asyncProperty( + // 生成可能为空的socketId + fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }), + // 生成可能为空的mapId + fc.option(fc.constantFrom('tavern', 'market'), { nil: '' }), + // 生成坐标 + fc.record({ + x: fc.integer({ min: 0, max: 2000 }), + y: fc.integer({ min: 0, max: 2000 }), + }), + async (socketId, mapId, position) => { + // 重置mock调用历史 + jest.clearAllMocks(); + + const positionRequest: PositionUpdateRequest = { + socketId: socketId || '', + x: position.x, + y: position.y, + mapId: mapId || '', + }; + + const result = await service.updatePlayerPosition(positionRequest); + + if (!socketId || socketId.trim().length === 0 || + !mapId || mapId.trim().length === 0) { + // 验证位置更新失败 + expect(result).toBe(false); + + // 验证没有调用SessionManager + expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); + } + } + ), + { numRuns: 50 } + ); + }, 30000); + + /** + * 属性: 位置更新失败时应该正确处理错误 + * 验证需求 4.1: 系统应正确处理位置更新过程中的错误 + */ + it('位置更新失败时应该正确处理错误', async () => { + await fc.assert( + fc.asyncProperty( + // 生成有效的socketId + fc.string({ minLength: 5, maxLength: 30 }) + .filter(s => s.trim().length > 0), + // 生成有效的坐标 + fc.record({ + x: fc.integer({ min: 0, max: 2000 }), + y: fc.integer({ min: 0, max: 2000 }), + }), + // 生成有效的地图ID + fc.constantFrom('tavern', 'novice_village', 'market'), + async (socketId, position, mapId) => { + const positionRequest: PositionUpdateRequest = { + socketId: socketId.trim(), + x: position.x, + y: position.y, + mapId, + }; + + // 模拟SessionManager抛出错误 + mockSessionManager.updatePlayerPosition.mockRejectedValue( + new Error('位置更新失败') + ); + + const result = await service.updatePlayerPosition(positionRequest); + + // 验证位置更新失败 + expect(result).toBe(false); + } + ), + { numRuns: 50 } + ); + }, 30000); + }); + + describe('processZulipMessage - 处理Zulip消息', () => { + it('应该正确处理Zulip消息并确定目标玩家', async () => { + const zulipMessage = { + id: 12345, + sender_full_name: 'Alice', + sender_email: 'alice@example.com', + content: 'Hello everyone!', + display_recipient: 'Tavern', + stream_name: 'Tavern', + }; + + mockConfigManager.getMapIdByStream.mockReturnValue('tavern'); + mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']); + + const result = await service.processZulipMessage(zulipMessage); + + expect(result.targetSockets).toEqual(['socket-1', 'socket-2']); + expect(result.message.t).toBe('chat_render'); + expect(result.message.from).toBe('Alice'); + expect(result.message.txt).toBe('Hello everyone!'); + expect(result.message.bubble).toBe(true); + }); + + it('应该在未知Stream时返回空的目标列表', async () => { + const zulipMessage = { + id: 12345, + sender_full_name: 'Alice', + content: 'Hello!', + display_recipient: 'UnknownStream', + }; + + mockConfigManager.getMapIdByStream.mockReturnValue(null); + + const result = await service.processZulipMessage(zulipMessage); + + expect(result.targetSockets).toEqual([]); + }); + }); + + describe('辅助方法', () => { + it('getSession - 应该返回会话信息', async () => { + const socketId = 'socket-123'; + const mockSession = createMockSession({ socketId }); + + mockSessionManager.getSession.mockResolvedValue(mockSession); + + const result = await service.getSession(socketId); + + expect(result).toBe(mockSession); + expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); + }); + + it('getSocketsInMap - 应该返回地图中的Socket列表', async () => { + const mapId = 'tavern'; + const socketIds = ['socket-1', 'socket-2', 'socket-3']; + + mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds); + + const result = await service.getSocketsInMap(mapId); + + expect(result).toBe(socketIds); + expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId); + }); + }); +}); \ No newline at end of file diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 0390857..1386948 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -22,14 +22,16 @@ * @since 2025-12-25 */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; import { randomUUID } from 'crypto'; -import { ZulipClientPoolService } from './services/zulip-client-pool.service'; -import { SessionManagerService } from './services/session-manager.service'; -import { MessageFilterService } from './services/message-filter.service'; -import { ZulipEventProcessorService } from './services/zulip-event-processor.service'; -import { ConfigManagerService } from './services/config-manager.service'; -import { ErrorHandlerService } from './services/error-handler.service'; +import { SessionManagerService } from './services/session_manager.service'; +import { MessageFilterService } from './services/message_filter.service'; +import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; +import { + IZulipClientPoolService, + IZulipConfigService, +} from '../../core/zulip/interfaces/zulip-core.interfaces'; +import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; /** * 玩家登录请求接口 @@ -79,18 +81,41 @@ export interface ChatMessageResponse { error?: string; } +/** + * Zulip集成主服务类 + * + * 职责: + * - 作为Zulip集成系统的主要协调服务 + * - 整合各个子服务,提供统一的业务接口 + * - 处理游戏客户端与Zulip之间的核心业务逻辑 + * - 管理玩家会话和消息路由 + * + * 主要方法: + * - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化 + * - handlePlayerLogout(): 处理玩家登出和资源清理 + * - sendChatMessage(): 处理游戏聊天消息发送到Zulip + * - updatePlayerPosition(): 更新玩家位置信息 + * + * 使用场景: + * - WebSocket网关调用处理消息路由 + * - 会话管理和状态维护 + * - 消息格式转换和过滤 + * - 游戏与Zulip的双向通信桥梁 + */ @Injectable() export class ZulipService { private readonly logger = new Logger(ZulipService.name); private readonly DEFAULT_MAP = 'whale_port'; constructor( - private readonly zulipClientPool: ZulipClientPoolService, + @Inject('ZULIP_CLIENT_POOL_SERVICE') + private readonly zulipClientPool: IZulipClientPoolService, private readonly sessionManager: SessionManagerService, private readonly messageFilter: MessageFilterService, private readonly eventProcessor: ZulipEventProcessorService, - private readonly configManager: ConfigManagerService, - private readonly errorHandler: ErrorHandlerService, + @Inject('ZULIP_CONFIG_SERVICE') + private readonly configManager: IZulipConfigService, + private readonly apiKeySecurityService: ApiKeySecurityService, ) { this.logger.log('ZulipService初始化完成'); } @@ -295,36 +320,38 @@ export class ZulipService { // 从Token中提取用户ID(模拟) const userId = `user_${token.substring(0, 8)}`; - // 为测试用户提供真实的 Zulip API Key + // 从ApiKeySecurityService获取真实的Zulip API Key let zulipApiKey = undefined; let zulipEmail = undefined; - // 检查是否是配置了真实 Zulip API Key 的测试用户 - const hasTestApiKey = token.includes('lCPWCPf'); - const hasUserApiKey = token.includes('W2KhXaQx'); - const hasOldApiKey = token.includes('MZ1jEMQo'); - const isRealUserToken = token === 'real_user_token_with_zulip_key_123'; - - this.logger.log('Token检查', { - operation: 'validateGameToken', - userId, - tokenPrefix: token.substring(0, 20), - hasUserApiKey, - hasOldApiKey, - isRealUserToken, - }); - - if (isRealUserToken || hasUserApiKey || hasTestApiKey || hasOldApiKey) { - // 使用用户的真实 API Key - // 注意:这个API Key对应的Zulip用户邮箱是 user8@zulip.xinghangee.icu - zulipApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; - zulipEmail = 'angjustinl@mail.angforever.top'; + try { + // 尝试从Redis获取存储的API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - this.logger.log('配置真实Zulip API Key', { + if (apiKeyResult.success && apiKeyResult.apiKey) { + zulipApiKey = apiKeyResult.apiKey; + // TODO: 从数据库获取用户的Zulip邮箱 + // 暂时使用模拟数据 + zulipEmail = 'angjustinl@163.com'; + + this.logger.log('从存储获取到Zulip API Key', { + operation: 'validateGameToken', + userId, + hasApiKey: true, + zulipEmail, + }); + } else { + this.logger.debug('用户没有存储的Zulip API Key', { + operation: 'validateGameToken', + userId, + }); + } + } catch (error) { + const err = error as Error; + this.logger.warn('获取Zulip API Key失败', { operation: 'validateGameToken', userId, - zulipEmail, - hasApiKey: true, + error: err.message, }); } @@ -332,7 +359,6 @@ export class ZulipService { userId, username: `Player_${userId.substring(5, 10)}`, email: `${userId}@example.com`, - // 实际项目中从数据库获取 zulipEmail, zulipApiKey, }; diff --git a/src/business/zulip/zulip-integration.e2e.spec.ts b/src/business/zulip/zulip_integration.e2e.spec.ts similarity index 99% rename from src/business/zulip/zulip-integration.e2e.spec.ts rename to src/business/zulip/zulip_integration.e2e.spec.ts index 084b348..b71da8f 100644 --- a/src/business/zulip/zulip-integration.e2e.spec.ts +++ b/src/business/zulip/zulip_integration.e2e.spec.ts @@ -56,7 +56,7 @@ describeE2E('Zulip Integration E2E Tests', () => { }); client.on('connect', () => resolve(client)); - client.on('connect_error', (err) => reject(err)); + client.on('connect_error', (err: any) => reject(err)); setTimeout(() => reject(new Error('Connection timeout')), 5000); }); diff --git a/src/business/zulip/zulip-websocket.gateway.spec.ts b/src/business/zulip/zulip_websocket.gateway.spec.ts similarity index 99% rename from src/business/zulip/zulip-websocket.gateway.spec.ts rename to src/business/zulip/zulip_websocket.gateway.spec.ts index 4f6a6e5..c1fc883 100644 --- a/src/business/zulip/zulip-websocket.gateway.spec.ts +++ b/src/business/zulip/zulip_websocket.gateway.spec.ts @@ -16,9 +16,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import * as fc from 'fast-check'; -import { ZulipWebSocketGateway } from './zulip-websocket.gateway'; +import { ZulipWebSocketGateway } from './zulip_websocket.gateway'; import { ZulipService, LoginResponse, ChatMessageResponse } from './zulip.service'; -import { SessionManagerService, GameSession } from './services/session-manager.service'; +import { SessionManagerService, GameSession } from './services/session_manager.service'; import { Server, Socket } from 'socket.io'; describe('ZulipWebSocketGateway', () => { diff --git a/src/business/zulip/zulip-websocket.gateway.ts b/src/business/zulip/zulip_websocket.gateway.ts similarity index 95% rename from src/business/zulip/zulip-websocket.gateway.ts rename to src/business/zulip/zulip_websocket.gateway.ts index d90dba1..50e0598 100644 --- a/src/business/zulip/zulip-websocket.gateway.ts +++ b/src/business/zulip/zulip_websocket.gateway.ts @@ -35,7 +35,7 @@ import { import { Server, Socket } from 'socket.io'; import { Injectable, Logger } from '@nestjs/common'; import { ZulipService } from './zulip.service'; -import { SessionManagerService } from './services/session-manager.service'; +import { SessionManagerService } from './services/session_manager.service'; /** * 登录消息接口 - 按guide.md格式 @@ -96,6 +96,29 @@ interface ClientData { connectedAt: Date; } +/** + * Zulip WebSocket网关类 + * + * 职责: + * - 处理所有Godot游戏客户端的WebSocket连接 + * - 实现游戏协议到Zulip协议的转换 + * - 提供统一的消息路由和权限控制 + * - 管理客户端连接状态和会话 + * + * 主要方法: + * - handleConnection(): 处理客户端连接建立 + * - handleDisconnect(): 处理客户端连接断开 + * - handleLogin(): 处理登录消息 + * - handleChat(): 处理聊天消息 + * - handlePositionUpdate(): 处理位置更新 + * - sendChatRender(): 向客户端发送聊天渲染消息 + * + * 使用场景: + * - 游戏客户端WebSocket通信的统一入口 + * - 消息协议转换和路由分发 + * - 连接状态管理和权限验证 + * - 实时消息推送和广播 + */ @Injectable() @WebSocketGateway({ cors: { origin: '*' }, diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts index 1a785cc..9a4bdb2 100644 --- a/src/core/db/users/users.entity.ts +++ b/src/core/db/users/users.entity.ts @@ -19,8 +19,9 @@ * @since 2025-12-17 */ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm'; import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum'; +import { ZulipAccounts } from '../zulip_accounts/zulip_accounts.entity'; /** * 用户实体类 @@ -432,4 +433,25 @@ export class Users { comment: '更新时间' }) updated_at: Date; + + /** + * 关联的Zulip账号 + * + * 关系设计: + * - 类型:一对一关系(OneToOne) + * - 外键:在ZulipAccounts表中 + * - 级联:不设置级联删除,保证数据安全 + * + * 业务规则: + * - 每个游戏用户最多关联一个Zulip账号 + * - 支持延迟加载,提高查询性能 + * - 可选关联,不是所有用户都有Zulip账号 + * + * 使用场景: + * - 游戏内聊天功能集成 + * - 跨平台消息同步 + * - 用户身份验证和权限管理 + */ + @OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser) + zulipAccount?: ZulipAccounts; } \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.entity.ts b/src/core/db/zulip_accounts/zulip_accounts.entity.ts new file mode 100644 index 0000000..10034bf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.entity.ts @@ -0,0 +1,185 @@ +/** + * Zulip账号关联实体 + * + * 功能描述: + * - 存储游戏用户与Zulip账号的关联关系 + * - 管理Zulip账号的基本信息和状态 + * - 提供账号验证和同步功能 + * + * 关联关系: + * - 与Users表建立一对一关系 + * - 存储Zulip用户ID、邮箱、API Key等信息 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, Index } from 'typeorm'; +import { Users } from '../users/users.entity'; + +@Entity('zulip_accounts') +@Index(['zulip_user_id'], { unique: true }) +@Index(['zulip_email'], { unique: true }) +export class ZulipAccounts { + /** + * 主键ID + */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: bigint; + + /** + * 关联的游戏用户ID + */ + @Column({ type: 'bigint', name: 'game_user_id', comment: '关联的游戏用户ID' }) + gameUserId: bigint; + + /** + * Zulip用户ID + */ + @Column({ type: 'int', name: 'zulip_user_id', comment: 'Zulip服务器上的用户ID' }) + zulipUserId: number; + + /** + * Zulip用户邮箱 + */ + @Column({ type: 'varchar', length: 255, name: 'zulip_email', comment: 'Zulip账号邮箱地址' }) + zulipEmail: string; + + /** + * Zulip用户全名 + */ + @Column({ type: 'varchar', length: 100, name: 'zulip_full_name', comment: 'Zulip账号全名' }) + zulipFullName: string; + + /** + * Zulip API Key(加密存储) + */ + @Column({ type: 'text', name: 'zulip_api_key_encrypted', comment: '加密存储的Zulip API Key' }) + zulipApiKeyEncrypted: string; + + /** + * 账号状态 + * - active: 正常激活状态 + * - inactive: 未激活状态 + * - suspended: 暂停状态 + * - error: 错误状态 + */ + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'suspended', 'error'], + default: 'active', + comment: '账号状态:active-正常,inactive-未激活,suspended-暂停,error-错误' + }) + status: 'active' | 'inactive' | 'suspended' | 'error'; + + /** + * 最后验证时间 + */ + @Column({ type: 'timestamp', name: 'last_verified_at', nullable: true, comment: '最后一次验证Zulip账号的时间' }) + lastVerifiedAt: Date | null; + + /** + * 最后同步时间 + */ + @Column({ type: 'timestamp', name: 'last_synced_at', nullable: true, comment: '最后一次同步数据的时间' }) + lastSyncedAt: Date | null; + + /** + * 错误信息 + */ + @Column({ type: 'text', name: 'error_message', nullable: true, comment: '最后一次操作的错误信息' }) + errorMessage: string | null; + + /** + * 重试次数 + */ + @Column({ type: 'int', name: 'retry_count', default: 0, comment: '创建或同步失败的重试次数' }) + retryCount: number; + + /** + * 创建时间 + */ + @CreateDateColumn({ name: 'created_at', comment: '记录创建时间' }) + createdAt: Date; + + /** + * 更新时间 + */ + @UpdateDateColumn({ name: 'updated_at', comment: '记录最后更新时间' }) + updatedAt: Date; + + /** + * 关联的游戏用户 + */ + @OneToOne(() => Users, user => user.zulipAccount) + @JoinColumn({ name: 'game_user_id' }) + gameUser: Users; + + /** + * 检查账号是否处于正常状态 + * + * @returns boolean 是否为正常状态 + */ + isActive(): boolean { + return this.status === 'active'; + } + + /** + * 检查账号是否需要重新验证 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns boolean 是否需要重新验证 + */ + needsVerification(maxAge: number = 24 * 60 * 60 * 1000): boolean { + if (!this.lastVerifiedAt) { + return true; + } + + const now = new Date(); + const timeDiff = now.getTime() - this.lastVerifiedAt.getTime(); + return timeDiff > maxAge; + } + + /** + * 更新验证时间 + */ + updateVerificationTime(): void { + this.lastVerifiedAt = new Date(); + } + + /** + * 更新同步时间 + */ + updateSyncTime(): void { + this.lastSyncedAt = new Date(); + } + + /** + * 设置错误状态 + * + * @param errorMessage 错误信息 + */ + setError(errorMessage: string): void { + this.status = 'error'; + this.errorMessage = errorMessage; + this.retryCount += 1; + } + + /** + * 清除错误状态 + */ + clearError(): void { + if (this.status === 'error') { + this.status = 'active'; + this.errorMessage = null; + } + } + + /** + * 重置重试计数 + */ + resetRetryCount(): void { + this.retryCount = 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.module.ts b/src/core/db/zulip_accounts/zulip_accounts.module.ts new file mode 100644 index 0000000..6c288ef --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.module.ts @@ -0,0 +1,81 @@ +/** + * Zulip账号关联数据模块 + * + * 功能描述: + * - 提供Zulip账号关联数据的访问接口 + * - 封装TypeORM实体和Repository + * - 为业务层提供数据访问服务 + * - 支持数据库和内存模式的动态切换 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { ZulipAccountsRepository } from './zulip_accounts.repository'; +import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository'; + +/** + * 检查数据库配置是否完整 + * + * @returns 是否配置了数据库 + */ +function isDatabaseConfigured(): boolean { + const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME']; + return requiredEnvVars.every(varName => process.env[varName]); +} + +@Global() +@Module({}) +export class ZulipAccountsModule { + /** + * 创建数据库模式的Zulip账号模块 + * + * @returns 配置了TypeORM的动态模块 + */ + static forDatabase(): DynamicModule { + return { + module: ZulipAccountsModule, + imports: [TypeOrmModule.forFeature([ZulipAccounts])], + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsRepository, + }, + ], + exports: ['ZulipAccountsRepository', TypeOrmModule], + }; + } + + /** + * 创建内存模式的Zulip账号模块 + * + * @returns 配置了内存存储的动态模块 + */ + static forMemory(): DynamicModule { + return { + module: ZulipAccountsModule, + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsMemoryRepository, + }, + ], + exports: ['ZulipAccountsRepository'], + }; + } + + /** + * 根据环境自动选择模式 + * + * @returns 动态模块 + */ + static forRoot(): DynamicModule { + return isDatabaseConfigured() + ? ZulipAccountsModule.forDatabase() + : ZulipAccountsModule.forMemory(); + } +} diff --git a/src/core/db/zulip_accounts/zulip_accounts.repository.ts b/src/core/db/zulip_accounts/zulip_accounts.repository.ts new file mode 100644 index 0000000..9991d03 --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.repository.ts @@ -0,0 +1,323 @@ +/** + * Zulip账号关联数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的CRUD操作 + * - 封装复杂查询逻辑和数据库交互 + * - 实现数据访问层的业务逻辑抽象 + * + * 主要功能: + * - 账号关联的创建、查询、更新、删除 + * - 支持按游戏用户ID、Zulip用户ID、邮箱查询 + * - 提供账号状态管理和批量操作 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; + +/** + * 创建Zulip账号关联的数据传输对象 + */ +export interface CreateZulipAccountDto { + gameUserId: bigint; + zulipUserId: number; + zulipEmail: string; + zulipFullName: string; + zulipApiKeyEncrypted: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; +} + +/** + * 更新Zulip账号关联的数据传输对象 + */ +export interface UpdateZulipAccountDto { + zulipFullName?: string; + zulipApiKeyEncrypted?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + lastVerifiedAt?: Date; + lastSyncedAt?: Date; + errorMessage?: string; + retryCount?: number; +} + +/** + * Zulip账号查询条件 + */ +export interface ZulipAccountQueryOptions { + gameUserId?: bigint; + zulipUserId?: number; + zulipEmail?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + includeGameUser?: boolean; +} + +@Injectable() +export class ZulipAccountsRepository { + constructor( + @InjectRepository(ZulipAccounts) + private readonly repository: Repository, + ) {} + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const zulipAccount = this.repository.create(createDto); + return await this.repository.save(zulipAccount); + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { gameUserId }, + relations, + }); + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipUserId }, + relations, + }); + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipEmail }, + relations, + }); + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { id }, + relations, + }); + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ id }, updateDto); + return await this.findById(id); + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ gameUserId }, updateDto); + return await this.findByGameUserId(gameUserId); + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + const result = await this.repository.delete({ id }); + return result.affected > 0; + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + const result = await this.repository.delete({ gameUserId }); + return result.affected > 0; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + const { includeGameUser, ...whereOptions } = options; + const relations = includeGameUser ? ['gameUser'] : []; + + // 构建查询条件 + const where: FindOptionsWhere = {}; + if (whereOptions.gameUserId) where.gameUserId = whereOptions.gameUserId; + if (whereOptions.zulipUserId) where.zulipUserId = whereOptions.zulipUserId; + if (whereOptions.zulipEmail) where.zulipEmail = whereOptions.zulipEmail; + if (whereOptions.status) where.status = whereOptions.status; + + return await this.repository.find({ + where, + relations, + order: { createdAt: 'DESC' }, + }); + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return await this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.status = :status', { status: 'active' }) + .andWhere( + '(zulip_accounts.last_verified_at IS NULL OR zulip_accounts.last_verified_at < :cutoffTime)', + { cutoffTime } + ) + .orderBy('zulip_accounts.last_verified_at', 'ASC', 'NULLS FIRST') + .getMany(); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数,默认3次 + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return await this.repository.find({ + where: { status: 'error' }, + order: { updatedAt: 'ASC' }, + }); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + const result = await this.repository + .createQueryBuilder() + .update(ZulipAccounts) + .set({ status }) + .whereInIds(ids) + .execute(); + + return result.affected || 0; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const result = await this.repository + .createQueryBuilder('zulip_accounts') + .select('zulip_accounts.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('zulip_accounts.status') + .getRawMany(); + + const statistics: Record = {}; + result.forEach(row => { + statistics[row.status] = parseInt(row.count, 10); + }); + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_email = :zulipEmail', { zulipEmail }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_user_id = :zulipUserId', { zulipUserId }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts new file mode 100644 index 0000000..e31e6cf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts @@ -0,0 +1,299 @@ +/** + * Zulip账号关联内存数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的内存存储实现 + * - 用于开发和测试环境 + * - 实现与数据库版本相同的接口 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { + CreateZulipAccountDto, + UpdateZulipAccountDto, + ZulipAccountQueryOptions, +} from './zulip_accounts.repository'; + +@Injectable() +export class ZulipAccountsMemoryRepository { + private accounts: Map = new Map(); + private currentId: bigint = BigInt(1); + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const account = new ZulipAccounts(); + account.id = this.currentId++; + account.gameUserId = createDto.gameUserId; + account.zulipUserId = createDto.zulipUserId; + account.zulipEmail = createDto.zulipEmail; + account.zulipFullName = createDto.zulipFullName; + account.zulipApiKeyEncrypted = createDto.zulipApiKeyEncrypted; + account.status = createDto.status || 'active'; + account.createdAt = new Date(); + account.updatedAt = new Date(); + + this.accounts.set(account.id, account); + return account; + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.gameUserId === gameUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipUserId === zulipUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipEmail === zulipEmail) { + return account; + } + } + return null; + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + return this.accounts.get(id) || null; + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = this.accounts.get(id); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = await this.findByGameUserId(gameUserId); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + return this.accounts.delete(id); + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.gameUserId === gameUserId) { + return this.accounts.delete(id); + } + } + return false; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + let results = Array.from(this.accounts.values()); + + if (options.gameUserId) { + results = results.filter(a => a.gameUserId === options.gameUserId); + } + if (options.zulipUserId) { + results = results.filter(a => a.zulipUserId === options.zulipUserId); + } + if (options.zulipEmail) { + results = results.filter(a => a.zulipEmail === options.zulipEmail); + } + if (options.status) { + results = results.filter(a => a.status === options.status); + } + + // 按创建时间降序排序 + results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return results; + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return Array.from(this.accounts.values()) + .filter(account => + account.status === 'active' && + (!account.lastVerifiedAt || account.lastVerifiedAt < cutoffTime) + ) + .sort((a, b) => { + if (!a.lastVerifiedAt) return -1; + if (!b.lastVerifiedAt) return 1; + return a.lastVerifiedAt.getTime() - b.lastVerifiedAt.getTime(); + }); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数(内存模式忽略) + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return Array.from(this.accounts.values()) + .filter(account => account.status === 'error') + .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime()); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + let count = 0; + for (const id of ids) { + const account = this.accounts.get(id); + if (account) { + account.status = status; + account.updatedAt = new Date(); + count++; + } + } + return count; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const statistics: Record = {}; + + for (const account of this.accounts.values()) { + const status = account.status; + statistics[status] = (statistics[status] || 0) + 1; + } + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipEmail === zulipEmail && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipUserId === zulipUserId && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } +} diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index a53f79f..38aac56 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -826,4 +826,36 @@ export class LoginCoreService { VerificationCodeType.EMAIL_VERIFICATION ); } + + /** + * 删除用户 + * + * 功能描述: + * 删除指定的用户记录,用于注册失败时的回滚操作 + * + * 业务逻辑: + * 1. 验证用户是否存在 + * 2. 执行用户删除操作 + * 3. 返回删除结果 + * + * @param userId 用户ID + * @returns Promise 是否删除成功 + * @throws NotFoundException 用户不存在时 + */ + async deleteUser(userId: bigint): Promise { + // 1. 验证用户是否存在 + const user = await this.usersService.findOne(userId); + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 2. 执行删除操作 + try { + await this.usersService.remove(userId); + return true; + } catch (error) { + console.error(`删除用户失败: ${userId}`, error); + return false; + } + } } \ No newline at end of file diff --git a/src/business/security/decorators/throttle.decorator.ts b/src/core/security_core/decorators/throttle.decorator.ts similarity index 100% rename from src/business/security/decorators/throttle.decorator.ts rename to src/core/security_core/decorators/throttle.decorator.ts diff --git a/src/business/security/decorators/timeout.decorator.ts b/src/core/security_core/decorators/timeout.decorator.ts similarity index 100% rename from src/business/security/decorators/timeout.decorator.ts rename to src/core/security_core/decorators/timeout.decorator.ts diff --git a/src/business/security/guards/throttle.guard.ts b/src/core/security_core/guards/throttle.guard.ts similarity index 100% rename from src/business/security/guards/throttle.guard.ts rename to src/core/security_core/guards/throttle.guard.ts diff --git a/src/business/security/index.ts b/src/core/security_core/index.ts similarity index 71% rename from src/business/security/index.ts rename to src/core/security_core/index.ts index 1453eb8..7781f83 100644 --- a/src/business/security/index.ts +++ b/src/core/security_core/index.ts @@ -1,5 +1,5 @@ /** - * 安全功能模块导出 + * 核心安全模块导出 * * 功能概述: * - 频率限制和防护机制 @@ -10,14 +10,14 @@ */ // 模块 -export * from './security.module'; +export * from './security_core.module'; // 守卫 export * from './guards/throttle.guard'; // 中间件 export * from './middleware/maintenance.middleware'; -export * from './middleware/content-type.middleware'; +export * from './middleware/content_type.middleware'; // 拦截器 export * from './interceptors/timeout.interceptor'; diff --git a/src/business/security/interceptors/timeout.interceptor.ts b/src/core/security_core/interceptors/timeout.interceptor.ts similarity index 100% rename from src/business/security/interceptors/timeout.interceptor.ts rename to src/core/security_core/interceptors/timeout.interceptor.ts diff --git a/src/business/security/middleware/content-type.middleware.ts b/src/core/security_core/middleware/content_type.middleware.ts similarity index 100% rename from src/business/security/middleware/content-type.middleware.ts rename to src/core/security_core/middleware/content_type.middleware.ts diff --git a/src/business/security/middleware/maintenance.middleware.ts b/src/core/security_core/middleware/maintenance.middleware.ts similarity index 100% rename from src/business/security/middleware/maintenance.middleware.ts rename to src/core/security_core/middleware/maintenance.middleware.ts diff --git a/src/business/security/security.module.ts b/src/core/security_core/security_core.module.ts similarity index 84% rename from src/business/security/security.module.ts rename to src/core/security_core/security_core.module.ts index 80cdd83..4ea6f7a 100644 --- a/src/business/security/security.module.ts +++ b/src/core/security_core/security_core.module.ts @@ -1,11 +1,11 @@ /** - * 安全功能模块 + * 核心安全模块 * * 功能描述: - * - 整合所有安全相关功能 + * - 提供系统级安全防护功能 * - 频率限制和请求超时控制 * - 维护模式和内容类型验证 - * - 系统安全防护机制 + * - 全局安全中间件和守卫 * * @author kiro-ai * @version 1.0.0 @@ -34,4 +34,4 @@ import { TimeoutInterceptor } from './interceptors/timeout.interceptor'; ], exports: [ThrottleGuard, TimeoutInterceptor], }) -export class SecurityModule {} \ No newline at end of file +export class SecurityCoreModule {} \ No newline at end of file diff --git a/src/business/zulip/config/index.ts b/src/core/zulip/config/index.ts similarity index 100% rename from src/business/zulip/config/index.ts rename to src/core/zulip/config/index.ts diff --git a/src/business/zulip/config/zulip.config.ts b/src/core/zulip/config/zulip.config.ts similarity index 100% rename from src/business/zulip/config/zulip.config.ts rename to src/core/zulip/config/zulip.config.ts diff --git a/src/core/zulip/index.ts b/src/core/zulip/index.ts new file mode 100644 index 0000000..5583b5d --- /dev/null +++ b/src/core/zulip/index.ts @@ -0,0 +1,26 @@ +/** + * Zulip核心服务模块导出 + * + * 功能描述: + * - 统一导出Zulip核心服务的接口和类型 + * - 为业务层提供清晰的导入路径 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-31 + */ + +// 导出核心服务接口 +export * from './interfaces/zulip-core.interfaces'; + +// 导出核心服务模块 +export { ZulipCoreModule } from './zulip-core.module'; + +// 导出具体实现类(供内部使用) +export { ZulipClientService } from './services/zulip_client.service'; +export { ZulipClientPoolService } from './services/zulip_client_pool.service'; +export { ConfigManagerService } from './services/config_manager.service'; +export { ApiKeySecurityService } from './services/api_key_security.service'; +export { ErrorHandlerService } from './services/error_handler.service'; +export { MonitoringService } from './services/monitoring.service'; +export { StreamInitializerService } from './services/stream_initializer.service'; \ No newline at end of file diff --git a/src/core/zulip/interfaces/zulip-core.interfaces.ts b/src/core/zulip/interfaces/zulip-core.interfaces.ts new file mode 100644 index 0000000..db8d38a --- /dev/null +++ b/src/core/zulip/interfaces/zulip-core.interfaces.ts @@ -0,0 +1,294 @@ +/** + * Zulip核心服务接口定义 + * + * 功能描述: + * - 定义Zulip核心服务的抽象接口 + * - 分离业务逻辑与技术实现 + * - 支持依赖注入和接口切换 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-31 + */ + +/** + * Zulip客户端配置接口 + */ +export interface ZulipClientConfig { + username: string; + apiKey: string; + realm: string; +} + +/** + * Zulip客户端实例接口 + */ +export interface ZulipClientInstance { + userId: string; + config: ZulipClientConfig; + client: any; + queueId?: string; + lastEventId: number; + createdAt: Date; + lastActivity: Date; + isValid: boolean; +} + +/** + * 发送消息结果接口 + */ +export interface SendMessageResult { + success: boolean; + messageId?: number; + error?: string; +} + +/** + * 事件队列注册结果接口 + */ +export interface RegisterQueueResult { + success: boolean; + queueId?: string; + lastEventId?: number; + error?: string; +} + +/** + * 获取事件结果接口 + */ +export interface GetEventsResult { + success: boolean; + events?: any[]; + error?: string; +} + +/** + * 客户端池统计信息接口 + */ +export interface PoolStats { + totalClients: number; + activeClients: number; + clientsWithQueues: number; + clientIds: string[]; +} + +/** + * Zulip客户端核心服务接口 + * + * 职责: + * - 封装Zulip REST API调用 + * - 处理API Key验证和错误处理 + * - 提供消息发送、事件队列管理等核心功能 + */ +export interface IZulipClientService { + /** + * 创建并初始化Zulip客户端 + */ + createClient(userId: string, config: ZulipClientConfig): Promise; + + /** + * 验证API Key有效性 + */ + validateApiKey(clientInstance: ZulipClientInstance): Promise; + + /** + * 发送消息到指定Stream/Topic + */ + sendMessage( + clientInstance: ZulipClientInstance, + stream: string, + topic: string, + content: string, + ): Promise; + + /** + * 注册事件队列 + */ + registerQueue( + clientInstance: ZulipClientInstance, + eventTypes?: string[], + ): Promise; + + /** + * 注销事件队列 + */ + deregisterQueue(clientInstance: ZulipClientInstance): Promise; + + /** + * 获取事件队列中的事件 + */ + getEvents( + clientInstance: ZulipClientInstance, + dontBlock?: boolean, + ): Promise; + + /** + * 销毁客户端实例 + */ + destroyClient(clientInstance: ZulipClientInstance): Promise; +} + +/** + * Zulip客户端池服务接口 + * + * 职责: + * - 管理用户专用的Zulip客户端实例 + * - 维护客户端连接池和生命周期 + * - 处理客户端的创建、销毁和状态管理 + */ +export interface IZulipClientPoolService { + /** + * 为用户创建专用Zulip客户端 + */ + createUserClient(userId: string, config: ZulipClientConfig): Promise; + + /** + * 获取用户的Zulip客户端 + */ + getUserClient(userId: string): Promise; + + /** + * 检查用户客户端是否存在 + */ + hasUserClient(userId: string): boolean; + + /** + * 发送消息到指定Stream/Topic + */ + sendMessage( + userId: string, + stream: string, + topic: string, + content: string, + ): Promise; + + /** + * 注册事件队列 + */ + registerEventQueue(userId: string): Promise; + + /** + * 注销事件队列 + */ + deregisterEventQueue(userId: string): Promise; + + /** + * 销毁用户客户端 + */ + destroyUserClient(userId: string): Promise; + + /** + * 获取客户端池统计信息 + */ + getPoolStats(): PoolStats; + + /** + * 清理过期客户端 + */ + cleanupIdleClients(maxIdleMinutes?: number): Promise; +} + +/** + * Zulip配置管理服务接口 + * + * 职责: + * - 管理地图到Zulip Stream的映射配置 + * - 提供Zulip服务器连接配置 + * - 支持配置文件的热重载 + */ +export interface IZulipConfigService { + /** + * 根据地图获取对应的Stream + */ + getStreamByMap(mapId: string): string | null; + + /** + * 根据Stream名称获取地图ID + */ + getMapIdByStream(streamName: string): string | null; + + /** + * 根据交互对象获取Topic + */ + getTopicByObject(mapId: string, objectId: string): string | null; + + /** + * 获取Zulip配置 + */ + getZulipConfig(): any; + + /** + * 检查地图是否存在 + */ + hasMap(mapId: string): boolean; + + /** + * 检查Stream是否存在 + */ + hasStream(streamName: string): boolean; + + /** + * 获取所有地图ID列表 + */ + getAllMapIds(): string[]; + + /** + * 获取所有Stream名称列表 + */ + getAllStreams(): string[]; + + /** + * 热重载配置 + */ + reloadConfig(): Promise; + + /** + * 验证配置有效性 + */ + validateConfig(): Promise<{ valid: boolean; errors: string[] }>; +} + +/** + * Zulip事件处理服务接口 + * + * 职责: + * - 处理从Zulip接收的事件队列消息 + * - 将Zulip消息转换为游戏协议格式 + * - 管理事件队列的生命周期 + */ +export interface IZulipEventProcessorService { + /** + * 启动事件处理循环 + */ + startEventProcessing(): Promise; + + /** + * 停止事件处理循环 + */ + stopEventProcessing(): Promise; + + /** + * 注册事件队列 + */ + registerEventQueue(queueId: string, userId: string, lastEventId?: number): Promise; + + /** + * 注销事件队列 + */ + unregisterEventQueue(queueId: string): Promise; + + /** + * 处理Zulip消息事件 + */ + processMessageEvent(event: any, senderUserId: string): Promise; + + /** + * 设置消息分发器 + */ + setMessageDistributor(distributor: any): void; + + /** + * 获取事件处理统计信息 + */ + getProcessingStats(): any; +} \ No newline at end of file diff --git a/src/business/zulip/interfaces/zulip.interfaces.ts b/src/core/zulip/interfaces/zulip.interfaces.ts similarity index 100% rename from src/business/zulip/interfaces/zulip.interfaces.ts rename to src/core/zulip/interfaces/zulip.interfaces.ts diff --git a/src/business/zulip/services/api-key-security.service.spec.ts b/src/core/zulip/services/api_key_security.service.spec.ts similarity index 99% rename from src/business/zulip/services/api-key-security.service.spec.ts rename to src/core/zulip/services/api_key_security.service.spec.ts index 995e283..a5698b2 100644 --- a/src/business/zulip/services/api-key-security.service.spec.ts +++ b/src/core/zulip/services/api_key_security.service.spec.ts @@ -17,7 +17,7 @@ import { ApiKeySecurityService, SecurityEventType, SecuritySeverity, -} from './api-key-security.service'; +} from './api_key_security.service'; import { IRedisService } from '../../../core/redis/redis.interface'; describe('ApiKeySecurityService', () => { diff --git a/src/business/zulip/services/api-key-security.service.ts b/src/core/zulip/services/api_key_security.service.ts similarity index 97% rename from src/business/zulip/services/api-key-security.service.ts rename to src/core/zulip/services/api_key_security.service.ts index bdcfd2c..a27ae3a 100644 --- a/src/business/zulip/services/api-key-security.service.ts +++ b/src/core/zulip/services/api_key_security.service.ts @@ -100,6 +100,28 @@ export interface GetApiKeyResult { message?: string; } +/** + * API密钥安全服务类 + * + * 职责: + * - 管理Zulip API密钥的安全存储 + * - 提供API密钥的加密和解密功能 + * - 记录API密钥的访问日志 + * - 监控API密钥的使用情况和安全事件 + * + * 主要方法: + * - storeApiKey(): 安全存储加密的API密钥 + * - retrieveApiKey(): 检索并解密API密钥 + * - validateApiKey(): 验证API密钥的有效性 + * - logSecurityEvent(): 记录安全相关事件 + * - getAccessStats(): 获取API密钥访问统计 + * + * 使用场景: + * - 用户API密钥的安全存储 + * - API密钥访问时的解密操作 + * - 安全事件的监控和记录 + * - API密钥使用情况的统计分析 + */ @Injectable() export class ApiKeySecurityService { private readonly logger = new Logger(ApiKeySecurityService.name); diff --git a/src/business/zulip/services/config-manager.service.spec.ts b/src/core/zulip/services/config_manager.service.spec.ts similarity index 99% rename from src/business/zulip/services/config-manager.service.spec.ts rename to src/core/zulip/services/config_manager.service.spec.ts index 49b7f94..39a91bb 100644 --- a/src/business/zulip/services/config-manager.service.spec.ts +++ b/src/core/zulip/services/config_manager.service.spec.ts @@ -12,8 +12,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as fc from 'fast-check'; -import { ConfigManagerService, MapConfig, ZulipConfig } from './config-manager.service'; -import { AppLoggerService } from '../../../core/utils/logger/logger.service'; +import { ConfigManagerService, MapConfig, ZulipConfig } from './config_manager.service'; +import { AppLoggerService } from '../../utils/logger/logger.service'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/business/zulip/services/config-manager.service.ts b/src/core/zulip/services/config_manager.service.ts similarity index 95% rename from src/business/zulip/services/config-manager.service.ts rename to src/core/zulip/services/config_manager.service.ts index a569824..5ee40d0 100644 --- a/src/business/zulip/services/config-manager.service.ts +++ b/src/core/zulip/services/config_manager.service.ts @@ -108,6 +108,28 @@ export interface InteractionObject extends InteractionObjectConfig { mapId: string; // 所属地图ID } +/** + * 配置管理服务类 + * + * 职责: + * - 管理地图到Zulip Stream的映射配置 + * - 提供Zulip服务器连接配置 + * - 支持配置文件的热重载 + * - 验证配置的完整性和有效性 + * + * 主要方法: + * - loadMapConfig(): 加载地图配置文件 + * - getStreamByMap(): 根据地图ID获取对应的Stream + * - getZulipConfig(): 获取Zulip服务器配置 + * - validateConfig(): 验证配置文件格式 + * - enableConfigWatcher(): 启用配置文件监控 + * + * 使用场景: + * - 系统启动时加载配置 + * - 消息路由时查找Stream映射 + * - 配置文件变更时自动重载 + * - 配置验证和错误处理 + */ @Injectable() export class ConfigManagerService implements OnModuleDestroy { private mapConfigs: Map = new Map(); @@ -117,10 +139,39 @@ export class ConfigManagerService implements OnModuleDestroy { private configLoadTime: Date; private configWatcher: fs.FSWatcher | null = null; private isWatcherEnabled: boolean = false; - private readonly CONFIG_DIR = path.join(process.cwd(), 'config', 'zulip'); + private readonly CONFIG_DIR = this.getConfigDir(); private readonly MAP_CONFIG_FILE = 'map-config.json'; private readonly logger = new Logger(ConfigManagerService.name); + /** + * 获取配置目录路径 + * + * 在开发环境中使用 config/zulip + * 在生产环境中使用 dist/zulip (编译后的位置) + */ + private getConfigDir(): string { + const isDevelopment = process.env.NODE_ENV !== 'production'; + + if (isDevelopment) { + // 开发环境:使用源码目录 + return path.join(process.cwd(), 'config', 'zulip'); + } else { + // 生产环境:使用编译后的目录 + const distConfigPath = path.join(process.cwd(), 'dist', 'zulip'); + const rootConfigPath = path.join(process.cwd(), 'config', 'zulip'); + + // 优先使用 dist/zulip,如果不存在则回退到 config/zulip + if (fs.existsSync(distConfigPath)) { + return distConfigPath; + } else if (fs.existsSync(rootConfigPath)) { + return rootConfigPath; + } else { + // 都不存在,使用默认路径 + return distConfigPath; + } + } + } + constructor() { this.logger.log('ConfigManagerService初始化完成'); @@ -216,6 +267,9 @@ export class ConfigManagerService implements OnModuleDestroy { * 4. 存储到内存映射 * * @returns Promise + * + * @throws Error 当配置格式无效时 + * @throws Error 当文件读取失败时 */ async loadMapConfig(): Promise { this.logger.log('开始加载地图配置', { diff --git a/src/business/zulip/services/error-handler.service.spec.ts b/src/core/zulip/services/error_handler.service.spec.ts similarity index 99% rename from src/business/zulip/services/error-handler.service.spec.ts rename to src/core/zulip/services/error_handler.service.spec.ts index 42c426c..0baf916 100644 --- a/src/business/zulip/services/error-handler.service.spec.ts +++ b/src/core/zulip/services/error_handler.service.spec.ts @@ -23,7 +23,7 @@ import { LoadStatus, ErrorHandlingResult, RetryConfig, -} from './error-handler.service'; +} from './error_handler.service'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; describe('ErrorHandlerService', () => { diff --git a/src/business/zulip/services/error-handler.service.ts b/src/core/zulip/services/error_handler.service.ts similarity index 97% rename from src/business/zulip/services/error-handler.service.ts rename to src/core/zulip/services/error_handler.service.ts index 0041fe3..f1f12c2 100644 --- a/src/business/zulip/services/error-handler.service.ts +++ b/src/core/zulip/services/error_handler.service.ts @@ -115,6 +115,28 @@ export enum LoadStatus { CRITICAL = 'critical', } +/** + * 错误处理服务类 + * + * 职责: + * - 统一处理系统错误和异常 + * - 实现重试机制和服务降级 + * - 监控系统健康状态和负载 + * - 提供错误恢复和告警功能 + * + * 主要方法: + * - handleError(): 处理各类错误和异常 + * - retryWithBackoff(): 带退避策略的重试机制 + * - enableDegradedMode(): 启用服务降级模式 + * - getServiceStatus(): 获取服务状态 + * - recordError(): 记录错误统计 + * + * 使用场景: + * - Zulip API调用失败时的错误处理 + * - 网络连接异常的重试机制 + * - 系统负载过高时的服务降级 + * - 错误监控和告警通知 + */ @Injectable() export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy { private readonly logger = new Logger(ErrorHandlerService.name); diff --git a/src/business/zulip/services/monitoring.service.spec.ts b/src/core/zulip/services/monitoring.service.spec.ts similarity index 100% rename from src/business/zulip/services/monitoring.service.spec.ts rename to src/core/zulip/services/monitoring.service.spec.ts diff --git a/src/business/zulip/services/monitoring.service.ts b/src/core/zulip/services/monitoring.service.ts similarity index 96% rename from src/business/zulip/services/monitoring.service.ts rename to src/core/zulip/services/monitoring.service.ts index 34ef9d3..8a6f65a 100644 --- a/src/business/zulip/services/monitoring.service.ts +++ b/src/core/zulip/services/monitoring.service.ts @@ -182,6 +182,29 @@ export interface MonitoringStats { }; } +/** + * 监控服务类 + * + * 职责: + * - 监控Zulip集成系统的运行状态 + * - 收集和统计系统性能指标 + * - 提供健康检查和告警功能 + * - 生成系统监控报告 + * + * 主要方法: + * - recordConnection(): 记录连接统计 + * - recordApiCall(): 记录API调用统计 + * - recordMessage(): 记录消息统计 + * - triggerAlert(): 触发告警 + * - getSystemStats(): 获取系统统计信息 + * - performHealthCheck(): 执行健康检查 + * + * 使用场景: + * - 系统性能监控和统计 + * - 异常情况的告警通知 + * - 系统健康状态检查 + * - 运维数据的收集和分析 + */ @Injectable() export class MonitoringService extends EventEmitter implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(MonitoringService.name); diff --git a/src/business/zulip/services/stream-initializer.service.ts b/src/core/zulip/services/stream_initializer.service.ts similarity index 92% rename from src/business/zulip/services/stream-initializer.service.ts rename to src/core/zulip/services/stream_initializer.service.ts index cbf2339..06b644b 100644 --- a/src/business/zulip/services/stream-initializer.service.ts +++ b/src/core/zulip/services/stream_initializer.service.ts @@ -21,8 +21,30 @@ */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigManagerService } from './config-manager.service'; +import { ConfigManagerService } from './config_manager.service'; +/** + * Stream初始化服务类 + * + * 职责: + * - 系统启动时自动检查并创建Zulip Streams + * - 确保所有地图对应的Stream都存在 + * - 验证Stream配置的完整性 + * - 提供Stream初始化状态监控 + * + * 主要方法: + * - onModuleInit(): 模块初始化时自动执行 + * - initializeStreams(): 初始化所有必需的Streams + * - createStreamIfNotExists(): 检查并创建单个Stream + * - validateStreamConfig(): 验证Stream配置 + * - getInitializationStatus(): 获取初始化状态 + * + * 使用场景: + * - 系统启动时自动初始化Streams + * - 确保消息路由的目标Stream存在 + * - 新增地图时自动创建对应Stream + * - 系统部署和配置验证 + */ @Injectable() export class StreamInitializerService implements OnModuleInit { private readonly logger = new Logger(StreamInitializerService.name); diff --git a/src/core/zulip/services/zulip_account.service.ts b/src/core/zulip/services/zulip_account.service.ts new file mode 100644 index 0000000..162ea7c --- /dev/null +++ b/src/core/zulip/services/zulip_account.service.ts @@ -0,0 +1,708 @@ +/** + * Zulip账号管理核心服务 + * + * 功能描述: + * - 自动创建Zulip用户账号 + * - 生成API Key并安全存储 + * - 处理账号创建失败场景 + * - 管理用户账号与游戏账号的关联 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 关联游戏账号与Zulip账号 + * + * 使用场景: + * - 用户注册时自动创建Zulip账号 + * - API Key管理和更新 + * - 账号关联和映射存储 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ZulipClientConfig } from '../interfaces/zulip-core.interfaces'; + +/** + * Zulip账号创建请求接口 + */ +export interface CreateZulipAccountRequest { + email: string; + fullName: string; + password?: string; + shortName?: string; +} + +/** + * Zulip账号创建结果接口 + */ +export interface CreateZulipAccountResult { + success: boolean; + userId?: number; + email?: string; + apiKey?: string; + error?: string; + errorCode?: string; +} + +/** + * API Key生成结果接口 + */ +export interface GenerateApiKeyResult { + success: boolean; + apiKey?: string; + error?: string; +} + +/** + * 账号验证结果接口 + */ +export interface ValidateAccountResult { + success: boolean; + isValid?: boolean; + userInfo?: any; + error?: string; +} + +/** + * 账号关联信息接口 + */ +export interface AccountLinkInfo { + gameUserId: string; + zulipUserId: number; + zulipEmail: string; + zulipApiKey: string; + createdAt: Date; + lastVerified?: Date; + isActive: boolean; +} + +/** + * Zulip账号管理服务类 + * + * 职责: + * - 处理Zulip用户账号的创建和管理 + * - 管理API Key的生成和存储 + * - 维护游戏账号与Zulip账号的关联关系 + * - 提供账号验证和状态检查功能 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为现有用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 建立游戏账号与Zulip账号的关联 + * - unlinkGameAccount(): 解除账号关联 + * + * 使用场景: + * - 用户注册流程中自动创建Zulip账号 + * - API Key管理和更新 + * - 账号状态监控和维护 + * - 跨平台账号同步 + */ +@Injectable() +export class ZulipAccountService { + private readonly logger = new Logger(ZulipAccountService.name); + private adminClient: any = null; + private readonly accountLinks = new Map(); + + constructor() { + this.logger.log('ZulipAccountService初始化完成'); + } + + /** + * 初始化管理员客户端 + * + * 功能描述: + * 使用管理员凭证初始化Zulip客户端,用于创建用户账号 + * + * @param adminConfig 管理员配置 + * @returns Promise 是否初始化成功 + */ + async initializeAdminClient(adminConfig: ZulipClientConfig): Promise { + this.logger.log('初始化Zulip管理员客户端', { + operation: 'initializeAdminClient', + realm: adminConfig.realm, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 创建管理员客户端 + this.adminClient = await zulipInit({ + username: adminConfig.username, + apiKey: adminConfig.apiKey, + realm: adminConfig.realm, + }); + + // 验证管理员权限 + const profile = await this.adminClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`管理员客户端验证失败: ${profile.msg || '未知错误'}`); + } + + this.logger.log('管理员客户端初始化成功', { + operation: 'initializeAdminClient', + adminEmail: profile.email, + isAdmin: profile.is_admin, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + this.logger.error('管理员客户端初始化失败', { + operation: 'initializeAdminClient', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 创建Zulip用户账号 + * + * 功能描述: + * 使用管理员权限在Zulip服务器上创建新的用户账号 + * + * 业务逻辑: + * 1. 验证管理员客户端是否已初始化 + * 2. 检查邮箱是否已存在 + * 3. 生成用户密码(如果未提供) + * 4. 调用Zulip API创建用户 + * 5. 为新用户生成API Key + * 6. 返回创建结果 + * + * @param request 账号创建请求 + * @returns Promise 创建结果 + */ + async createZulipAccount(request: CreateZulipAccountRequest): Promise { + const startTime = Date.now(); + + this.logger.log('开始创建Zulip账号', { + operation: 'createZulipAccount', + email: request.email, + fullName: request.fullName, + timestamp: new Date().toISOString(), + }); + + try { + // 1. 验证管理员客户端 + if (!this.adminClient) { + throw new Error('管理员客户端未初始化'); + } + + // 2. 验证请求参数 + if (!request.email || !request.email.trim()) { + throw new Error('邮箱地址不能为空'); + } + + if (!request.fullName || !request.fullName.trim()) { + throw new Error('用户全名不能为空'); + } + + // 3. 检查邮箱格式 + if (!this.isValidEmail(request.email)) { + throw new Error('邮箱格式无效'); + } + + // 4. 检查用户是否已存在 + const existingUser = await this.checkUserExists(request.email); + if (existingUser) { + this.logger.warn('用户已存在', { + operation: 'createZulipAccount', + email: request.email, + }); + return { + success: false, + error: '用户已存在', + errorCode: 'USER_ALREADY_EXISTS', + }; + } + + // 5. 生成密码(如果未提供) + const password = request.password || this.generateRandomPassword(); + const shortName = request.shortName || this.generateShortName(request.email); + + // 6. 创建用户参数 + const createParams = { + email: request.email, + password: password, + full_name: request.fullName, + short_name: shortName, + }; + + // 7. 调用Zulip API创建用户 + const createResponse = await this.adminClient.users.create(createParams); + + if (createResponse.result !== 'success') { + this.logger.warn('Zulip用户创建失败', { + operation: 'createZulipAccount', + email: request.email, + error: createResponse.msg, + }); + return { + success: false, + error: createResponse.msg || '用户创建失败', + errorCode: 'ZULIP_CREATE_FAILED', + }; + } + + // 8. 为新用户生成API Key + const apiKeyResult = await this.generateApiKeyForUser(request.email, password); + + if (!apiKeyResult.success) { + this.logger.warn('API Key生成失败,但用户已创建', { + operation: 'createZulipAccount', + email: request.email, + error: apiKeyResult.error, + }); + // 用户已创建,但API Key生成失败 + return { + success: true, + userId: createResponse.user_id, + email: request.email, + error: `用户创建成功,但API Key生成失败: ${apiKeyResult.error}`, + errorCode: 'API_KEY_GENERATION_FAILED', + }; + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建成功', { + operation: 'createZulipAccount', + email: request.email, + userId: createResponse.user_id, + hasApiKey: !!apiKeyResult.apiKey, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + userId: createResponse.user_id, + email: request.email, + apiKey: apiKeyResult.apiKey, + }; + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('创建Zulip账号失败', { + operation: 'createZulipAccount', + email: request.email, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + errorCode: 'ACCOUNT_CREATION_FAILED', + }; + } + } + + /** + * 为用户生成API Key + * + * 功能描述: + * 使用用户凭证获取API Key + * + * @param email 用户邮箱 + * @param password 用户密码 + * @returns Promise 生成结果 + */ + async generateApiKeyForUser(email: string, password: string): Promise { + this.logger.log('为用户生成API Key', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 使用用户凭证获取API Key + const userClient = await zulipInit({ + username: email, + password: password, + realm: this.getRealmFromAdminClient(), + }); + + // 验证客户端并获取API Key + const profile = await userClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`API Key获取失败: ${profile.msg || '未知错误'}`); + } + + // 从客户端配置中提取API Key + const apiKey = userClient.config?.apiKey; + + if (!apiKey) { + throw new Error('无法从客户端配置中获取API Key'); + } + + this.logger.log('API Key生成成功', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + apiKey: apiKey, + }; + + } catch (error) { + const err = error as Error; + + this.logger.error('API Key生成失败', { + operation: 'generateApiKeyForUser', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 验证Zulip账号有效性 + * + * 功能描述: + * 验证指定的Zulip账号是否存在且有效 + * + * @param email 用户邮箱 + * @param apiKey 用户API Key(可选) + * @returns Promise 验证结果 + */ + async validateZulipAccount(email: string, apiKey?: string): Promise { + this.logger.log('验证Zulip账号', { + operation: 'validateZulipAccount', + email, + hasApiKey: !!apiKey, + timestamp: new Date().toISOString(), + }); + + try { + if (apiKey) { + // 使用API Key验证 + const zulipInit = await this.loadZulipModule(); + const userClient = await zulipInit({ + username: email, + apiKey: apiKey, + realm: this.getRealmFromAdminClient(), + }); + + const profile = await userClient.users.me.getProfile(); + + if (profile.result === 'success') { + this.logger.log('账号验证成功(API Key)', { + operation: 'validateZulipAccount', + email, + userId: profile.user_id, + }); + + return { + success: true, + isValid: true, + userInfo: profile, + }; + } else { + return { + success: true, + isValid: false, + error: profile.msg || 'API Key验证失败', + }; + } + } else { + // 仅检查用户是否存在 + const userExists = await this.checkUserExists(email); + + this.logger.log('账号存在性检查完成', { + operation: 'validateZulipAccount', + email, + exists: userExists, + }); + + return { + success: true, + isValid: userExists, + }; + } + + } catch (error) { + const err = error as Error; + + this.logger.error('账号验证失败', { + operation: 'validateZulipAccount', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 关联游戏账号与Zulip账号 + * + * 功能描述: + * 建立游戏用户ID与Zulip账号的映射关系 + * + * @param gameUserId 游戏用户ID + * @param zulipUserId Zulip用户ID + * @param zulipEmail Zulip邮箱 + * @param zulipApiKey Zulip API Key + * @returns Promise 是否关联成功 + */ + async linkGameAccount( + gameUserId: string, + zulipUserId: number, + zulipEmail: string, + zulipApiKey: string, + ): Promise { + this.logger.log('关联游戏账号与Zulip账号', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + try { + // 验证参数 + if (!gameUserId || !zulipUserId || !zulipEmail || !zulipApiKey) { + throw new Error('关联参数不完整'); + } + + // 创建关联信息 + const linkInfo: AccountLinkInfo = { + gameUserId, + zulipUserId, + zulipEmail, + zulipApiKey, + createdAt: new Date(), + isActive: true, + }; + + // 存储关联信息(实际项目中应存储到数据库) + this.accountLinks.set(gameUserId, linkInfo); + + this.logger.log('账号关联成功', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('账号关联失败', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 解除游戏账号与Zulip账号的关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否解除成功 + */ + async unlinkGameAccount(gameUserId: string): Promise { + this.logger.log('解除账号关联', { + operation: 'unlinkGameAccount', + gameUserId, + timestamp: new Date().toISOString(), + }); + + try { + const linkInfo = this.accountLinks.get(gameUserId); + + if (linkInfo) { + linkInfo.isActive = false; + this.accountLinks.delete(gameUserId); + + this.logger.log('账号关联解除成功', { + operation: 'unlinkGameAccount', + gameUserId, + zulipEmail: linkInfo.zulipEmail, + }); + } + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('解除账号关联失败', { + operation: 'unlinkGameAccount', + gameUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 获取游戏账号的Zulip关联信息 + * + * @param gameUserId 游戏用户ID + * @returns AccountLinkInfo | null 关联信息 + */ + getAccountLink(gameUserId: string): AccountLinkInfo | null { + return this.accountLinks.get(gameUserId) || null; + } + + /** + * 获取所有账号关联信息 + * + * @returns AccountLinkInfo[] 所有关联信息 + */ + getAllAccountLinks(): AccountLinkInfo[] { + return Array.from(this.accountLinks.values()).filter(link => link.isActive); + } + + /** + * 检查用户是否已存在 + * + * @param email 用户邮箱 + * @returns Promise 用户是否存在 + * @private + */ + private async checkUserExists(email: string): Promise { + try { + if (!this.adminClient) { + return false; + } + + // 获取所有用户列表 + const usersResponse = await this.adminClient.users.retrieve(); + + if (usersResponse.result === 'success') { + const users = usersResponse.members || []; + return users.some((user: any) => user.email === email); + } + + return false; + + } catch (error) { + const err = error as Error; + this.logger.warn('检查用户存在性失败', { + operation: 'checkUserExists', + email, + error: err.message, + }); + return false; + } + } + + /** + * 验证邮箱格式 + * + * @param email 邮箱地址 + * @returns boolean 是否为有效邮箱 + * @private + */ + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + /** + * 生成随机密码 + * + * @returns string 随机密码 + * @private + */ + private generateRandomPassword(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + let password = ''; + for (let i = 0; i < 12; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return password; + } + + /** + * 从邮箱生成短名称 + * + * @param email 邮箱地址 + * @returns string 短名称 + * @private + */ + private generateShortName(email: string): string { + const localPart = email.split('@')[0]; + // 移除特殊字符,只保留字母数字和下划线 + return localPart.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase(); + } + + /** + * 从管理员客户端获取Realm + * + * @returns string Realm URL + * @private + */ + private getRealmFromAdminClient(): string { + if (!this.adminClient || !this.adminClient.config) { + throw new Error('管理员客户端未初始化或配置缺失'); + } + return this.adminClient.config.realm; + } + + /** + * 动态加载zulip-js模块 + * + * @returns Promise zulip-js初始化函数 + * @private + */ + private async loadZulipModule(): Promise { + try { + // 使用动态导入加载zulip-js + const zulipModule = await import('zulip-js'); + return zulipModule.default || zulipModule; + } catch (error) { + const err = error as Error; + this.logger.error('加载zulip-js模块失败', { + operation: 'loadZulipModule', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + throw new Error(`加载zulip-js模块失败: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/src/business/zulip/services/zulip-client.service.spec.ts b/src/core/zulip/services/zulip_client.service.spec.ts similarity index 99% rename from src/business/zulip/services/zulip-client.service.spec.ts rename to src/core/zulip/services/zulip_client.service.spec.ts index 4316892..f2d0ddf 100644 --- a/src/business/zulip/services/zulip-client.service.spec.ts +++ b/src/core/zulip/services/zulip_client.service.spec.ts @@ -12,7 +12,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as fc from 'fast-check'; -import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip-client.service'; +import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip_client.service'; import { AppLoggerService } from '../../../core/utils/logger/logger.service'; describe('ZulipClientService', () => { diff --git a/src/business/zulip/services/zulip-client.service.ts b/src/core/zulip/services/zulip_client.service.ts similarity index 96% rename from src/business/zulip/services/zulip-client.service.ts rename to src/core/zulip/services/zulip_client.service.ts index 07cc647..5355612 100644 --- a/src/business/zulip/services/zulip-client.service.ts +++ b/src/core/zulip/services/zulip_client.service.ts @@ -77,6 +77,28 @@ export interface GetEventsResult { error?: string; } +/** + * Zulip客户端服务类 + * + * 职责: + * - 封装Zulip REST API调用 + * - 处理Zulip客户端的创建和配置 + * - 管理事件队列的注册和轮询 + * - 提供消息发送和接收功能 + * + * 主要方法: + * - createClient(): 创建并初始化Zulip客户端 + * - registerQueue(): 注册Zulip事件队列 + * - sendMessage(): 发送消息到Zulip Stream + * - getEvents(): 获取Zulip事件 + * - validateConfig(): 验证客户端配置 + * + * 使用场景: + * - 为每个用户创建独立的Zulip客户端 + * - 处理与Zulip服务器的所有通信 + * - 消息的发送和事件的接收 + * - API调用的错误处理和重试 + */ @Injectable() export class ZulipClientService { private readonly logger = new Logger(ZulipClientService.name); diff --git a/src/business/zulip/services/zulip-client-pool.service.spec.ts b/src/core/zulip/services/zulip_client_pool.service.spec.ts similarity index 98% rename from src/business/zulip/services/zulip-client-pool.service.spec.ts rename to src/core/zulip/services/zulip_client_pool.service.spec.ts index 2cdd754..4a5bb64 100644 --- a/src/business/zulip/services/zulip-client-pool.service.spec.ts +++ b/src/core/zulip/services/zulip_client_pool.service.spec.ts @@ -12,9 +12,9 @@ */ import { Test, TestingModule } from '@nestjs/testing'; -import { ZulipClientPoolService, PoolStats } from './zulip-client-pool.service'; -import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip-client.service'; -import { AppLoggerService } from '../../../core/utils/logger/logger.service'; +import { ZulipClientPoolService, PoolStats } from './zulip_client_pool.service'; +import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip_client.service'; +import { AppLoggerService } from '../../utils/logger/logger.service'; describe('ZulipClientPoolService', () => { let service: ZulipClientPoolService; diff --git a/src/business/zulip/services/zulip-client-pool.service.ts b/src/core/zulip/services/zulip_client_pool.service.ts similarity index 95% rename from src/business/zulip/services/zulip-client-pool.service.ts rename to src/core/zulip/services/zulip_client_pool.service.ts index 5e43e14..743d539 100644 --- a/src/business/zulip/services/zulip-client-pool.service.ts +++ b/src/core/zulip/services/zulip_client_pool.service.ts @@ -35,7 +35,7 @@ import { SendMessageResult, RegisterQueueResult, GetEventsResult, -} from './zulip-client.service'; +} from './zulip_client.service'; /** * 用户客户端信息接口 @@ -57,6 +57,28 @@ export interface PoolStats { clientIds: string[]; } +/** + * Zulip客户端池服务类 + * + * 职责: + * - 管理用户专用的Zulip客户端实例 + * - 维护客户端连接池和生命周期 + * - 处理客户端的创建、销毁和状态管理 + * - 提供客户端池统计和监控功能 + * + * 主要方法: + * - createUserClient(): 为用户创建专用Zulip客户端 + * - getUserClient(): 获取用户的Zulip客户端 + * - destroyUserClient(): 销毁用户的Zulip客户端 + * - getPoolStats(): 获取客户端池统计信息 + * - startEventPolling(): 启动事件轮询 + * + * 使用场景: + * - 玩家登录时创建专用客户端 + * - 消息发送时获取客户端实例 + * - 玩家登出时清理客户端资源 + * - 系统监控和性能统计 + */ @Injectable() export class ZulipClientPoolService implements OnModuleDestroy { private readonly clientPool = new Map(); diff --git a/src/business/zulip/types/zulip-js.d.ts b/src/core/zulip/types/zulip-js.d.ts similarity index 100% rename from src/business/zulip/types/zulip-js.d.ts rename to src/core/zulip/types/zulip-js.d.ts diff --git a/src/core/zulip/zulip-core.module.ts b/src/core/zulip/zulip-core.module.ts new file mode 100644 index 0000000..134ee45 --- /dev/null +++ b/src/core/zulip/zulip-core.module.ts @@ -0,0 +1,71 @@ +/** + * Zulip核心服务模块 + * + * 功能描述: + * - 提供Zulip技术实现相关的核心服务 + * - 封装第三方API调用和技术细节 + * - 为业务层提供抽象接口 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-12-31 + */ + +import { Module } from '@nestjs/common'; +import { ZulipClientService } from './services/zulip_client.service'; +import { ZulipClientPoolService } from './services/zulip_client_pool.service'; +import { ConfigManagerService } from './services/config_manager.service'; +import { ApiKeySecurityService } from './services/api_key_security.service'; +import { ErrorHandlerService } from './services/error_handler.service'; +import { MonitoringService } from './services/monitoring.service'; +import { StreamInitializerService } from './services/stream_initializer.service'; +import { ZulipAccountService } from './services/zulip_account.service'; +import { RedisModule } from '../redis/redis.module'; + +@Module({ + imports: [ + // Redis模块 - ApiKeySecurityService需要REDIS_SERVICE + RedisModule, + ], + providers: [ + // 核心客户端服务 + { + provide: 'ZULIP_CLIENT_SERVICE', + useClass: ZulipClientService, + }, + { + provide: 'ZULIP_CLIENT_POOL_SERVICE', + useClass: ZulipClientPoolService, + }, + { + provide: 'ZULIP_CONFIG_SERVICE', + useClass: ConfigManagerService, + }, + + // 辅助服务 + ApiKeySecurityService, + ErrorHandlerService, + MonitoringService, + StreamInitializerService, + ZulipAccountService, + + // 直接提供类(用于内部依赖) + ZulipClientService, + ZulipClientPoolService, + ConfigManagerService, + ], + exports: [ + // 导出接口标识符供业务层使用 + 'ZULIP_CLIENT_SERVICE', + 'ZULIP_CLIENT_POOL_SERVICE', + 'ZULIP_CONFIG_SERVICE', + + // 导出辅助服务 + ApiKeySecurityService, + ErrorHandlerService, + MonitoringService, + StreamInitializerService, + ZulipAccountService, + ], +}) +export class ZulipCoreModule {} \ No newline at end of file diff --git a/test-comprehensive.ps1 b/test-comprehensive.ps1 deleted file mode 100644 index 41d36f6..0000000 --- a/test-comprehensive.ps1 +++ /dev/null @@ -1,333 +0,0 @@ -# Comprehensive API Test Script -# 综合API测试脚本 - 完整的后端功能测试 -# -# 🧪 测试内容: -# 1. 基础API功能(应用状态、注册、登录) -# 2. 邮箱验证码流程(发送、验证、冲突检测) -# 3. 验证码冷却时间清除功能 -# 4. 限流保护机制 -# 5. 密码重置流程 -# 6. 验证码登录功能 -# 7. 错误处理和边界条件 -# -# 🚀 使用方法: -# .\test-comprehensive.ps1 # 运行完整测试 -# .\test-comprehensive.ps1 -SkipThrottleTest # 跳过限流测试 -# .\test-comprehensive.ps1 -SkipCooldownTest # 跳过冷却测试 -# .\test-comprehensive.ps1 -BaseUrl "https://your-server.com" # 测试远程服务器 - -param( - [string]$BaseUrl = "http://localhost:3000", - [switch]$SkipThrottleTest = $false, - [switch]$SkipCooldownTest = $false -) - -$ErrorActionPreference = "Continue" - -Write-Host "🧪 Comprehensive API Test Suite" -ForegroundColor Green -Write-Host "===============================" -ForegroundColor Green -Write-Host "Base URL: $BaseUrl" -ForegroundColor Yellow -Write-Host "Skip Throttle Test: $SkipThrottleTest" -ForegroundColor Yellow -Write-Host "Skip Cooldown Test: $SkipCooldownTest" -ForegroundColor Yellow - -# Helper function to handle API responses -function Test-ApiCall { - param( - [string]$TestName, - [string]$Url, - [string]$Body, - [string]$Method = "POST", - [int]$ExpectedStatus = 200, - [switch]$Silent = $false - ) - - if (-not $Silent) { - Write-Host "`n📋 $TestName" -ForegroundColor Yellow - } - - try { - $response = Invoke-RestMethod -Uri $Url -Method $Method -Body $Body -ContentType "application/json" -ErrorAction Stop - if (-not $Silent) { - Write-Host "✅ SUCCESS ($(if ($response.success) { 'true' } else { 'false' }))" -ForegroundColor Green - Write-Host "Message: $($response.message)" -ForegroundColor Cyan - } - return $response - } catch { - $statusCode = $_.Exception.Response.StatusCode.value__ - if (-not $Silent) { - Write-Host "❌ FAILED ($statusCode)" -ForegroundColor $(if ($statusCode -eq $ExpectedStatus) { "Yellow" } else { "Red" }) - } - - if ($_.Exception.Response) { - $stream = $_.Exception.Response.GetResponseStream() - $reader = New-Object System.IO.StreamReader($stream) - $responseBody = $reader.ReadToEnd() - $reader.Close() - $stream.Close() - - if ($responseBody) { - try { - $errorResponse = $responseBody | ConvertFrom-Json - if (-not $Silent) { - Write-Host "Message: $($errorResponse.message)" -ForegroundColor Cyan - Write-Host "Error Code: $($errorResponse.error_code)" -ForegroundColor Gray - } - return $errorResponse - } catch { - if (-not $Silent) { - Write-Host "Raw Response: $responseBody" -ForegroundColor Gray - } - } - } - } - return $null - } -} - -# Clear throttle first -Write-Host "`n🔄 Clearing throttle records..." -ForegroundColor Blue -try { - Invoke-RestMethod -Uri "$BaseUrl/auth/debug-clear-throttle" -Method POST | Out-Null - Write-Host "✅ Throttle cleared" -ForegroundColor Green -} catch { - Write-Host "⚠️ Could not clear throttle" -ForegroundColor Yellow -} - -# Test Results Tracking -$testResults = @{ - AppStatus = $false - BasicAPI = $false - EmailConflict = $false - VerificationCodeLogin = $false - CooldownClearing = $false - ThrottleProtection = $false - PasswordReset = $false -} - -Write-Host "`n" + "="*60 -ForegroundColor Cyan -Write-Host "🧪 Test Suite 0: Application Status" -ForegroundColor Cyan -Write-Host "="*60 -ForegroundColor Cyan - -# Test application status -$result0 = Test-ApiCall -TestName "Check application status" -Url "$BaseUrl" -Method "GET" -Body "" - -if ($result0 -and $result0.service -eq "Pixel Game Server") { - $testResults.AppStatus = $true - Write-Host "✅ PASS: Application is running" -ForegroundColor Green - Write-Host " Service: $($result0.service)" -ForegroundColor Cyan - Write-Host " Version: $($result0.version)" -ForegroundColor Cyan - Write-Host " Environment: $($result0.environment)" -ForegroundColor Cyan -} else { - Write-Host "❌ FAIL: Application status check failed" -ForegroundColor Red -} - -Write-Host "`n" + "="*60 -ForegroundColor Cyan -Write-Host "🧪 Test Suite 1: Basic API Functionality" -ForegroundColor Cyan -Write-Host "="*60 -ForegroundColor Cyan - -# Generate unique test data -$testEmail = "comprehensive_test_$(Get-Random)@example.com" -$testUsername = "comp_test_$(Get-Random)" - -# Test 1: Send verification code -$result1 = Test-ApiCall -TestName "Send email verification code" -Url "$BaseUrl/auth/send-email-verification" -Body (@{ - email = $testEmail -} | ConvertTo-Json) - -if ($result1 -and $result1.data.verification_code) { - $verificationCode = $result1.data.verification_code - Write-Host "Got verification code: $verificationCode" -ForegroundColor Green - - # Test 2: Register user - $result2 = Test-ApiCall -TestName "Register new user" -Url "$BaseUrl/auth/register" -Body (@{ - username = $testUsername - password = "password123" - nickname = "Comprehensive Test User" - email = $testEmail - email_verification_code = $verificationCode - } | ConvertTo-Json) - - if ($result2 -and $result2.success) { - # Test 3: Login user - $result3 = Test-ApiCall -TestName "Login with registered user" -Url "$BaseUrl/auth/login" -Body (@{ - identifier = $testUsername - password = "password123" - } | ConvertTo-Json) - - if ($result3 -and $result3.success) { - $testResults.BasicAPI = $true - Write-Host "✅ PASS: Basic API functionality working" -ForegroundColor Green - } - } -} - -Write-Host "`n" + "="*60 -ForegroundColor Cyan -Write-Host "🧪 Test Suite 2: Email Conflict Detection" -ForegroundColor Cyan -Write-Host "="*60 -ForegroundColor Cyan - -# Test email conflict detection -$result4 = Test-ApiCall -TestName "Test email conflict detection" -Url "$BaseUrl/auth/send-email-verification" -Body (@{ - email = $testEmail -} | ConvertTo-Json) -ExpectedStatus 409 - -if ($result4 -and $result4.message -like "*已被注册*") { - $testResults.EmailConflict = $true - Write-Host "✅ PASS: Email conflict detection working" -ForegroundColor Green -} else { - Write-Host "❌ FAIL: Email conflict detection not working" -ForegroundColor Red -} - -Write-Host "`n" + "="*60 -ForegroundColor Cyan -Write-Host "🧪 Test Suite 3: Verification Code Login" -ForegroundColor Cyan -Write-Host "="*60 -ForegroundColor Cyan - -# Test verification code login -if ($result2 -and $result2.success) { - $userEmail = $result2.data.user.email - - # Send login verification code - $result4a = Test-ApiCall -TestName "Send login verification code" -Url "$BaseUrl/auth/send-login-verification-code" -Body (@{ - identifier = $userEmail - } | ConvertTo-Json) - - if ($result4a -and $result4a.data.verification_code) { - $loginCode = $result4a.data.verification_code - - # Login with verification code - $result4b = Test-ApiCall -TestName "Login with verification code" -Url "$BaseUrl/auth/verification-code-login" -Body (@{ - identifier = $userEmail - verification_code = $loginCode - } | ConvertTo-Json) - - if ($result4b -and $result4b.success) { - $testResults.VerificationCodeLogin = $true - Write-Host "✅ PASS: Verification code login working" -ForegroundColor Green - } else { - Write-Host "❌ FAIL: Verification code login failed" -ForegroundColor Red - } - } -} - -if (-not $SkipCooldownTest) { - Write-Host "`n" + "="*60 -ForegroundColor Cyan - Write-Host "🧪 Test Suite 4: Cooldown Clearing & Password Reset" -ForegroundColor Cyan - Write-Host "="*60 -ForegroundColor Cyan - - # Test cooldown clearing with password reset - if ($result2 -and $result2.success) { - $userEmail = $result2.data.user.email - - # Send password reset code - $result5 = Test-ApiCall -TestName "Send password reset code" -Url "$BaseUrl/auth/forgot-password" -Body (@{ - identifier = $userEmail - } | ConvertTo-Json) - - if ($result5 -and $result5.data.verification_code) { - $resetCode = $result5.data.verification_code - - # Reset password - $result6 = Test-ApiCall -TestName "Reset password (should clear cooldown)" -Url "$BaseUrl/auth/reset-password" -Body (@{ - identifier = $userEmail - verification_code = $resetCode - new_password = "newpassword123" - } | ConvertTo-Json) - - if ($result6 -and $result6.success) { - $testResults.PasswordReset = $true - Write-Host "✅ PASS: Password reset working" -ForegroundColor Green - - # Test immediate code sending (should work if cooldown cleared) - Start-Sleep -Seconds 1 - $result7 = Test-ApiCall -TestName "Send reset code immediately (test cooldown clearing)" -Url "$BaseUrl/auth/forgot-password" -Body (@{ - identifier = $userEmail - } | ConvertTo-Json) - - if ($result7 -and $result7.success) { - $testResults.CooldownClearing = $true - Write-Host "✅ PASS: Cooldown clearing working" -ForegroundColor Green - } else { - Write-Host "❌ FAIL: Cooldown not cleared properly" -ForegroundColor Red - } - } else { - Write-Host "❌ FAIL: Password reset failed" -ForegroundColor Red - } - } - } -} - -if (-not $SkipThrottleTest) { - Write-Host "`n" + "="*60 -ForegroundColor Cyan - Write-Host "🧪 Test Suite 5: Throttle Protection" -ForegroundColor Cyan - Write-Host "="*60 -ForegroundColor Cyan - - $successCount = 0 - $throttleCount = 0 - - Write-Host "Testing throttle limits (making 12 registration requests)..." -ForegroundColor Yellow - - for ($i = 1; $i -le 12; $i++) { - $result = Test-ApiCall -TestName "Registration attempt $i" -Url "$BaseUrl/auth/register" -Body (@{ - username = "throttle_test_$i" - password = "password123" - nickname = "Throttle Test $i" - } | ConvertTo-Json) -Silent - - if ($result -and $result.success) { - $successCount++ - Write-Host " Request $i`: ✅ Success" -ForegroundColor Green - } else { - $throttleCount++ - Write-Host " Request $i`: 🚦 Throttled" -ForegroundColor Yellow - } - - Start-Sleep -Milliseconds 100 - } - - Write-Host "`nThrottle Results: $successCount success, $throttleCount throttled" -ForegroundColor Cyan - - if ($successCount -ge 8 -and $throttleCount -ge 1) { - $testResults.ThrottleProtection = $true - Write-Host "✅ PASS: Throttle protection working" -ForegroundColor Green - } else { - Write-Host "❌ FAIL: Throttle protection not working properly" -ForegroundColor Red - } -} - -Write-Host "`n🎯 Test Results Summary" -ForegroundColor Green -Write-Host "=======================" -ForegroundColor Green - -$passCount = 0 -$totalTests = 0 - -foreach ($test in $testResults.GetEnumerator()) { - $totalTests++ - if ($test.Value) { - $passCount++ - Write-Host "✅ $($test.Key): PASS" -ForegroundColor Green - } else { - Write-Host "❌ $($test.Key): FAIL" -ForegroundColor Red - } -} - -Write-Host "`n📊 Overall Result: $passCount/$totalTests tests passed" -ForegroundColor $(if ($passCount -eq $totalTests) { "Green" } else { "Yellow" }) - -if ($passCount -eq $totalTests) { - Write-Host "🎉 All tests passed! API is working correctly." -ForegroundColor Green -} else { - Write-Host "⚠️ Some tests failed. Please check the implementation." -ForegroundColor Yellow -} - -Write-Host "`n💡 Usage Tips:" -ForegroundColor Cyan -Write-Host " • Use -SkipThrottleTest to skip throttle testing" -ForegroundColor White -Write-Host " • Use -SkipCooldownTest to skip cooldown testing" -ForegroundColor White -Write-Host " • Check server logs for detailed error information" -ForegroundColor White -Write-Host " • For production testing: .\test-comprehensive.ps1 -BaseUrl 'https://your-server.com'" -ForegroundColor White - -Write-Host "`n📋 Test Coverage:" -ForegroundColor Cyan -Write-Host " ✓ Application Status & Health Check" -ForegroundColor White -Write-Host " ✓ User Registration & Login Flow" -ForegroundColor White -Write-Host " ✓ Email Verification & Conflict Detection" -ForegroundColor White -Write-Host " ✓ Verification Code Login" -ForegroundColor White -Write-Host " ✓ Password Reset Flow" -ForegroundColor White -Write-Host " ✓ Cooldown Time Clearing" -ForegroundColor White -Write-Host " ✓ Rate Limiting & Throttle Protection" -ForegroundColor White \ No newline at end of file diff --git a/test/core/db/users.test.ts b/test/core/db/users.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/tsconfig.json b/tsconfig.json index 37fb249..d48d43a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,16 @@ { "compilerOptions": { "target": "ES2020", - "module": "Node16", + "module": "commonjs", "lib": ["ES2020"], - "moduleResolution": "node16", + "moduleResolution": "node", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "strict": true, + "noImplicitAny": false, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, @@ -20,5 +21,5 @@ "typeRoots": ["./node_modules/@types"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "client"] } diff --git a/webhook-handler.js.example b/webhook-handler.js.example deleted file mode 100644 index 3a97c12..0000000 --- a/webhook-handler.js.example +++ /dev/null @@ -1,86 +0,0 @@ -const http = require('http'); -const crypto = require('crypto'); -const { exec } = require('child_process'); - -// 配置 - 复制此文件为 webhook-handler.js 并修改配置 -const PORT = 9000; -const SECRET = 'your_webhook_secret_change_this'; // 与 Gitea 中配置的密钥一致 -const DEPLOY_SCRIPT = '/var/www/pixel-game-server/deploy.sh'; // 修改为实际路径 - -// 验证 Gitea 签名 -function verifySignature(payload, signature, secret) { - const hmac = crypto.createHmac('sha256', secret); - hmac.update(payload); - const calculatedSignature = hmac.digest('hex'); - return crypto.timingSafeEqual( - Buffer.from(signature, 'hex'), - Buffer.from(calculatedSignature, 'hex') - ); -} - -// 创建 HTTP 服务器 -const server = http.createServer((req, res) => { - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'text/plain' }); - res.end('Method Not Allowed'); - return; - } - - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - - req.on('end', () => { - try { - // 验证签名 - const signature = req.headers['x-gitea-signature']; - if (!signature || !verifySignature(body, signature.replace('sha256=', ''), SECRET)) { - console.log('签名验证失败'); - res.writeHead(401, { 'Content-Type': 'text/plain' }); - res.end('Unauthorized'); - return; - } - - const payload = JSON.parse(body); - - // 检查是否是推送到 main 分支 - if (payload.ref === 'refs/heads/main') { - console.log('收到 main 分支推送,开始部署...'); - - // 执行部署脚本 - exec(`bash ${DEPLOY_SCRIPT}`, (error, stdout, stderr) => { - if (error) { - console.error('部署失败:', error); - console.error('stderr:', stderr); - } else { - console.log('部署成功:', stdout); - } - }); - - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Deployment triggered'); - } else { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Not main branch, ignored'); - } - } catch (error) { - console.error('处理 webhook 失败:', error); - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end('Internal Server Error'); - } - }); -}); - -server.listen(PORT, () => { - console.log(`Webhook 处理器运行在端口 ${PORT}`); -}); - -// 优雅关闭 -process.on('SIGTERM', () => { - console.log('收到 SIGTERM,正在关闭服务器...'); - server.close(() => { - console.log('服务器已关闭'); - process.exit(0); - }); -}); \ No newline at end of file