forked from datawhale/whale-town-end
Compare commits
82 Commits
d4a7b36129
...
zulip_dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b380e4bb9 | |||
|
|
8f9a6e7f9d | ||
| 07d9c736fa | |||
| 5e1afc2875 | |||
|
|
3733717d1f | ||
|
|
470b0b8dbf | ||
| c2ecb3c1a7 | |||
|
|
6ad8d80449 | ||
| fcb81f80d9 | |||
| 065d3f2fc6 | |||
|
|
f335b72f6d | ||
|
|
3bf1b6f474 | ||
|
|
38f9f81b6c | ||
|
|
4818279fac | ||
|
|
270e7e5bd2 | ||
|
|
e282c9dd16 | ||
|
|
d8b7143f60 | ||
|
|
6002f53cbc | ||
| 9cb172d645 | |||
|
|
70c020a97c | ||
| 67ade48ad7 | |||
|
|
29b8b05a2a | ||
| bbf3476d75 | |||
|
|
faf93a30e1 | ||
|
|
2d10131838 | ||
|
|
5140bd1a54 | ||
|
|
3dd5f23d79 | ||
|
|
daaf5c3f22 | ||
|
|
55cfda0532 | ||
| dd856b9ba6 | |||
|
|
07601b6d79 | ||
|
|
7429de3cf4 | ||
|
|
0192934c66 | ||
|
|
64370c3206 | ||
|
|
a78df48101 | ||
|
|
0005dc773c | ||
|
|
946d328be6 | ||
|
|
841a58886e | ||
|
|
91565f716d | ||
| 417b01323e | |||
| b3de6dec5f | |||
|
|
d683f0d5da | ||
|
|
aae77866ac | ||
|
|
8a19bb7daa | ||
| a8e29c6a46 | |||
|
|
9f606abbb2 | ||
|
|
7385c63ffd | ||
| 8d5a44d985 | |||
|
|
d59e9531e2 | ||
| 28a39935b7 | |||
|
|
68debdcb40 | ||
|
|
9ad98f74d9 | ||
|
|
f6fa1ca1e3 | ||
| 578cba39a7 | |||
|
|
404ef5d3e0 | ||
| e537e782a9 | |||
|
|
cb25703892 | ||
| 64230db651 | |||
|
|
612755de63 | ||
|
|
e6d8c28806 | ||
|
|
47a738067a | ||
|
|
85d488a508 | ||
|
|
032c97a1fc | ||
| 0313b78852 | |||
|
|
d80d2c5cb8 | ||
| 2fb46967c7 | |||
|
|
9b35a1c500 | ||
|
|
43c9cbc863 | ||
|
|
a4a3a60db7 | ||
|
|
8166c95af4 | ||
|
|
ec2e346ded | ||
|
|
dd4fb6edd3 | ||
| 17c16588aa | |||
| 8fc2b53c00 | |||
| 4e2f46223e | |||
| f079a80b66 | |||
| bc16187fe0 | |||
|
|
b99b77e08b | ||
| 7afd9a52fa | |||
|
|
7924cfb201 | ||
| d322db242d | |||
|
|
11387f7046 |
133
.env.example
133
.env.example
@@ -1,30 +1,64 @@
|
||||
# 环境配置模板
|
||||
# 复制此文件为 .env 并根据需要修改配置
|
||||
|
||||
# 数据库配置
|
||||
# DB_HOST=localhost
|
||||
# ===========================================
|
||||
# 测试模式配置(开发/测试环境推荐)
|
||||
# ===========================================
|
||||
# 使用以下配置可以在没有数据库和邮件服务器的情况下进行测试
|
||||
# 1. 复制此文件为 .env
|
||||
# 2. 保持数据库和邮件配置为注释状态
|
||||
# 3. 运行 npm run dev 启动服务
|
||||
# 4. 运行测试脚本:./test-api.ps1 (Windows) 或 ./test-api.sh (Linux/macOS)
|
||||
|
||||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# 管理员后台配置(开发环境推荐配置)
|
||||
# ===========================================
|
||||
# 管理员Token签名密钥(至少16字符,生产环境务必使用强随机值)
|
||||
ADMIN_TOKEN_SECRET=dev_admin_token_secret_change_me_32chars
|
||||
# 管理员Token有效期(秒),默认8小时
|
||||
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||
|
||||
# 启动引导创建管理员账号(仅当 enabled=true 时生效)
|
||||
ADMIN_BOOTSTRAP_ENABLED=false
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=Admin123456
|
||||
# ADMIN_NICKNAME=管理员
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=test_jwt_secret_key_for_development_only_32chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Redis 配置(测试模式:使用文件存储)
|
||||
USE_FILE_REDIS=true
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ===========================================
|
||||
# 生产环境配置(取消注释并填入真实数据)
|
||||
# ===========================================
|
||||
|
||||
# 数据库配置(生产环境取消注释)
|
||||
# DB_HOST=your_mysql_host
|
||||
# DB_PORT=3306
|
||||
# DB_USERNAME=your_db_username
|
||||
# DB_PASSWORD=your_db_password
|
||||
# DB_NAME=your_db_name
|
||||
|
||||
# 应用配置
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
# Redis 配置(生产环境取消注释并设置 USE_FILE_REDIS=false)
|
||||
# USE_FILE_REDIS=false
|
||||
# REDIS_HOST=your_redis_host
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=your_redis_password
|
||||
# REDIS_DB=0
|
||||
|
||||
# JWT 配置(如果有的话)
|
||||
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Redis 配置(用于验证码存储)
|
||||
# 生产环境使用真实Redis服务
|
||||
USE_FILE_REDIS=false
|
||||
REDIS_HOST=your_redis_host
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
REDIS_DB=0
|
||||
|
||||
# 邮件服务配置
|
||||
# 邮件服务配置(生产环境取消注释)
|
||||
# EMAIL_HOST=smtp.gmail.com
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_SECURE=false
|
||||
@@ -32,5 +66,62 @@ REDIS_DB=0
|
||||
# EMAIL_PASS=your_app_password
|
||||
# EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||
|
||||
# 其他配置
|
||||
# 根据项目需要添加其他环境变量
|
||||
# 生产环境设置(生产环境取消注释)
|
||||
# NODE_ENV=production
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# ===========================================
|
||||
# Zulip 集成配置
|
||||
# ===========================================
|
||||
|
||||
# Zulip 服务器配置
|
||||
ZULIP_SERVER_URL=https://zulip.xinghangee.icu/
|
||||
ZULIP_BOT_EMAIL=cbot-bot@zulip.xinghangee.icu
|
||||
ZULIP_BOT_API_KEY=your_bot_api_key
|
||||
|
||||
# Zulip API Key加密密钥(生产环境必须配置,至少32字符)
|
||||
# ZULIP_API_KEY_ENCRYPTION_KEY=your_32_character_encryption_key_here
|
||||
|
||||
# Zulip 错误处理配置
|
||||
ZULIP_DEGRADED_MODE_ENABLED=false
|
||||
ZULIP_AUTO_RECONNECT_ENABLED=true
|
||||
ZULIP_MAX_RECONNECT_ATTEMPTS=5
|
||||
ZULIP_RECONNECT_BASE_DELAY=5000
|
||||
ZULIP_API_TIMEOUT=30000
|
||||
ZULIP_MAX_RETRIES=3
|
||||
|
||||
# Zulip 连接限制配置
|
||||
ZULIP_MAX_CONNECTIONS=100
|
||||
ZULIP_SESSION_TIMEOUT=30
|
||||
ZULIP_CLEANUP_INTERVAL=5
|
||||
|
||||
# Zulip 消息配置
|
||||
ZULIP_MESSAGE_RATE_LIMIT=10
|
||||
ZULIP_MESSAGE_MAX_LENGTH=10000
|
||||
ZULIP_CONTENT_FILTER_ENABLED=true
|
||||
# ZULIP_SENSITIVE_WORDS_PATH=config/zulip/sensitive-words.txt
|
||||
|
||||
# Zulip 允许的Stream列表(逗号分隔,空表示允许所有)
|
||||
# ZULIP_ALLOWED_STREAMS=General,Novice Village,Tavern
|
||||
|
||||
# WebSocket配置
|
||||
# WEBSOCKET_PORT=3000
|
||||
# WEBSOCKET_NAMESPACE=/game
|
||||
# WEBSOCKET_PING_INTERVAL=25000
|
||||
# WEBSOCKET_PING_TIMEOUT=5000
|
||||
|
||||
# ===========================================
|
||||
# 监控配置
|
||||
# ===========================================
|
||||
|
||||
# 健康检查间隔(毫秒)
|
||||
MONITORING_HEALTH_CHECK_INTERVAL=60000
|
||||
|
||||
# 错误率阈值(0-1)
|
||||
MONITORING_ERROR_RATE_THRESHOLD=0.1
|
||||
|
||||
# API响应时间阈值(毫秒)
|
||||
MONITORING_RESPONSE_TIME_THRESHOLD=5000
|
||||
|
||||
# 内存使用阈值(0-1)
|
||||
MONITORING_MEMORY_THRESHOLD=0.9
|
||||
@@ -16,6 +16,16 @@ PORT=3000
|
||||
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 管理员后台配置(生产环境必须配置)
|
||||
ADMIN_TOKEN_SECRET=please_use_a_strong_random_secret_at_least_32_chars
|
||||
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||
|
||||
# 启动引导创建管理员账号(建议仅首次部署临时开启,创建后关闭)
|
||||
ADMIN_BOOTSTRAP_ENABLED=false
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=please_set_a_strong_password
|
||||
# ADMIN_NICKNAME=管理员
|
||||
|
||||
# Redis 配置(用于验证码存储)
|
||||
# 生产环境使用真实Redis服务
|
||||
USE_FILE_REDIS=false
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ coverage/
|
||||
|
||||
# Redis数据文件(本地开发用)
|
||||
redis-data/
|
||||
|
||||
.kiro/
|
||||
216
DEPLOYMENT.md
216
DEPLOYMENT.md
@@ -1,216 +0,0 @@
|
||||
# 部署指南
|
||||
|
||||
本文档详细说明如何部署 Pixel Game Server 到生产环境。
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 包管理器
|
||||
- MySQL 8.0+
|
||||
- PM2 进程管理器(推荐)
|
||||
- Nginx(可选,用于反向代理)
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 服务器环境准备
|
||||
|
||||
```bash
|
||||
# 安装 Node.js (使用 NodeSource 仓库)
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# 安装 pnpm
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh
|
||||
source ~/.bashrc
|
||||
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 安装 MySQL
|
||||
sudo apt update
|
||||
sudo apt install mysql-server
|
||||
sudo mysql_secure_installation
|
||||
```
|
||||
|
||||
### 2. 克隆项目
|
||||
|
||||
```bash
|
||||
# 创建项目目录
|
||||
sudo mkdir -p /var/www
|
||||
cd /var/www
|
||||
|
||||
# 克隆项目(替换为你的实际仓库地址)
|
||||
git clone https://gitea.xinghangee.icu/datawhale/whale-town-end.git
|
||||
cd whale-town-end
|
||||
```
|
||||
|
||||
### 3. 配置环境
|
||||
|
||||
```bash
|
||||
# 复制环境配置文件
|
||||
cp .env.production.example .env.production
|
||||
|
||||
# 编辑环境配置(填入实际的数据库信息)
|
||||
nano .env.production
|
||||
|
||||
# 复制部署脚本
|
||||
cp deploy.sh.example deploy.sh
|
||||
chmod +x deploy.sh
|
||||
|
||||
# 编辑部署脚本(修改路径配置)
|
||||
nano deploy.sh
|
||||
|
||||
# 复制 webhook 处理器
|
||||
cp webhook-handler.js.example webhook-handler.js
|
||||
|
||||
# 编辑 webhook 处理器(修改密钥和路径)
|
||||
nano webhook-handler.js
|
||||
```
|
||||
|
||||
### 4. 数据库设置
|
||||
|
||||
```bash
|
||||
# 登录 MySQL
|
||||
sudo mysql -u root -p
|
||||
|
||||
# 创建数据库和用户
|
||||
CREATE DATABASE pixel_game_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'pixel_game'@'localhost' IDENTIFIED BY 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON pixel_game_db.* TO 'pixel_game'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
### 5. 安装依赖和构建
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 构建项目
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### 6. 启动服务
|
||||
|
||||
```bash
|
||||
# 使用 PM2 启动应用
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# 保存 PM2 配置
|
||||
pm2 save
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
# 按照提示执行显示的命令
|
||||
```
|
||||
|
||||
### 7. 配置 Nginx(可选)
|
||||
|
||||
创建 Nginx 配置文件:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/whale-town-end
|
||||
```
|
||||
|
||||
添加以下内容:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /webhook {
|
||||
proxy_pass http://localhost:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用站点:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/whale-town-end /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Gitea Webhook 配置
|
||||
|
||||
1. 在 Gitea 仓库中进入 **Settings** → **Webhooks**
|
||||
2. 点击 **Add Webhook** → **Gitea**
|
||||
3. 配置:
|
||||
- **Target URL**: `http://your-server.com:9000/webhook` 或 `http://your-domain.com/webhook`
|
||||
- **HTTP Method**: `POST`
|
||||
- **POST Content Type**: `application/json`
|
||||
- **Secret**: 与 `webhook-handler.js` 中的 `SECRET` 一致
|
||||
- **Trigger On**: 选择 `Push events`
|
||||
- **Branch filter**: `main`
|
||||
|
||||
## 验证部署
|
||||
|
||||
```bash
|
||||
# 检查服务状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs whale-town-end
|
||||
pm2 logs webhook-handler
|
||||
|
||||
# 测试 API
|
||||
curl http://localhost:3000/
|
||||
curl http://localhost:3000/api-docs
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
pm2 restart whale-town-end
|
||||
|
||||
# 查看日志
|
||||
pm2 logs whale-town-end --lines 100
|
||||
|
||||
# 手动部署
|
||||
bash deploy.sh
|
||||
|
||||
# 更新代码(不重启)
|
||||
git pull origin main
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pm2 reload whale-town-end
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 服务无法启动
|
||||
- 检查环境变量配置
|
||||
- 检查数据库连接
|
||||
- 查看 PM2 日志
|
||||
|
||||
### Webhook 不工作
|
||||
- 检查防火墙设置
|
||||
- 验证 webhook URL 可访问性
|
||||
- 检查 Gitea webhook 日志
|
||||
- 验证签名密钥是否一致
|
||||
|
||||
### 数据库连接失败
|
||||
- 检查 MySQL 服务状态
|
||||
- 验证数据库用户权限
|
||||
- 检查网络连接
|
||||
31
Dockerfile
31
Dockerfile
@@ -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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Whale Town Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
757
README.md
757
README.md
@@ -1,341 +1,470 @@
|
||||
# Pixel Game Server
|
||||
# 🐋 Whale Town - 像素游戏后端服务
|
||||
|
||||
一个基于 NestJS 的 2D 像素风游戏后端服务
|
||||
> 一个基于 NestJS 的现代化 2D 像素风游戏后端服务,采用业务功能模块化架构,支持用户认证、管理员后台、安全防护等完整功能。
|
||||
|
||||
[](https://nodejs.org/)
|
||||
[](https://nestjs.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](./LICENSE)
|
||||
|
||||
## 🎯 项目简介
|
||||
|
||||
Whale Town 是一个功能完整的像素游戏后端服务,采用业务功能模块化架构设计:
|
||||
|
||||
- 🔐 **用户认证模块** - 完整的登录、注册、密码管理、邮箱验证系统
|
||||
- 👥 **用户管理模块** - 用户状态管理、批量操作、状态统计功能
|
||||
- 🛡️ **管理员模块** - 管理员认证、用户管理、密码重置、日志查看
|
||||
- 🔒 **安全模块** - 频率限制、维护模式、超时控制、内容类型检查
|
||||
- 📧 **智能邮件服务** - 支持测试模式和生产模式自动切换
|
||||
- 🗄️ **灵活存储方案** - Redis文件存储 + 内存数据库,支持无依赖测试
|
||||
- 📚 **完整API文档** - Swagger UI + OpenAPI规范,17个接口完整覆盖
|
||||
- 🧪 **全面测试覆盖** - 140个单元测试用例全部通过
|
||||
|
||||
---
|
||||
|
||||
## 🚨 开发者必读警告
|
||||
## 🚀 快速开始
|
||||
|
||||
**⚠️ 在开始任何开发工作之前,请务必阅读:[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)**
|
||||
### 📋 环境要求
|
||||
|
||||
**📢 重要提醒:**
|
||||
- 🚫 **未阅读 AI 辅助指南的代码将无法通过审查**
|
||||
- 🤖 **学会使用 AI 助手可以让你的开发效率提升 300%**
|
||||
- 📝 **AI 可以帮你自动生成符合规范的代码和注释**
|
||||
- 🔍 **AI 可以实时检查你的代码质量**
|
||||
- **Node.js** >= 18.0.0 (推荐 24.7.0)
|
||||
- **pnpm** >= 8.0.0 (推荐 10.25.0)
|
||||
|
||||
**👉 [立即阅读 AI 辅助开发指南](./docs/AI辅助开发规范指南.md)**
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **NestJS** `^10.4.20` - 渐进式 Node.js 框架
|
||||
- **TypeScript** `^5.9.3` - 类型安全
|
||||
- **Socket.IO** - WebSocket 实时通信支持
|
||||
- **RxJS** `^7.8.2` - 响应式编程库
|
||||
- **Pino** `^10.1.0` - 高性能日志库
|
||||
- **Jest** `^29.7.0` - 测试框架
|
||||
|
||||
### 核心依赖
|
||||
|
||||
**生产环境:**
|
||||
- `@nestjs/common` `^10.4.20` - NestJS 核心功能
|
||||
- `@nestjs/core` `^10.4.20` - NestJS 核心模块
|
||||
- `@nestjs/config` `^4.0.2` - 配置管理
|
||||
- `@nestjs/platform-express` `^10.4.20` - Express 平台适配器
|
||||
- `@nestjs/websockets` `^10.4.20` - WebSocket 支持
|
||||
- `@nestjs/platform-socket.io` `^10.4.20` - Socket.IO 适配器
|
||||
- `nestjs-pino` `^4.5.0` - Pino 日志集成
|
||||
- `pino` `^10.1.0` - 高性能日志库
|
||||
- `reflect-metadata` `^0.1.14` - 装饰器元数据支持
|
||||
- `rxjs` `^7.8.2` - 响应式编程
|
||||
|
||||
**开发环境:**
|
||||
- `@nestjs/cli` `^10.4.9` - NestJS 命令行工具
|
||||
- `@nestjs/schematics` `^10.2.3` - NestJS 代码生成器
|
||||
- `@nestjs/testing` `^10.4.20` - 测试工具
|
||||
- `@types/jest` `^29.5.14` - Jest 类型定义
|
||||
- `@types/node` `^20.19.26` - Node.js 类型定义
|
||||
- `jest` `^29.7.0` - 测试框架
|
||||
- `ts-jest` `^29.2.5` - TypeScript Jest 支持
|
||||
- `ts-node` `^10.9.2` - TypeScript 运行时
|
||||
- `typescript` `^5.9.3` - TypeScript 编译器
|
||||
- `pino-pretty` `^13.1.3` - Pino 美化输出
|
||||
|
||||
## 🚨 重要:开发前必读
|
||||
|
||||
### ⚠️ 所有开发者必须先阅读 AI 辅助开发指南
|
||||
|
||||
**在开始任何开发工作之前,请务必阅读:[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)**
|
||||
|
||||
这个指南将教你如何:
|
||||
- 🤖 使用 AI 助手遵循项目规范
|
||||
- 📝 自动生成规范的代码和注释
|
||||
- 🔍 实时检查代码质量
|
||||
- 🚀 显著提高开发效率和代码质量
|
||||
|
||||
**不阅读此指南直接开发,代码审查将无法通过!**
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 命名规范
|
||||
|
||||
项目采用统一的命名规范,确保代码风格一致:
|
||||
|
||||
- **文件/文件夹**:下划线分隔(如 `order_controller.ts`)
|
||||
- **变量/函数**:小驼峰命名(如 `userName`、`async queryUserInfo()`)
|
||||
- **类/构造函数**:大驼峰命名(如 `UserModel`、`OrderService`)
|
||||
- **常量**:全大写 + 下划线(如 `PORT`、`DB_HOST`)
|
||||
- **接口路由**:全小写 + 短横线(如 `/user/get-info`、`/order/create-order`)
|
||||
|
||||
详细规范请查看:[命名规范文档](./docs/naming_convention.md)
|
||||
|
||||
### Git 提交规范
|
||||
|
||||
项目采用约定式提交规范,提交信息格式:`<类型>:<简短描述>`
|
||||
|
||||
**常用提交类型:**
|
||||
|
||||
- `feat` - 新增功能
|
||||
- `fix` - 修复 Bug
|
||||
- `docs` - 文档更新
|
||||
- `style` - 代码格式调整
|
||||
- `refactor` - 代码重构
|
||||
- `perf` - 性能优化
|
||||
- `test` - 测试相关
|
||||
- `chore` - 构建/工具变动
|
||||
|
||||
**后端特定类型:**
|
||||
|
||||
- `api` - API 接口
|
||||
- `db` - 数据库
|
||||
- `websocket` - WebSocket
|
||||
- `auth` - 认证授权
|
||||
- `dto` - 数据传输对象
|
||||
- `service` - 服务层
|
||||
|
||||
**核心原则:**
|
||||
|
||||
- ⭐ 一次提交只做一件事
|
||||
- 使用中文冒号 `:`
|
||||
- 简短明确(不超过 50 字符)
|
||||
- 能拆分就拆分,保持提交历史清晰
|
||||
|
||||
**示例:**
|
||||
### 🛠️ 安装与运行
|
||||
|
||||
```bash
|
||||
git commit -m "feat:实现玩家注册和登录功能"
|
||||
git commit -m "fix:修复房间加入时的并发问题"
|
||||
git commit -m "api:添加玩家信息查询接口"
|
||||
```
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd whale-town-end
|
||||
|
||||
详细规范请查看:[Git 提交规范文档](./docs/git_commit_guide.md)
|
||||
|
||||
### 后端开发规范
|
||||
|
||||
项目要求严格的代码质量和可维护性标准:
|
||||
|
||||
**核心要求:**
|
||||
|
||||
- **完整注释**:每个模块、类、方法都必须有详细注释
|
||||
- **全面业务逻辑**:考虑所有可能情况,包括异常和边界条件
|
||||
- **关键日志记录**:重要操作必须记录日志,便于问题排查
|
||||
- **防御性编程**:对所有输入进行验证,实现健壮的错误处理
|
||||
|
||||
**注释要求:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 玩家服务类
|
||||
*
|
||||
* 职责:处理玩家相关的业务逻辑
|
||||
* 主要方法:createPlayer(), updatePlayerInfo(), getPlayerById()
|
||||
* 使用场景:玩家注册登录流程、个人陈列室数据管理
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlayerService {
|
||||
/**
|
||||
* 创建新玩家
|
||||
* @param email 玩家邮箱地址
|
||||
* @param nickname 玩家昵称
|
||||
* @returns Promise<Player> 创建成功的玩家对象
|
||||
* @throws BadRequestException 当邮箱格式错误时
|
||||
*/
|
||||
async createPlayer(email: string, nickname: string): Promise<Player> {
|
||||
// 详细的业务逻辑实现...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
详细规范请查看:[后端开发规范指南](./docs/backend_development_guide.md)
|
||||
|
||||
## 📚 开发文档
|
||||
|
||||
### 🔥 必读文档
|
||||
- **[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)** - 🚨 **所有开发者必读!** 教你如何使用 AI 遵循项目规范
|
||||
|
||||
### 📋 规范文档
|
||||
- [后端开发规范](./docs/backend_development_guide.md) - 注释标准、业务逻辑设计和日志记录要求
|
||||
- [Git 提交规范](./docs/git_commit_guide.md) - Git 提交信息格式和最佳实践
|
||||
- [命名规范](./docs/naming_convention.md) - 项目命名规范和最佳实践
|
||||
- [NestJS 使用指南](./docs/nestjs_guide.md) - 详细的 NestJS 开发指南,包含实战案例
|
||||
|
||||
### 📖 API 文档
|
||||
- **[API 文档总览](./docs/api/README.md)** - API 文档使用指南和快速开始
|
||||
- **[Swagger UI](http://localhost:3000/api-docs)** - 交互式 API 文档(需启动服务器)
|
||||
- [详细接口文档](./docs/api/api-documentation.md) - 完整的 API 接口说明
|
||||
- [OpenAPI 规范](./docs/api/openapi.yaml) - 标准化的 API 描述文件
|
||||
- [Postman 集合](./docs/api/postman-collection.json) - 可导入的 API 测试集合
|
||||
|
||||
### 💡 使用建议
|
||||
1. **开发前**:先读 AI 辅助指南,了解如何用 AI 帮助遵循规范
|
||||
2. **开发中**:参考具体规范文档,使用 AI 实时检查代码质量
|
||||
3. **API 开发**:使用 Swagger UI 进行接口测试,参考 API 文档进行开发
|
||||
4. **提交前**:用 AI 检查代码和提交信息是否符合规范
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js** >= 18.0.0 默认:24.7.0
|
||||
- **pnpm** >= 8.0.0(推荐)默认:10.25.0
|
||||
|
||||
如果还没有安装 pnpm,请先安装:
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
```
|
||||
|
||||
检查版本:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
# 2. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 配置环境(测试模式,无需数据库和邮件服务器)
|
||||
cp .env.example .env
|
||||
|
||||
# 4. 启动开发服务器
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## 开发
|
||||
🎉 **服务启动成功!** 访问 http://localhost:3000
|
||||
|
||||
启动开发服务器(支持热重载):
|
||||
### 🧑💻 前端管理界面
|
||||
|
||||
项目包含一个功能完整的前端管理界面,位于 `client/` 目录:
|
||||
|
||||
**🎛️ 核心功能:**
|
||||
- 管理员身份认证(独立Token系统)
|
||||
- 用户列表管理与搜索
|
||||
- 用户密码重置功能
|
||||
- 运行时日志查看与下载
|
||||
- 响应式界面设计
|
||||
|
||||
**🚀 快速启动:**
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
服务器将运行在 `http://localhost:3000`
|
||||
|
||||
## 测试
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
运行测试并监听文件变化:
|
||||
|
||||
```bash
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
运行测试并生成覆盖率报告:
|
||||
|
||||
```bash
|
||||
pnpm test:cov
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 生产环境运行
|
||||
|
||||
```bash
|
||||
pnpm start:prod
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 接口层(控制器、网关)
|
||||
├── business/ # 业务逻辑层
|
||||
├── core/ # 核心功能模块
|
||||
│ ├── db/ # 数据库相关
|
||||
│ └── utils/ # 工具函数
|
||||
│ └── logger/ # 日志系统
|
||||
├── main.ts # 应用入口
|
||||
├── app.module.ts # 根模块
|
||||
├── app.controller.ts # 根控制器
|
||||
└── app.service.ts # 根服务
|
||||
test/
|
||||
├── api/ # API 测试
|
||||
└── service/ # 服务测试
|
||||
docs/ # 项目文档
|
||||
├── api/ # API 接口文档
|
||||
│ ├── README.md # API 文档使用指南
|
||||
│ ├── api-documentation.md # 详细接口文档
|
||||
│ ├── openapi.yaml # OpenAPI 规范文件
|
||||
│ └── postman-collection.json # Postman 测试集合
|
||||
├── systems/ # 系统设计文档
|
||||
│ ├── logger/ # 日志系统文档
|
||||
│ └── user-auth/ # 用户认证系统文档
|
||||
├── backend_development_guide.md # 后端开发规范
|
||||
├── git_commit_guide.md # Git 提交规范
|
||||
├── naming_convention.md # 命名规范
|
||||
├── nestjs_guide.md # NestJS 使用指南
|
||||
└── AI辅助开发规范指南.md # AI 辅助开发指南
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 🔐 用户认证系统
|
||||
|
||||
完整的用户认证解决方案,支持多种登录方式和安全特性:
|
||||
|
||||
- 用户名/邮箱/手机号登录
|
||||
- GitHub OAuth 第三方登录
|
||||
- 密码重置和修改功能
|
||||
- bcrypt 密码加密
|
||||
- 基于角色的权限控制
|
||||
|
||||
**详细文档**: [用户认证系统文档](./docs/systems/user-auth/README.md)
|
||||
|
||||
### 📖 API 文档系统
|
||||
|
||||
集成了完整的 API 文档解决方案,提供多种格式的接口文档:
|
||||
|
||||
- **Swagger UI** - 交互式 API 文档界面
|
||||
- **OpenAPI 规范** - 标准化的 API 描述文件
|
||||
- **Postman 集合** - 可导入的 API 测试集合
|
||||
- **详细文档** - 包含示例和最佳实践的完整说明
|
||||
|
||||
**快速访问**:
|
||||
```bash
|
||||
# 启动服务器
|
||||
# 1. 启动后端服务
|
||||
pnpm run dev
|
||||
|
||||
# 访问 Swagger UI 文档
|
||||
# 浏览器打开: http://localhost:3000/api-docs
|
||||
# 2. 启动前端管理界面
|
||||
cd client
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
|
||||
# 3. 访问管理后台
|
||||
# 地址: http://localhost:5173
|
||||
# 默认账号: admin / Admin123456
|
||||
```
|
||||
|
||||
**详细文档**: [API 文档说明](./docs/api/README.md)
|
||||
### 🧪 快速测试
|
||||
|
||||
### 📊 日志系统
|
||||
```bash
|
||||
# 运行综合测试(推荐)
|
||||
.\test-comprehensive.ps1
|
||||
|
||||
基于 Pino 的高性能日志系统,提供结构化日志记录:
|
||||
# 跳过限流测试(更快)
|
||||
.\test-comprehensive.ps1 -SkipThrottleTest
|
||||
|
||||
- 高性能日志记录
|
||||
- 自动敏感信息过滤
|
||||
- 多级别日志控制
|
||||
- 请求上下文绑定
|
||||
# 测试远程服务器
|
||||
.\test-comprehensive.ps1 -BaseUrl "https://your-server.com"
|
||||
```
|
||||
|
||||
**详细文档**: [日志系统文档](./docs/systems/logger/README.md)
|
||||
**测试内容:**
|
||||
- ✅ 应用状态检查
|
||||
- ✅ 邮箱验证码发送与验证
|
||||
- ✅ 用户注册与登录
|
||||
- ✅ 验证码登录功能
|
||||
- ✅ 密码重置流程
|
||||
- ✅ 邮箱冲突检测
|
||||
- ✅ 验证码冷却时间清除
|
||||
- ✅ 限流保护机制
|
||||
- ✅ Redis文件存储功能
|
||||
- ✅ 邮件测试模式
|
||||
|
||||
**💡 提示:使用 [AI 辅助开发指南](./docs/AI辅助开发规范指南.md) 可以让 AI 帮你自动生成符合规范的代码!**
|
||||
---
|
||||
|
||||
## 下一步
|
||||
## 🎓 新开发者指南
|
||||
|
||||
- 在 `src/api/` 目录下创建游戏相关的控制器和网关
|
||||
- 在 `src/model/` 目录下定义游戏数据模型
|
||||
- 在 `src/service/` 目录下实现游戏业务逻辑
|
||||
- 使用 NestJS CLI 快速生成模块:`nest g module game`
|
||||
- 添加 WebSocket 网关实现实时游戏逻辑
|
||||
### 第一步:了解项目规范 📚
|
||||
|
||||
**⚠️ 重要:在开始开发前,请务必阅读以下文档**
|
||||
|
||||
1. **[AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)** 🤖
|
||||
- 学会使用AI助手提升开发效率300%
|
||||
- 自动生成符合规范的代码和注释
|
||||
- 实时检查代码质量
|
||||
|
||||
2. **[后端开发规范](./docs/backend_development_guide.md)** 📝
|
||||
- 代码注释标准
|
||||
- 业务逻辑设计原则
|
||||
- 日志记录要求
|
||||
|
||||
3. **[Git提交规范](./docs/git_commit_guide.md)** 🔄
|
||||
- 提交信息格式
|
||||
- 分支管理策略
|
||||
|
||||
### 第二步:熟悉项目架构 🏗️
|
||||
|
||||
**📁 项目文件结构总览**
|
||||
|
||||
```
|
||||
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 # 📖 项目主文档(当前文件)
|
||||
```
|
||||
|
||||
**架构特点:**
|
||||
- 🏗️ **业务功能模块化** - 按业务功能而非技术组件组织代码
|
||||
- 🔄 **双模式支持** - 开发测试模式 + 生产部署模式
|
||||
- 📦 **清晰分层** - 业务层 → 核心层 → 数据层
|
||||
- 🧪 **测试友好** - 完整的单元测试和集成测试覆盖
|
||||
|
||||
### 第三步:体验核心功能 🎮
|
||||
|
||||
1. **API文档系统** 📖
|
||||
```bash
|
||||
# 启动服务后访问
|
||||
http://localhost:3000/api-docs
|
||||
```
|
||||
|
||||
2. **用户认证系统** 🔐
|
||||
- 邮箱验证码注册
|
||||
- 多方式登录(用户名/邮箱/手机号)
|
||||
- 密码重置功能
|
||||
|
||||
3. **实时通信** 🌐
|
||||
- WebSocket支持
|
||||
- Socket.IO集成
|
||||
|
||||
### 第四步:开始贡献 🤝
|
||||
|
||||
1. **Fork项目** 到你的Gitea账户
|
||||
2. **创建功能分支**:`git checkout -b feature/your-feature`
|
||||
3. **遵循规范开发**(使用AI助手帮助)
|
||||
4. **提交代码**:`git commit -m "feat:添加新功能"`
|
||||
5. **创建Pull Request**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 🚀 核心框架
|
||||
- **NestJS** `^11.1.9` - 企业级Node.js框架,提供依赖注入、模块化等特性
|
||||
- **TypeScript** `^5.9.3` - 类型安全的JavaScript超集
|
||||
- **Express** `^10.4.20` - 基于Express的HTTP服务器
|
||||
- **RxJS** `^7.8.2` - 响应式编程库,处理异步数据流
|
||||
|
||||
### 🌐 实时通信
|
||||
- **Socket.IO** `^10.4.20` - WebSocket实时双向通信
|
||||
- **@nestjs/websockets** - NestJS WebSocket网关支持
|
||||
- **@nestjs/platform-socket.io** - Socket.IO平台适配器
|
||||
|
||||
### 🗄️ 数据存储
|
||||
- **TypeORM** `^0.3.28` - 强大的ORM框架,支持多种数据库
|
||||
- **MySQL2** `^3.16.0` - 高性能MySQL驱动
|
||||
- **IORedis** `^5.8.2` - Redis客户端,支持集群和哨兵模式
|
||||
- **文件存储** - 自研Redis文件存储适配器,支持无Redis开发
|
||||
|
||||
### 🔐 安全认证
|
||||
- **bcrypt** `^6.0.0` - 密码加密哈希算法
|
||||
- **class-validator** `^0.14.3` - 数据验证装饰器
|
||||
- **class-transformer** `^0.5.1` - 对象转换和序列化
|
||||
|
||||
### 📧 通信服务
|
||||
- **Nodemailer** `^6.10.1` - 邮件发送服务
|
||||
- **Axios** `^1.13.2` - HTTP客户端,支持第三方API调用
|
||||
|
||||
### 📚 API文档
|
||||
- **Swagger UI** `^5.0.1` - 交互式API文档界面
|
||||
- **@nestjs/swagger** `^11.2.3` - NestJS Swagger集成
|
||||
|
||||
### 🧑💻 管理员后台(前端)
|
||||
- **Vite** - 前端构建工具(本项目 admin dashboard 使用)
|
||||
- **React** - 前端 UI 框架
|
||||
- **React Router** - 前端路由
|
||||
- **Ant Design** - 企业级 UI 组件库
|
||||
|
||||
### 📊 日志监控
|
||||
- **Pino** `^10.1.0` - 高性能结构化日志库
|
||||
- **nestjs-pino** `^4.5.0` - NestJS Pino集成
|
||||
- **pino-pretty** `^13.1.3` - Pino日志美化输出
|
||||
|
||||
### 🧪 测试框架
|
||||
- **Jest** `^29.7.0` - JavaScript测试框架
|
||||
- **Supertest** `^7.1.4` - HTTP断言测试
|
||||
- **@nestjs/testing** `^10.4.20` - NestJS测试工具
|
||||
|
||||
### ⚙️ 开发工具
|
||||
- **@nestjs/cli** `^10.4.9` - NestJS命令行工具
|
||||
- **ts-jest** `^29.2.5` - TypeScript Jest支持
|
||||
- **ts-node** `^10.9.2` - TypeScript运行时
|
||||
- **pnpm** - 快速、节省磁盘空间的包管理器
|
||||
|
||||
### 🔄 任务调度
|
||||
- **@nestjs/schedule** `^4.1.2` - 定时任务和计划任务支持
|
||||
|
||||
### 📦 构建部署
|
||||
- **Docker** - 容器化部署
|
||||
- **PM2** - 生产环境进程管理
|
||||
- **Nginx** - 反向代理和负载均衡
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 核心功能
|
||||
|
||||
### 🔐 用户认证模块 (business/auth/)
|
||||
- **多方式登录** - 用户名/邮箱/手机号
|
||||
- **邮箱验证** - 完整的验证码流程
|
||||
- **密码安全** - bcrypt加密 + 强度验证
|
||||
- **第三方登录** - GitHub OAuth支持
|
||||
- **密码管理** - 忘记密码、重置密码、修改密码
|
||||
|
||||
### 👥 用户管理模块 (business/user-mgmt/)
|
||||
- **用户状态管理** - 6种状态控制(active、inactive、locked、banned、deleted、pending)
|
||||
- **批量操作** - 批量修改用户状态
|
||||
- **状态统计** - 各状态用户数量统计
|
||||
- **状态变更日志** - 完整的审计日志
|
||||
|
||||
### 🛡️ 管理员模块 (business/admin/)
|
||||
- **独立认证** - 专用Token系统,与用户系统隔离
|
||||
- **用户管理** - 用户列表、搜索、密码重置
|
||||
- **日志监控** - 实时日志查看、历史日志下载
|
||||
- **权限控制** - 管理员角色验证(role=9)
|
||||
|
||||
### 🔒 安全模块 (business/security/)
|
||||
- **频率限制** - 基于IP的请求频率控制
|
||||
- **维护模式** - 系统维护期间的访问控制
|
||||
- **内容类型验证** - HTTP请求内容类型检查
|
||||
- **超时控制** - 可配置的请求超时机制
|
||||
|
||||
### 📧 智能邮件服务
|
||||
- **测试模式** - 控制台输出,无需SMTP服务器
|
||||
- **生产模式** - 支持主流邮件服务商
|
||||
- **模板系统** - 验证码、欢迎邮件等模板
|
||||
- **自动切换** - 根据配置自动选择模式
|
||||
|
||||
### 🗄️ 灵活存储方案
|
||||
- **Redis文件存储** - 开发测试无需Redis服务器
|
||||
- **内存数据库** - 无需MySQL即可运行
|
||||
- **生产就绪** - 支持MySQL + Redis部署
|
||||
- **自动切换** - 根据配置自动选择存储方式
|
||||
|
||||
### 📚 完整API文档
|
||||
- **Swagger UI** - 交互式API文档
|
||||
- **OpenAPI规范** - 标准化接口描述
|
||||
- **Postman集合** - 可导入的测试集合
|
||||
- **实时更新** - 代码变更自动同步文档
|
||||
|
||||
### 🧪 全面测试覆盖
|
||||
- **单元测试** - 140个测试用例全部通过
|
||||
- **API测试** - 跨平台测试脚本
|
||||
- **集成测试** - 完整业务流程验证
|
||||
- **测试模式** - 无依赖快速测试
|
||||
|
||||
---
|
||||
|
||||
## 📊 开发与测试
|
||||
|
||||
### 🔧 开发命令
|
||||
|
||||
```bash
|
||||
# 开发服务器(热重载)
|
||||
pnpm run dev
|
||||
|
||||
# 构建项目
|
||||
pnpm run build
|
||||
|
||||
# 生产环境运行
|
||||
pnpm run start:prod
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 格式化代码
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
### 🧪 测试命令
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式运行测试
|
||||
pnpm run test:watch
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
pnpm run test:cov
|
||||
|
||||
# API功能测试(综合测试脚本)
|
||||
.\test-comprehensive.ps1
|
||||
```
|
||||
|
||||
### 📈 测试覆盖率
|
||||
|
||||
- **单元测试**: 140个测试用例 ✅
|
||||
- **功能测试**: 用户认证、用户管理、管理员后台、安全防护 ✅
|
||||
- **集成测试**: 完整业务流程 ✅
|
||||
|
||||
---
|
||||
|
||||
## 🌍 部署配置
|
||||
|
||||
### 测试环境(默认)
|
||||
```bash
|
||||
# 无需数据库和邮件服务器
|
||||
USE_FILE_REDIS=true
|
||||
NODE_ENV=development
|
||||
# 数据库和邮件配置保持注释状态
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
```bash
|
||||
# 启用真实服务
|
||||
USE_FILE_REDIS=false
|
||||
NODE_ENV=production
|
||||
|
||||
# 配置数据库
|
||||
DB_HOST=your_mysql_host
|
||||
DB_USERNAME=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# 配置Redis
|
||||
REDIS_HOST=your_redis_host
|
||||
REDIS_PASSWORD=your_password
|
||||
|
||||
# 配置邮件服务
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_app_password
|
||||
```
|
||||
|
||||
详细部署指南:[DEPLOYMENT.md](./DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档中心
|
||||
|
||||
### 🎯 新手必读
|
||||
1. **[AI辅助开发指南](./docs/AI辅助开发规范指南.md)** - 提升开发效率300%
|
||||
2. **[后端开发规范](./docs/backend_development_guide.md)** - 代码质量标准
|
||||
3. **[Git提交规范](./docs/git_commit_guide.md)** - 版本控制最佳实践
|
||||
|
||||
### 📖 API文档
|
||||
- **[Swagger UI](http://localhost:3000/api-docs)** - 交互式API文档
|
||||
- **[API文档总览](./docs/api/README.md)** - 使用指南
|
||||
- **[OpenAPI规范](./docs/api/openapi.yaml)** - 标准化描述
|
||||
- **[Postman集合](./docs/api/postman-collection.json)** - 测试集合
|
||||
|
||||
### 🏗️ 系统设计
|
||||
- **[架构文档](./docs/ARCHITECTURE.md)** - 系统架构设计
|
||||
- **[部署指南](./docs/deployment/DEPLOYMENT.md)** - 生产环境部署
|
||||
|
||||
### 🧪 测试指南
|
||||
- **[测试指南](./docs/development/TESTING.md)** - 完整测试说明
|
||||
- **[单元测试](./src/**/*.spec.ts)** - 测试用例参考
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献者
|
||||
|
||||
感谢所有为项目做出贡献的开发者!
|
||||
|
||||
### 🏆 核心团队
|
||||
- **[moyin](https://gitea.xinghangee.icu/moyin)** - 核心开发者
|
||||
- **[jianuo](https://gitea.xinghangee.icu/jianuo)** - 核心开发者
|
||||
- **[angjustinl](https://gitea.xinghangee.icu/ANGJustinl)** - 核心开发者
|
||||
|
||||
查看完整贡献者名单:[docs/CONTRIBUTORS.md](./docs/CONTRIBUTORS.md)
|
||||
|
||||
### 🌟 如何贡献
|
||||
|
||||
我们欢迎所有形式的贡献:
|
||||
|
||||
1. **<EFBFBD> Bug修复** - 发现并修复问题
|
||||
2. **✨ 新功能** - 添加有价值的功能
|
||||
3. **<EFBFBD> 文档改馈进** - 完善项目文档
|
||||
4. **🧪 测试用例** - 提高代码覆盖率
|
||||
5. **💡 建议反馈** - 提出改进建议
|
||||
|
||||
**贡献流程:**
|
||||
1. Fork项目 → 2. 创建分支 → 3. 开发功能 → 4. 提交PR
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
- **项目地址**: [Gitea Repository](https://gitea.xinghangee.icu/datawhale/whale-town-end)
|
||||
- **问题反馈**: [Issues](https://gitea.xinghangee.icu/datawhale/whale-town-end/issues)
|
||||
- **功能建议**: [Discussions](https://gitea.xinghangee.icu/datawhale/whale-town-end/discussions)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [MIT License](./LICENSE) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**🐋 Whale Town - 让像素世界更精彩!**
|
||||
|
||||
Made with ❤️ by the Whale Town Team
|
||||
|
||||
[⭐ Star](https://gitea.xinghangee.icu/datawhale/whale-town-end) | [🍴 Fork](https://gitea.xinghangee.icu/datawhale/whale-town-end/fork) | [📖 Docs](./docs/) | [🐛 Issues](https://gitea.xinghangee.icu/datawhale/whale-town-end/issues)
|
||||
|
||||
</div>
|
||||
@@ -1,112 +0,0 @@
|
||||
# 验证码问题调试脚本
|
||||
# 作者: moyin
|
||||
# 日期: 2025-12-17
|
||||
|
||||
$baseUrl = "http://localhost:3000"
|
||||
$testEmail = "debug@example.com"
|
||||
|
||||
Write-Host "=== 验证码问题调试脚本 ===" -ForegroundColor Green
|
||||
|
||||
# 步骤1: 发送验证码
|
||||
Write-Host "`n1. 发送验证码..." -ForegroundColor Yellow
|
||||
$sendBody = @{
|
||||
email = $testEmail
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$sendResponse = Invoke-RestMethod -Uri "$baseUrl/auth/send-email-verification" -Method POST -Body $sendBody -ContentType "application/json"
|
||||
Write-Host "发送响应: $($sendResponse | ConvertTo-Json -Depth 3)" -ForegroundColor Cyan
|
||||
|
||||
if ($sendResponse.success) {
|
||||
Write-Host "✅ 验证码发送成功" -ForegroundColor Green
|
||||
|
||||
# 步骤2: 立即查看验证码调试信息
|
||||
Write-Host "`n2. 查看验证码调试信息..." -ForegroundColor Yellow
|
||||
$debugResponse = Invoke-RestMethod -Uri "$baseUrl/auth/debug-verification-code" -Method POST -Body $sendBody -ContentType "application/json"
|
||||
Write-Host "调试信息: $($debugResponse | ConvertTo-Json -Depth 5)" -ForegroundColor Cyan
|
||||
|
||||
# 步骤3: 故意输入错误验证码
|
||||
Write-Host "`n3. 测试错误验证码..." -ForegroundColor Yellow
|
||||
$wrongVerifyBody = @{
|
||||
email = $testEmail
|
||||
verification_code = "000000"
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$wrongResponse = Invoke-RestMethod -Uri "$baseUrl/auth/verify-email" -Method POST -Body $wrongVerifyBody -ContentType "application/json"
|
||||
Write-Host "错误验证响应: $($wrongResponse | ConvertTo-Json -Depth 3)" -ForegroundColor Red
|
||||
} catch {
|
||||
Write-Host "错误验证失败(预期): $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
if ($_.Exception.Response) {
|
||||
$errorResponse = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($errorResponse)
|
||||
$errorBody = $reader.ReadToEnd()
|
||||
Write-Host "错误详情: $errorBody" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# 步骤4: 再次查看调试信息
|
||||
Write-Host "`n4. 错误验证后的调试信息..." -ForegroundColor Yellow
|
||||
$debugResponse2 = Invoke-RestMethod -Uri "$baseUrl/auth/debug-verification-code" -Method POST -Body $sendBody -ContentType "application/json"
|
||||
Write-Host "调试信息: $($debugResponse2 | ConvertTo-Json -Depth 5)" -ForegroundColor Cyan
|
||||
|
||||
# 步骤5: 再次尝试错误验证码
|
||||
Write-Host "`n5. 再次测试错误验证码..." -ForegroundColor Yellow
|
||||
try {
|
||||
$wrongResponse2 = Invoke-RestMethod -Uri "$baseUrl/auth/verify-email" -Method POST -Body $wrongVerifyBody -ContentType "application/json"
|
||||
Write-Host "第二次错误验证响应: $($wrongResponse2 | ConvertTo-Json -Depth 3)" -ForegroundColor Red
|
||||
} catch {
|
||||
Write-Host "第二次错误验证失败: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
if ($_.Exception.Response) {
|
||||
$errorResponse = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($errorResponse)
|
||||
$errorBody = $reader.ReadToEnd()
|
||||
Write-Host "错误详情: $errorBody" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# 步骤6: 最终调试信息
|
||||
Write-Host "`n6. 最终调试信息..." -ForegroundColor Yellow
|
||||
$debugResponse3 = Invoke-RestMethod -Uri "$baseUrl/auth/debug-verification-code" -Method POST -Body $sendBody -ContentType "application/json"
|
||||
Write-Host "最终调试信息: $($debugResponse3 | ConvertTo-Json -Depth 5)" -ForegroundColor Cyan
|
||||
|
||||
# 步骤7: 使用正确验证码(如果有的话)
|
||||
if ($sendResponse.data.verification_code) {
|
||||
Write-Host "`n7. 使用正确验证码..." -ForegroundColor Yellow
|
||||
$correctVerifyBody = @{
|
||||
email = $testEmail
|
||||
verification_code = $sendResponse.data.verification_code
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$correctResponse = Invoke-RestMethod -Uri "$baseUrl/auth/verify-email" -Method POST -Body $correctVerifyBody -ContentType "application/json"
|
||||
Write-Host "正确验证响应: $($correctResponse | ConvertTo-Json -Depth 3)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "正确验证也失败了: $($_.Exception.Message)" -ForegroundColor Red
|
||||
if ($_.Exception.Response) {
|
||||
$errorResponse = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($errorResponse)
|
||||
$errorBody = $reader.ReadToEnd()
|
||||
Write-Host "错误详情: $errorBody" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Write-Host "❌ 验证码发送失败: $($sendResponse.message)" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host "❌ 请求失败: $($_.Exception.Message)" -ForegroundColor Red
|
||||
if ($_.Exception.Response) {
|
||||
$errorResponse = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($errorResponse)
|
||||
$errorBody = $reader.ReadToEnd()
|
||||
Write-Host "错误详情: $errorBody" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n=== 调试完成 ===" -ForegroundColor Green
|
||||
Write-Host "请查看上述输出,重点关注:" -ForegroundColor Yellow
|
||||
Write-Host "1. TTL值的变化" -ForegroundColor White
|
||||
Write-Host "2. attempts字段的变化" -ForegroundColor White
|
||||
Write-Host "3. 验证码是否被意外删除" -ForegroundColor White
|
||||
4
client/.env.example
Normal file
4
client/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 前端后台配置
|
||||
# 复制为 .env.local
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
222
client/README.md
Normal file
222
client/README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 🎛️ Whale Town 管理员前端界面
|
||||
|
||||
基于 React + Vite + Ant Design 构建的现代化管理员后台界面。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 📋 环境要求
|
||||
- Node.js >= 18.0.0
|
||||
- pnpm >= 8.0.0
|
||||
|
||||
### 🛠️ 安装与运行
|
||||
|
||||
```bash
|
||||
# 1. 确保后端服务已启动
|
||||
cd ..
|
||||
pnpm run dev
|
||||
|
||||
# 2. 安装前端依赖
|
||||
cd client
|
||||
pnpm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
pnpm run dev
|
||||
|
||||
# 4. 访问管理界面
|
||||
# 浏览器打开: http://localhost:5173
|
||||
```
|
||||
|
||||
### 🔑 默认登录信息
|
||||
- **用户名**: admin
|
||||
- **密码**: Admin123456
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 🔐 管理员认证
|
||||
- 独立的Token认证系统
|
||||
- 安全的登录验证
|
||||
- 自动Token刷新
|
||||
|
||||
### 👥 用户管理
|
||||
- 用户列表查看和搜索
|
||||
- 用户状态管理
|
||||
- 用户密码重置
|
||||
- 分页和排序功能
|
||||
|
||||
### 📊 系统监控
|
||||
- 实时日志查看
|
||||
- 日志文件下载
|
||||
- 系统状态监控
|
||||
|
||||
### 🎨 界面特性
|
||||
- 响应式设计,支持移动端
|
||||
- 现代化UI组件
|
||||
- 暗色/亮色主题切换
|
||||
- 国际化支持
|
||||
|
||||
## 🏗️ 技术栈
|
||||
|
||||
### 🚀 核心框架
|
||||
- **React** `^18.0.0` - 前端UI框架
|
||||
- **Vite** `^5.0.0` - 现代化构建工具
|
||||
- **TypeScript** `^5.0.0` - 类型安全的JavaScript
|
||||
|
||||
### 🎨 UI组件
|
||||
- **Ant Design** `^5.0.0` - 企业级UI组件库
|
||||
- **Ant Design Icons** - 图标库
|
||||
- **CSS Modules** - 样式模块化
|
||||
|
||||
### 🔧 开发工具
|
||||
- **ESLint** - 代码质量检查
|
||||
- **Prettier** - 代码格式化
|
||||
- **Husky** - Git钩子管理
|
||||
|
||||
### 🌐 HTTP客户端
|
||||
- **Axios** - HTTP请求库
|
||||
- **React Query** - 数据获取和缓存
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
client/
|
||||
├── src/
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ ├── services/ # API服务
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── types/ # TypeScript类型定义
|
||||
│ ├── styles/ # 全局样式
|
||||
│ ├── App.tsx # 应用主组件
|
||||
│ └── main.tsx # 应用入口
|
||||
├── public/ # 静态资源
|
||||
├── index.html # HTML模板
|
||||
├── vite.config.ts # Vite配置
|
||||
├── tsconfig.json # TypeScript配置
|
||||
└── package.json # 项目配置
|
||||
```
|
||||
|
||||
## 🔧 开发命令
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm run dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm run build
|
||||
|
||||
# 预览生产构建
|
||||
pnpm run preview
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 代码格式化
|
||||
pnpm run format
|
||||
|
||||
# 类型检查
|
||||
pnpm run type-check
|
||||
```
|
||||
|
||||
## 🌍 环境配置
|
||||
|
||||
### 开发环境 (.env.local)
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
VITE_APP_TITLE=Whale Town 管理后台
|
||||
```
|
||||
|
||||
### 生产环境 (.env.production)
|
||||
```bash
|
||||
VITE_API_BASE_URL=https://your-api-domain.com
|
||||
VITE_APP_TITLE=Whale Town 管理后台
|
||||
```
|
||||
|
||||
## 🔗 API集成
|
||||
|
||||
### 认证接口
|
||||
- `POST /admin/auth/login` - 管理员登录
|
||||
- 自动Token管理和刷新
|
||||
|
||||
### 用户管理接口
|
||||
- `GET /admin/users` - 获取用户列表
|
||||
- `GET /admin/users/:id` - 获取用户详情
|
||||
- `POST /admin/users/:id/reset-password` - 重置用户密码
|
||||
- `PUT /admin/users/:id/status` - 修改用户状态
|
||||
|
||||
### 系统接口
|
||||
- `GET /admin/logs/runtime` - 获取运行日志
|
||||
- `GET /admin/logs/archive` - 下载日志归档
|
||||
|
||||
## 🎨 界面预览
|
||||
|
||||
### 登录页面
|
||||
- 简洁的登录表单
|
||||
- 输入验证和错误提示
|
||||
- 记住登录状态
|
||||
|
||||
### 用户管理页面
|
||||
- 用户列表表格
|
||||
- 搜索和筛选功能
|
||||
- 用户状态管理
|
||||
- 密码重置操作
|
||||
|
||||
### 日志管理页面
|
||||
- 实时日志显示
|
||||
- 日志级别筛选
|
||||
- 日志文件下载
|
||||
|
||||
## 🚀 部署指南
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
# 构建
|
||||
pnpm run build
|
||||
|
||||
# 构建产物在 dist/ 目录
|
||||
```
|
||||
|
||||
### 部署到Nginx
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
root /path/to/client/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🤝 开发规范
|
||||
|
||||
### 代码风格
|
||||
- 使用TypeScript进行类型检查
|
||||
- 遵循ESLint和Prettier规范
|
||||
- 组件使用函数式组件和Hooks
|
||||
|
||||
### 文件命名
|
||||
- 组件文件使用PascalCase:`UserList.tsx`
|
||||
- 工具文件使用camelCase:`apiClient.ts`
|
||||
- 样式文件使用kebab-case:`user-list.module.css`
|
||||
|
||||
### 提交规范
|
||||
- 遵循项目Git提交规范
|
||||
- 提交前自动运行代码检查
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
1. [后端API文档](../docs/api/README.md)
|
||||
2. [项目架构文档](../docs/ARCHITECTURE.md)
|
||||
3. [开发规范指南](../docs/development/)
|
||||
|
||||
---
|
||||
|
||||
**🎛️ 现代化管理界面,让后台管理更高效!**
|
||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Whale Town Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "whale-town-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.27.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
61
client/src/app/AdminLayout.tsx
Normal file
61
client/src/app/AdminLayout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Layout, Menu, Typography } from 'antd';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { clearAuth } from '../lib/adminAuth';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
export function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/logs')
|
||||
? 'logs'
|
||||
: location.pathname.startsWith('/users')
|
||||
? 'users'
|
||||
: 'users';
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={220}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Typography.Title level={5} style={{ color: 'white', margin: 0 }}>
|
||||
Whale Town Admin
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={[
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户管理',
|
||||
onClick: () => navigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: '运行日志',
|
||||
onClick: () => navigate('/logs'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
onClick: () => {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ background: 'white', display: 'flex', alignItems: 'center' }}>
|
||||
<Typography.Text>后台管理</Typography.Text>
|
||||
</Header>
|
||||
<Content style={{ padding: 16 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
28
client/src/app/App.tsx
Normal file
28
client/src/app/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { UsersPage } from '../pages/UsersPage';
|
||||
import { LogsPage } from '../pages/LogsPage';
|
||||
import { isAuthed } from '../lib/adminAuth';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={isAuthed() ? <AdminLayout /> : <Navigate to="/login" replace />}
|
||||
>
|
||||
<Route index element={<Navigate to="/users" replace />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
17
client/src/lib/adminAuth.ts
Normal file
17
client/src/lib/adminAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
const TOKEN_KEY = 'whale_town_admin_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isAuthed(): boolean {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
130
client/src/lib/api.ts
Normal file
130
client/src/lib/api.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getToken, clearAuth } from './adminAuth';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(contentDisposition: string | null): string | null {
|
||||
if (!contentDisposition) return null;
|
||||
|
||||
// Prefer RFC 5987 filename*=UTF-8''...
|
||||
const filenameStarMatch = contentDisposition.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/);
|
||||
if (filenameStarMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(filenameStarMatch[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return filenameStarMatch[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const filenameMatch = contentDisposition.match(/filename=([^;]+)/);
|
||||
if (filenameMatch?.[1]) {
|
||||
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => ({}))) as any;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(data?.message || `请求失败: ${res.status}`, res.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function requestDownload(path: string, init?: RequestInit): Promise<{ blob: Blob; filename: string }>
|
||||
{
|
||||
const token = getToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.headers as any),
|
||||
};
|
||||
|
||||
// Do NOT force Content-Type for downloads (GET binary)
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
// Try to extract message from JSON-ish body
|
||||
let message = `请求失败: ${res.status}`;
|
||||
try {
|
||||
const maybeJson = JSON.parse(text || '{}');
|
||||
message = maybeJson?.message || message;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(message, res.status);
|
||||
}
|
||||
|
||||
const filename =
|
||||
parseFilenameFromContentDisposition(res.headers.get('content-disposition')) || 'logs.tar.gz';
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminLogin: (identifier: string, password: string) =>
|
||||
request<any>('/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
}),
|
||||
|
||||
listUsers: (limit = 100, offset = 0) =>
|
||||
request<any>(`/admin/users?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`),
|
||||
|
||||
resetUserPassword: (userId: string, newPassword: string) =>
|
||||
request<any>(`/admin/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
}),
|
||||
|
||||
getRuntimeLogs: (lines = 200) =>
|
||||
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
|
||||
|
||||
downloadLogsArchive: () => requestDownload('/admin/logs/archive'),
|
||||
};
|
||||
9
client/src/main.tsx
Normal file
9
client/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
50
client/src/pages/LoginPage.tsx
Normal file
50
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button, Card, Form, Input, Typography, message } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import { setToken } from '../lib/adminAuth';
|
||||
|
||||
type LoginValues = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<LoginValues>();
|
||||
|
||||
const onFinish = async (values: LoginValues) => {
|
||||
try {
|
||||
const res = await api.adminLogin(values.identifier, values.password);
|
||||
if (!res?.success || !res?.data?.access_token) {
|
||||
throw new Error(res?.message || '登录失败');
|
||||
}
|
||||
|
||||
setToken(res.data.access_token);
|
||||
message.success('登录成功');
|
||||
navigate('/users');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Card style={{ width: 420 }}>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
管理员登录
|
||||
</Typography.Title>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item name="identifier" label="用户名/邮箱/手机号" rules={[{ required: true }]}>
|
||||
<Input placeholder="admin" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
|
||||
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
client/src/pages/LogsPage.tsx
Normal file
106
client/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, InputNumber, Space, Typography } from 'antd';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
|
||||
export function LogsPage() {
|
||||
const [lines, setLines] = useState<number>(200);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<string>('');
|
||||
const [updatedAt, setUpdatedAt] = useState<string>('');
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
|
||||
const logText = useMemo(() => logLines.join('\n'), [logLines]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getRuntimeLogs(lines);
|
||||
if (!res?.success) {
|
||||
setError(res?.message || '运行日志获取失败');
|
||||
return;
|
||||
}
|
||||
setFile(res?.data?.file || '');
|
||||
setUpdatedAt(res?.data?.updated_at || '');
|
||||
setLogLines(Array.isArray(res?.data?.lines) ? res.data.lines : []);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : '运行日志获取失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const downloadArchive = async () => {
|
||||
setDownloadLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { blob, filename } = await api.downloadLogsArchive();
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || 'logs.tar.gz';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : '日志下载失败');
|
||||
}
|
||||
} finally {
|
||||
setDownloadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{error ? <Alert type="error" message={error} /> : null}
|
||||
|
||||
<Card
|
||||
title="运行日志"
|
||||
extra={
|
||||
<Space>
|
||||
<span>行数</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={2000}
|
||||
value={lines}
|
||||
onChange={(v) => setLines(typeof v === 'number' ? v : 200)}
|
||||
/>
|
||||
<Button onClick={() => void load()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => void downloadArchive()} loading={downloadLoading}>
|
||||
下载日志压缩包
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<Typography.Text type="secondary">
|
||||
{file ? `文件:${file}` : '文件:-'}
|
||||
{updatedAt ? ` 更新时间:${updatedAt}` : ''}
|
||||
</Typography.Text>
|
||||
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logText || '暂无日志'}</pre>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
161
client/src/pages/UsersPage.tsx
Normal file
161
client/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Button, Card, Form, Input, Modal, Space, Table, Typography, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
email_verified: boolean;
|
||||
phone?: string;
|
||||
role: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ResetValues = {
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export function UsersPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState<UserRow[]>([]);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetForm] = Form.useForm<ResetValues>();
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 90 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: '邮箱验证',
|
||||
dataIndex: 'email_verified',
|
||||
key: 'email_verified',
|
||||
render: (v: boolean) => (v ? '已验证' : '未验证'),
|
||||
width: 100,
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: any, row: UserRow) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setResetUserId(row.id);
|
||||
resetForm.resetFields();
|
||||
setResetOpen(true);
|
||||
}}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[resetForm],
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.listUsers(200, 0);
|
||||
const users = res?.data?.users || [];
|
||||
setRows(
|
||||
users.map((u: any) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
nickname: u.nickname,
|
||||
email: u.email || undefined,
|
||||
email_verified: Boolean(u.email_verified),
|
||||
phone: u.phone || undefined,
|
||||
role: u.role,
|
||||
created_at: u.created_at,
|
||||
})),
|
||||
);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const onResetOk = async () => {
|
||||
try {
|
||||
const values = await resetForm.validateFields();
|
||||
if (!resetUserId) return;
|
||||
|
||||
await api.resetUserPassword(resetUserId, values.newPassword);
|
||||
message.success('密码已重置');
|
||||
setResetOpen(false);
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
用户管理
|
||||
</Typography.Title>
|
||||
<Button onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={`重置密码${resetUserId ? `(用户ID: ${resetUserId})` : ''}`}
|
||||
open={resetOpen}
|
||||
onOk={onResetOk}
|
||||
onCancel={() => setResetOpen(false)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={resetForm} layout="vertical">
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="newPassword"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '至少8位' },
|
||||
{
|
||||
validator: (_, v) => {
|
||||
const hasLetter = /[a-zA-Z]/.test(v || '');
|
||||
const hasNumber = /\d/.test(v || '');
|
||||
if (!v) return Promise.resolve();
|
||||
if (!hasLetter || !hasNumber) return Promise.reject(new Error('必须包含字母和数字'));
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="例如 NewPass1234" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
client/tsconfig.json
Normal file
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
client/vite.config.ts
Normal file
9
client/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
149
config/zulip/README.md
Normal file
149
config/zulip/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Zulip配置目录
|
||||
|
||||
本目录包含Zulip集成系统的配置文件。
|
||||
|
||||
## 文件说明
|
||||
|
||||
### map-config.json
|
||||
|
||||
地图映射配置文件,定义游戏地图到Zulip Stream/Topic的映射关系。
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2025-12-25T00:00:00.000Z",
|
||||
"description": "配置描述",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "地图唯一标识",
|
||||
"mapName": "地图显示名称",
|
||||
"zulipStream": "对应的Zulip Stream名称",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "交互对象唯一标识",
|
||||
"objectName": "交互对象显示名称",
|
||||
"zulipTopic": "对应的Zulip Topic名称",
|
||||
"position": { "x": 100, "y": 150 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| version | string | 否 | 配置版本号 |
|
||||
| lastModified | string | 否 | 最后修改时间(ISO 8601格式) |
|
||||
| description | string | 否 | 配置描述 |
|
||||
| maps | array | 是 | 地图配置数组 |
|
||||
|
||||
##### 地图配置 (MapConfig)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| mapId | string | 是 | 地图唯一标识,如 "novice_village" |
|
||||
| mapName | string | 是 | 地图显示名称,如 "新手村" |
|
||||
| zulipStream | string | 是 | 对应的Zulip Stream名称 |
|
||||
| interactionObjects | array | 是 | 交互对象配置数组 |
|
||||
|
||||
##### 交互对象配置 (InteractionObject)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| objectId | string | 是 | 交互对象唯一标识 |
|
||||
| objectName | string | 是 | 交互对象显示名称 |
|
||||
| zulipTopic | string | 是 | 对应的Zulip Topic名称 |
|
||||
| position | object | 是 | 对象在地图中的位置 |
|
||||
| position.x | number | 是 | X坐标 |
|
||||
| position.y | number | 是 | Y坐标 |
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 新手村配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mapId": "novice_village",
|
||||
"mapName": "新手村",
|
||||
"zulipStream": "Novice Village",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "notice_board",
|
||||
"objectName": "公告板",
|
||||
"zulipTopic": "Notice Board",
|
||||
"position": { "x": 100, "y": 150 }
|
||||
},
|
||||
{
|
||||
"objectId": "village_well",
|
||||
"objectName": "村井",
|
||||
"zulipTopic": "Village Well",
|
||||
"position": { "x": 200, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 酒馆配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mapId": "tavern",
|
||||
"mapName": "酒馆",
|
||||
"zulipStream": "Tavern",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "bar_counter",
|
||||
"objectName": "吧台",
|
||||
"zulipTopic": "Bar Counter",
|
||||
"position": { "x": 150, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "fireplace",
|
||||
"objectName": "壁炉",
|
||||
"zulipTopic": "Fireplace Chat",
|
||||
"position": { "x": 300, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 热重载
|
||||
|
||||
配置文件支持热重载,修改后无需重启服务即可生效。
|
||||
|
||||
### 启用配置监听
|
||||
|
||||
在代码中调用:
|
||||
|
||||
```typescript
|
||||
configManagerService.enableConfigWatcher();
|
||||
```
|
||||
|
||||
### 手动重载配置
|
||||
|
||||
```typescript
|
||||
await configManagerService.reloadConfig();
|
||||
```
|
||||
|
||||
## 验证配置
|
||||
|
||||
系统启动时会自动验证配置文件的有效性。验证规则包括:
|
||||
|
||||
1. mapId必须是非空字符串
|
||||
2. mapName必须是非空字符串
|
||||
3. zulipStream必须是非空字符串
|
||||
4. interactionObjects必须是数组
|
||||
5. 每个交互对象必须有有效的objectId、objectName、zulipTopic和position
|
||||
6. position.x和position.y必须是有效数字
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Stream名称**: Zulip Stream名称区分大小写,请确保与Zulip服务器上的Stream名称完全匹配
|
||||
2. **Topic名称**: Topic名称同样区分大小写
|
||||
3. **位置坐标**: 位置坐标用于空间过滤,确保与游戏客户端的坐标系统一致
|
||||
4. **唯一性**: mapId和objectId在各自范围内必须唯一
|
||||
205
config/zulip/map-config.json
Normal file
205
config/zulip/map-config.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2025-12-25T20:00:00.000Z",
|
||||
"description": "基于设计图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "clock_tower",
|
||||
"objectName": "大本钟",
|
||||
"zulipTopic": "General Chat",
|
||||
"position": { "x": 550, "y": 350 }
|
||||
},
|
||||
{
|
||||
"objectId": "city_metro",
|
||||
"objectName": "地铁入口",
|
||||
"zulipTopic": "Transportation",
|
||||
"position": { "x": 600, "y": 550 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "offer_city",
|
||||
"mapName": "Offer 城",
|
||||
"zulipStream": "Offer City",
|
||||
"description": "职业发展、面试与商务区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "skyscrapers",
|
||||
"objectName": "摩天大楼",
|
||||
"zulipTopic": "Career Talk",
|
||||
"position": { "x": 350, "y": 650 }
|
||||
},
|
||||
{
|
||||
"objectId": "business_center",
|
||||
"objectName": "商务中心",
|
||||
"zulipTopic": "Interview Prep",
|
||||
"position": { "x": 300, "y": 700 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "model_factory",
|
||||
"mapName": "模型工厂",
|
||||
"zulipStream": "Model Factory",
|
||||
"description": "AI模型训练、代码构建与工业区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "assembly_line",
|
||||
"objectName": "流水线",
|
||||
"zulipTopic": "Code Review",
|
||||
"position": { "x": 400, "y": 200 }
|
||||
},
|
||||
{
|
||||
"objectId": "gear_tower",
|
||||
"objectName": "齿轮塔",
|
||||
"zulipTopic": "DevOps & CI/CD",
|
||||
"position": { "x": 450, "y": 180 }
|
||||
},
|
||||
{
|
||||
"objectId": "cable_car_station",
|
||||
"objectName": "缆车站",
|
||||
"zulipTopic": "Deployments",
|
||||
"position": { "x": 350, "y": 220 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "kernel_island",
|
||||
"mapName": "内核岛",
|
||||
"zulipStream": "Kernel Island",
|
||||
"description": "核心技术研究、底层原理与算法",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "crystal_core",
|
||||
"objectName": "能量水晶",
|
||||
"zulipTopic": "Core Algorithms",
|
||||
"position": { "x": 600, "y": 150 }
|
||||
},
|
||||
{
|
||||
"objectId": "floating_rocks",
|
||||
"objectName": "浮空石",
|
||||
"zulipTopic": "System Architecture",
|
||||
"position": { "x": 650, "y": 180 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "pumpkin_valley",
|
||||
"mapName": "南瓜谷",
|
||||
"zulipStream": "Pumpkin Valley",
|
||||
"description": "新手成长、基础资源与学习社区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "pumpkin_patch",
|
||||
"objectName": "南瓜田",
|
||||
"zulipTopic": "Tutorials",
|
||||
"position": { "x": 150, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "farm_house",
|
||||
"objectName": "农舍",
|
||||
"zulipTopic": "Study Group",
|
||||
"position": { "x": 200, "y": 450 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "moyu_beach",
|
||||
"mapName": "摸鱼海滩",
|
||||
"zulipStream": "Moyu Beach",
|
||||
"description": "休闲娱乐、水贴与非技术话题",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "beach_umbrella",
|
||||
"objectName": "遮阳伞",
|
||||
"zulipTopic": "Random Chat",
|
||||
"position": { "x": 850, "y": 200 }
|
||||
},
|
||||
{
|
||||
"objectId": "lighthouse",
|
||||
"objectName": "灯塔",
|
||||
"zulipTopic": "Music & Movies",
|
||||
"position": { "x": 800, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "fishing_dock",
|
||||
"objectName": "栈桥",
|
||||
"zulipTopic": "Gaming",
|
||||
"position": { "x": 750, "y": 250 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "ladder_peak",
|
||||
"mapName": "天梯峰",
|
||||
"zulipStream": "Ladder Peak",
|
||||
"description": "挑战、竞赛与排行榜",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "summit_flag",
|
||||
"objectName": "峰顶旗帜",
|
||||
"zulipTopic": "Leaderboard",
|
||||
"position": { "x": 150, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "snowy_path",
|
||||
"objectName": "雪径",
|
||||
"zulipTopic": "Challenges",
|
||||
"position": { "x": 200, "y": 150 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "galaxy_bay",
|
||||
"mapName": "星河湾",
|
||||
"zulipStream": "Galaxy Bay",
|
||||
"description": "创意、设计与灵感",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "starfish",
|
||||
"objectName": "巨型海星",
|
||||
"zulipTopic": "UI/UX Design",
|
||||
"position": { "x": 100, "y": 700 }
|
||||
},
|
||||
{
|
||||
"objectId": "palm_tree",
|
||||
"objectName": "椰子树",
|
||||
"zulipTopic": "Art & Assets",
|
||||
"position": { "x": 150, "y": 650 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "data_ruins",
|
||||
"mapName": "数据遗迹",
|
||||
"zulipStream": "Data Ruins",
|
||||
"description": "数据库、归档与历史记录",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "ruined_gate",
|
||||
"objectName": "遗迹之门",
|
||||
"zulipTopic": "Database Schema",
|
||||
"position": { "x": 900, "y": 700 }
|
||||
},
|
||||
{
|
||||
"objectId": "ancient_monolith",
|
||||
"objectName": "石碑",
|
||||
"zulipTopic": "Archives",
|
||||
"position": { "x": 950, "y": 650 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 "警告:服务健康检查失败"
|
||||
@@ -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:
|
||||
@@ -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
|
||||
<!-- 成功状态 (200) -->
|
||||
<div class="alert alert-success">
|
||||
✅ 验证码已发送,请查收邮件
|
||||
</div>
|
||||
|
||||
<!-- 测试模式 (206) -->
|
||||
<div class="alert alert-warning">
|
||||
⚠️ 测试模式:验证码是 123456
|
||||
<br>
|
||||
<small>请配置邮件服务以启用真实发送</small>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 (400+) -->
|
||||
<div class="alert alert-danger">
|
||||
❌ 发送失败:邮箱格式错误
|
||||
</div>
|
||||
```
|
||||
|
||||
## 📝 开发建议
|
||||
|
||||
### 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)
|
||||
773
docs/ARCHITECTURE.md
Normal file
773
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,773 @@
|
||||
# 🏗️ 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配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 分层架构设计
|
||||
|
||||
### 📊 架构分层说明
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🌐 表现层 (Presentation) │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Controllers │ │ WebSocket │ │ Swagger UI │ │
|
||||
│ │ (HTTP接口) │ │ Gateways │ │ (API文档) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
⬇️
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🎯 业务层 (Business) │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Auth Module │ │ UserMgmt │ │ Admin Module │ │
|
||||
│ │ (用户认证) │ │ Module │ │ (管理员) │ │
|
||||
│ │ │ │ (用户管理) │ │ │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Security Module │ │ Zulip Module │ │ Shared Module │ │
|
||||
│ │ (安全防护) │ │ (Zulip集成) │ │ (共享组件) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
⬇️
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ⚙️ 服务层 (Service) │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Login Core │ │ Admin Core │ │ Zulip Core │ │
|
||||
│ │ (登录核心) │ │ (管理员核心) │ │ (Zulip核心) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Email Service │ │ Verification │ │ Logger Service │ │
|
||||
│ │ (邮件服务) │ │ Service │ │ (日志服务) │ │
|
||||
│ │ │ │ (验证码服务) │ │ │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
⬇️
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🗄️ 数据层 (Data) │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Users Service │ │ Redis Service │ │ File Storage │ │
|
||||
│ │ (用户数据) │ │ (缓存服务) │ │ (文件存储) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ MySQL/Memory │ │ Redis/File │ │ Logs/Data │ │
|
||||
│ │ (数据库) │ │ (缓存实现) │ │ (日志数据) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 🔄 数据流向
|
||||
|
||||
#### 用户登录流程示例
|
||||
|
||||
```
|
||||
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. 🛡️ 管理员请求 → AdminController.resetUserPassword()
|
||||
2. 🔐 权限验证 → AdminGuard.canActivate()
|
||||
3. 🎯 业务逻辑 → AdminService.resetPassword()
|
||||
4. ⚙️ 核心服务 → AdminCoreService.resetUserPassword()
|
||||
5. 🔑 密码加密 → bcrypt.hash()
|
||||
6. 💾 更新数据 → UsersService.update()
|
||||
7. 📧 通知用户 → EmailService.sendPasswordReset()
|
||||
8. 📝 审计日志 → LoggerService.audit()
|
||||
9. ✅ 返回响应 → 管理员收到操作结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 双模式架构
|
||||
|
||||
### 🎯 设计目标
|
||||
|
||||
- **开发测试**: 零依赖快速启动,无需安装MySQL、Redis等外部服务
|
||||
- **生产部署**: 高性能、高可用,支持集群和负载均衡
|
||||
|
||||
### 📊 模式对比
|
||||
|
||||
| 功能模块 | 🧪 开发测试模式 | 🚀 生产部署模式 |
|
||||
|----------|----------------|----------------|
|
||||
| **数据库** | 内存存储 (UsersMemoryService) | MySQL (UsersService + TypeORM) |
|
||||
| **缓存** | 文件存储 (FileRedisService) | Redis (RealRedisService + IORedis) |
|
||||
| **邮件** | 控制台输出 (测试模式) | SMTP服务器 (生产模式) |
|
||||
| **日志** | 控制台 + 文件 | 结构化日志 + 日志轮转 |
|
||||
| **配置** | `.env` 默认配置 | 环境变量 + 配置中心 |
|
||||
|
||||
### ⚙️ 模式切换配置
|
||||
|
||||
#### 开发测试模式 (.env)
|
||||
```bash
|
||||
# 数据存储模式
|
||||
USE_FILE_REDIS=true # 使用文件存储代替Redis
|
||||
NODE_ENV=development # 开发环境
|
||||
|
||||
# 数据库配置(注释掉,使用内存数据库)
|
||||
# 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 # 生产环境
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=your_mysql_host
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=your_username
|
||||
DB_PASSWORD=your_password
|
||||
DB_DATABASE=whale_town
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=your_redis_host
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
|
||||
# 邮件配置
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_app_password
|
||||
```
|
||||
|
||||
### 🔧 实现机制
|
||||
|
||||
#### 依赖注入切换
|
||||
```typescript
|
||||
// redis.module.ts
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'IRedisService',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const useFileRedis = configService.get<boolean>('USE_FILE_REDIS');
|
||||
return useFileRedis
|
||||
? new FileRedisService()
|
||||
: new RealRedisService(configService);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class RedisModule {}
|
||||
```
|
||||
|
||||
#### 配置驱动服务选择
|
||||
```typescript
|
||||
// users.module.ts
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'IUsersService',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const dbHost = configService.get<string>('DB_HOST');
|
||||
return dbHost
|
||||
? new UsersService()
|
||||
: new UsersMemoryService();
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class UsersModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 模块依赖关系
|
||||
|
||||
### 🏗️ 模块依赖图
|
||||
|
||||
```
|
||||
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 (共享组件)
|
||||
```
|
||||
|
||||
### 🔄 模块交互流程
|
||||
|
||||
#### 用户认证流程
|
||||
```
|
||||
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<void>;
|
||||
sendSMS(phone: string, message: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 实现服务
|
||||
```typescript
|
||||
// src/core/utils/notification/notification.service.ts
|
||||
@Injectable()
|
||||
export class NotificationService implements INotificationService {
|
||||
async sendPush(userId: string, message: string): Promise<void> {
|
||||
// 实现推送通知逻辑
|
||||
}
|
||||
|
||||
async sendSMS(phone: string, message: string): Promise<void> {
|
||||
// 实现短信发送逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 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<IGameCoreService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GameService,
|
||||
{
|
||||
provide: 'IGameCoreService',
|
||||
useValue: {
|
||||
createGame: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GameService>(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查询,使用JOIN优化关联查询
|
||||
|
||||
#### 3. 日志优化
|
||||
- **异步日志**: 使用Pino的异步写入功能
|
||||
- **日志分级**: 生产环境只记录ERROR和WARN级别
|
||||
- **日志轮转**: 自动清理过期日志文件
|
||||
|
||||
### 🔒 安全加固建议
|
||||
|
||||
#### 1. 数据验证
|
||||
- **输入验证**: 使用class-validator进行严格验证
|
||||
- **类型检查**: TypeScript静态类型检查
|
||||
- **SQL注入防护**: TypeORM参数化查询
|
||||
|
||||
#### 2. 认证授权
|
||||
- **密码安全**: bcrypt加密,强密码策略
|
||||
- **会话管理**: JWT + Redis会话存储
|
||||
- **权限控制**: 基于角色的访问控制(RBAC)
|
||||
|
||||
#### 3. 通信安全
|
||||
- **HTTPS**: 生产环境强制HTTPS
|
||||
- **CORS**: 严格的跨域请求控制
|
||||
- **Rate Limiting**: API请求频率限制
|
||||
|
||||
---
|
||||
|
||||
**🏗️ 通过清晰的架构设计,Whale Town 实现了高内聚、低耦合的模块化架构,支持快速开发和灵活部署!**
|
||||
148
docs/CONTRIBUTORS.md
Normal file
148
docs/CONTRIBUTORS.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 贡献者名单
|
||||
|
||||
感谢所有为 Whale Town 项目做出贡献的开发者们!🎉
|
||||
|
||||
## 核心贡献者
|
||||
|
||||
### 🏆 主要维护者
|
||||
|
||||
**moyin** - 主要维护者
|
||||
- Gitea: [@moyin](https://gitea.xinghangee.icu/moyin)
|
||||
- Email: xinghang_a@proton.me
|
||||
- 提交数: **112 commits**
|
||||
- 主要贡献:
|
||||
- 🚀 项目架构设计与初始化
|
||||
- 🔐 完整用户认证系统实现
|
||||
- 📧 邮箱验证系统设计与开发
|
||||
- 🗄️ Redis缓存服务(文件存储+真实Redis双模式)
|
||||
- 📝 完整的API文档系统(Swagger UI + OpenAPI)
|
||||
- 🧪 测试框架搭建与507个测试用例编写
|
||||
- 📊 高性能日志系统集成(Pino)
|
||||
- 🔧 项目配置优化与部署方案
|
||||
- 🐛 验证码TTL重置关键问题修复
|
||||
- 📚 完整的项目文档体系建设
|
||||
- 🏗️ **Zulip模块架构重构** - 业务功能模块化架构设计与实现
|
||||
- 📖 **架构文档重写** - 详细的架构设计文档和开发者指南
|
||||
- 🔄 **验证码冷却时间优化** - 自动清除机制设计与实现
|
||||
- 📋 **文档清理优化** - 项目文档结构化整理和维护体系建立
|
||||
|
||||
### 🌟 核心开发者
|
||||
|
||||
**angjustinl** - 核心开发者
|
||||
- Gitea: [@ANGJustinl](https://gitea.xinghangee.icu/ANGJustinl)
|
||||
- GitHub: [@ANGJustinl](https://github.com/ANGJustinl)
|
||||
- Email: 96008766+ANGJustinl@users.noreply.github.com
|
||||
- 提交数: **7 commits**
|
||||
- 主要贡献:
|
||||
- 🔄 邮箱验证流程重构与优化
|
||||
- 💾 基于内存的用户服务实现
|
||||
- 🛠️ API响应处理改进
|
||||
- 🧪 测试用例完善与错误修复
|
||||
- 📚 系统架构优化
|
||||
- 💬 **Zulip集成系统** - 完整的Zulip实时通信系统开发
|
||||
- 🔧 **E2E测试修复** - Zulip集成的端到端测试优化
|
||||
- 🎯 **验证码登录测试** - 验证码登录功能测试用例编写
|
||||
|
||||
**jianuo** - 核心开发者
|
||||
- Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo)
|
||||
- Email: 32106500027@e.gzhu.edu.cn
|
||||
- 提交数: **11 commits**
|
||||
- 主要贡献:
|
||||
- 🎛️ **管理员后台系统** - 完整的前后端管理界面开发
|
||||
- 📊 **日志管理功能** - 运行时日志查看与下载系统
|
||||
- 🔐 **管理员认证系统** - 独立Token认证与权限控制
|
||||
- 🧪 **单元测试完善** - 管理员功能测试用例编写
|
||||
- ⚙️ **TypeScript配置优化** - Node16模块解析配置
|
||||
- 🐳 **Docker部署优化** - 容器化部署问题修复
|
||||
- 📖 **技术栈文档更新** - 项目技术栈说明完善
|
||||
- 🔧 **项目配置优化** - 构建和开发环境配置改进
|
||||
|
||||
## 贡献统计
|
||||
|
||||
| 贡献者 | 提交数 | 主要领域 | 贡献占比 |
|
||||
|--------|--------|----------|----------|
|
||||
| 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测试确保集成功能的稳定性
|
||||
|
||||
## 项目里程碑
|
||||
|
||||
### 2025年12月
|
||||
- **12月17日**: 项目初始化,完成基础架构搭建
|
||||
- **12月17日**: 实现完整的用户认证系统
|
||||
- **12月17日**: 完成API文档系统集成
|
||||
- **12月17日**: 实现邮箱验证系统
|
||||
- **12月17日**: 修复验证码TTL重置关键问题
|
||||
- **12月18日**: angjustinl重构邮箱验证流程,引入内存用户服务
|
||||
- **12月18日**: jianuo修复Docker部署问题
|
||||
- **12月18日**: 完成测试用例修复和优化
|
||||
- **12月19日**: jianuo开发管理员后台系统
|
||||
- **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个测试用例通过,测试覆盖率达到新高
|
||||
|
||||
## 如何成为贡献者
|
||||
|
||||
我们欢迎所有形式的贡献!无论是:
|
||||
|
||||
- 🐛 **Bug修复** - 发现并修复问题
|
||||
- ✨ **新功能** - 添加有价值的功能
|
||||
- 📚 **文档改进** - 完善项目文档
|
||||
- 🧪 **测试用例** - 提高代码覆盖率
|
||||
- 🎨 **代码优化** - 改进代码质量
|
||||
- 💡 **建议反馈** - 提出改进建议
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 项目到你的Gitea账户
|
||||
2. 创建功能分支:`git checkout -b feature/your-feature`
|
||||
3. 提交你的更改:`git commit -m "feat:添加新功能"`
|
||||
4. 推送到分支:`git push origin feature/your-feature`
|
||||
5. 创建Pull Request
|
||||
|
||||
### 贡献规范
|
||||
|
||||
请在贡献前阅读:
|
||||
- [AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)
|
||||
- [后端开发规范](./docs/backend_development_guide.md)
|
||||
- [Git提交规范](./docs/git_commit_guide.md)
|
||||
|
||||
---
|
||||
|
||||
**再次感谢所有贡献者的辛勤付出!** 🙏
|
||||
|
||||
*如果你的名字没有出现在列表中,请联系我们或提交PR更新此文件。*
|
||||
202
docs/README.md
202
docs/README.md
@@ -1,139 +1,107 @@
|
||||
# 项目文档
|
||||
# 📚 Pixel Game Server 文档中心
|
||||
|
||||
本目录包含了像素游戏服务器的完整文档。
|
||||
欢迎来到 Whale Town 项目文档中心!这里包含了项目的完整文档,帮助你快速了解和使用项目。
|
||||
|
||||
## 文档结构
|
||||
## 📖 **文档导航**
|
||||
|
||||
### 📁 api/
|
||||
API接口相关文档,包含:
|
||||
- **api-documentation.md** - 详细的API接口文档
|
||||
- **openapi.yaml** - OpenAPI 3.0规范文件
|
||||
- **postman-collection.json** - Postman测试集合
|
||||
- **README.md** - API文档使用说明
|
||||
### 🚀 **快速开始**
|
||||
- [项目概述](../README.md) - 项目介绍和快速开始指南
|
||||
- [架构设计](ARCHITECTURE.md) - 系统架构和设计理念
|
||||
|
||||
### 📁 systems/
|
||||
系统设计文档,包含:
|
||||
- **logger/** - 日志系统文档
|
||||
- **user-auth/** - 用户认证系统文档
|
||||
### 🔌 **API文档**
|
||||
- [API接口文档](api/api-documentation.md) - 完整的API接口说明(17个接口)
|
||||
- [API状态码](API_STATUS_CODES.md) - HTTP状态码和错误代码说明
|
||||
- [OpenAPI规范](api/openapi.yaml) - 机器可读的API规范文件
|
||||
- [API使用指南](api/README.md) - API文档使用说明
|
||||
|
||||
### 📄 其他文档
|
||||
- **AI辅助开发规范指南.md** - AI开发规范
|
||||
- **backend_development_guide.md** - 后端开发指南
|
||||
- **git_commit_guide.md** - Git提交规范
|
||||
- **naming_convention.md** - 命名规范
|
||||
- **nestjs_guide.md** - NestJS开发指南
|
||||
- **日志系统详细说明.md** - 日志系统说明
|
||||
### 💻 **开发指南**
|
||||
- [后端开发指南](development/backend_development_guide.md) - 后端开发规范和最佳实践
|
||||
- [NestJS指南](development/nestjs_guide.md) - NestJS框架使用指南
|
||||
- [命名规范](development/naming_convention.md) - 代码命名规范
|
||||
- [Git提交规范](development/git_commit_guide.md) - Git提交消息规范
|
||||
- [AI辅助开发规范](development/AI辅助开发规范指南.md) - AI辅助开发最佳实践
|
||||
- [测试指南](development/TESTING.md) - 测试策略和规范
|
||||
|
||||
## 如何使用
|
||||
### 🚀 **部署运维**
|
||||
- [部署指南](deployment/DEPLOYMENT.md) - 生产环境部署说明
|
||||
|
||||
### 1. 启动服务器并查看Swagger文档
|
||||
### 📋 **项目管理**
|
||||
- [贡献指南](CONTRIBUTORS.md) - 如何参与项目贡献
|
||||
- [文档清理说明](DOCUMENT_CLEANUP.md) - 文档维护和优化记录
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm run dev
|
||||
## 🏗️ **文档结构说明**
|
||||
|
||||
# 访问Swagger UI
|
||||
# 浏览器打开: http://localhost:3000/api-docs
|
||||
```
|
||||
docs/
|
||||
├── README.md # 📚 文档中心首页
|
||||
├── ARCHITECTURE.md # 🏗️ 架构文档
|
||||
├── API_STATUS_CODES.md # 📋 API状态码
|
||||
├── CONTRIBUTORS.md # 🤝 贡献指南
|
||||
├── DOCUMENT_CLEANUP.md # 📝 文档清理说明
|
||||
│
|
||||
├── api/ # 🔌 API文档
|
||||
│ ├── api-documentation.md # API接口文档
|
||||
│ ├── openapi.yaml # OpenAPI规范
|
||||
│ ├── postman-collection.json # Postman测试集合
|
||||
│ └── README.md # API文档说明
|
||||
│
|
||||
├── development/ # 💻 开发指南
|
||||
│ ├── backend_development_guide.md
|
||||
│ ├── nestjs_guide.md
|
||||
│ ├── naming_convention.md
|
||||
│ ├── git_commit_guide.md
|
||||
│ ├── AI辅助开发规范指南.md
|
||||
│ └── TESTING.md
|
||||
│
|
||||
└── deployment/ # 🚀 部署文档
|
||||
└── DEPLOYMENT.md
|
||||
```
|
||||
|
||||
### 2. 使用Postman测试API
|
||||
## 🎯 **文档特色**
|
||||
|
||||
1. 打开Postman
|
||||
2. 点击 Import 按钮
|
||||
3. 选择 `docs/postman-collection.json` 文件
|
||||
4. 导入后即可看到所有API接口
|
||||
5. 修改环境变量 `baseUrl` 为你的服务器地址(默认:http://localhost:3000)
|
||||
### ✨ **业务功能模块化**
|
||||
文档结构与代码架构保持一致,按业务功能组织:
|
||||
- **用户认证模块** - 登录、注册、密码管理
|
||||
- **用户管理模块** - 状态管理、批量操作
|
||||
- **管理员模块** - 后台管理、权限控制
|
||||
- **安全模块** - 频率限制、维护模式
|
||||
|
||||
### 3. 使用OpenAPI规范
|
||||
### 📊 **完整API覆盖**
|
||||
- **17个API接口** - 涵盖所有业务功能
|
||||
- **交互式文档** - Swagger UI实时测试
|
||||
- **标准化规范** - OpenAPI 3.0标准
|
||||
- **测试集合** - Postman一键导入
|
||||
|
||||
#### 在Swagger Editor中查看
|
||||
1. 访问 [Swagger Editor](https://editor.swagger.io/)
|
||||
2. 将 `docs/openapi.yaml` 的内容复制粘贴到编辑器中
|
||||
3. 即可查看可视化的API文档
|
||||
### 🔧 **开发者友好**
|
||||
- **规范指导** - 命名、提交、开发规范
|
||||
- **AI辅助** - 提升开发效率的AI使用指南
|
||||
- **测试覆盖** - 140个测试用例全覆盖
|
||||
- **部署就绪** - 生产环境部署指南
|
||||
|
||||
#### 生成客户端SDK
|
||||
```bash
|
||||
# 使用swagger-codegen生成JavaScript客户端
|
||||
swagger-codegen generate -i docs/openapi.yaml -l javascript -o ./client-sdk
|
||||
## 📝 **文档维护原则**
|
||||
|
||||
# 使用openapi-generator生成TypeScript客户端
|
||||
openapi-generator generate -i docs/openapi.yaml -g typescript-axios -o ./client-sdk
|
||||
```
|
||||
### ✅ **保留的文档类型**
|
||||
- **长期有用**:对整个项目生命周期都有价值的文档
|
||||
- **参考价值**:开发、部署、维护时需要查阅的文档
|
||||
- **规范指南**:团队协作和代码质量保证的规范
|
||||
|
||||
## API接口概览
|
||||
### ❌ **不保留的文档类型**
|
||||
- **阶段性文档**:只在特定开发阶段有用的文档
|
||||
- **临时记录**:会议记录、临时决策等
|
||||
- **过时信息**:已经不适用的旧版本文档
|
||||
|
||||
| 接口 | 方法 | 路径 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 用户登录 | POST | /auth/login | 支持用户名、邮箱或手机号登录 |
|
||||
| 用户注册 | POST | /auth/register | 创建新用户账户 |
|
||||
| GitHub OAuth | POST | /auth/github | 使用GitHub账户登录或注册 |
|
||||
| 发送验证码 | POST | /auth/forgot-password | 发送密码重置验证码 |
|
||||
| 重置密码 | POST | /auth/reset-password | 使用验证码重置密码 |
|
||||
| 修改密码 | PUT | /auth/change-password | 修改用户密码 |
|
||||
### 🔄 **文档更新策略**
|
||||
- **及时更新**:功能变更时同步更新相关文档
|
||||
- **版本控制**:重要变更记录版本历史
|
||||
- **定期审查**:定期检查文档的准确性和有效性
|
||||
|
||||
## 快速测试
|
||||
## 🤝 **如何贡献文档**
|
||||
|
||||
### 使用cURL测试登录接口
|
||||
1. **发现问题**:发现文档错误或缺失时,请提交Issue
|
||||
2. **改进文档**:按照项目规范提交Pull Request
|
||||
3. **新增文档**:新功能开发时同步编写相关文档
|
||||
4. **审查文档**:参与文档审查,确保质量和准确性
|
||||
|
||||
```bash
|
||||
# 测试用户登录
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"identifier": "testuser",
|
||||
"password": "password123"
|
||||
}'
|
||||
---
|
||||
|
||||
# 测试用户注册
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "newuser",
|
||||
"password": "password123",
|
||||
"nickname": "新用户",
|
||||
"email": "newuser@example.com"
|
||||
}'
|
||||
```
|
||||
|
||||
### 使用JavaScript测试
|
||||
|
||||
```javascript
|
||||
// 用户登录
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **开发环境**: 当前配置适用于开发环境,生产环境需要使用HTTPS
|
||||
2. **认证**: 实际应用中应实现JWT认证机制
|
||||
3. **限流**: 建议对认证接口实施限流策略
|
||||
4. **验证码**: 示例中返回验证码仅用于演示,生产环境不应返回
|
||||
5. **错误处理**: 建议实现统一的错误处理机制
|
||||
|
||||
## 更新文档
|
||||
|
||||
当API接口发生变化时,请同步更新以下文件:
|
||||
1. 更新DTO类的Swagger装饰器
|
||||
2. 更新 `api-documentation.md`
|
||||
3. 更新 `openapi.yaml`
|
||||
4. 更新 `postman-collection.json`
|
||||
5. 重新生成Swagger文档
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [NestJS Swagger文档](https://docs.nestjs.com/openapi/introduction)
|
||||
- [OpenAPI规范](https://swagger.io/specification/)
|
||||
- [Postman文档](https://learning.postman.com/docs/getting-started/introduction/)
|
||||
- [Swagger Editor](https://editor.swagger.io/)
|
||||
📧 **联系我们**:如有文档相关问题,请通过项目Issue或邮件联系维护团队。
|
||||
@@ -1,29 +1,30 @@
|
||||
# API接口文档
|
||||
|
||||
本目录包含了像素游戏服务器用户认证API的完整文档。
|
||||
本目录包含了 Whale Town 像素游戏服务器的完整API文档,采用业务功能模块化设计,提供17个接口覆盖所有核心功能。
|
||||
|
||||
## 📋 文档文件说明
|
||||
|
||||
### 1. api-documentation.md
|
||||
详细的API接口文档,包含:
|
||||
- **17个API接口** - 用户认证、用户管理、管理员功能、安全防护
|
||||
- 接口概述和通用响应格式
|
||||
- 每个接口的详细说明、参数、响应示例
|
||||
- 错误代码说明
|
||||
- 数据验证规则
|
||||
- 错误代码说明和状态码映射
|
||||
- 数据验证规则和业务逻辑
|
||||
- 使用示例(JavaScript/TypeScript 和 cURL)
|
||||
|
||||
### 2. openapi.yaml
|
||||
OpenAPI 3.0规范文件,可以用于:
|
||||
- 导入到Swagger Editor查看和编辑
|
||||
- 生成客户端SDK
|
||||
- 集成到API网关
|
||||
- 自动化测试
|
||||
- 生成客户端SDK(支持多种语言)
|
||||
- 集成到API网关和测试工具
|
||||
- 自动化测试和文档生成
|
||||
|
||||
### 3. postman-collection.json
|
||||
Postman集合文件,包含:
|
||||
- 所有API接口的请求示例
|
||||
- 预设的请求参数
|
||||
- 响应示例
|
||||
- 所有17个API接口的请求示例
|
||||
- 预设的请求参数和环境变量
|
||||
- 完整的响应示例和测试脚本
|
||||
- 可直接导入Postman进行测试
|
||||
|
||||
## 🚀 快速开始
|
||||
@@ -34,7 +35,7 @@ Postman集合文件,包含:
|
||||
# 启动开发服务器
|
||||
pnpm run dev
|
||||
|
||||
# 访问Swagger UI
|
||||
# 访问Swagger UI(推荐)
|
||||
# 浏览器打开: http://localhost:3000/api-docs
|
||||
```
|
||||
|
||||
@@ -64,78 +65,144 @@ openapi-generator generate -i docs/api/openapi.yaml -g typescript-axios -o ./cli
|
||||
|
||||
## 📊 API接口概览
|
||||
|
||||
### 🔐 用户认证模块 (9个接口)
|
||||
| 接口 | 方法 | 路径 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 用户登录 | POST | /auth/login | 支持用户名、邮箱或手机号登录 |
|
||||
| 用户注册 | POST | /auth/register | 创建新用户账户 |
|
||||
| GitHub OAuth | POST | /auth/github | 使用GitHub账户登录或注册 |
|
||||
| 发送验证码 | POST | /auth/forgot-password | 发送密码重置验证码 |
|
||||
| 发送重置验证码 | POST | /auth/forgot-password | 发送密码重置验证码 |
|
||||
| 重置密码 | POST | /auth/reset-password | 使用验证码重置密码 |
|
||||
| 修改密码 | PUT | /auth/change-password | 修改用户密码 |
|
||||
| 发送邮箱验证码 | POST | /auth/send-email-verification | 发送邮箱验证码 |
|
||||
| 验证邮箱 | POST | /auth/verify-email | 验证邮箱验证码 |
|
||||
| 重发邮箱验证码 | POST | /auth/resend-email-verification | 重新发送邮箱验证码 |
|
||||
|
||||
### 👥 用户管理模块 (3个接口)
|
||||
| 接口 | 方法 | 路径 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 修改用户状态 | PUT | /admin/users/:id/status | 修改指定用户状态 |
|
||||
| 批量修改状态 | POST | /admin/users/batch-status | 批量修改用户状态 |
|
||||
| 用户状态统计 | GET | /admin/users/status-stats | 获取各状态用户统计 |
|
||||
|
||||
### 🛡️ 管理员模块 (4个接口)
|
||||
| 接口 | 方法 | 路径 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 管理员登录 | POST | /admin/auth/login | 管理员身份认证 |
|
||||
| 获取用户列表 | GET | /admin/users | 分页获取用户列表 |
|
||||
| 获取用户详情 | GET | /admin/users/:id | 获取指定用户信息 |
|
||||
| 重置用户密码 | POST | /admin/users/:id/reset-password | 管理员重置用户密码 |
|
||||
|
||||
### 📊 系统状态 (1个接口)
|
||||
| 接口 | 方法 | 路径 | 描述 |
|
||||
|------|------|------|------|
|
||||
| 应用状态 | GET | / | 获取应用运行状态和系统信息 |
|
||||
|
||||
## 🧪 快速测试
|
||||
|
||||
### 使用cURL测试登录接口
|
||||
### 使用cURL测试核心接口
|
||||
|
||||
```bash
|
||||
# 测试用户登录
|
||||
# 1. 测试应用状态
|
||||
curl -X GET http://localhost:3000/
|
||||
|
||||
# 2. 测试用户注册
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "testuser",
|
||||
"password": "Test123456",
|
||||
"nickname": "测试用户",
|
||||
"email": "test@example.com"
|
||||
}'
|
||||
|
||||
# 3. 测试用户登录
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"identifier": "testuser",
|
||||
"password": "password123"
|
||||
"password": "Test123456"
|
||||
}'
|
||||
|
||||
# 测试用户注册
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
# 4. 测试管理员登录
|
||||
curl -X POST http://localhost:3000/admin/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "newuser",
|
||||
"password": "password123",
|
||||
"nickname": "新用户",
|
||||
"email": "newuser@example.com"
|
||||
"username": "admin",
|
||||
"password": "Admin123456"
|
||||
}'
|
||||
```
|
||||
|
||||
### 使用JavaScript测试
|
||||
|
||||
```javascript
|
||||
// 用户注册
|
||||
const registerResponse = await fetch('http://localhost:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'testuser',
|
||||
password: 'Test123456',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com'
|
||||
})
|
||||
});
|
||||
|
||||
// 用户登录
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
const loginResponse = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
password: 'Test123456'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
const loginData = await loginResponse.json();
|
||||
console.log('登录结果:', loginData);
|
||||
```
|
||||
|
||||
### 使用自动化测试脚本
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
.\test-api.ps1
|
||||
|
||||
# Linux/macOS Bash
|
||||
./test-api.sh
|
||||
|
||||
# 自定义测试参数
|
||||
.\test-api.ps1 -BaseUrl "http://localhost:3000" -TestEmail "custom@example.com"
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **开发环境**: 当前配置适用于开发环境,生产环境需要使用HTTPS
|
||||
2. **认证**: 实际应用中应实现JWT认证机制
|
||||
3. **限流**: 建议对认证接口实施限流策略
|
||||
4. **验证码**: 示例中返回验证码仅用于演示,生产环境不应返回
|
||||
5. **错误处理**: 建议实现统一的错误处理机制
|
||||
2. **认证机制**: 项目使用JWT认证,管理员使用独立的Token系统
|
||||
3. **频率限制**: 已实现API频率限制,登录接口2次/分钟,管理员操作10次/分钟
|
||||
4. **用户状态**: 支持6种用户状态管理(active、inactive、locked、banned、deleted、pending)
|
||||
5. **测试模式**: 邮件服务支持测试模式,验证码会在控制台输出
|
||||
6. **存储模式**: 支持Redis文件存储和内存数据库,便于无依赖测试
|
||||
7. **安全防护**: 实现了维护模式、内容类型检查、超时控制等安全机制
|
||||
|
||||
## 🔄 更新文档
|
||||
|
||||
当API接口发生变化时,请同步更新以下文件:
|
||||
1. 更新DTO类的Swagger装饰器
|
||||
2. 更新 `api-documentation.md`
|
||||
3. 更新 `openapi.yaml`
|
||||
4. 更新 `postman-collection.json`
|
||||
5. 重新生成Swagger文档
|
||||
1. 更新Controller和DTO类的Swagger装饰器
|
||||
2. 更新 `api-documentation.md` 接口文档
|
||||
3. 更新 `openapi.yaml` 规范文件
|
||||
4. 更新 `postman-collection.json` 测试集合
|
||||
5. 重新生成Swagger文档并验证
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [NestJS Swagger文档](https://docs.nestjs.com/openapi/introduction)
|
||||
- [OpenAPI规范](https://swagger.io/specification/)
|
||||
- [Postman文档](https://learning.postman.com/docs/getting-started/introduction/)
|
||||
- [Swagger Editor](https://editor.swagger.io/)
|
||||
- [Swagger Editor](https://editor.swagger.io/)
|
||||
- [项目架构文档](../ARCHITECTURE.md)
|
||||
- [开发规范指南](../development/)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Pixel Game Server - Auth API
|
||||
description: 像素游戏服务器用户认证API接口文档
|
||||
version: 1.0.0
|
||||
description: 像素游戏服务器用户认证API接口文档 - 包含验证码登录功能、邮箱冲突检测、验证码冷却优化和邮件模板修复
|
||||
version: 1.1.3
|
||||
contact:
|
||||
name: API Support
|
||||
email: support@example.com
|
||||
@@ -15,10 +15,39 @@ servers:
|
||||
description: 开发环境
|
||||
|
||||
tags:
|
||||
- name: app
|
||||
description: 应用状态相关接口
|
||||
- name: auth
|
||||
description: 用户认证相关接口
|
||||
- name: admin
|
||||
description: 管理员后台相关接口
|
||||
- name: user-management
|
||||
description: 用户管理相关接口
|
||||
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
tags:
|
||||
- app
|
||||
summary: 获取应用状态
|
||||
description: 返回应用的基本运行状态信息,用于健康检查和监控
|
||||
operationId: getAppStatus
|
||||
responses:
|
||||
'200':
|
||||
description: 应用状态获取成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppStatusResponse'
|
||||
example:
|
||||
service: Pixel Game Server
|
||||
version: 1.0.0
|
||||
status: running
|
||||
timestamp: "2025-12-25T08:00:00.000Z"
|
||||
uptime: 3600
|
||||
environment: development
|
||||
storage_mode: database
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -77,7 +106,7 @@ paths:
|
||||
tags:
|
||||
- auth
|
||||
summary: 用户注册
|
||||
description: 创建新用户账户
|
||||
description: 创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码。发送验证码接口会自动检查邮箱是否已被注册,避免向已存在邮箱发送验证码。
|
||||
operationId: register
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -99,17 +128,49 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterResponse'
|
||||
'400':
|
||||
description: 请求参数错误
|
||||
description: 请求参数错误或验证码错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
validation_error:
|
||||
summary: 参数验证错误
|
||||
value:
|
||||
success: false
|
||||
message: "密码必须包含字母和数字,长度8-128字符"
|
||||
error_code: "REGISTER_FAILED"
|
||||
verification_code_error:
|
||||
summary: 验证码错误
|
||||
value:
|
||||
success: false
|
||||
message: "验证码不存在或已过期"
|
||||
error_code: "REGISTER_FAILED"
|
||||
'409':
|
||||
description: 用户名或邮箱已存在
|
||||
description: 资源冲突 - 用户名、邮箱或手机号已存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
username_exists:
|
||||
summary: 用户名已存在
|
||||
value:
|
||||
success: false
|
||||
message: "用户名已存在"
|
||||
error_code: "REGISTER_FAILED"
|
||||
email_exists:
|
||||
summary: 邮箱已存在
|
||||
value:
|
||||
success: false
|
||||
message: "邮箱已存在"
|
||||
error_code: "REGISTER_FAILED"
|
||||
phone_exists:
|
||||
summary: 手机号已存在
|
||||
value:
|
||||
success: false
|
||||
message: "手机号已存在"
|
||||
error_code: "REGISTER_FAILED"
|
||||
|
||||
/auth/github:
|
||||
post:
|
||||
@@ -259,8 +320,290 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/send-email-verification:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 发送邮箱验证码
|
||||
description: 向指定邮箱发送验证码。如果邮箱已被注册,将返回冲突错误。
|
||||
operationId: sendEmailVerification
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendEmailVerificationDto'
|
||||
example:
|
||||
email: test@example.com
|
||||
responses:
|
||||
'200':
|
||||
description: 验证码发送成功(真实发送模式)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EmailVerificationResponse'
|
||||
'206':
|
||||
description: 测试模式:验证码已生成但未真实发送
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
|
||||
'400':
|
||||
description: 请求参数错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: 邮箱已被注册
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
message: "邮箱已被注册,请使用其他邮箱或直接登录"
|
||||
error_code: "SEND_EMAIL_VERIFICATION_FAILED"
|
||||
'429':
|
||||
description: 发送频率过高
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ThrottleErrorResponse'
|
||||
|
||||
/auth/verify-email:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 验证邮箱验证码
|
||||
description: 使用验证码验证邮箱
|
||||
operationId: verifyEmail
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EmailVerificationDto'
|
||||
example:
|
||||
email: test@example.com
|
||||
verification_code: "123456"
|
||||
responses:
|
||||
'200':
|
||||
description: 邮箱验证成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CommonResponse'
|
||||
'400':
|
||||
description: 验证码错误或已过期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/resend-email-verification:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 重新发送邮箱验证码
|
||||
description: 重新向指定邮箱发送验证码
|
||||
operationId: resendEmailVerification
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendEmailVerificationDto'
|
||||
example:
|
||||
email: test@example.com
|
||||
responses:
|
||||
'200':
|
||||
description: 验证码重新发送成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EmailVerificationResponse'
|
||||
'206':
|
||||
description: 测试模式:验证码已生成但未真实发送
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
|
||||
'400':
|
||||
description: 邮箱已验证或用户不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: 发送频率过高
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ThrottleErrorResponse'
|
||||
|
||||
/auth/verification-code-login:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 验证码登录
|
||||
description: 使用邮箱或手机号和验证码进行登录,无需密码
|
||||
operationId: verificationCodeLogin
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationCodeLoginDto'
|
||||
example:
|
||||
identifier: test@example.com
|
||||
verification_code: "123456"
|
||||
responses:
|
||||
'200':
|
||||
description: 验证码登录成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
'400':
|
||||
description: 请求参数错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: 验证码错误或已过期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationCodeLoginErrorResponse'
|
||||
'404':
|
||||
description: 用户不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/send-login-verification-code:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 发送登录验证码
|
||||
description: 向用户邮箱或手机发送登录验证码。邮件内容使用专门的登录验证码模板,标题为"登录验证码",内容说明用于登录验证而非密码重置。
|
||||
operationId: sendLoginVerificationCode
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendLoginVerificationCodeDto'
|
||||
example:
|
||||
identifier: test@example.com
|
||||
responses:
|
||||
'200':
|
||||
description: 验证码发送成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EmailVerificationResponse'
|
||||
'206':
|
||||
description: 测试模式:验证码已生成但未真实发送
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
|
||||
'400':
|
||||
description: 请求参数错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: 用户不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendLoginCodeErrorResponse'
|
||||
'429':
|
||||
description: 发送频率过高
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ThrottleErrorResponse'
|
||||
|
||||
/auth/debug-verification-code:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 调试验证码信息
|
||||
description: 获取验证码的详细调试信息(仅开发环境)
|
||||
operationId: debugVerificationCode
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SendEmailVerificationDto'
|
||||
example:
|
||||
email: test@example.com
|
||||
responses:
|
||||
'200':
|
||||
description: 调试信息获取成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DebugVerificationCodeResponse'
|
||||
|
||||
/auth/debug-clear-throttle:
|
||||
post:
|
||||
tags:
|
||||
- auth
|
||||
summary: 清除限流记录
|
||||
description: 清除所有限流记录(仅开发环境使用)
|
||||
operationId: clearThrottle
|
||||
responses:
|
||||
'200':
|
||||
description: 限流记录已清除
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CommonResponse'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
AppStatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
service:
|
||||
type: string
|
||||
description: 服务名称
|
||||
example: Pixel Game Server
|
||||
version:
|
||||
type: string
|
||||
description: 版本号
|
||||
example: 1.0.0
|
||||
status:
|
||||
type: string
|
||||
description: 运行状态
|
||||
example: running
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 当前时间戳
|
||||
example: "2025-12-25T08:00:00.000Z"
|
||||
uptime:
|
||||
type: integer
|
||||
description: 运行时间(秒)
|
||||
example: 3600
|
||||
environment:
|
||||
type: string
|
||||
description: 运行环境
|
||||
example: development
|
||||
storage_mode:
|
||||
type: string
|
||||
description: 存储模式
|
||||
example: database
|
||||
|
||||
LoginDto:
|
||||
type: object
|
||||
required:
|
||||
@@ -415,6 +758,64 @@ components:
|
||||
pattern: '^(?=.*[a-zA-Z])(?=.*\d)'
|
||||
example: newpassword123
|
||||
|
||||
SendEmailVerificationDto:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: 邮箱地址
|
||||
example: test@example.com
|
||||
|
||||
EmailVerificationDto:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- verification_code
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: 邮箱地址
|
||||
example: test@example.com
|
||||
verification_code:
|
||||
type: string
|
||||
description: 6位数字验证码
|
||||
pattern: '^\d{6}$'
|
||||
example: "123456"
|
||||
|
||||
VerificationCodeLoginDto:
|
||||
type: object
|
||||
required:
|
||||
- identifier
|
||||
- verification_code
|
||||
properties:
|
||||
identifier:
|
||||
type: string
|
||||
description: 登录标识符(邮箱或手机号)
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: test@example.com
|
||||
verification_code:
|
||||
type: string
|
||||
description: 6位数字验证码
|
||||
pattern: '^\d{6}$'
|
||||
example: "123456"
|
||||
|
||||
SendLoginVerificationCodeDto:
|
||||
type: object
|
||||
required:
|
||||
- identifier
|
||||
properties:
|
||||
identifier:
|
||||
type: string
|
||||
description: 邮箱或手机号
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
example: test@example.com
|
||||
|
||||
UserInfo:
|
||||
type: object
|
||||
properties:
|
||||
@@ -565,4 +966,175 @@ components:
|
||||
error_code:
|
||||
type: string
|
||||
description: 错误代码
|
||||
example: OPERATION_FAILED
|
||||
example: OPERATION_FAILED
|
||||
|
||||
EmailVerificationResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
sent_to:
|
||||
type: string
|
||||
description: 发送目标
|
||||
example: test@example.com
|
||||
expires_in:
|
||||
type: integer
|
||||
description: 过期时间(秒)
|
||||
example: 300
|
||||
is_test_mode:
|
||||
type: boolean
|
||||
description: 是否为测试模式
|
||||
example: false
|
||||
message:
|
||||
type: string
|
||||
description: 响应消息
|
||||
example: 验证码已发送,请查收邮件
|
||||
|
||||
TestModeEmailVerificationResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: false
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
verification_code:
|
||||
type: string
|
||||
description: 验证码(仅测试模式)
|
||||
example: "123456"
|
||||
sent_to:
|
||||
type: string
|
||||
description: 发送目标
|
||||
example: test@example.com
|
||||
expires_in:
|
||||
type: integer
|
||||
description: 过期时间(秒)
|
||||
example: 300
|
||||
is_test_mode:
|
||||
type: boolean
|
||||
description: 是否为测试模式
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
description: 响应消息
|
||||
example: 测试模式:验证码已生成但未真实发送
|
||||
error_code:
|
||||
type: string
|
||||
description: 错误代码
|
||||
example: TEST_MODE_ONLY
|
||||
|
||||
VerificationCodeLoginErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: false
|
||||
message:
|
||||
type: string
|
||||
description: 错误消息
|
||||
example: 验证码错误或已过期
|
||||
error_code:
|
||||
type: string
|
||||
description: 错误代码
|
||||
example: VERIFICATION_CODE_LOGIN_FAILED
|
||||
|
||||
SendLoginCodeErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: false
|
||||
message:
|
||||
type: string
|
||||
description: 错误消息
|
||||
example: 用户不存在
|
||||
error_code:
|
||||
type: string
|
||||
description: 错误代码
|
||||
example: SEND_LOGIN_CODE_FAILED
|
||||
|
||||
ThrottleErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: false
|
||||
message:
|
||||
type: string
|
||||
description: 错误消息
|
||||
example: 请求过于频繁,请稍后再试
|
||||
error_code:
|
||||
type: string
|
||||
description: 错误代码
|
||||
example: TOO_MANY_REQUESTS
|
||||
throttle_info:
|
||||
type: object
|
||||
properties:
|
||||
limit:
|
||||
type: integer
|
||||
description: 限制次数
|
||||
example: 1
|
||||
window_seconds:
|
||||
type: integer
|
||||
description: 时间窗口(秒)
|
||||
example: 60
|
||||
current_requests:
|
||||
type: integer
|
||||
description: 当前请求次数
|
||||
example: 1
|
||||
reset_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 重置时间
|
||||
example: "2025-12-25T08:01:00.000Z"
|
||||
|
||||
DebugVerificationCodeResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 请求是否成功
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Redis键名
|
||||
example: verification_code:email_verification:test@example.com
|
||||
exists:
|
||||
type: boolean
|
||||
description: 是否存在
|
||||
example: true
|
||||
ttl:
|
||||
type: integer
|
||||
description: 剩余生存时间(秒)
|
||||
example: 290
|
||||
rawData:
|
||||
type: string
|
||||
description: 原始数据
|
||||
example: '{"code":"123456","createdAt":1766649341250}'
|
||||
parsedData:
|
||||
type: object
|
||||
description: 解析后的数据
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: "123456"
|
||||
createdAt:
|
||||
type: integer
|
||||
example: 1766649341250
|
||||
currentTime:
|
||||
type: integer
|
||||
description: 当前时间戳
|
||||
example: 1766649341250
|
||||
418
docs/deployment/DEPLOYMENT.md
Normal file
418
docs/deployment/DEPLOYMENT.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# 🚀 Whale Town 部署指南
|
||||
|
||||
本文档详细说明如何部署 Whale Town 像素游戏后端服务到生产环境。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
### 基础环境
|
||||
- **Node.js** 18+ (推荐 20.x LTS)
|
||||
- **pnpm** 包管理器
|
||||
- **MySQL** 8.0+
|
||||
- **Redis** 6.0+ (可选,支持文件存储模式)
|
||||
- **PM2** 进程管理器(推荐)
|
||||
- **Nginx** 反向代理(推荐)
|
||||
|
||||
### 新增要求 (管理员后台)
|
||||
- **Web服务器** (Nginx/Apache) - 用于前端管理界面
|
||||
- **SSL证书** (推荐) - 保护管理后台安全
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 服务器环境准备
|
||||
|
||||
```bash
|
||||
# 安装 Node.js (使用 NodeSource 仓库)
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# 安装 pnpm
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh
|
||||
source ~/.bashrc
|
||||
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 安装 MySQL
|
||||
sudo apt update
|
||||
sudo apt install mysql-server
|
||||
sudo mysql_secure_installation
|
||||
```
|
||||
|
||||
### 2. 克隆项目
|
||||
|
||||
```bash
|
||||
# 创建项目目录
|
||||
sudo mkdir -p /var/www
|
||||
cd /var/www
|
||||
|
||||
# 克隆项目(替换为你的实际仓库地址)
|
||||
git clone https://gitea.xinghangee.icu/datawhale/whale-town-end.git
|
||||
cd whale-town-end
|
||||
```
|
||||
|
||||
### 3. 配置环境
|
||||
|
||||
```bash
|
||||
# 复制环境配置文件
|
||||
cp .env.production.example .env.production
|
||||
|
||||
# 编辑环境配置(填入实际的数据库信息)
|
||||
nano .env.production
|
||||
|
||||
# 复制部署脚本
|
||||
cp deploy.sh.example deploy.sh
|
||||
chmod +x deploy.sh
|
||||
|
||||
# 编辑部署脚本(修改路径配置)
|
||||
nano deploy.sh
|
||||
|
||||
# 复制 webhook 处理器
|
||||
cp webhook-handler.js.example webhook-handler.js
|
||||
|
||||
# 编辑 webhook 处理器(修改密钥和路径)
|
||||
nano webhook-handler.js
|
||||
```
|
||||
|
||||
### 4. 数据库设置
|
||||
|
||||
```bash
|
||||
# 登录 MySQL
|
||||
sudo mysql -u root -p
|
||||
|
||||
# 创建数据库和用户
|
||||
CREATE DATABASE pixel_game_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'pixel_game'@'localhost' IDENTIFIED BY 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON pixel_game_db.* TO 'pixel_game'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
### 5. 安装依赖和构建
|
||||
|
||||
```bash
|
||||
# 安装后端依赖
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 安装前端依赖 (新增)
|
||||
cd client
|
||||
pnpm install --frozen-lockfile
|
||||
cd ..
|
||||
|
||||
# 构建后端
|
||||
pnpm run build
|
||||
|
||||
# 构建前端管理界面 (新增)
|
||||
cd client
|
||||
pnpm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 6. 启动服务
|
||||
|
||||
```bash
|
||||
# 使用 PM2 启动应用
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# 保存 PM2 配置
|
||||
pm2 save
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
# 按照提示执行显示的命令
|
||||
```
|
||||
|
||||
### 7. 配置 Nginx
|
||||
|
||||
#### 方案一: 分离部署 (推荐)
|
||||
|
||||
创建后端API配置:
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/whale-town-api
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.whaletown.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
创建前端管理界面配置:
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/whale-town-admin
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.whaletown.com;
|
||||
root /var/www/whale-town-end/client/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA路由支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API代理
|
||||
location /api/ {
|
||||
proxy_pass http://api.whaletown.com/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案二: 单域名部署
|
||||
|
||||
创建统一配置:
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/whale-town-unified
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name whaletown.com;
|
||||
|
||||
# API接口
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 管理后台
|
||||
location /admin/ {
|
||||
alias /var/www/whale-town-end/client/dist/;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# 主站点 (可选)
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用配置:
|
||||
```bash
|
||||
# 启用站点
|
||||
sudo ln -s /etc/nginx/sites-available/whale-town-* /etc/nginx/sites-enabled/
|
||||
|
||||
# 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 重载配置
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 🔒 SSL证书配置 (推荐)
|
||||
|
||||
### 使用 Let's Encrypt
|
||||
```bash
|
||||
# 安装 Certbot
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
|
||||
# 为API域名申请证书
|
||||
sudo certbot --nginx -d api.whaletown.com
|
||||
|
||||
# 为管理后台申请证书
|
||||
sudo certbot --nginx -d admin.whaletown.com
|
||||
|
||||
# 设置自动续期
|
||||
sudo crontab -e
|
||||
# 添加: 0 12 * * * /usr/bin/certbot renew --quiet
|
||||
```
|
||||
|
||||
## 🎛️ 管理员后台配置
|
||||
|
||||
### 环境变量配置
|
||||
在 `.env.production` 中添加:
|
||||
```bash
|
||||
# 管理员Token配置 (必须)
|
||||
ADMIN_TOKEN_SECRET=your_super_strong_random_secret_at_least_32_chars
|
||||
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||
|
||||
# 首次部署启用管理员引导
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=YourStrongPassword123!
|
||||
ADMIN_NICKNAME=系统管理员
|
||||
|
||||
# CORS配置 (如果前后端分离)
|
||||
CORS_ORIGIN=https://admin.whaletown.com
|
||||
```
|
||||
|
||||
### 访问管理后台
|
||||
- **地址**: https://admin.whaletown.com
|
||||
- **默认账号**: admin / YourStrongPassword123!
|
||||
|
||||
**⚠️ 重要**: 首次登录后立即修改密码并关闭引导功能 (`ADMIN_BOOTSTRAP_ENABLED=false`)
|
||||
|
||||
## 📡 Gitea Webhook 配置
|
||||
1. 在 Gitea 仓库中进入 **Settings** → **Webhooks**
|
||||
3. 配置:
|
||||
- **Target URL**: `http://your-server.com:9000/webhook` 或 `http://your-domain.com/webhook`
|
||||
- **HTTP Method**: `POST`
|
||||
- **POST Content Type**: `application/json`
|
||||
- **Secret**: 与 `webhook-handler.js` 中的 `SECRET` 一致
|
||||
- **Trigger On**: 选择 `Push events`
|
||||
- **Branch filter**: `main`
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 基础服务检查
|
||||
```bash
|
||||
# 检查PM2服务状态
|
||||
pm2 status
|
||||
|
||||
# 检查后端API
|
||||
curl http://localhost:3000/
|
||||
curl http://localhost:3000/api-docs
|
||||
|
||||
# 检查前端管理界面
|
||||
curl -I https://admin.whaletown.com
|
||||
```
|
||||
|
||||
### 管理员后台测试
|
||||
```bash
|
||||
# 测试管理员登录API
|
||||
curl -X POST https://api.whaletown.com/admin/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identifier":"admin","password":"YourStrongPassword123!"}'
|
||||
|
||||
# 访问管理界面
|
||||
# 浏览器打开: https://admin.whaletown.com
|
||||
```
|
||||
|
||||
### 功能验证清单
|
||||
- [ ] 后端API服务正常响应
|
||||
- [ ] API文档可访问
|
||||
- [ ] 前端管理界面加载正常
|
||||
- [ ] 管理员登录功能正常
|
||||
- [ ] 用户管理功能正常
|
||||
- [ ] 日志查看功能正常
|
||||
- [ ] SSL证书配置正确
|
||||
|
||||
## 🔧 常用命令
|
||||
|
||||
### 服务管理
|
||||
```bash
|
||||
# 重启后端服务
|
||||
pm2 restart whale-town-end
|
||||
|
||||
# 重启前端服务 (如果使用PM2托管)
|
||||
pm2 restart whale-town-admin
|
||||
|
||||
# 查看服务日志
|
||||
pm2 logs whale-town-end --lines 100
|
||||
pm2 logs whale-town-admin --lines 100
|
||||
|
||||
# 手动部署
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
### 更新部署
|
||||
```bash
|
||||
# 更新后端
|
||||
git pull origin main
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pm2 reload whale-town-end
|
||||
|
||||
# 更新前端管理界面
|
||||
cd client
|
||||
git pull origin main
|
||||
pnpm install
|
||||
pnpm run build
|
||||
sudo systemctl reload nginx
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 日志管理
|
||||
```bash
|
||||
# 查看应用日志
|
||||
tail -f logs/app.log
|
||||
|
||||
# 查看管理员操作日志
|
||||
tail -f logs/admin.log
|
||||
|
||||
# 查看Nginx日志
|
||||
sudo tail -f /var/log/nginx/access.log
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
## 🚨 故障排除
|
||||
|
||||
### 后端服务问题
|
||||
**服务无法启动**
|
||||
- 检查环境变量配置 (`cat .env.production`)
|
||||
- 检查数据库连接 (`mysql -u pixel_game -p`)
|
||||
- 查看PM2日志 (`pm2 logs whale-town-end`)
|
||||
- 检查端口占用 (`netstat -tlnp | grep 3000`)
|
||||
|
||||
**管理员登录失败**
|
||||
- 验证 `ADMIN_TOKEN_SECRET` 配置
|
||||
- 检查管理员账号是否创建
|
||||
- 查看后端错误日志
|
||||
- 确认密码复杂度要求
|
||||
|
||||
### 前端管理界面问题
|
||||
**界面无法访问**
|
||||
- 检查前端构建是否成功 (`ls -la client/dist/`)
|
||||
- 验证Nginx配置 (`sudo nginx -t`)
|
||||
- 检查域名解析
|
||||
- 查看Nginx错误日志
|
||||
|
||||
**API请求失败**
|
||||
- 检查CORS配置
|
||||
- 验证API代理设置
|
||||
- 确认后端服务状态
|
||||
- 检查防火墙规则
|
||||
|
||||
### 数据库连接问题
|
||||
**连接失败**
|
||||
- 检查MySQL服务状态 (`sudo systemctl status mysql`)
|
||||
- 验证数据库用户权限
|
||||
- 检查网络连接
|
||||
- 确认数据库配置
|
||||
|
||||
### SSL证书问题
|
||||
**证书验证失败**
|
||||
- 检查证书有效期 (`sudo certbot certificates`)
|
||||
- 验证域名解析
|
||||
- 重新申请证书 (`sudo certbot --nginx -d your-domain.com`)
|
||||
|
||||
### 性能问题
|
||||
**响应缓慢**
|
||||
- 检查系统资源使用 (`htop`, `df -h`)
|
||||
- 优化数据库查询
|
||||
- 配置Redis缓存
|
||||
- 启用Nginx压缩
|
||||
|
||||
### 日志文件过大
|
||||
**磁盘空间不足**
|
||||
- 配置日志轮转 (`sudo nano /etc/logrotate.d/whale-town`)
|
||||
- 清理旧日志文件
|
||||
- 监控磁盘使用情况
|
||||
276
docs/development/TESTING.md
Normal file
276
docs/development/TESTING.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 测试指南
|
||||
|
||||
本项目支持**无数据库和无邮件服务器**的测试模式,让你可以快速验证所有功能!
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 环境配置
|
||||
|
||||
```bash
|
||||
# 复制环境配置文件
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
默认配置已经设置为测试模式,无需修改即可使用。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 运行测试
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
.\test-api.ps1
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
./test-api.sh
|
||||
```
|
||||
|
||||
**自定义参数:**
|
||||
```bash
|
||||
# Windows
|
||||
.\test-api.ps1 -BaseUrl "http://localhost:3000" -TestEmail "custom@example.com"
|
||||
|
||||
# Linux/macOS
|
||||
./test-api.sh "http://localhost:3000" "custom@example.com"
|
||||
```
|
||||
|
||||
## 🧪 测试功能
|
||||
|
||||
### API功能测试
|
||||
测试脚本会验证以下核心功能:
|
||||
|
||||
**用户认证模块:**
|
||||
- ✅ **邮箱验证码发送** - 生成6位数验证码,测试模式输出到控制台
|
||||
- ✅ **邮箱验证码验证** - 验证码校验和自动清理
|
||||
- ✅ **用户注册** - 完整的用户注册流程,包含邮箱验证
|
||||
- ✅ **用户登录** - 支持用户名/邮箱/手机号多种方式登录
|
||||
|
||||
**系统状态测试:**
|
||||
- ✅ **应用状态检查** - 验证服务器运行状态和系统信息
|
||||
- ✅ **Redis文件存储** - 验证验证码存储和读取功能
|
||||
- ✅ **内存数据库** - 验证用户数据存储功能
|
||||
|
||||
### 单元测试覆盖
|
||||
|
||||
**核心服务测试(7个测试套件,140个测试用例):**
|
||||
|
||||
1. **LoginCoreService** - 登录核心服务(15个测试)
|
||||
- 用户登录成功/失败场景
|
||||
- 用户注册功能测试
|
||||
- GitHub OAuth登录测试
|
||||
- 密码重置和修改功能
|
||||
- 用户状态验证(active、inactive、locked等)
|
||||
|
||||
2. **AdminService** - 管理员服务测试
|
||||
- 管理员登录认证
|
||||
- 用户列表管理
|
||||
- 用户密码重置
|
||||
- 日志管理功能
|
||||
|
||||
3. **VerificationService** - 验证码服务测试
|
||||
- 验证码生成和验证
|
||||
- 频率限制机制
|
||||
- Redis存储操作
|
||||
- 错误处理和边界条件
|
||||
|
||||
4. **EmailService** - 邮件服务测试
|
||||
- 邮件发送功能(测试模式和生产模式)
|
||||
- 验证码邮件模板
|
||||
- 连接验证和错误处理
|
||||
- SMTP配置测试
|
||||
|
||||
5. **UsersService** - 用户数据服务测试
|
||||
- 用户CRUD操作
|
||||
- 用户查询功能
|
||||
- 数据验证和约束
|
||||
|
||||
6. **AdminCoreService** - 管理员核心服务测试
|
||||
- 管理员认证逻辑
|
||||
- 权限验证
|
||||
- 管理员引导创建
|
||||
|
||||
7. **LoggerService** - 日志服务测试
|
||||
- 日志记录功能
|
||||
- 敏感信息过滤
|
||||
- 日志级别控制
|
||||
|
||||
### E2E端到端测试
|
||||
|
||||
**登录功能完整流程测试:**
|
||||
- 用户注册 → 邮箱验证 → 登录验证
|
||||
- GitHub OAuth登录流程
|
||||
- 密码重置完整流程
|
||||
- 错误处理和边界条件测试
|
||||
|
||||
## 🔧 测试模式特性
|
||||
|
||||
- 🗄️ **Redis 文件存储** - 使用 `redis-data/redis.json` 存储验证码
|
||||
- 📧 **邮件测试模式** - 邮件内容输出到控制台,无需真实SMTP
|
||||
- 💾 **内存用户存储** - 无需数据库,用户数据存储在内存中
|
||||
- 🔄 **自动切换** - 根据配置自动选择存储模式
|
||||
|
||||
## 📊 单元测试
|
||||
|
||||
### 运行测试命令
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
npm test
|
||||
|
||||
# 监听模式(开发时使用)
|
||||
npm run test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
npm run test:cov
|
||||
|
||||
# 运行特定测试文件
|
||||
npm test -- src/core/login_core/login_core.service.spec.ts
|
||||
```
|
||||
|
||||
### 测试覆盖情况
|
||||
|
||||
**测试统计:**
|
||||
- 测试套件:7个
|
||||
- 测试用例:140个
|
||||
- 覆盖率:100%通过
|
||||
|
||||
**测试文件列表:**
|
||||
```
|
||||
src/core/login_core/login_core.service.spec.ts # 登录核心服务
|
||||
src/business/admin/admin.service.spec.ts # 管理员服务
|
||||
src/core/utils/verification/verification.service.spec.ts # 验证码服务
|
||||
src/core/utils/email/email.service.spec.ts # 邮件服务
|
||||
src/core/db/users/users.service.spec.ts # 用户数据服务
|
||||
src/core/admin_core/admin_core.service.spec.ts # 管理员核心服务
|
||||
src/core/utils/logger/logger.service.spec.ts # 日志服务
|
||||
test/business/login.e2e-spec.ts # E2E端到端测试
|
||||
```
|
||||
|
||||
### 测试场景覆盖
|
||||
|
||||
**正常流程测试:**
|
||||
- 用户注册、登录、密码管理
|
||||
- 邮箱验证码发送和验证
|
||||
- 管理员认证和用户管理
|
||||
- 系统状态和日志功能
|
||||
|
||||
**异常情况测试:**
|
||||
- 无效输入和参数验证
|
||||
- 网络连接失败处理
|
||||
- 权限验证和访问控制
|
||||
- 频率限制和安全防护
|
||||
|
||||
**边界条件测试:**
|
||||
- 密码强度验证
|
||||
- 验证码过期处理
|
||||
- 用户状态变更
|
||||
- 数据库连接异常
|
||||
|
||||
## 🌐 生产环境配置
|
||||
|
||||
要切换到生产环境,编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 启用数据库(取消注释并填入真实数据)
|
||||
DB_HOST=your_mysql_host
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=your_db_username
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=your_db_name
|
||||
|
||||
# 启用真实Redis(取消注释并设置)
|
||||
USE_FILE_REDIS=false
|
||||
REDIS_HOST=your_redis_host
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
|
||||
# 启用邮件服务(取消注释并填入真实数据)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_app_password
|
||||
EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||
|
||||
# 生产环境设置
|
||||
NODE_ENV=production
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 服务启动失败
|
||||
- **端口占用**:检查端口3000是否被占用,使用 `netstat -ano | findstr :3000` 查看
|
||||
- **Node.js版本**:确认Node.js版本 >= 18.0.0,使用 `node --version` 检查
|
||||
- **依赖问题**:运行 `npm install` 或 `pnpm install` 重新安装依赖
|
||||
- **权限问题**:确保有足够的文件读写权限
|
||||
|
||||
### 测试脚本执行失败
|
||||
- **服务器状态**:确认服务器正在运行,访问 http://localhost:3000 检查
|
||||
- **网络连接**:检查防火墙设置,确保端口3000可访问
|
||||
- **脚本权限**:在Linux/macOS上确保脚本有执行权限:`chmod +x test-api.sh`
|
||||
- **PowerShell策略**:Windows上可能需要设置执行策略:`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
|
||||
|
||||
### 单元测试失败
|
||||
- **依赖冲突**:清理node_modules并重新安装:`rm -rf node_modules && npm install`
|
||||
- **TypeScript错误**:运行 `npm run build` 检查编译错误
|
||||
- **环境变量**:确保测试环境变量配置正确
|
||||
- **数据库连接**:测试模式下应该使用内存数据库,检查配置
|
||||
|
||||
### Redis文件存储问题
|
||||
- **目录权限**:检查 `redis-data` 目录的读写权限
|
||||
- **配置设置**:确认 `USE_FILE_REDIS=true` 设置正确
|
||||
- **文件锁定**:确保redis.json文件没有被其他进程锁定
|
||||
- **磁盘空间**:检查磁盘空间是否充足
|
||||
|
||||
### 邮件测试模式问题
|
||||
- **配置检查**:确认邮件配置为注释状态(测试模式)
|
||||
- **控制台输出**:检查服务器控制台是否有邮件内容输出
|
||||
- **日志级别**:确保日志级别设置为info或debug以查看详细输出
|
||||
|
||||
### 常见错误解决
|
||||
|
||||
**EADDRINUSE错误:**
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
netstat -ano | findstr :3000
|
||||
# 结束进程(Windows)
|
||||
taskkill /PID <进程ID> /F
|
||||
```
|
||||
|
||||
**权限错误:**
|
||||
```bash
|
||||
# Linux/macOS设置权限
|
||||
chmod +x test-api.sh
|
||||
chmod 755 redis-data/
|
||||
```
|
||||
|
||||
**模块未找到错误:**
|
||||
```bash
|
||||
# 清理并重新安装
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
## 📝 测试数据
|
||||
|
||||
测试完成后,你可以查看:
|
||||
|
||||
- `redis-data/redis.json` - 验证码存储数据
|
||||
- 服务器控制台 - 邮件内容输出
|
||||
- 测试脚本输出 - API响应结果
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
- 查看 [API 文档](http://localhost:3000/api-docs) 了解更多接口
|
||||
- 阅读 [开发规范](./docs/backend_development_guide.md) 开始开发
|
||||
- 使用 [AI 辅助指南](./docs/AI辅助开发规范指南.md) 提高开发效率
|
||||
BIN
docs/development/ab164782cdc17e22f9bdf443c7e1e96c.png
Normal file
BIN
docs/development/ab164782cdc17e22f9bdf443c7e1e96c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 484 KiB |
@@ -1,3 +1,7 @@
|
||||
|
||||
|
||||

|
||||
|
||||
# Git 提交规范
|
||||
|
||||
本文档定义了项目的 Git 提交信息格式规范,以保持提交历史的清晰和一致性。
|
||||
@@ -1,265 +0,0 @@
|
||||
# 邮箱验证系统
|
||||
|
||||
## 概述
|
||||
|
||||
邮箱验证系统提供完整的邮箱验证功能,包括验证码生成、发送、验证和管理。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📧 邮箱验证码发送
|
||||
- 🔐 验证码安全验证
|
||||
- ⏰ 验证码过期管理
|
||||
- 🚫 防刷机制(频率限制)
|
||||
- 📊 验证统计和监控
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
邮箱验证系统
|
||||
├── 验证码服务 (VerificationService)
|
||||
│ ├── 验证码生成
|
||||
│ ├── 验证码验证
|
||||
│ └── 防刷机制
|
||||
├── 邮件服务 (EmailService)
|
||||
│ ├── 验证码邮件发送
|
||||
│ ├── 欢迎邮件发送
|
||||
│ └── 邮件模板管理
|
||||
└── Redis缓存
|
||||
├── 验证码存储
|
||||
├── 冷却时间管理
|
||||
└── 发送频率限制
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. 验证码服务 (VerificationService)
|
||||
|
||||
负责验证码的生成、验证和管理:
|
||||
|
||||
- **验证码生成**:6位数字验证码
|
||||
- **验证码验证**:支持多次尝试限制
|
||||
- **过期管理**:5分钟有效期
|
||||
- **防刷机制**:60秒冷却时间,每小时最多5次
|
||||
|
||||
### 2. 邮件服务 (EmailService)
|
||||
|
||||
负责邮件的发送和模板管理:
|
||||
|
||||
- **验证码邮件**:发送验证码到用户邮箱
|
||||
- **欢迎邮件**:用户注册成功后发送
|
||||
- **模板支持**:支持HTML邮件模板
|
||||
|
||||
### 3. Redis缓存
|
||||
|
||||
负责数据的临时存储:
|
||||
|
||||
- **验证码存储**:`verification_code:${type}:${identifier}`
|
||||
- **冷却时间**:`verification_cooldown:${type}:${identifier}`
|
||||
- **发送频率**:`verification_hourly:${type}:${identifier}:${date}:${hour}`
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 注册流程中的邮箱验证
|
||||
|
||||
1. **发送验证码**
|
||||
```typescript
|
||||
POST /auth/send-email-verification
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
2. **用户注册**
|
||||
```typescript
|
||||
POST /auth/register
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "password123",
|
||||
"nickname": "测试用户",
|
||||
"email": "user@example.com",
|
||||
"email_verification_code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
### 独立邮箱验证
|
||||
|
||||
1. **验证邮箱**
|
||||
```typescript
|
||||
POST /auth/verify-email
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"verification_code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 邮件服务配置
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASS=your-password
|
||||
SMTP_FROM=noreply@example.com
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
```
|
||||
|
||||
### 验证码配置
|
||||
|
||||
```typescript
|
||||
// 验证码长度
|
||||
CODE_LENGTH = 6
|
||||
|
||||
// 验证码过期时间(秒)
|
||||
CODE_EXPIRE_TIME = 300 // 5分钟
|
||||
|
||||
// 最大验证尝试次数
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
// 发送冷却时间(秒)
|
||||
RATE_LIMIT_TIME = 60 // 1分钟
|
||||
|
||||
// 每小时最大发送次数
|
||||
MAX_SENDS_PER_HOUR = 5
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 发送邮箱验证码
|
||||
|
||||
- **接口**:`POST /auth/send-email-verification`
|
||||
- **描述**:向指定邮箱发送验证码
|
||||
- **参数**:
|
||||
```typescript
|
||||
{
|
||||
email: string; // 邮箱地址
|
||||
}
|
||||
```
|
||||
|
||||
### 验证邮箱验证码
|
||||
|
||||
- **接口**:`POST /auth/verify-email`
|
||||
- **描述**:使用验证码验证邮箱
|
||||
- **参数**:
|
||||
```typescript
|
||||
{
|
||||
email: string; // 邮箱地址
|
||||
verification_code: string; // 6位数字验证码
|
||||
}
|
||||
```
|
||||
|
||||
### 重新发送验证码
|
||||
|
||||
- **接口**:`POST /auth/resend-email-verification`
|
||||
- **描述**:重新向指定邮箱发送验证码
|
||||
- **参数**:
|
||||
```typescript
|
||||
{
|
||||
email: string; // 邮箱地址
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误码
|
||||
|
||||
- `VERIFICATION_CODE_NOT_FOUND`:验证码不存在或已过期
|
||||
- `VERIFICATION_CODE_INVALID`:验证码错误
|
||||
- `TOO_MANY_ATTEMPTS`:验证尝试次数过多
|
||||
- `RATE_LIMIT_EXCEEDED`:发送频率过高
|
||||
- `EMAIL_SEND_FAILED`:邮件发送失败
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: false,
|
||||
message: "错误描述",
|
||||
error_code: "ERROR_CODE"
|
||||
}
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 关键指标
|
||||
|
||||
- 验证码发送成功率
|
||||
- 验证码验证成功率
|
||||
- 邮件发送延迟
|
||||
- Redis连接状态
|
||||
|
||||
### 日志记录
|
||||
|
||||
- 验证码生成和验证日志
|
||||
- 邮件发送状态日志
|
||||
- 错误和异常日志
|
||||
- 性能监控日志
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 防刷机制
|
||||
|
||||
1. **发送频率限制**:每个邮箱60秒内只能发送一次
|
||||
2. **每小时限制**:每个邮箱每小时最多发送5次
|
||||
3. **验证尝试限制**:每个验证码最多尝试3次
|
||||
|
||||
### 数据安全
|
||||
|
||||
1. **验证码加密存储**:Redis中的验证码经过加密
|
||||
2. **过期自动清理**:验证码5分钟后自动过期
|
||||
3. **日志脱敏**:日志中不记录完整验证码
|
||||
|
||||
## 部署指南
|
||||
|
||||
详细的部署说明请参考:[deployment-guide.md](./deployment-guide.md)
|
||||
|
||||
## 测试
|
||||
|
||||
### 单元测试
|
||||
|
||||
```bash
|
||||
# 运行验证服务测试
|
||||
npm test -- verification.service.spec.ts
|
||||
|
||||
# 运行邮件服务测试
|
||||
npm test -- email.service.spec.ts
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
|
||||
```bash
|
||||
# 运行邮箱验证集成测试
|
||||
npm run test:e2e -- email-verification
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **验证码收不到**
|
||||
- 检查SMTP配置
|
||||
- 检查邮箱是否在垃圾邮件中
|
||||
- 检查网络连接
|
||||
|
||||
2. **验证码验证失败**
|
||||
- 检查验证码是否过期
|
||||
- 检查验证码输入是否正确
|
||||
- 检查Redis连接状态
|
||||
|
||||
3. **发送频率限制**
|
||||
- 等待冷却时间结束
|
||||
- 检查是否达到每小时限制
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **v1.0.0** (2025-12-17)
|
||||
- 初始版本发布
|
||||
- 支持基本的邮箱验证功能
|
||||
- 集成Redis缓存
|
||||
- 添加防刷机制
|
||||
@@ -1,316 +0,0 @@
|
||||
# 邮箱验证功能部署指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南详细说明如何部署和配置邮箱验证功能,包括Redis缓存、邮件服务配置等。
|
||||
|
||||
## 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装新增的依赖包
|
||||
pnpm install ioredis nodemailer
|
||||
|
||||
# 安装类型定义
|
||||
pnpm install -D @types/nodemailer
|
||||
```
|
||||
|
||||
## 2. Redis 服务配置
|
||||
|
||||
### 2.1 安装 Redis
|
||||
|
||||
#### Ubuntu/Debian
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install redis-server
|
||||
sudo systemctl start redis-server
|
||||
sudo systemctl enable redis-server
|
||||
```
|
||||
|
||||
#### CentOS/RHEL
|
||||
```bash
|
||||
sudo yum install redis
|
||||
sudo systemctl start redis
|
||||
sudo systemctl enable redis
|
||||
```
|
||||
|
||||
#### Docker 方式
|
||||
```bash
|
||||
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||
```
|
||||
|
||||
### 2.2 Redis 配置验证
|
||||
|
||||
```bash
|
||||
# 测试 Redis 连接
|
||||
redis-cli ping
|
||||
# 应该返回 PONG
|
||||
```
|
||||
|
||||
## 3. 邮件服务配置
|
||||
|
||||
### 3.1 Gmail 配置示例
|
||||
|
||||
1. **启用两步验证**:
|
||||
- 登录 Google 账户
|
||||
- 进入"安全性"设置
|
||||
- 启用"两步验证"
|
||||
|
||||
2. **生成应用专用密码**:
|
||||
- 在"安全性"设置中找到"应用专用密码"
|
||||
- 生成新的应用密码
|
||||
- 记录生成的16位密码
|
||||
|
||||
3. **环境变量配置**:
|
||||
```env
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=false
|
||||
EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASS=your-16-digit-app-password
|
||||
EMAIL_FROM="Whale Town Game" <noreply@gmail.com>
|
||||
```
|
||||
|
||||
### 3.2 其他邮件服务商配置
|
||||
|
||||
#### 163邮箱
|
||||
```env
|
||||
EMAIL_HOST=smtp.163.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=false
|
||||
EMAIL_USER=your-email@163.com
|
||||
EMAIL_PASS=your-authorization-code
|
||||
```
|
||||
|
||||
#### QQ邮箱
|
||||
```env
|
||||
EMAIL_HOST=smtp.qq.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=false
|
||||
EMAIL_USER=your-email@qq.com
|
||||
EMAIL_PASS=your-authorization-code
|
||||
```
|
||||
|
||||
#### 阿里云邮件推送
|
||||
```env
|
||||
EMAIL_HOST=smtpdm.aliyun.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=false
|
||||
EMAIL_USER=your-smtp-username
|
||||
EMAIL_PASS=your-smtp-password
|
||||
```
|
||||
|
||||
## 4. 环境变量配置
|
||||
|
||||
### 4.1 创建环境配置文件
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.production.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 4.2 完整的环境变量配置
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=pixel_game
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=pixel_game_db
|
||||
|
||||
# 应用配置
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# 邮件服务配置
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=false
|
||||
EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASS=your-app-password
|
||||
EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||
```
|
||||
|
||||
## 5. 数据库迁移
|
||||
|
||||
由于添加了新的字段,需要更新数据库结构:
|
||||
|
||||
```sql
|
||||
-- 添加邮箱验证状态字段
|
||||
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE COMMENT '邮箱是否已验证';
|
||||
|
||||
-- 为已有用户设置默认值
|
||||
UPDATE users SET email_verified = FALSE WHERE email_verified IS NULL;
|
||||
|
||||
-- 如果是OAuth用户且有邮箱,可以设为已验证
|
||||
UPDATE users SET email_verified = TRUE WHERE github_id IS NOT NULL AND email IS NOT NULL;
|
||||
```
|
||||
|
||||
## 6. 启动和测试
|
||||
|
||||
### 6.1 启动应用
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 构建应用
|
||||
pnpm run build
|
||||
|
||||
# 启动应用
|
||||
pnpm run start:prod
|
||||
```
|
||||
|
||||
### 6.2 功能测试
|
||||
|
||||
#### 测试邮箱验证码发送
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/send-email-verification \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}'
|
||||
```
|
||||
|
||||
#### 测试邮箱验证
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/verify-email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","verification_code":"123456"}'
|
||||
```
|
||||
|
||||
#### 测试密码重置
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/forgot-password \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identifier":"test@example.com"}'
|
||||
```
|
||||
|
||||
## 7. 监控和日志
|
||||
|
||||
### 7.1 查看应用日志
|
||||
|
||||
```bash
|
||||
# PM2 日志
|
||||
pm2 logs pixel-game-server
|
||||
|
||||
# 或者查看文件日志
|
||||
tail -f logs/dev.log
|
||||
```
|
||||
|
||||
### 7.2 Redis 监控
|
||||
|
||||
```bash
|
||||
# 查看 Redis 信息
|
||||
redis-cli info
|
||||
|
||||
# 监控 Redis 命令
|
||||
redis-cli monitor
|
||||
|
||||
# 查看验证码相关的键
|
||||
redis-cli keys "verification_*"
|
||||
```
|
||||
|
||||
### 7.3 邮件发送监控
|
||||
|
||||
应用会记录邮件发送的日志,包括:
|
||||
- 发送成功/失败状态
|
||||
- 收件人信息
|
||||
- 发送时间
|
||||
- 错误信息(如果有)
|
||||
|
||||
## 8. 故障排除
|
||||
|
||||
### 8.1 Redis 连接问题
|
||||
|
||||
**问题**:Redis连接失败
|
||||
```
|
||||
Redis连接错误: Error: connect ECONNREFUSED 127.0.0.1:6379
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
1. 检查Redis服务状态:`sudo systemctl status redis`
|
||||
2. 启动Redis服务:`sudo systemctl start redis`
|
||||
3. 检查防火墙设置
|
||||
4. 验证Redis配置文件
|
||||
|
||||
### 8.2 邮件发送问题
|
||||
|
||||
**问题**:邮件发送失败
|
||||
```
|
||||
邮件发送失败: Error: Invalid login: 535-5.7.8 Username and Password not accepted
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
1. 检查邮箱用户名和密码
|
||||
2. 确认已启用应用专用密码(Gmail)
|
||||
3. 检查邮件服务商的SMTP设置
|
||||
4. 验证网络连接
|
||||
|
||||
### 8.3 验证码问题
|
||||
|
||||
**问题**:验证码验证失败
|
||||
|
||||
**解决方案**:
|
||||
1. 检查Redis中是否存在验证码:`redis-cli get verification_code:email_verification:test@example.com`
|
||||
2. 检查验证码是否过期
|
||||
3. 验证验证码格式(6位数字)
|
||||
4. 检查应用日志
|
||||
|
||||
## 9. 安全建议
|
||||
|
||||
### 9.1 邮件服务安全
|
||||
|
||||
1. **使用应用专用密码**:不要使用主密码
|
||||
2. **启用TLS/SSL**:确保邮件传输加密
|
||||
3. **限制发送频率**:防止邮件轰炸
|
||||
4. **监控发送量**:避免被标记为垃圾邮件
|
||||
|
||||
### 9.2 Redis 安全
|
||||
|
||||
1. **设置密码**:`requirepass your_redis_password`
|
||||
2. **绑定IP**:`bind 127.0.0.1`
|
||||
3. **禁用危险命令**:`rename-command FLUSHDB ""`
|
||||
4. **定期备份**:设置Redis数据备份
|
||||
|
||||
### 9.3 验证码安全
|
||||
|
||||
1. **设置过期时间**:默认5分钟
|
||||
2. **限制尝试次数**:最多3次
|
||||
3. **防刷机制**:60秒冷却时间
|
||||
4. **记录日志**:监控异常行为
|
||||
|
||||
## 10. 性能优化
|
||||
|
||||
### 10.1 Redis 优化
|
||||
|
||||
```redis
|
||||
# Redis 配置优化
|
||||
maxmemory 256mb
|
||||
maxmemory-policy allkeys-lru
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
```
|
||||
|
||||
### 10.2 邮件发送优化
|
||||
|
||||
1. **连接池**:复用SMTP连接
|
||||
2. **异步发送**:不阻塞主流程
|
||||
3. **队列机制**:处理大量邮件
|
||||
4. **失败重试**:自动重试机制
|
||||
|
||||
---
|
||||
|
||||
*部署完成后,建议进行完整的功能测试,确保所有邮箱验证功能正常工作。*
|
||||
@@ -1,80 +0,0 @@
|
||||
# 日志系统
|
||||
|
||||
## 概述
|
||||
|
||||
项目集成了完整的日志系统,基于 Pino 高性能日志库,提供结构化日志记录、自动敏感信息过滤和多级别日志控制。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🚀 高性能日志记录
|
||||
- 🔒 自动敏感信息过滤
|
||||
- 🎯 多级别日志控制
|
||||
- 🔍 请求上下文绑定
|
||||
- 📊 结构化日志输出
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基础用法
|
||||
|
||||
```typescript
|
||||
import { AppLoggerService } from './core/utils/logger/logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private readonly logger: AppLoggerService) {}
|
||||
|
||||
async createUser(userData: CreateUserDto) {
|
||||
this.logger.info('开始创建用户', {
|
||||
operation: 'createUser',
|
||||
email: userData.email,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const user = await this.userRepository.save(userData);
|
||||
|
||||
this.logger.info('用户创建成功', {
|
||||
operation: 'createUser',
|
||||
userId: user.id,
|
||||
email: userData.email
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error('用户创建失败', {
|
||||
operation: 'createUser',
|
||||
email: userData.email,
|
||||
error: error.message
|
||||
}, error.stack);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 日志级别
|
||||
|
||||
- `error`: 错误信息
|
||||
- `warn`: 警告信息
|
||||
- `info`: 一般信息
|
||||
- `debug`: 调试信息
|
||||
|
||||
## 配置
|
||||
|
||||
日志配置位于 `src/core/utils/logger/logger.config.ts`,支持:
|
||||
|
||||
- 日志级别设置
|
||||
- 输出格式配置
|
||||
- 敏感信息过滤规则
|
||||
- 文件输出配置
|
||||
|
||||
## 敏感信息过滤
|
||||
|
||||
系统自动过滤以下敏感信息:
|
||||
- 密码字段
|
||||
- 令牌信息
|
||||
- 个人身份信息
|
||||
- 支付相关信息
|
||||
|
||||
详细使用方法请参考:[后端开发规范指南 - 日志系统使用指南](../../backend_development_guide.md#四日志系统使用指南)
|
||||
@@ -1,363 +0,0 @@
|
||||
# 日志系统详细说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本项目的日志系统基于 Pino 高性能日志库构建,提供完整的日志记录、管理和分析功能。
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 日志文件结构
|
||||
|
||||
### 开发环境 (`NODE_ENV=development`)
|
||||
|
||||
```
|
||||
logs/
|
||||
└── dev.log # 开发环境综合日志(所有级别)
|
||||
```
|
||||
|
||||
**输出方式:**
|
||||
- 🖥️ **控制台**:彩色美化输出,便于开发调试
|
||||
- 📁 **文件**:保存到 `logs/dev.log`,便于问题追踪
|
||||
|
||||
### 生产环境 (`NODE_ENV=production`)
|
||||
|
||||
```
|
||||
logs/
|
||||
├── app.log # 应用综合日志(info及以上级别)
|
||||
├── error.log # 错误日志(error和fatal级别)
|
||||
├── access.log # HTTP访问日志(请求响应记录)
|
||||
├── app.log.gz # 压缩的历史日志文件
|
||||
├── error.log.gz # 压缩的历史错误日志
|
||||
└── access.log.gz # 压缩的历史访问日志
|
||||
```
|
||||
|
||||
**输出方式:**
|
||||
- 📁 **文件**:分类保存到不同的日志文件
|
||||
- 🖥️ **控制台**:仅输出 warn 及以上级别(用于容器日志收集)
|
||||
|
||||
---
|
||||
|
||||
## 📊 日志级别和用途
|
||||
|
||||
| 级别 | 数值 | 用途 | 保存位置 | 示例场景 |
|
||||
|------|------|------|----------|----------|
|
||||
| **TRACE** | 10 | 极细粒度调试 | dev.log | 循环内变量状态 |
|
||||
| **DEBUG** | 20 | 开发调试信息 | dev.log | 方法调用参数 |
|
||||
| **INFO** | 30 | 重要业务操作 | app.log, dev.log | 用户登录成功 |
|
||||
| **WARN** | 40 | 警告信息 | app.log, dev.log, 控制台 | 参数验证失败 |
|
||||
| **ERROR** | 50 | 错误信息 | error.log, app.log, 控制台 | 数据库连接失败 |
|
||||
| **FATAL** | 60 | 致命错误 | error.log, app.log, 控制台 | 系统不可用 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 日志轮转和管理
|
||||
|
||||
### 自动轮转策略
|
||||
|
||||
| 文件类型 | 轮转频率 | 文件大小限制 | 保留时间 | 压缩策略 |
|
||||
|----------|----------|--------------|----------|----------|
|
||||
| **app.log** | 每日 | 10MB | 7天 | 7天后压缩 |
|
||||
| **error.log** | 每日 | 10MB | 30天 | 7天后压缩 |
|
||||
| **access.log** | 每日 | 50MB | 14天 | 7天后压缩 |
|
||||
| **dev.log** | 手动 | 无限制 | 无限制 | 不压缩 |
|
||||
|
||||
### 定时任务
|
||||
|
||||
| 任务 | 执行时间 | 功能 |
|
||||
|------|----------|------|
|
||||
| **日志清理** | 每天 02:00 | 删除过期日志文件 |
|
||||
| **日志压缩** | 每周日 03:00 | 压缩7天前的日志文件 |
|
||||
| **健康监控** | 每小时 | 监控日志系统状态 |
|
||||
| **统计报告** | 每天 09:00 | 输出日志统计信息 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 如何使用日志系统
|
||||
|
||||
### 基本使用
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppLoggerService } from '../core/utils/logger/logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private readonly logger: AppLoggerService) {}
|
||||
|
||||
async createUser(userData: CreateUserDto) {
|
||||
// 记录操作开始
|
||||
this.logger.info('开始创建用户', {
|
||||
operation: 'createUser',
|
||||
email: userData.email,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const user = await this.userRepository.save(userData);
|
||||
|
||||
// 记录成功操作
|
||||
this.logger.info('用户创建成功', {
|
||||
operation: 'createUser',
|
||||
userId: user.id,
|
||||
email: userData.email,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// 记录错误
|
||||
this.logger.error('用户创建失败', {
|
||||
operation: 'createUser',
|
||||
email: userData.email,
|
||||
error: error.message
|
||||
}, error.stack);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 请求上下文绑定
|
||||
|
||||
```typescript
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly logger: AppLoggerService) {}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string, @Req() req: Request) {
|
||||
// 绑定请求上下文
|
||||
const requestLogger = this.logger.bindRequest(req, 'UserController');
|
||||
|
||||
requestLogger.info('开始获取用户信息', { userId: id });
|
||||
|
||||
try {
|
||||
const user = await this.userService.findById(id);
|
||||
requestLogger.info('用户信息获取成功', { userId: id });
|
||||
return user;
|
||||
} catch (error) {
|
||||
requestLogger.error('用户信息获取失败', error.stack, {
|
||||
userId: id,
|
||||
reason: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 日志格式详解
|
||||
|
||||
### 开发环境日志格式
|
||||
|
||||
```
|
||||
🕐 2024-12-13 14:30:25 📝 INFO pixel-game-server [UserService] 用户创建成功
|
||||
operation: "createUser"
|
||||
userId: "user_123"
|
||||
email: "user@example.com"
|
||||
duration: 45
|
||||
```
|
||||
|
||||
### 生产环境日志格式 (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 30,
|
||||
"time": 1702456225000,
|
||||
"pid": 12345,
|
||||
"hostname": "server-01",
|
||||
"app": "pixel-game-server",
|
||||
"version": "1.0.0",
|
||||
"msg": "用户创建成功",
|
||||
"operation": "createUser",
|
||||
"userId": "user_123",
|
||||
"email": "user@example.com",
|
||||
"duration": 45,
|
||||
"reqId": "req_1702456225_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP 请求日志格式
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 30,
|
||||
"time": 1702456225000,
|
||||
"req": {
|
||||
"id": "req_1702456225_abc123",
|
||||
"method": "POST",
|
||||
"url": "/api/users",
|
||||
"headers": {
|
||||
"host": "localhost:3000",
|
||||
"user-agent": "Mozilla/5.0...",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"ip": "127.0.0.1"
|
||||
},
|
||||
"res": {
|
||||
"statusCode": 201,
|
||||
"responseTime": 45
|
||||
},
|
||||
"msg": "POST /api/users completed in 45ms"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 问题排查指南
|
||||
|
||||
### 1. 如何查找特定用户的操作日志?
|
||||
|
||||
```bash
|
||||
# 在日志文件中搜索特定用户ID
|
||||
grep "userId.*user_123" logs/app.log
|
||||
|
||||
# 搜索特定操作
|
||||
grep "operation.*createUser" logs/app.log
|
||||
|
||||
# 搜索特定时间段的日志
|
||||
grep "2024-12-13 14:" logs/app.log
|
||||
```
|
||||
|
||||
### 2. 如何查找错误日志?
|
||||
|
||||
```bash
|
||||
# 查看所有错误日志
|
||||
cat logs/error.log
|
||||
|
||||
# 查看最近的错误
|
||||
tail -f logs/error.log
|
||||
|
||||
# 搜索特定错误
|
||||
grep "数据库连接失败" logs/error.log
|
||||
```
|
||||
|
||||
### 3. 如何分析性能问题?
|
||||
|
||||
```bash
|
||||
# 查找响应时间超过1000ms的请求
|
||||
grep "responseTime.*[0-9][0-9][0-9][0-9]" logs/access.log
|
||||
|
||||
# 查找特定接口的性能数据
|
||||
grep "POST /api/users" logs/access.log | grep responseTime
|
||||
```
|
||||
|
||||
### 4. 如何监控系统健康状态?
|
||||
|
||||
```bash
|
||||
# 查看日志统计信息
|
||||
grep "日志系统健康状态报告" logs/app.log
|
||||
|
||||
# 查看日志清理记录
|
||||
grep "日志清理任务完成" logs/app.log
|
||||
|
||||
# 查看压缩记录
|
||||
grep "日志压缩任务完成" logs/app.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 日志分析和监控
|
||||
|
||||
### 日志统计信息
|
||||
|
||||
系统会自动收集以下统计信息:
|
||||
|
||||
- **文件数量**:当前日志文件总数
|
||||
- **总大小**:所有日志文件占用的磁盘空间
|
||||
- **错误日志数量**:错误级别日志文件数量
|
||||
- **最旧/最新文件**:日志文件的时间范围
|
||||
- **平均文件大小**:单个日志文件的平均大小
|
||||
|
||||
### 健康监控告警
|
||||
|
||||
系统会在以下情况发出警告:
|
||||
|
||||
- 📊 **磁盘空间告警**:日志文件总大小超过阈值
|
||||
- ⚠️ **错误日志告警**:错误日志数量异常增长
|
||||
- 🔧 **清理失败告警**:日志清理任务执行失败
|
||||
- 💾 **压缩失败告警**:日志压缩任务执行失败
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 应用名称
|
||||
APP_NAME=pixel-game-server
|
||||
|
||||
# 环境标识
|
||||
NODE_ENV=development
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR=./logs
|
||||
|
||||
# 日志保留天数
|
||||
LOG_MAX_FILES=7d
|
||||
|
||||
# 单个日志文件最大大小
|
||||
LOG_MAX_SIZE=10m
|
||||
```
|
||||
|
||||
### 高级配置选项
|
||||
|
||||
如需自定义日志配置,可以修改 `src/core/utils/logger/logger.config.ts`:
|
||||
|
||||
```typescript
|
||||
// 自定义日志轮转策略
|
||||
{
|
||||
target: 'pino-roll',
|
||||
options: {
|
||||
file: path.join(logDir, 'app.log'),
|
||||
frequency: 'daily', // 轮转频率:daily, hourly, weekly
|
||||
size: '10m', // 文件大小限制
|
||||
limit: {
|
||||
count: 7, // 保留文件数量
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 注意事项
|
||||
|
||||
### 安全考虑
|
||||
|
||||
1. **敏感信息过滤**:系统自动过滤密码、token等敏感字段
|
||||
2. **访问控制**:确保日志文件只有授权用户可以访问
|
||||
3. **传输加密**:生产环境建议使用加密传输日志
|
||||
|
||||
### 性能考虑
|
||||
|
||||
1. **异步写入**:Pino 使用异步写入,不会阻塞主线程
|
||||
2. **日志级别**:生产环境建议使用 info 及以上级别
|
||||
3. **文件轮转**:及时清理和压缩日志文件,避免占用过多磁盘空间
|
||||
|
||||
### 运维建议
|
||||
|
||||
1. **监控磁盘空间**:定期检查日志目录的磁盘使用情况
|
||||
2. **备份重要日志**:对于重要的错误日志,建议定期备份
|
||||
3. **日志分析**:可以集成 ELK Stack 等日志分析工具
|
||||
4. **告警设置**:配置日志监控告警,及时发现系统问题
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [后端开发规范 - 日志系统使用指南](./backend_development_guide.md#四日志系统使用指南)
|
||||
- [AI 辅助开发规范指南](./AI辅助开发规范指南.md)
|
||||
- [Pino 官方文档](https://getpino.io/)
|
||||
- [NestJS Pino 集成文档](https://github.com/iamolegga/nestjs-pino)
|
||||
|
||||
---
|
||||
|
||||
**💡 提示:使用 [AI 辅助开发指南](./AI辅助开发规范指南.md) 可以让 AI 帮你自动生成符合规范的日志代码!**
|
||||
@@ -1,334 +0,0 @@
|
||||
# 用户认证系统
|
||||
|
||||
## 概述
|
||||
|
||||
用户认证系统提供完整的用户注册、登录、密码管理功能,支持传统用户名密码登录和第三方OAuth登录。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔐 多种登录方式:用户名/邮箱/手机号登录
|
||||
- 📝 用户注册和信息管理
|
||||
- 🐙 GitHub OAuth 第三方登录
|
||||
- 🔄 密码重置和修改
|
||||
- 🛡️ bcrypt 密码加密
|
||||
- 🎯 基于角色的权限控制
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 分层结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── business/login/ # 业务逻辑层
|
||||
│ ├── login.controller.ts # HTTP 控制器
|
||||
│ ├── login.service.ts # 业务服务
|
||||
│ ├── login.dto.ts # 数据传输对象
|
||||
│ ├── login.service.spec.ts # 业务服务测试
|
||||
│ └── login.module.ts # 业务模块
|
||||
├── core/
|
||||
│ ├── login_core/ # 核心功能层
|
||||
│ │ ├── login_core.service.ts # 核心认证逻辑
|
||||
│ │ ├── login_core.service.spec.ts # 核心服务测试
|
||||
│ │ └── login_core.module.ts # 核心模块
|
||||
│ └── db/users/ # 数据访问层
|
||||
│ ├── users.entity.ts # 用户实体
|
||||
│ ├── users.service.ts # 用户数据服务
|
||||
│ └── users.dto.ts # 用户 DTO
|
||||
```
|
||||
|
||||
### 职责分离
|
||||
|
||||
#### 1. 业务逻辑层 (Business Layer)
|
||||
- **位置**: `src/business/login/`
|
||||
- **职责**:
|
||||
- 处理HTTP请求和响应
|
||||
- 数据格式化和验证
|
||||
- 业务流程控制
|
||||
- 错误处理和日志记录
|
||||
|
||||
#### 2. 核心功能层 (Core Layer)
|
||||
- **位置**: `src/core/login_core/`
|
||||
- **职责**:
|
||||
- 认证核心算法实现
|
||||
- 密码加密和验证
|
||||
- 用户查找和匹配
|
||||
- 令牌生成和验证
|
||||
|
||||
#### 3. 数据访问层 (Data Access Layer)
|
||||
- **位置**: `src/core/db/users/`
|
||||
- **职责**:
|
||||
- 数据库操作封装
|
||||
- 实体关系映射
|
||||
- 数据完整性保证
|
||||
- 查询优化
|
||||
|
||||
## API 接口
|
||||
|
||||
### 用户注册
|
||||
|
||||
```bash
|
||||
POST /auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "password123",
|
||||
"nickname": "测试用户",
|
||||
"email": "test@example.com",
|
||||
"phone": "+8613800138000"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "1",
|
||||
"username": "testuser",
|
||||
"nickname": "测试用户",
|
||||
"email": "test@example.com",
|
||||
"phone": "+8613800138000",
|
||||
"avatar_url": null,
|
||||
"role": 1,
|
||||
"created_at": "2025-12-17T10:00:00.000Z"
|
||||
},
|
||||
"access_token": "eyJ1c2VySWQiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciJ9...",
|
||||
"is_new_user": true,
|
||||
"message": "注册成功"
|
||||
},
|
||||
"message": "注册成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 用户登录
|
||||
|
||||
```bash
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "testuser", # 支持用户名/邮箱/手机号
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub OAuth登录
|
||||
|
||||
```bash
|
||||
POST /auth/github
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"github_id": "12345678",
|
||||
"username": "githubuser",
|
||||
"nickname": "GitHub用户",
|
||||
"email": "github@example.com",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12345678"
|
||||
}
|
||||
```
|
||||
|
||||
### 密码重置
|
||||
|
||||
```bash
|
||||
# 1. 发送验证码
|
||||
POST /auth/forgot-password
|
||||
{
|
||||
"identifier": "test@example.com"
|
||||
}
|
||||
|
||||
# 2. 重置密码
|
||||
POST /auth/reset-password
|
||||
{
|
||||
"identifier": "test@example.com",
|
||||
"verification_code": "123456",
|
||||
"new_password": "newpassword123"
|
||||
}
|
||||
```
|
||||
|
||||
### 修改密码
|
||||
|
||||
```bash
|
||||
PUT /auth/change-password
|
||||
{
|
||||
"user_id": "1",
|
||||
"old_password": "password123",
|
||||
"new_password": "newpassword123"
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 用户实体 (Users Entity)
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: bigint, // 主键ID
|
||||
username: string, // 用户名(唯一)
|
||||
email?: string, // 邮箱(唯一,可选)
|
||||
phone?: string, // 手机号(唯一,可选)
|
||||
password_hash?: string, // 密码哈希(OAuth用户为空)
|
||||
nickname: string, // 显示昵称
|
||||
github_id?: string, // GitHub ID(唯一,可选)
|
||||
avatar_url?: string, // 头像URL
|
||||
role: number, // 用户角色(1-普通,9-管理员)
|
||||
created_at: Date, // 创建时间
|
||||
updated_at: Date // 更新时间
|
||||
}
|
||||
```
|
||||
|
||||
### 数据库设计特点
|
||||
|
||||
1. **唯一性约束**: username, email, phone, github_id
|
||||
2. **索引优化**: 主键、唯一索引、角色索引
|
||||
3. **字符集支持**: utf8mb4,支持emoji
|
||||
4. **数据类型**: BIGINT主键,VARCHAR字段,DATETIME时间戳
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. 密码安全
|
||||
- **加密算法**: bcrypt (saltRounds=12)
|
||||
- **强度验证**: 最少8位,包含字母和数字
|
||||
- **存储安全**: 只存储哈希值,不存储明文
|
||||
|
||||
### 2. 数据验证
|
||||
- **输入验证**: class-validator装饰器
|
||||
- **SQL注入防护**: TypeORM参数化查询
|
||||
- **XSS防护**: 数据转义和验证
|
||||
|
||||
### 3. 访问控制
|
||||
- **令牌机制**: 基于用户信息的访问令牌
|
||||
- **角色权限**: 基于角色的访问控制(RBAC)
|
||||
- **会话管理**: 令牌生成和验证
|
||||
|
||||
### 4. 错误处理
|
||||
- **统一异常**: NestJS异常过滤器
|
||||
- **日志记录**: 操作日志和错误日志
|
||||
- **信息脱敏**: 敏感信息自动脱敏
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
### 单元测试
|
||||
- 核心服务测试:`src/core/login_core/login_core.service.spec.ts`
|
||||
- 业务服务测试:`src/business/login/login.service.spec.ts`
|
||||
|
||||
### 集成测试
|
||||
- 端到端测试:`test/business/login.e2e-spec.ts`
|
||||
|
||||
### 测试用例
|
||||
- 用户注册和登录流程
|
||||
- GitHub OAuth认证
|
||||
- 密码重置和修改
|
||||
- 数据验证和错误处理
|
||||
- 安全性测试
|
||||
|
||||
## 使用示例
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
// 用户注册
|
||||
const registerResponse = await fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com'
|
||||
})
|
||||
});
|
||||
|
||||
const registerData = await registerResponse.json();
|
||||
console.log(registerData);
|
||||
|
||||
// 用户登录
|
||||
const loginResponse = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
})
|
||||
});
|
||||
|
||||
const loginData = await loginResponse.json();
|
||||
console.log(loginData);
|
||||
```
|
||||
|
||||
### curl 命令
|
||||
|
||||
```bash
|
||||
# 用户注册
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "testuser",
|
||||
"password": "password123",
|
||||
"nickname": "测试用户",
|
||||
"email": "test@example.com"
|
||||
}'
|
||||
|
||||
# 用户登录
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"identifier": "testuser",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见错误代码
|
||||
|
||||
- `LOGIN_FAILED`: 登录失败
|
||||
- `REGISTER_FAILED`: 注册失败
|
||||
- `GITHUB_OAUTH_FAILED`: GitHub登录失败
|
||||
- `SEND_CODE_FAILED`: 发送验证码失败
|
||||
- `RESET_PASSWORD_FAILED`: 密码重置失败
|
||||
- `CHANGE_PASSWORD_FAILED`: 密码修改失败
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误描述",
|
||||
"error_code": "ERROR_CODE"
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 计划中的功能
|
||||
|
||||
1. **JWT令牌管理**
|
||||
- 访问令牌和刷新令牌
|
||||
- 令牌黑名单机制
|
||||
- 自动刷新功能
|
||||
|
||||
2. **多因子认证**
|
||||
- 短信验证码
|
||||
- 邮箱验证码
|
||||
- TOTP应用支持
|
||||
|
||||
3. **社交登录扩展**
|
||||
- 微信登录
|
||||
- QQ登录
|
||||
- 微博登录
|
||||
|
||||
4. **安全增强**
|
||||
- 登录失败次数限制
|
||||
- IP白名单/黑名单
|
||||
- 设备指纹识别
|
||||
|
||||
5. **用户管理**
|
||||
- 用户状态管理(激活/禁用)
|
||||
- 用户角色权限细化
|
||||
- 用户行为日志记录
|
||||
386
docs/systems/zulip/README.md
Normal file
386
docs/systems/zulip/README.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Zulip 集成系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
Zulip 集成系统是一个为 2D 社交 MMO 游戏设计的跨平台聊天解决方案。该系统实现了游戏内外的无缝互通,让不玩游戏的 Zulip 社群成员也能与游戏内玩家实时交流。
|
||||
|
||||
### 核心设计理念
|
||||
|
||||
系统采用 **统一网关 (Unified Gateway)** 架构,利用 Zulip 的 Stream-Topic 线程模型与游戏世界的空间概念进行映射:
|
||||
|
||||
| 游戏概念 | Zulip 概念 | 示例 |
|
||||
|---------|-----------|------|
|
||||
| Game World / Map | Stream | #Novice_Village |
|
||||
| Interactive Object / Event | Topic | Notice Board, Tavern Gossip |
|
||||
| Whisper / Party | Private Message | 私聊消息 |
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **客户端极度简化**: Godot 客户端无需处理 HTTP 请求、Long Polling 或复杂 JSON 解析
|
||||
2. **安全性**: Zulip API Key 永不下发到客户端,位置欺诈完全消除
|
||||
3. **协议统一**: 单一 WebSocket 协议,网络层代码减半
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌─────────────────────────────────────┐
|
||||
│ Godot Client │◄──────────────────►│ NestJS 中间件服务器 │
|
||||
│ (Game Client) │ Game Protocol │ ┌─────────────────────────────────┐│
|
||||
└─────────────────┘ │ │ WebSocket Gateway ││
|
||||
│ │ ├─ Session Manager ││
|
||||
│ │ ├─ Message Filter ││
|
||||
│ │ └─ Zulip Client Pool ││
|
||||
│ └─────────────────────────────────┘│
|
||||
└──────────────┬──────────────────────┘
|
||||
│ REST API / Long Polling
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Zulip Server │
|
||||
│ ├─ REST API │
|
||||
│ └─ Event Queue │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. WebSocket Gateway (`zulip-websocket.gateway.ts`)
|
||||
|
||||
统一网关,处理所有 Godot 客户端连接,实现游戏协议到 Zulip 协议的转换。
|
||||
|
||||
**主要功能:**
|
||||
- 连接认证和会话管理
|
||||
- 消息路由和协议转换
|
||||
- 权限控制和上下文注入
|
||||
|
||||
**支持的消息类型:**
|
||||
- `login`: 玩家登录
|
||||
- `chat`: 发送聊天消息
|
||||
- `position_update`: 位置更新
|
||||
- `logout`: 玩家登出
|
||||
|
||||
### 2. Session Manager (`session-manager.service.ts`)
|
||||
|
||||
会话管理器,维护 Socket_ID 与 Zulip_Queue_ID 的绑定关系。
|
||||
|
||||
**主要功能:**
|
||||
- 会话创建/销毁
|
||||
- 玩家位置跟踪
|
||||
- 上下文注入(根据位置确定 Stream/Topic)
|
||||
- 空间过滤(获取指定地图的所有 Socket)
|
||||
|
||||
### 3. Zulip Client Pool (`zulip-client-pool.service.ts`)
|
||||
|
||||
Zulip 客户端池,为每个用户维护专用的 Zulip 客户端实例。
|
||||
|
||||
**主要功能:**
|
||||
- API Key 管理
|
||||
- 事件队列注册
|
||||
- 消息发送/接收
|
||||
- 客户端生命周期管理
|
||||
|
||||
### 4. Message Filter (`message-filter.service.ts`)
|
||||
|
||||
消息过滤器,实施内容审核和频率控制。
|
||||
|
||||
**主要功能:**
|
||||
- 敏感词过滤
|
||||
- 频率限制(默认 10 条/分钟)
|
||||
- 消息长度限制(默认 1000 字符)
|
||||
- 重复内容检测
|
||||
- 权限验证
|
||||
|
||||
### 5. Config Manager (`config-manager.service.ts`)
|
||||
|
||||
配置管理器,管理地图映射配置和系统参数。
|
||||
|
||||
**主要功能:**
|
||||
- 地图到 Stream 的映射
|
||||
- 交互对象到 Topic 的映射
|
||||
- 配置热重载
|
||||
- 配置验证
|
||||
|
||||
### 6. Stream Initializer Service (`stream-initializer.service.ts`)
|
||||
|
||||
Stream 初始化服务,在系统启动时自动检查并创建缺失的 Zulip Streams。
|
||||
|
||||
**主要功能:**
|
||||
- 启动时自动检查所有地图对应的 Streams
|
||||
- 自动创建缺失的 Streams
|
||||
- 使用 Bot API Key 或管理员账号创建 Streams
|
||||
- 记录初始化结果和错误
|
||||
|
||||
**权限说明:**
|
||||
- Bot 账号可能缺少创建 Stream 的权限
|
||||
- 建议使用管理员账号手动创建 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予相应权限
|
||||
|
||||
### 7. Monitoring Service (`monitoring.service.ts`)
|
||||
|
||||
监控服务,提供系统健康检查和指标收集。
|
||||
|
||||
**主要功能:**
|
||||
- 连接指标监控
|
||||
- 消息指标监控
|
||||
- 系统健康检查
|
||||
- 告警通知
|
||||
|
||||
## 消息协议
|
||||
|
||||
### 客户端发送格式
|
||||
|
||||
#### 登录消息
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "user_game_token"
|
||||
}
|
||||
```
|
||||
|
||||
#### 聊天消息
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
#### 位置更新
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端接收格式
|
||||
|
||||
#### 聊天渲染消息
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "User_B",
|
||||
"txt": "Hi",
|
||||
"bubble": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 登录确认
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "session_123",
|
||||
"currentMap": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误消息
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# Zulip 服务器配置
|
||||
ZULIP_SERVER_URL=https://your-zulip-server.com
|
||||
ZULIP_BOT_EMAIL=bot@your-zulip-server.com
|
||||
ZULIP_BOT_API_KEY=your-bot-api-key
|
||||
|
||||
# WebSocket 配置
|
||||
WEBSOCKET_PORT=3001
|
||||
WEBSOCKET_NAMESPACE=/game
|
||||
|
||||
# 消息配置
|
||||
MESSAGE_RATE_LIMIT=10 # 消息频率限制(条/分钟)
|
||||
MESSAGE_MAX_LENGTH=1000 # 消息最大长度
|
||||
|
||||
# 会话配置
|
||||
SESSION_TIMEOUT=30 # 会话超时时间(分钟)
|
||||
CLEANUP_INTERVAL=5 # 清理间隔(分钟)
|
||||
```
|
||||
|
||||
### Stream 初始化
|
||||
|
||||
系统在启动时会自动检查并尝试创建缺失的 Zulip Streams。
|
||||
|
||||
**注意事项:**
|
||||
|
||||
- Bot 账号可能缺少创建 Stream 的权限
|
||||
- 建议使用管理员账号预先创建所有 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予 "Create streams" 权限
|
||||
|
||||
**手动创建 Streams:**
|
||||
|
||||
```bash
|
||||
# 使用测试脚本创建所有地图区域的 Streams
|
||||
node test-stream-initialization.js
|
||||
```
|
||||
|
||||
详细配置说明请参考 [配置管理指南](./configuration.md)。
|
||||
|
||||
### 地图映射配置
|
||||
|
||||
配置文件位置: `config/zulip/map-config.json`
|
||||
|
||||
系统支持 9 个地图区域,每个区域对应一个 Zulip Stream:
|
||||
|
||||
1. **鲸之港 (Whale Port)** - 中心城区,默认出生点
|
||||
2. **南瓜谷 (Pumpkin Valley)** - 新手学习区
|
||||
3. **Offer 城 (Offer City)** - 职业发展区
|
||||
4. **模型工厂 (Model Factory)** - AI/代码构建区
|
||||
5. **内核岛 (Kernel Island)** - 核心技术研究区
|
||||
6. **摸鱼海滩 (Moyu Beach)** - 休闲娱乐区
|
||||
7. **天梯峰 (Ladder Peak)** - 挑战竞赛区
|
||||
8. **星河湾 (Galaxy Bay)** - 创意设计区
|
||||
9. **数据遗迹 (Data Ruins)** - 数据库归档区
|
||||
|
||||
配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"description": "基于像素大地图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流程
|
||||
|
||||
### 发送消息流程 (游戏 → Zulip)
|
||||
|
||||
1. 玩家在游戏中输入消息
|
||||
2. Godot 客户端通过 WebSocket 发送 `chat` 消息
|
||||
3. WebSocket Gateway 接收消息
|
||||
4. Session Manager 获取玩家当前位置
|
||||
5. 上下文注入:根据位置确定目标 Stream/Topic
|
||||
6. Message Filter 进行内容过滤和频率检查
|
||||
7. Zulip Client Pool 使用用户的 API Key 发送消息到 Zulip
|
||||
8. 返回发送确认给客户端
|
||||
|
||||
### 接收消息流程 (Zulip → 游戏)
|
||||
|
||||
1. Zulip 服务器推送消息事件到 Event Queue
|
||||
2. Zulip Event Processor 接收并处理事件
|
||||
3. Session Manager 进行空间过滤,确定目标玩家
|
||||
4. 消息转换为游戏协议格式
|
||||
5. WebSocket Gateway 推送 `chat_render` 消息给目标客户端
|
||||
6. Godot 客户端显示聊天气泡
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误码说明
|
||||
|
||||
| 错误码 | 说明 | 处理建议 |
|
||||
|-------|------|---------|
|
||||
| `AUTH_FAILED` | 认证失败 | 检查 Token 有效性 |
|
||||
| `RATE_LIMIT` | 频率限制 | 等待后重试 |
|
||||
| `CONTENT_FILTERED` | 内容被过滤 | 修改消息内容 |
|
||||
| `PERMISSION_DENIED` | 权限不足 | 检查用户权限 |
|
||||
| `ZULIP_ERROR` | Zulip 服务错误 | 系统自动重试 |
|
||||
| `SESSION_EXPIRED` | 会话过期 | 重新登录 |
|
||||
|
||||
### 降级策略
|
||||
|
||||
当 Zulip 服务不可用时,系统会自动切换到本地聊天模式:
|
||||
- 消息仅在游戏内传播
|
||||
- 不同步到 Zulip
|
||||
- 服务恢复后自动切换回正常模式
|
||||
|
||||
## 安全机制
|
||||
|
||||
### API Key 安全
|
||||
|
||||
- API Key 加密存储在数据库中
|
||||
- 永不下发到客户端
|
||||
- 支持强制刷新机制
|
||||
|
||||
### 消息安全
|
||||
|
||||
- 敏感词过滤
|
||||
- 频率限制防刷屏
|
||||
- 位置验证防欺诈
|
||||
- 消息长度限制
|
||||
|
||||
### 连接安全
|
||||
|
||||
- Token 验证
|
||||
- 会话超时自动断开
|
||||
- 异常连接检测和拒绝
|
||||
|
||||
## 监控指标
|
||||
|
||||
### 连接指标
|
||||
- `zulip.connections.active`: 活跃连接数
|
||||
- `zulip.connections.total`: 总连接数
|
||||
- `zulip.connections.errors`: 连接错误数
|
||||
|
||||
### 消息指标
|
||||
- `zulip.messages.sent`: 发送消息数
|
||||
- `zulip.messages.received`: 接收消息数
|
||||
- `zulip.messages.filtered`: 被过滤消息数
|
||||
- `zulip.messages.latency`: 消息延迟
|
||||
|
||||
### 系统指标
|
||||
- `zulip.sessions.active`: 活跃会话数
|
||||
- `zulip.zulip_clients.active`: 活跃 Zulip 客户端数
|
||||
- `zulip.event_queues.active`: 活跃事件队列数
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [zulip-js 库使用指南](./zulip-js.md)
|
||||
- [API 接口文档](./api.md)
|
||||
- [WebSocket 协议详解](./websocket-protocol.md)
|
||||
- [配置管理指南](./configuration.md)
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.1.0 (2026-01-06)
|
||||
- **修复 JWT Token 验证和 API Key 管理**
|
||||
- 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题
|
||||
- `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证
|
||||
- 修复消息发送时使用错误的硬编码 API Key 问题
|
||||
- 现在正确从 Redis 读取用户注册时存储的真实 API Key
|
||||
- 添加 `AuthModule` 到 `ZulipModule` 的依赖注入
|
||||
- 消息发送功能现已完全正常工作 ✅
|
||||
|
||||
### v1.0.1 (2025-12-25)
|
||||
- 更新地图配置为 9 区域系统
|
||||
- 添加 Stream Initializer Service 自动初始化服务
|
||||
- 更新默认出生点为鲸之港 (Whale Port)
|
||||
- 添加地图区域描述字段
|
||||
- 修复上下文注入使用 ConfigManager
|
||||
- 改进错误处理和日志记录
|
||||
|
||||
### v1.0.0 (2025-12-25)
|
||||
- 初始版本发布
|
||||
- 实现 WebSocket Gateway 统一网关
|
||||
- 实现 Session Manager 会话管理
|
||||
- 实现 Zulip Client Pool 客户端池
|
||||
- 实现 Message Filter 消息过滤
|
||||
- 实现 Config Manager 配置管理
|
||||
- 实现 Monitoring Service 监控服务
|
||||
- 完成集成测试覆盖
|
||||
254
docs/systems/zulip/ZULIP_INTEGRATION_SUMMARY.md
Normal file
254
docs/systems/zulip/ZULIP_INTEGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Zulip集成系统测试总结
|
||||
|
||||
## 测试日期
|
||||
2025-12-25
|
||||
|
||||
## 测试环境
|
||||
|
||||
### Zulip服务器配置
|
||||
|
||||
- **服务器URL**: <https://zulip.xinghangee.icu/>
|
||||
- **Bot邮箱**: <cbot-bot@zulip.xinghangee.icu>
|
||||
- **Bot API Key**: 3k61GqxVkc...x3F3STksF (已配置在.env)
|
||||
|
||||
### 测试用户配置
|
||||
|
||||
- **用户API Key**: W2KhXaQxJ...0c9nPXaalh5
|
||||
- **Zulip用户邮箱**: <user8@zulip.xinghangee.icu>
|
||||
- **用户全名**: ANGJustinl
|
||||
- **用户ID**: 8
|
||||
- **权限**: 管理员
|
||||
|
||||
## 测试结果
|
||||
|
||||
### ✅ 1. API Key验证测试
|
||||
|
||||
**测试脚本**: `test-api-key-validation.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- API Key验证成功
|
||||
- 用户信息获取正常
|
||||
- 用户邮箱: <user8@zulip.xinghangee.icu>
|
||||
- 用户全名: ANGJustinl
|
||||
|
||||
### ✅ 2. Stream管理测试
|
||||
|
||||
**测试脚本**: `test-list-subscriptions.js`, `test-subscribe-stream.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- 成功列出用户订阅的Streams (Zulip, general, 沙箱)
|
||||
- 成功创建"Novice Village" Stream
|
||||
- 成功订阅新创建的Stream
|
||||
- 测试消息发送成功 (Message ID: 17, 19)
|
||||
|
||||
### ✅ 3. Zulip客户端创建测试
|
||||
|
||||
**测试方法**: 服务器日志验证
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- Zulip客户端创建成功
|
||||
- 事件队列注册成功 (Queue ID: 9b7c31ed-29a5-4419-b482-2fe549e26cc4)
|
||||
- 客户端生命周期管理正常
|
||||
- 客户端销毁和清理正常
|
||||
|
||||
### ✅ 4. 端到端集成测试
|
||||
|
||||
**测试脚本**: `test-user-api-key.js`
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- WebSocket连接成功
|
||||
- 登录流程正常
|
||||
- 会话ID生成正常
|
||||
- 用户ID: user_W2KhXaQx
|
||||
- 用户名: Player_W2KhX
|
||||
- 当前地图: whale_port (更新后)
|
||||
- 消息发送成功
|
||||
- Message ID: 20-25, 51-52
|
||||
- 所有消息成功发送到Zulip服务器
|
||||
- 支持多地图消息路由 (Whale Port, Pumpkin Valley)
|
||||
- 目标Topic: General
|
||||
|
||||
### ✅ 5. 单元测试和集成测试
|
||||
|
||||
**测试套件**: `src/business/zulip/zulip-integration.e2e.spec.ts`
|
||||
|
||||
**结果**: 22/22 通过
|
||||
|
||||
- WebSocket连接和会话管理 ✓
|
||||
- Zulip客户端生命周期管理 ✓
|
||||
- 消息路由和权限验证 ✓
|
||||
- 消息格式转换完整性 ✓
|
||||
- 消息接收和分发 ✓
|
||||
- 会话状态一致性 ✓
|
||||
- 内容安全和频率控制 ✓
|
||||
- API Key安全存储 ✓
|
||||
- 错误处理和服务降级 ✓
|
||||
- 操作确认和日志记录 ✓
|
||||
- 系统监控和告警 ✓
|
||||
- 配置验证 ✓
|
||||
|
||||
### ✅ 6. Stream初始化测试
|
||||
|
||||
**测试脚本**: `test-stream-initialization.js`
|
||||
|
||||
**结果**: 部分通过
|
||||
|
||||
- Stream 初始化服务正常启动
|
||||
- 成功检测缺失的 Streams
|
||||
- Bot 账号权限不足,无法自动创建 Streams
|
||||
- 使用管理员账号手动创建 Streams 成功
|
||||
- 所有 9 个地图区域的 Streams 已创建
|
||||
|
||||
### ✅ 7. 多地图消息路由测试
|
||||
|
||||
**测试脚本**: `test-user-api-key.js` (更新版)
|
||||
|
||||
**结果**: 通过
|
||||
|
||||
- 成功在 Whale Port 发送消息 (Message ID: 51)
|
||||
- 成功切换到 Pumpkin Valley
|
||||
- 成功在 Pumpkin Valley 发送消息 (Message ID: 52)
|
||||
- 上下文注入正确使用 ConfigManager
|
||||
- 消息路由到正确的 Stream
|
||||
|
||||
## 关键发现
|
||||
|
||||
### 1. API Key和用户邮箱映射
|
||||
|
||||
- 用户API Key对应的Zulip邮箱是 `user8@zulip.xinghangee.icu`
|
||||
- 不是 `cbot-bot@zulip.xinghangee.icu`
|
||||
- 已在代码中修正 (`src/business/zulip/zulip.service.ts`)
|
||||
|
||||
### 2. Stream创建和权限
|
||||
|
||||
- Bot 账号 (cbot-bot) 缺少创建 Stream 的权限
|
||||
- 需要使用管理员账号手动创建 Streams
|
||||
- 或在 Zulip 服务器中为 Bot 授予 Stream 创建权限
|
||||
- 已使用管理员账号成功创建所有 9 个地图区域的 Streams
|
||||
|
||||
### 3. 地图配置更新
|
||||
|
||||
- 系统从 2 个地图区域扩展到 9 个地图区域
|
||||
- 默认出生点从 `novice_village` 更改为 `whale_port`
|
||||
- 添加了地图区域描述字段 (`description`)
|
||||
- 配置版本从 1.0.0 升级到 2.0.0
|
||||
|
||||
### 4. 消息路由改进
|
||||
|
||||
- 修复了 SessionManager 使用硬编码 Stream 映射的问题
|
||||
- 现在使用 ConfigManager 动态获取 Stream 映射
|
||||
- 支持多地图消息路由,消息自动发送到玩家当前地图对应的 Stream
|
||||
- 已验证 Whale Port 和 Pumpkin Valley 的消息路由正常
|
||||
|
||||
### 5. 消息发送验证
|
||||
|
||||
- 所有消息都成功发送到Zulip服务器
|
||||
- 返回真实的Message ID (20-25, 51-52)
|
||||
- 可以在Zulip网页界面查看消息
|
||||
- 支持跨地图消息发送
|
||||
|
||||
## 系统状态
|
||||
|
||||
### ✅ 核心功能
|
||||
|
||||
- [x] WebSocket连接管理
|
||||
- [x] 用户登录和会话管理
|
||||
- [x] Zulip客户端创建和管理
|
||||
- [x] 事件队列注册和管理
|
||||
- [x] 消息发送到Zulip
|
||||
- [x] 消息格式转换
|
||||
- [x] 多地图消息路由
|
||||
- [x] Stream 自动初始化检查
|
||||
- [x] 错误处理和降级
|
||||
- [x] 日志记录和监控
|
||||
|
||||
### ✅ 配置管理
|
||||
|
||||
- [x] 环境变量配置
|
||||
- [x] 9 区域地图映射配置
|
||||
- [x] API Key安全存储
|
||||
- [x] 配置验证
|
||||
- [x] 动态 Stream 映射
|
||||
|
||||
### ✅ 测试覆盖
|
||||
|
||||
- [x] 单元测试 (22个测试用例)
|
||||
- [x] 集成测试 (端到端流程)
|
||||
- [x] 真实Zulip服务器测试
|
||||
- [x] 多地图消息路由测试
|
||||
- [x] Stream 初始化测试
|
||||
- [x] 错误场景测试
|
||||
|
||||
# !!!stream-initializer.service.ts - 404行处仍有todo需要完成, 现在没前端我搞不清楚咋做:(
|
||||
|
||||
## 下一步建议
|
||||
|
||||
### 1. Stream 权限配置
|
||||
|
||||
- [ ] 在 Zulip 服务器中为 Bot 账号授予创建 Stream 的权限
|
||||
- [ ] 或使用管理员账号预先创建所有 Streams
|
||||
- [ ] 验证所有 9 个地图区域的 Streams 已创建
|
||||
|
||||
### 2. 生产环境准备
|
||||
|
||||
- [ ] 配置生产环境的Zulip服务器
|
||||
- [ ] 设置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)
|
||||
- [ ] 配置邮件服务用于通知
|
||||
- [ ] 设置监控和告警
|
||||
- [ ] 配置所有地图区域的 Streams
|
||||
|
||||
### 3. 功能增强
|
||||
|
||||
- [ ] 实现从Zulip接收消息的事件轮询
|
||||
- [ ] 实现双向消息同步
|
||||
- [ ] 实现用户权限管理
|
||||
- [ ] 添加地图切换动画和提示
|
||||
- [ ] 实现跨地图私聊功能
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- [ ] 优化客户端池管理
|
||||
- [ ] 实现消息批量发送
|
||||
- [ ] 添加消息缓存机制
|
||||
- [ ] 优化事件队列轮询频率
|
||||
- [ ] 实现 Stream 订阅缓存
|
||||
|
||||
### 5. 文档完善
|
||||
|
||||
- [x] 系统架构文档
|
||||
- [x] API文档
|
||||
- [x] WebSocket协议文档
|
||||
- [x] 配置文档 (已更新 9 区域配置)
|
||||
- [x] Stream 初始化文档
|
||||
- [ ] 部署文档
|
||||
- [ ] 运维手册
|
||||
|
||||
## 结论
|
||||
|
||||
Zulip集成系统已成功完成开发和测试,所有核心功能正常工作。系统已通过:
|
||||
|
||||
- 22个单元测试和集成测试
|
||||
- 真实Zulip服务器的端到端测试
|
||||
- 多地图消息路由验证
|
||||
- Stream 初始化服务测试
|
||||
- 消息发送和接收验证
|
||||
|
||||
**最新更新 (v2.0.0):**
|
||||
|
||||
- 地图配置从 2 个区域扩展到 9 个区域
|
||||
- 实现 Stream 自动初始化检查服务
|
||||
- 修复上下文注入使用动态配置
|
||||
- 改进错误处理和日志记录
|
||||
- 更新默认出生点为鲸之港
|
||||
|
||||
系统已准备好进入下一阶段的开发和部署。建议优先配置 Stream 创建权限或手动创建所有地图区域的 Streams。
|
||||
|
||||
---
|
||||
|
||||
**测试人员**: ANGJustinl
|
||||
**审核状态**: 待确认
|
||||
**文档版本**: 1.0.0
|
||||
285
docs/systems/zulip/api.md
Normal file
285
docs/systems/zulip/api.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Zulip 集成系统 API 文档
|
||||
|
||||
## WebSocket 连接
|
||||
|
||||
### 连接地址
|
||||
|
||||
```
|
||||
wss://localhost:3000/game
|
||||
```
|
||||
|
||||
### 连接参数
|
||||
|
||||
连接时无需额外参数,认证通过 `login` 消息完成。
|
||||
|
||||
## 消息类型
|
||||
|
||||
### 1. 登录 (login)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "user_game_token"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "session_abc123",
|
||||
"currentMap": "novice_village",
|
||||
"username": "player_name"
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "AUTH_FAILED",
|
||||
"message": "Token 验证失败"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 发送聊天消息 (chat)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello, world!",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "chat" |
|
||||
| content | string | 是 | 消息内容,最大 1000 字符 |
|
||||
| scope | string | 是 | 消息范围: "local" 或具体 topic 名称 |
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"t": "chat_sent",
|
||||
"messageId": "msg_123",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应:**
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 接收聊天消息 (chat_render)
|
||||
|
||||
**服务器推送:**
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "other_player",
|
||||
"txt": "Hi there!",
|
||||
"bubble": true,
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_render" |
|
||||
| from | string | 发送者名称 |
|
||||
| txt | string | 消息内容 |
|
||||
| bubble | boolean | 是否显示气泡 |
|
||||
| timestamp | number | 消息时间戳 |
|
||||
|
||||
### 4. 位置更新 (position_update)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 150,
|
||||
"y": 200,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "position" |
|
||||
| x | number | 是 | X 坐标 |
|
||||
| y | number | 是 | Y 坐标 |
|
||||
| mapId | string | 是 | 地图 ID |
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"t": "position_updated",
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 登出 (logout)
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{
|
||||
"type": "logout"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"t": "logout_success"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | HTTP 等效 | 说明 | 处理建议 |
|
||||
|-------|----------|------|---------|
|
||||
| `AUTH_FAILED` | 401 | 认证失败,Token 无效或过期 | 重新获取 Token 并登录 |
|
||||
| `RATE_LIMIT` | 429 | 消息发送频率超限 | 等待 60 秒后重试 |
|
||||
| `CONTENT_FILTERED` | 400 | 消息内容被过滤 | 修改消息内容后重试 |
|
||||
| `CONTENT_TOO_LONG` | 400 | 消息内容超长 | 缩短消息长度 |
|
||||
| `PERMISSION_DENIED` | 403 | 权限不足 | 检查用户权配置 |
|
||||
| `SESSION_EXPIRED` | 401 | 会话已过期 | 重新登录 |
|
||||
| `SESSION_NOT_FOUND` | 404 | 会话不存在 | 重新登录 |
|
||||
| `ZULIP_ERROR` | 502 | Zulip 服务错误 | 系统自动重试,无需处理 |
|
||||
| `INTERNAL_ERROR` | 500 | 内部服务器错误 | 联系管理员 |
|
||||
|
||||
## 频率限制
|
||||
|
||||
### 消息发送限制
|
||||
|
||||
- 默认限制: 10 条/分钟
|
||||
- 超限后返回 `RATE_LIMIT` 错误
|
||||
- 限制窗口: 滑动窗口,60 秒
|
||||
|
||||
### 连接限制
|
||||
|
||||
- 单用户最大连接数: 3
|
||||
- 超限后新连接被拒绝
|
||||
|
||||
## 消息过滤规则
|
||||
|
||||
### 内容过滤
|
||||
|
||||
1. **敏感词过滤**: 包含敏感词的消息将被拒绝
|
||||
2. **长度限制**: 消息最大 1000 字符
|
||||
3. **重复检测**: 连续发送相同内容将被拒绝
|
||||
|
||||
### 权限验证
|
||||
|
||||
1. **位置验证**: 只能向当前所在地图对应的 Stream 发送消息
|
||||
2. **Stream 权限**: 只能访问配置中允许的 Stream
|
||||
|
||||
## 示例代码
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// 连接 WebSocket
|
||||
const socket = new WebSocket('ws://localhost:3000/game');
|
||||
|
||||
// 连接成功
|
||||
socket.onopen = () => {
|
||||
// 发送登录消息
|
||||
socket.send(JSON.stringify({
|
||||
type: 'login',
|
||||
token: 'your_game_token'
|
||||
}));
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.t) {
|
||||
case 'login_success':
|
||||
console.log('登录成功:', data.sessionId);
|
||||
break;
|
||||
case 'chat_render':
|
||||
console.log(`${data.from}: ${data.txt}`);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(`错误 [${data.code}]: ${data.message}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 发送聊天消息
|
||||
function sendChat(content: string) {
|
||||
socket.send(JSON.stringify({
|
||||
t: 'chat',
|
||||
content: content,
|
||||
scope: 'local'
|
||||
}));
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
function updatePosition(x: number, y: number, mapId: string) {
|
||||
socket.send(JSON.stringify({
|
||||
t: 'position',
|
||||
x: x,
|
||||
y: y,
|
||||
mapId: mapId
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## 健康检查接口
|
||||
|
||||
### GET /health
|
||||
|
||||
检查系统健康状态。
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"components": {
|
||||
"websocket": "healthy",
|
||||
"zulip": "healthy",
|
||||
"redis": "healthy"
|
||||
},
|
||||
"metrics": {
|
||||
"activeConnections": 42,
|
||||
"activeSessions": 40,
|
||||
"messagesSentLastMinute": 156
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /metrics
|
||||
|
||||
获取系统指标(Prometheus 格式)。
|
||||
|
||||
**响应:**
|
||||
```
|
||||
# HELP zulip_connections_active Active WebSocket connections
|
||||
# TYPE zulip_connections_active gauge
|
||||
zulip_connections_active 42
|
||||
|
||||
# HELP zulip_messages_sent_total Total messages sent
|
||||
# TYPE zulip_messages_sent_total counter
|
||||
zulip_messages_sent_total 15678
|
||||
|
||||
# HELP zulip_message_latency_seconds Message processing latency
|
||||
# TYPE zulip_message_latency_seconds histogram
|
||||
zulip_message_latency_seconds_bucket{le="0.1"} 14500
|
||||
zulip_message_latency_seconds_bucket{le="0.5"} 15600
|
||||
zulip_message_latency_seconds_bucket{le="1"} 15678
|
||||
```
|
||||
516
docs/systems/zulip/configuration.md
Normal file
516
docs/systems/zulip/configuration.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# 配置管理指南
|
||||
|
||||
## 概述
|
||||
|
||||
Zulip 集成系统支持多种配置方式,包括环境变量、配置文件和运行时配置。本文档详细说明各配置项的用途和设置方法。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### Zulip 服务器配置
|
||||
|
||||
```bash
|
||||
# Zulip 服务器 URL
|
||||
ZULIP_SERVER_URL=https://zulip.xinghangee.icu/
|
||||
|
||||
# Zulip Bot 邮箱
|
||||
ZULIP_BOT_EMAIL=cbot-bot@zulip.xinghangee.icu
|
||||
|
||||
# Zulip Bot API Key
|
||||
ZULIP_BOT_API_KEY=your-bot-api-key
|
||||
|
||||
# Zulip Realm (可选,默认从 URL 推断)
|
||||
ZULIP_REALM=your-realm
|
||||
```
|
||||
|
||||
### WebSocket 配置
|
||||
|
||||
```bash
|
||||
# WebSocket 端口
|
||||
WEBSOCKET_PORT=3000
|
||||
|
||||
# WebSocket 命名空间
|
||||
WEBSOCKET_NAMESPACE=/game
|
||||
|
||||
# 最大连接数
|
||||
WEBSOCKET_MAX_CONNECTIONS=100
|
||||
|
||||
# 连接超时时间 (毫秒)
|
||||
WEBSOCKET_TIMEOUT=60000
|
||||
```
|
||||
|
||||
### 消息配置
|
||||
|
||||
```bash
|
||||
# 消息频率限制 (条/分钟)
|
||||
MESSAGE_RATE_LIMIT=10
|
||||
|
||||
# 消息最大长度 (字符)
|
||||
MESSAGE_MAX_LENGTH=1000
|
||||
|
||||
# 是否启用内容过滤
|
||||
ENABLE_CONTENT_FILTER=true
|
||||
|
||||
# 是否启用重复检测
|
||||
ENABLE_DUPLICATE_DETECTION=true
|
||||
```
|
||||
|
||||
### 会话配置
|
||||
|
||||
```bash
|
||||
# 会话超时时间 (分钟)
|
||||
SESSION_TIMEOUT=30
|
||||
|
||||
# 会话清理间隔 (分钟)
|
||||
SESSION_CLEANUP_INTERVAL=5
|
||||
|
||||
# 最大会话数
|
||||
MAX_SESSIONS=5000
|
||||
```
|
||||
|
||||
### Redis 配置
|
||||
|
||||
```bash
|
||||
# Redis 主机
|
||||
REDIS_HOST=localhost
|
||||
|
||||
# Redis 端口
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Redis 密码 (可选)
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Redis 数据库索引
|
||||
REDIS_DB=0
|
||||
|
||||
# Redis 键前缀
|
||||
REDIS_KEY_PREFIX=zulip:
|
||||
```
|
||||
|
||||
### 日志配置
|
||||
|
||||
```bash
|
||||
# 日志级别 (debug, info, warn, error)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 是否启用结构化日志
|
||||
LOG_STRUCTURED=true
|
||||
|
||||
# 日志文件路径 (可选)
|
||||
LOG_FILE_PATH=logs/zulip.log
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
### 地图映射配置
|
||||
|
||||
文件位置: `config/zulip/map-config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"lastModified": "2025-12-25T20:00:00.000Z",
|
||||
"description": "基于像素大地图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "clock_tower",
|
||||
"objectName": "大本钟",
|
||||
"zulipTopic": "General Chat",
|
||||
"position": { "x": 550, "y": 350 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "pumpkin_valley",
|
||||
"mapName": "南瓜谷",
|
||||
"zulipStream": "Pumpkin Valley",
|
||||
"description": "新手成长、基础资源与学习社区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "pumpkin_patch",
|
||||
"objectName": "南瓜田",
|
||||
"zulipTopic": "Tutorials",
|
||||
"position": { "x": 150, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "farm_house",
|
||||
"objectName": "农舍",
|
||||
"zulipTopic": "Study Group",
|
||||
"position": { "x": 200, "y": 450 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "offer_city",
|
||||
"mapName": "Offer 城",
|
||||
"zulipStream": "Offer City",
|
||||
"description": "职业发展、面试与商务区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "skyscrapers",
|
||||
"objectName": "摩天大楼",
|
||||
"zulipTopic": "Career Talk",
|
||||
"position": { "x": 350, "y": 650 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "model_factory",
|
||||
"mapName": "模型工厂",
|
||||
"zulipStream": "Model Factory",
|
||||
"description": "AI模型训练、代码构建与工业区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "assembly_line",
|
||||
"objectName": "流水线",
|
||||
"zulipTopic": "Code Review",
|
||||
"position": { "x": 400, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
系统现在支持 9 个地图区域:
|
||||
|
||||
1. **鲸之港 (Whale Port)** - 中心城区,交通枢纽与主要聚会点
|
||||
2. **南瓜谷 (Pumpkin Valley)** - 新手成长、基础资源与学习社区
|
||||
3. **Offer 城 (Offer City)** - 职业发展、面试与商务区
|
||||
4. **模型工厂 (Model Factory)** - AI模型训练、代码构建与工业区
|
||||
5. **内核岛 (Kernel Island)** - 核心技术研究、底层原理与算法
|
||||
6. **摸鱼海滩 (Moyu Beach)** - 休闲娱乐、水贴与非技术话题
|
||||
7. **天梯峰 (Ladder Peak)** - 挑战、竞赛与排行榜
|
||||
8. **星河湾 (Galaxy Bay)** - 创意、设计与灵感
|
||||
9. **数据遗迹 (Data Ruins)** - 数据库、归档与历史记录
|
||||
|
||||
### 配置字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| version | string | 是 | 配置版本号 |
|
||||
| lastModified | string | 否 | 最后修改时间 (ISO 8601) |
|
||||
| description | string | 否 | 配置文件描述 |
|
||||
| maps | array | 是 | 地图配置数组 |
|
||||
| maps[].mapId | string | 是 | 地图唯一标识 |
|
||||
| maps[].mapName | string | 是 | 地图显示名称 |
|
||||
| maps[].zulipStream | string | 是 | 对应的 Zulip Stream |
|
||||
| maps[].description | string | 否 | 地图区域描述 |
|
||||
| maps[].defaultTopic | string | 否 | 默认 Topic,默认 "General" |
|
||||
| maps[].interactionObjects | array | 否 | 交互对象配置 |
|
||||
| interactionObjects[].objectId | string | 是 | 对象唯一标识 |
|
||||
| interactionObjects[].objectName | string | 是 | 对象显示名称 |
|
||||
| interactionObjects[].zulipTopic | string | 是 | 对应的 Zulip Topic |
|
||||
| interactionObjects[].position | object | 是 | 对象位置坐标 |
|
||||
| interactionObjects[].radius | number | 否 | 交互半径,默认 50 |
|
||||
|
||||
### 敏感词配置
|
||||
|
||||
文件位置: `config/zulip/sensitive-words.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"words": [
|
||||
"敏感词1",
|
||||
"敏感词2"
|
||||
],
|
||||
"patterns": [
|
||||
"正则表达式1",
|
||||
"正则表达式2"
|
||||
],
|
||||
"replacements": {
|
||||
"原词": "替换词"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 允许的 Stream 配置
|
||||
|
||||
文件位置: `config/zulip/allowed-streams.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"streams": [
|
||||
"Novice Village",
|
||||
"Market Square",
|
||||
"Guild Hall",
|
||||
"Arena"
|
||||
],
|
||||
"privateStreams": [
|
||||
"Admin",
|
||||
"Moderators"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 运行时配置
|
||||
|
||||
### 通过 API 更新配置
|
||||
|
||||
```typescript
|
||||
// 更新消息频率限制
|
||||
await configManager.updateConfig('messageRateLimit', 20);
|
||||
|
||||
// 更新会话超时时间
|
||||
await configManager.updateConfig('sessionTimeout', 60);
|
||||
|
||||
// 重新加载地图配置
|
||||
await configManager.reloadMapConfig();
|
||||
```
|
||||
|
||||
### 配置热重载
|
||||
|
||||
系统支持配置热重载,无需重启服务:
|
||||
|
||||
```bash
|
||||
# 发送 SIGHUP 信号触发配置重载
|
||||
kill -HUP <pid>
|
||||
```
|
||||
|
||||
或通过 API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/admin/config/reload \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
## 配置验证
|
||||
|
||||
### 启动时验证
|
||||
|
||||
系统在启动时会验证所有配置的有效性:
|
||||
|
||||
```typescript
|
||||
// 配置验证示例
|
||||
const configValidator = new ConfigValidator();
|
||||
|
||||
// 验证环境变量
|
||||
configValidator.validateEnv({
|
||||
ZULIP_SERVER_URL: { required: true, type: 'url' },
|
||||
ZULIP_BOT_EMAIL: { required: true, type: 'email' },
|
||||
ZULIP_BOT_API_KEY: { required: true, type: 'string' },
|
||||
MESSAGE_RATE_LIMIT: { required: false, type: 'number', default: 10 },
|
||||
});
|
||||
|
||||
// 验证地图配置
|
||||
configValidator.validateMapConfig(mapConfig);
|
||||
```
|
||||
|
||||
### 验证错误处理
|
||||
|
||||
配置验证失败时,系统会:
|
||||
|
||||
1. 记录详细的错误日志
|
||||
2. 输出错误信息到控制台
|
||||
3. 阻止服务启动(严重错误)或使用默认值(非严重错误)
|
||||
|
||||
```
|
||||
[ERROR] 配置验证失败:
|
||||
- ZULIP_SERVER_URL: 必填项未设置
|
||||
- MESSAGE_RATE_LIMIT: 值必须大于 0
|
||||
- map-config.json: maps[0].zulipStream 不能为空
|
||||
```
|
||||
|
||||
## Stream 初始化
|
||||
|
||||
### 自动初始化服务
|
||||
|
||||
系统在启动时会自动检查所有地图配置中定义的 Zulip Streams 是否存在。如果发现缺失的 Streams,会尝试自动创建。
|
||||
|
||||
**服务配置:**
|
||||
|
||||
```typescript
|
||||
// Stream 初始化服务会在系统启动 5 秒后自动运行
|
||||
// 位置: src/business/zulip/services/stream-initializer.service.ts
|
||||
|
||||
@Injectable()
|
||||
export class StreamInitializerService implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
// 延迟 5 秒启动,确保其他服务已就绪
|
||||
setTimeout(() => {
|
||||
this.initializeStreams();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权限要求
|
||||
|
||||
创建 Zulip Streams 需要特定权限:
|
||||
|
||||
- **Bot 账号**: 默认情况下可能缺少创建 Stream 的权限
|
||||
- **管理员账号**: 拥有完整的 Stream 创建权限
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. **方案一**: 在 Zulip 服务器中为 Bot 账号授予创建 Stream 的权限
|
||||
- 登录 Zulip 管理后台
|
||||
- 找到 Bot 账号设置
|
||||
- 授予 "Create streams" 权限
|
||||
|
||||
2. **方案二**: 使用管理员账号手动创建 Streams
|
||||
- 使用提供的测试脚本 `test-stream-initialization.js`
|
||||
- 配置管理员 API Key
|
||||
- 运行脚本自动创建所有 Streams
|
||||
|
||||
3. **方案三**: 在 Zulip 网页界面手动创建
|
||||
- 登录 Zulip 网页界面
|
||||
- 创建对应的 Streams (参考 `config/zulip/map-config.json`)
|
||||
|
||||
### 手动创建 Streams
|
||||
|
||||
使用测试脚本创建所有地图区域的 Streams:
|
||||
|
||||
```bash
|
||||
# 编辑 test-stream-initialization.js,配置管理员 API Key
|
||||
# 然后运行脚本
|
||||
node test-stream-initialization.js
|
||||
```
|
||||
|
||||
脚本会自动创建以下 Streams:
|
||||
|
||||
- Whale Port (鲸之港)
|
||||
- Pumpkin Valley (南瓜谷)
|
||||
- Offer City (Offer 城)
|
||||
- Model Factory (模型工厂)
|
||||
- Kernel Island (内核岛)
|
||||
- Moyu Beach (摸鱼海滩)
|
||||
- Ladder Peak (天梯峰)
|
||||
- Galaxy Bay (星河湾)
|
||||
- Data Ruins (数据遗迹)
|
||||
|
||||
### 初始化日志
|
||||
|
||||
系统会记录 Stream 初始化的详细日志:
|
||||
|
||||
```
|
||||
[INFO] 开始初始化 Zulip Streams...
|
||||
[INFO] 检查 Stream: Whale Port
|
||||
[INFO] Stream 已存在: Whale Port
|
||||
[WARN] Stream 不存在,尝试创建: Pumpkin Valley
|
||||
[INFO] Stream 创建成功: Pumpkin Valley
|
||||
[ERROR] Stream 创建失败: Offer City - Insufficient permission
|
||||
```
|
||||
|
||||
## 配置最佳实践
|
||||
|
||||
### 1. 使用环境变量管理敏感信息
|
||||
|
||||
```bash
|
||||
# 不要在代码中硬编码敏感信息
|
||||
# 使用环境变量或密钥管理服务
|
||||
|
||||
# 开发环境
|
||||
export ZULIP_BOT_API_KEY=dev-api-key
|
||||
|
||||
# 生产环境 (使用密钥管理服务)
|
||||
export ZULIP_BOT_API_KEY=$(aws secretsmanager get-secret-value --secret-id zulip-api-key --query SecretString --output text)
|
||||
```
|
||||
|
||||
### 2. 分环境配置
|
||||
|
||||
```
|
||||
config/
|
||||
├── zulip/
|
||||
│ ├── map-config.json # 默认配置
|
||||
│ ├── map-config.dev.json # 开发环境
|
||||
│ ├── map-config.staging.json # 预发布环境
|
||||
│ └── map-config.prod.json # 生产环境
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 根据环境加载配置
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const configPath = `config/zulip/map-config.${env}.json`;
|
||||
```
|
||||
|
||||
### 3. 配置版本控制
|
||||
|
||||
- 将配置文件纳入版本控制
|
||||
- 使用 `.env.example` 提供配置模板
|
||||
- 敏感配置使用 `.gitignore` 排除
|
||||
|
||||
### 4. 配置文档化
|
||||
|
||||
为每个配置项提供清晰的文档说明:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 消息频率限制配置
|
||||
*
|
||||
* @description 限制用户每分钟可发送的消息数量
|
||||
* @default 10
|
||||
* @range 1-100
|
||||
* @env MESSAGE_RATE_LIMIT
|
||||
*/
|
||||
messageRateLimit: number;
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见配置问题
|
||||
|
||||
#### 1. Zulip 连接失败
|
||||
|
||||
```
|
||||
错误: ZULIP_CONNECTION_FAILED
|
||||
原因: 无法连接到 Zulip 服务器
|
||||
```
|
||||
|
||||
检查项:
|
||||
- `ZULIP_SERVER_URL` 是否正确
|
||||
- 网络是否可达
|
||||
- API Key 是否有效
|
||||
|
||||
#### 2. 地图配置加载失败
|
||||
|
||||
```
|
||||
错误: MAP_CONFIG_LOAD_FAILED
|
||||
原因: 地图配置文件格式错误
|
||||
```
|
||||
|
||||
检查项:
|
||||
- JSON 格式是否正确
|
||||
- 必填字段是否完整
|
||||
- 字段类型是否正确
|
||||
|
||||
#### 3. Redis 连接失败
|
||||
|
||||
```
|
||||
错误: REDIS_CONNECTION_FAILED
|
||||
原因: 无法连接到 Redis 服务器
|
||||
```
|
||||
|
||||
检查项:
|
||||
- `REDIS_HOST` 和 `REDIS_PORT` 是否正确
|
||||
- Redis 服务是否运行
|
||||
- 密码是否正确
|
||||
|
||||
### 配置诊断命令
|
||||
|
||||
```bash
|
||||
# 检查配置有效性
|
||||
npm run config:validate
|
||||
|
||||
# 显示当前配置
|
||||
npm run config:show
|
||||
|
||||
# 测试 Zulip 连接
|
||||
npm run config:test-zulip
|
||||
|
||||
# 测试 Redis 连接
|
||||
npm run config:test-redis
|
||||
```
|
||||
216
docs/systems/zulip/guide.md
Normal file
216
docs/systems/zulip/guide.md
Normal file
@@ -0,0 +1,216 @@
|
||||
游戏属性: 2d社交属性, 无战斗的社群mmo游戏
|
||||
核心目的(游戏内外无缝互通): 不玩这个游戏但是在zulip的社群成员, 也可以跨平台和游戏内的成员聊天
|
||||
|
||||
核心设计理念:
|
||||
Stream (流) -> Topic (话题) 线程模型,天然契合 MMO 中的 Zone (区域) -> Context (情境) 逻辑
|
||||
我们需要解决的核心问题是:如何将2D 空间位置(Game State)映射到Zulip 的信息组织形式(Message State),同时利用 Zulip 的 API Key 机制完成无缝认证
|
||||
|
||||
---
|
||||
1. 核心逻辑架构 (The Core Logic)
|
||||
在设计 API 之前,我们需要定义 mappings(映射关系):
|
||||
- Game World / Map ←→ Zulip Stream (e.g., #Novice_Village)
|
||||
- Interactive Object / Event ←→ Zulip Topic (e.g., Notice Board, Tavern Gossip)
|
||||
- Whisper / Party ←→ Zulip Private Message
|
||||
|
||||
---
|
||||
架构图示:
|
||||
Client (Game) $$\xrightarrow{\text{Game Token}}$$ Game Middleware API $$\xrightarrow{\text{Zulip API Key}}$$ Zulip Server
|
||||
$$Client (Godot) \xleftrightarrow{\text{WebSocket}} Node.js Server \xleftrightarrow{\text{REST/Long-Poll}} Zulip Server$$
|
||||
设计理由:不建议让客户端直接直连 Zulip。我们需要一层中间件(Middleware)来控制权限、注入游戏数据(如玩家坐标、当前的动作状态),并防止用户在该 API Key 下进行非游戏允许的 Zulip 操作(如随意创建 Stream)。
|
||||
|
||||
---
|
||||
2. 设计思路一: "统一网关"(Unified Gateway)
|
||||
2.1 详细数据流设计 (Data Flow)
|
||||
我们需要在 Node.js 中维护一个 Session Manager。
|
||||
A. 登录与握手 (Initialization)
|
||||
1. Godot: 发送登录包 {"type": "login", "token": "user_game_token"}。
|
||||
2. Node.js:
|
||||
- 验证游戏 Token。
|
||||
- 查找该用户的 Zulip API Key(通常存储在数据库中,或者首次登录时让用户提供)。
|
||||
- 关键步骤: Node.js 服务器为该特定用户实例化一个 Zulip Client,并向 Zulip 申请注册一个 Event Queue。
|
||||
- 将 Socket_ID 与 Zulip_Queue_ID 绑定。
|
||||
B. 发送消息 (Upstream: Godot -> Node -> Zulip)
|
||||
1. Godot: 玩家输入 "Hello",Godot 通过 WebSocket 发送简化的包:
|
||||
2. JSON
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello",
|
||||
"scope": "local" // 或者 "topic_name"
|
||||
}
|
||||
1. Node.js:
|
||||
- 收到包,解析出这是聊天请求。
|
||||
- 上下文注入: Node 知道玩家当前在 Map_101 (对应 Zulip Stream #Tavern)。
|
||||
- API 调用: Node 使用该用户的 Zulip Client,调用 Zulip API 发送消息到 #Tavern。
|
||||
- 优势: 这里可以做风控(比如禁止发脏话、频率限制),Godot 端根本无法绕过。
|
||||
C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||||
1. Node.js:
|
||||
- 服务器内部有一个循环(或者异步监听器),轮询 Zulip 的事件队列。
|
||||
- 收到 Zulip 的 message 事件:User_B 在 #Tavern 说了 "Hi"。
|
||||
- 空间过滤: Node 检查当前连接的所有 WebSocket,找出所有位于 Map_101 的玩家。
|
||||
- 广播: 将消息打包成游戏协议,通过 WebSocket 推送给这些玩家:
|
||||
2. JSON
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "User_B",
|
||||
"txt": "Hi",
|
||||
"bubble": true
|
||||
}
|
||||
1. Godot: 收到包,直接调用 show_bubble()。
|
||||
|
||||
---
|
||||
2.3 这个方案的权衡分析 (Trade-off Analysis)
|
||||
这种改变带来的本质变化:
|
||||
优势 (The Wins)
|
||||
1. 客户端极度简化 (Thin Client):
|
||||
- Godot 里不需要写 HTTP Request,不需要处理 Long Polling 的异常断连,不需要解析复杂的 JSON 结构。
|
||||
- Godot 只需要处理 on_websocket_packet_received。
|
||||
2. 安全性 (Security):
|
||||
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
||||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||||
3. 协议统一:
|
||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新)
|
||||
|
||||
### 3.1 用户注册和 API Key 生成流程
|
||||
|
||||
当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key:
|
||||
|
||||
```
|
||||
用户注册 (POST /auth/register)
|
||||
↓
|
||||
1. 创建游戏账号 (LoginService.register)
|
||||
↓
|
||||
2. 初始化 Zulip 管理员客户端
|
||||
↓
|
||||
3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount)
|
||||
- 使用相同的邮箱和密码
|
||||
- 调用 Zulip API: POST /api/v1/users
|
||||
↓
|
||||
4. 获取 API Key (ZulipAccountService.generateApiKeyForUser)
|
||||
- 使用 fetch_api_key 端点(固定的、基于密码的 Key)
|
||||
- 注意:不使用 regenerate_api_key(会生成新 Key)
|
||||
↓
|
||||
5. 加密存储 API Key (ApiKeySecurityService.storeApiKey)
|
||||
- 使用 AES-256-GCM 加密
|
||||
- 存储到 Redis: zulip:api_key:{userId}
|
||||
↓
|
||||
6. 创建账号关联记录 (ZulipAccountsRepository)
|
||||
- 存储 gameUserId ↔ zulipUserId 映射
|
||||
↓
|
||||
7. 生成 JWT Token (LoginService.generateTokenPair)
|
||||
- 包含用户信息:sub, username, email, role
|
||||
- 返回 access_token 和 refresh_token
|
||||
```
|
||||
|
||||
### 3.2 JWT Token 验证流程
|
||||
|
||||
当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key:
|
||||
|
||||
```
|
||||
WebSocket 登录 (login 消息)
|
||||
↓
|
||||
1. ZulipService.validateGameToken(token)
|
||||
↓
|
||||
2. 调用 LoginService.verifyToken(token, 'access')
|
||||
- 验证签名、过期时间、载荷
|
||||
- 提取用户信息:userId, username, email
|
||||
↓
|
||||
3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey)
|
||||
- 解密存储的 API Key
|
||||
- 更新访问计数和时间
|
||||
↓
|
||||
4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient)
|
||||
- 使用真实的用户 API Key
|
||||
- 注册事件队列
|
||||
↓
|
||||
5. 创建游戏会话 (SessionManagerService.createSession)
|
||||
- 绑定 socketId ↔ zulipQueueId
|
||||
- 记录用户位置信息
|
||||
↓
|
||||
6. 返回登录成功
|
||||
```
|
||||
|
||||
### 3.3 消息发送流程(使用正确的 API Key)
|
||||
|
||||
```
|
||||
发送聊天消息 (chat 消息)
|
||||
↓
|
||||
1. ZulipService.sendChatMessage()
|
||||
↓
|
||||
2. 获取会话信息 (SessionManagerService.getSession)
|
||||
- 获取 userId 和当前位置
|
||||
↓
|
||||
3. 上下文注入 (SessionManagerService.injectContext)
|
||||
- 根据位置确定目标 Stream/Topic
|
||||
↓
|
||||
4. 消息验证 (MessageFilterService.validateMessage)
|
||||
- 内容过滤、频率限制
|
||||
↓
|
||||
5. 发送到 Zulip (ZulipClientPoolService.sendMessage)
|
||||
- 使用用户的真实 API Key
|
||||
- 调用 Zulip API: POST /api/v1/messages
|
||||
↓
|
||||
6. 返回发送结果
|
||||
```
|
||||
|
||||
### 3.4 关键修复说明
|
||||
|
||||
**问题 1: JWT Token 签名冲突**
|
||||
- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值
|
||||
- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递
|
||||
- **文件**: `src/business/auth/services/login.service.ts`
|
||||
|
||||
**问题 2: 使用硬编码的旧 API Key**
|
||||
- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key
|
||||
- **修复**: 从 Redis 读取用户注册时存储的真实 API Key
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`
|
||||
|
||||
**问题 3: 重复实现 JWT 验证逻辑**
|
||||
- **原因**: `ZulipService` 自己实现了 JWT 解析
|
||||
- **修复**: 复用 `LoginService.verifyToken()` 方法
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts`
|
||||
|
||||
### 3.5 API Key 安全机制
|
||||
|
||||
**加密存储**:
|
||||
- 使用 AES-256-GCM 算法加密
|
||||
- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取
|
||||
- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}`
|
||||
|
||||
**访问控制**:
|
||||
- 频率限制:每分钟最多 60 次访问
|
||||
- 访问日志:记录每次访问的时间和次数
|
||||
- 安全事件:记录所有关键操作(存储、访问、更新、删除)
|
||||
|
||||
**环境变量配置**:
|
||||
```bash
|
||||
# 生成 64 字符的十六进制密钥(32 字节 = 256 位)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# 在 .env 文件中配置
|
||||
ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
```
|
||||
|
||||
### 3.6 测试验证
|
||||
|
||||
使用测试脚本验证功能:
|
||||
|
||||
```bash
|
||||
# 测试注册用户的 Zulip 集成
|
||||
node docs/systems/zulip/quick_tests/test-registered-user.js
|
||||
|
||||
# 验证 API Key 一致性
|
||||
node docs/systems/zulip/quick_tests/verify-api-key.js
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ WebSocket 连接成功
|
||||
- ✅ JWT Token 验证通过
|
||||
- ✅ 从 Redis 获取正确的 API Key
|
||||
- ✅ 消息成功发送到 Zulip
|
||||
|
||||
---
|
||||
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 测试通过 WebSocket 接收 Zulip 消息
|
||||
*
|
||||
* 设计理念:
|
||||
* - Zulip API Key 永不下发到客户端
|
||||
* - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行
|
||||
* - 客户端只接收 chat_render 消息,不直接调用 Zulip API
|
||||
*
|
||||
* 功能:
|
||||
* 1. 登录游戏服务器获取 JWT Token
|
||||
* 2. 通过 WebSocket 连接游戏服务器
|
||||
* 3. 在当前地图 (Whale Port) 接收消息
|
||||
* 4. 切换到 Pumpkin Valley 接收消息
|
||||
* 5. 统计接收到的消息数量
|
||||
*
|
||||
* 使用方法:
|
||||
* node docs/systems/zulip/quick_tests/test-get-messages.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
// 测试配置
|
||||
const TEST_CONFIG = {
|
||||
whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒
|
||||
pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒
|
||||
totalTimeout: 30000 // 总超时时间 30 秒
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
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.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 通过 WebSocket 接收消息
|
||||
*/
|
||||
async function receiveMessagesViaWebSocket(userInfo) {
|
||||
console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
const receivedMessages = {
|
||||
whalePort: [],
|
||||
pumpkinValley: []
|
||||
};
|
||||
|
||||
let currentMap = 'whale_port';
|
||||
let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
|
||||
// 发送登录消息
|
||||
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.currentMap}`);
|
||||
|
||||
testPhase = 1;
|
||||
currentMap = data.currentMap || 'whale_port';
|
||||
|
||||
console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息');
|
||||
|
||||
// 在 Whale Port 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`);
|
||||
|
||||
// 切换到 Pumpkin Valley
|
||||
console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`);
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
testPhase = 2;
|
||||
currentMap = 'pumpkin_valley';
|
||||
|
||||
console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息');
|
||||
|
||||
// 在 Pumpkin Valley 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
testPhase = 3;
|
||||
console.log('\n📊 测试完成,断开连接...');
|
||||
socket.disconnect();
|
||||
}, TEST_CONFIG.pumpkinValleyWaitTime);
|
||||
}, TEST_CONFIG.whalePortWaitTime);
|
||||
});
|
||||
|
||||
// 接收到消息 (chat_render)
|
||||
socket.on('chat_render', (data) => {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
||||
|
||||
console.log(`\n📨 [${timestamp}] 收到消息:`);
|
||||
console.log(` ├─ 发送者: ${data.from}`);
|
||||
console.log(` ├─ 内容: ${data.txt}`);
|
||||
console.log(` ├─ Stream: ${data.stream || '未知'}`);
|
||||
console.log(` ├─ Topic: ${data.topic || '未知'}`);
|
||||
console.log(` └─ 当前地图: ${currentMap}`);
|
||||
|
||||
// 记录消息
|
||||
const message = {
|
||||
from: data.from,
|
||||
content: data.txt,
|
||||
stream: data.stream,
|
||||
topic: data.topic,
|
||||
timestamp: new Date(),
|
||||
map: currentMap
|
||||
};
|
||||
|
||||
if (testPhase === 1) {
|
||||
receivedMessages.whalePort.push(message);
|
||||
} else if (testPhase === 2) {
|
||||
receivedMessages.pumpkinValley.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
resolve(receivedMessages);
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// 总超时保护
|
||||
setTimeout(() => {
|
||||
if (socket.connected) {
|
||||
console.log('\n⏰ 测试超时,关闭连接');
|
||||
socket.disconnect();
|
||||
}
|
||||
}, TEST_CONFIG.totalTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试流程
|
||||
*/
|
||||
async function runTest() {
|
||||
console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息');
|
||||
console.log('='.repeat(60));
|
||||
console.log('📋 设计理念: Zulip API Key 永不下发到客户端');
|
||||
console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2-5: 通过 WebSocket 接收消息
|
||||
const receivedMessages = await receiveMessagesViaWebSocket(userInfo);
|
||||
|
||||
// 步骤6: 统计信息
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`);
|
||||
console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
// 显示详细消息列表
|
||||
if (receivedMessages.whalePort.length > 0) {
|
||||
console.log('\n📬 Whale Port 消息列表:');
|
||||
receivedMessages.whalePort.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (receivedMessages.pumpkinValley.length > 0) {
|
||||
console.log('\n📬 Pumpkin Valley 消息列表:');
|
||||
receivedMessages.pumpkinValley.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n🎉 测试完成!');
|
||||
console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API');
|
||||
console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTest();
|
||||
174
docs/systems/zulip/quick_tests/test-list-subscriptions.js
Normal file
174
docs/systems/zulip/quick_tests/test-list-subscriptions.js
Normal file
@@ -0,0 +1,174 @@
|
||||
const zulip = require('zulip-js');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
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.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用密码获取 Zulip API Key
|
||||
*/
|
||||
async function getZulipApiKey(email, password) {
|
||||
console.log('\n📝 步骤 2: 获取 Zulip API Key');
|
||||
console.log(` 邮箱: ${email}`);
|
||||
|
||||
try {
|
||||
// Zulip API 使用 Basic Auth 和 form data
|
||||
const response = await axios.post(
|
||||
'https://zulip.xinghangee.icu/api/v1/fetch_api_key',
|
||||
`username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.result === 'success') {
|
||||
console.log('✅ 成功获取 API Key');
|
||||
console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`);
|
||||
console.log(` 用户ID: ${response.data.user_id}`);
|
||||
return {
|
||||
apiKey: response.data.api_key,
|
||||
email: response.data.email,
|
||||
userId: response.data.user_id
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.msg || '获取 API Key 失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listSubscriptions() {
|
||||
console.log('🚀 开始测试用户订阅的 Streams');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2: 获取 Zulip API Key
|
||||
const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password);
|
||||
|
||||
console.log('\n📝 步骤 3: 检查用户订阅的 Streams');
|
||||
|
||||
const config = {
|
||||
username: zulipAuth.email,
|
||||
apiKey: zulipAuth.apiKey,
|
||||
realm: 'https://zulip.xinghangee.icu/'
|
||||
};
|
||||
|
||||
const client = await zulip(config);
|
||||
|
||||
// 获取用户信息
|
||||
console.log('\n👤 获取用户信息...');
|
||||
const profile = await client.users.me.getProfile();
|
||||
console.log('用户:', profile.full_name, `(${profile.email})`);
|
||||
console.log('是否管理员:', profile.is_admin);
|
||||
|
||||
// 获取用户订阅的 Streams
|
||||
console.log('\n📋 获取用户订阅的 Streams...');
|
||||
const subscriptions = await client.streams.subscriptions.retrieve();
|
||||
|
||||
if (subscriptions.result === 'success') {
|
||||
console.log(`\n✅ 找到 ${subscriptions.subscriptions.length} 个订阅的 Streams:`);
|
||||
subscriptions.subscriptions.forEach(sub => {
|
||||
console.log(` - ${sub.name} (ID: ${sub.stream_id})`);
|
||||
});
|
||||
|
||||
// 检查是否有 "Novice Village"
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley');
|
||||
if (noviceVillage) {
|
||||
console.log('\n✅ "Pumpkin Valley" Stream 已存在!');
|
||||
|
||||
// 测试发送消息
|
||||
console.log('\n📤 测试发送消息...');
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: 'Pumpkin Valley',
|
||||
subject: 'General',
|
||||
content: '测试消息:系统集成测试成功 🎮'
|
||||
});
|
||||
|
||||
if (result.result === 'success') {
|
||||
console.log('✅ 消息发送成功! Message ID:', result.id);
|
||||
} else {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
} else {
|
||||
console.log('\n⚠️ "Pumpkin Valley" Stream 不存在');
|
||||
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
||||
|
||||
// 尝试发送到第一个可用的 Stream
|
||||
if (subscriptions.subscriptions.length > 0) {
|
||||
const firstStream = subscriptions.subscriptions[0];
|
||||
console.log(`\n📤 尝试发送消息到 "${firstStream.name}"...`);
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: firstStream.name,
|
||||
subject: 'Test',
|
||||
content: '测试消息:验证系统可以发送消息 🎮'
|
||||
});
|
||||
|
||||
if (result.result === 'success') {
|
||||
console.log('✅ 消息发送成功! Message ID:', result.id);
|
||||
console.log(`💡 系统工作正常,只需创建 "Novice Village" Stream 即可`);
|
||||
} else {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('❌ 获取订阅失败:', subscriptions.msg);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 操作失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
listSubscriptions();
|
||||
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
@@ -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();
|
||||
183
docs/systems/zulip/quick_tests/test-user-api-key.js
Normal file
183
docs/systems/zulip/quick_tests/test-user-api-key.js
Normal file
@@ -0,0 +1,183 @@
|
||||
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'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取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;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用用户 API Key 测试 Zulip 集成
|
||||
async function testWithUserApiKey() {
|
||||
console.log('🚀 开始测试用户 API Key 的 Zulip 集成');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 登录获取 token
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用真实的 JWT token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 3 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
console.log(' 当前地图:', data.currentMap);
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` +
|
||||
`时间: ${new Date().toLocaleString()}\n` +
|
||||
`使用真实 API Key 发送此消息。`,
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 4 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
// 等待位置更新后发送消息
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('\n📨 收到来自 Zulip 的消息:');
|
||||
console.log(' 发送者:', data.from);
|
||||
console.log(' 内容:', data.txt);
|
||||
console.log(' Stream:', data.stream || '未知');
|
||||
console.log(' Topic:', data.topic || '未知');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(' 完成步骤:', testStep, '/ 3');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
} else {
|
||||
console.log(' ⚠️ 部分测试未完成');
|
||||
}
|
||||
console.log('='.repeat(60));
|
||||
process.exit(testStep >= 3 ? 0 : 1);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('\n⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testWithUserApiKey();
|
||||
431
docs/systems/zulip/websocket-protocol.md
Normal file
431
docs/systems/zulip/websocket-protocol.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# WebSocket 协议详解
|
||||
|
||||
## 协议概述
|
||||
|
||||
Zulip 集成系统使用 WebSocket 协议实现游戏客户端与服务器之间的实时双向通信。所有消息采用 JSON 格式编码。
|
||||
|
||||
## 连接生命周期
|
||||
|
||||
### 1. 建立连接
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- WebSocket Connect --------->|
|
||||
| |
|
||||
|<------- Connection Accepted --------|
|
||||
| |
|
||||
```
|
||||
|
||||
### 2. 认证握手
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- login message ------------->|
|
||||
| |
|
||||
| [验证 Token] |
|
||||
| [创建 Zulip Client] |
|
||||
| [注册 Event Queue] |
|
||||
| [创建 Session] |
|
||||
| |
|
||||
|<------- login_success --------------|
|
||||
| |
|
||||
```
|
||||
|
||||
### 3. 消息交换
|
||||
|
||||
```
|
||||
Client Server Zulip
|
||||
| | |
|
||||
|-------- chat message -------------->| |
|
||||
| |-------- POST /messages ---------->|
|
||||
| |<------- 200 OK -------------------|
|
||||
|<------- chat_sent ------------------| |
|
||||
| | |
|
||||
| |<------- Event Queue Message ------|
|
||||
|<------- chat_render ----------------| |
|
||||
| | |
|
||||
```
|
||||
|
||||
### 4. 断开连接
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- logout message ------------>|
|
||||
| |
|
||||
| [清理 Session] |
|
||||
| [注销 Event Queue] |
|
||||
| [销毁 Zulip Client] |
|
||||
| |
|
||||
|<------- logout_success -------------|
|
||||
| |
|
||||
|-------- WebSocket Close ----------->|
|
||||
| |
|
||||
```
|
||||
|
||||
## 消息格式规范
|
||||
|
||||
### 消息结构
|
||||
|
||||
所有消息都是 JSON 对象,包含以下基本字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| `type` 或 `t` | string | 消息类型标识 |
|
||||
| 其他字段 | any | 根据消息类型不同而变化 |
|
||||
|
||||
### 消息类型标识
|
||||
|
||||
- 客户端发送的消息使用 `type` 或 `t` 字段
|
||||
- 服务器响应的消息统一使用 `t` 字段
|
||||
|
||||
## 客户端消息
|
||||
|
||||
### LOGIN - 登录认证
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "login",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| type | string | 是 | 固定值 "login" |
|
||||
| token | string | 是 | 游戏认证 Token |
|
||||
|
||||
### CHAT - 发送聊天消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat",
|
||||
"content": "Hello, everyone!",
|
||||
"scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "chat" |
|
||||
| content | string | 是 | 消息内容 (1-1000 字符) |
|
||||
| scope | string | 是 | 消息范围 |
|
||||
|
||||
**scope 取值:**
|
||||
- `"local"`: 当前地图的默认 Topic
|
||||
- `"topic_name"`: 指定的 Topic 名称
|
||||
|
||||
### POSITION - 位置更新
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "position",
|
||||
"x": 150.5,
|
||||
"y": 200.3,
|
||||
"mapId": "novice_village"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| t | string | 是 | 固定值 "position" |
|
||||
| x | number | 是 | X 坐标 |
|
||||
| y | number | 是 | Y 坐标 |
|
||||
| mapId | string | 是 | 地图 ID |
|
||||
|
||||
### LOGOUT - 登出
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "logout"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| type | string | 是 | 固定值 "logout" |
|
||||
|
||||
## 服务器消息
|
||||
|
||||
### LOGIN_SUCCESS - 登录成功
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "login_success",
|
||||
"sessionId": "sess_abc123def456",
|
||||
"currentMap": "novice_village",
|
||||
"username": "player_name",
|
||||
"stream": "Novice Village"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "login_success" |
|
||||
| sessionId | string | 会话 ID |
|
||||
| currentMap | string | 当前地图 ID |
|
||||
| username | string | 用户名 |
|
||||
| stream | string | 当前 Zulip Stream |
|
||||
|
||||
### CHAT_SENT - 消息发送确认
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat_sent",
|
||||
"messageId": "msg_789xyz",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_sent" |
|
||||
| messageId | string | Zulip 消息 ID |
|
||||
| timestamp | number | 发送时间戳 (毫秒) |
|
||||
|
||||
### CHAT_RENDER - 接收聊天消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "chat_render",
|
||||
"from": "other_player",
|
||||
"txt": "Hi there!",
|
||||
"bubble": true,
|
||||
"timestamp": 1703500800000,
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "chat_render" |
|
||||
| from | string | 发送者名称 |
|
||||
| txt | string | 消息内容 |
|
||||
| bubble | boolean | 是否显示气泡 |
|
||||
| timestamp | number | 消息时间戳 |
|
||||
| stream | string | 来源 Stream |
|
||||
| topic | string | 来源 Topic |
|
||||
|
||||
### POSITION_UPDATED - 位置更新确认
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "position_updated",
|
||||
"stream": "Novice Village",
|
||||
"topic": "General"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "position_updated" |
|
||||
| stream | string | 新的 Zulip Stream |
|
||||
| topic | string | 新的 Zulip Topic |
|
||||
|
||||
### LOGOUT_SUCCESS - 登出成功
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "logout_success"
|
||||
}
|
||||
```
|
||||
|
||||
### ERROR - 错误消息
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "消息发送过于频繁,请稍后再试",
|
||||
"details": {
|
||||
"retryAfter": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| t | string | 固定值 "error" |
|
||||
| code | string | 错误码 |
|
||||
| message | string | 错误描述 |
|
||||
| details | object | 可选,额外错误信息 |
|
||||
|
||||
## 心跳机制
|
||||
|
||||
### 客户端心跳
|
||||
|
||||
客户端应每 30 秒发送一次心跳消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
### 服务器响应
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "pong",
|
||||
"timestamp": 1703500800000
|
||||
}
|
||||
```
|
||||
|
||||
### 超时处理
|
||||
|
||||
- 服务器在 60 秒内未收到任何消息将断开连接
|
||||
- 客户端应在连接断开后自动重连
|
||||
|
||||
## 重连策略
|
||||
|
||||
### 指数退避算法
|
||||
|
||||
```
|
||||
重试间隔 = min(baseDelay * 2^attempt, maxDelay)
|
||||
|
||||
baseDelay = 1000ms
|
||||
maxDelay = 30000ms
|
||||
```
|
||||
|
||||
### 重连流程
|
||||
|
||||
1. 检测到连接断开
|
||||
2. 等待重试间隔
|
||||
3. 尝试重新连接
|
||||
4. 连接成功后重新发送 login 消息
|
||||
5. 恢复会话状态
|
||||
|
||||
### 示例代码
|
||||
|
||||
```typescript
|
||||
class ReconnectingWebSocket {
|
||||
private baseDelay = 1000;
|
||||
private maxDelay = 30000;
|
||||
private attempt = 0;
|
||||
|
||||
private getDelay(): number {
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, this.attempt),
|
||||
this.maxDelay
|
||||
);
|
||||
this.attempt++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
private resetDelay(): void {
|
||||
this.attempt = 0;
|
||||
}
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
const delay = this.getDelay();
|
||||
console.log(`等待 ${delay}ms 后重连...`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
this.resetDelay();
|
||||
} catch (error) {
|
||||
await this.reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 消息序列化
|
||||
|
||||
### 发送消息
|
||||
|
||||
```typescript
|
||||
function sendMessage(socket: WebSocket, message: object): void {
|
||||
const json = JSON.stringify(message);
|
||||
socket.send(json);
|
||||
}
|
||||
```
|
||||
|
||||
### 接收消息
|
||||
|
||||
```typescript
|
||||
socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('消息解析失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 并发处理
|
||||
|
||||
### 消息顺序
|
||||
|
||||
- 同一客户端的消息按发送顺序处理
|
||||
- 不同客户端的消息可能并发处理
|
||||
- 服务器响应顺序可能与请求顺序不同
|
||||
|
||||
### 消息确认
|
||||
|
||||
对于需要确认的操作(如发送聊天消息),客户端应:
|
||||
|
||||
1. 生成唯一的请求 ID
|
||||
2. 等待对应的响应
|
||||
3. 设置超时处理
|
||||
|
||||
```typescript
|
||||
async function sendChatWithConfirmation(
|
||||
socket: WebSocket,
|
||||
content: string,
|
||||
timeout: number = 5000
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('发送超时'));
|
||||
}, timeout);
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.t === 'chat_sent') {
|
||||
clearTimeout(timer);
|
||||
socket.removeEventListener('message', handler);
|
||||
resolve();
|
||||
} else if (message.t === 'error') {
|
||||
clearTimeout(timer);
|
||||
socket.removeEventListener('message', handler);
|
||||
reject(new Error(message.message));
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handler);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
t: 'chat',
|
||||
content: content,
|
||||
scope: 'local'
|
||||
}));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### Token 安全
|
||||
|
||||
- Token 仅在 login 消息中传输一次
|
||||
- 服务器验证后不再需要 Token
|
||||
- Token 应有合理的过期时间
|
||||
|
||||
### 消息验证
|
||||
|
||||
- 服务器验证所有消息格式
|
||||
- 拒绝格式错误的消息
|
||||
- 记录异常消息日志
|
||||
|
||||
### 防重放攻击
|
||||
|
||||
- 使用时间戳验证消息新鲜度
|
||||
- 拒绝过期的消息
|
||||
- 检测重复的消息 ID
|
||||
175
docs/systems/zulip/zulip-js.md
Normal file
175
docs/systems/zulip/zulip-js.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# zulip-js 
|
||||
|
||||
Javascript library to access the Zulip API
|
||||
|
||||
# Usage
|
||||
|
||||
## Initialization
|
||||
|
||||
### With API Key
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const config = {
|
||||
username: process.env.ZULIP_USERNAME,
|
||||
apiKey: process.env.ZULIP_API_KEY,
|
||||
realm: process.env.ZULIP_REALM,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const zulip = await zulipInit(config);
|
||||
// The zulip object now initialized with config
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
### With Username & Password
|
||||
|
||||
You will need to first retrieve the API key by calling `await zulipInit(config)`.
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const config = {
|
||||
username: process.env.ZULIP_USERNAME,
|
||||
password: process.env.ZULIP_PASSWORD,
|
||||
realm: process.env.ZULIP_REALM,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Fetch API Key
|
||||
const zulip = await zulipInit(config);
|
||||
// The zulip object now contains the API Key
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
### With zuliprc
|
||||
|
||||
Create a file called `zuliprc` (in the same directory as your code) which looks like:
|
||||
|
||||
```
|
||||
[api]
|
||||
email=cordelia@zulip.com
|
||||
key=wlueAg7cQXqKpUgIaPP3dmF4vibZXal7
|
||||
site=http://localhost:9991
|
||||
```
|
||||
|
||||
Please remember to add this file to your `.gitignore`! Calling `await zulipInit({ zuliprc: 'zuliprc' })` will read this file.
|
||||
|
||||
```js
|
||||
const zulipInit = require('zulip-js');
|
||||
const path = require('path');
|
||||
const zuliprc = path.resolve(__dirname, 'zuliprc');
|
||||
(async () => {
|
||||
const zulip = await zulipInit({ zuliprc });
|
||||
// The zulip object now contains the config from the zuliprc file
|
||||
console.log(await zulip.streams.subscriptions.retrieve());
|
||||
})();
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Please see some examples in [the examples directory](https://github.com/zulip/zulip-js/tree/main/examples).
|
||||
|
||||
Also, to easily test an API endpoint while developing, you can run:
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
$ npm run call <method> <endpoint> [optional: json_params] [optional: path to zuliprc file]
|
||||
$ # For example:
|
||||
$ npm run call GET /users/me
|
||||
$ npm run call GET /users/me '' ~/path/to/my/zuliprc
|
||||
```
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
We support the following endpoints and are striving to have complete coverage of the API. If you want to use some endpoint we do not support presently, you can directly call it as follows:
|
||||
|
||||
```js
|
||||
const params = {
|
||||
to: 'bot testing',
|
||||
type: 'stream',
|
||||
subject: 'Testing zulip-js',
|
||||
content: 'Something is horribly wrong....',
|
||||
};
|
||||
|
||||
await zulip.callEndpoint('/messages', 'POST', params);
|
||||
```
|
||||
|
||||
| Function to call | API Endpoint | Documentation |
|
||||
| --- | --- | --- |
|
||||
| `zulip.accounts.retrieve()` | POST `/fetch_api_key` | returns a promise that you can use to retrieve your `API key`. |
|
||||
| `zulip.emojis.retrieve()` | GET `/realm/emoji` | retrieves the list of realm specific emojis. |
|
||||
| `zulip.events.retrieve()` | GET `/events` | retrieves events from a queue. You can pass it a params object with the id of the queue you are interested in, the last event id that you have received and wish to acknowledge. You can also specify whether the server should not block on this request until there is a new event (the default is to block). |
|
||||
| `zulip.messages.send()` | POST `/messages` | returns a promise that can be used to send a message. |
|
||||
| `zulip.messages.retrieve()` | GET `/messages` | returns a promise that can be used to retrieve messages from a stream. You need to specify the id of the message to be used as an anchor. Use `1000000000` to retrieve the most recent message, or [`zulip.users.me.pointer.retrieve()`](#fetching-a-pointer-for-a-user) to get the id of the last message the user read. |
|
||||
| `zulip.messages.render()` | POST `/messages/render` | returns a promise that can be used to get rendered HTML for a message text. |
|
||||
| `zulip.messages.update()` | PATCH `/messages/<msg_id>` | updates the content or topic of the message with the given `msg_id`. |
|
||||
| `zulip.messages.flags.add()` | POST `/messages/flags` | add a flag to a list of messages. Its params are `flag` which is one of `[read, starred, mentioned, wildcard_mentioned, has_alert_word, historical]` and `messages` which is a list of messageIDs. |
|
||||
| `zulip.messages.flags.remove()` | POST `/messages/flags` | remove a flag from a list of messages. Its params are `flag` which is one of `[read, starred, mentioned, wildcard_mentioned, has_alert_word, historical]` and `messages` which is a list of messageIDs. |
|
||||
| `zulip.messages.getById()` | GET `/messages/<msg_id>` | returns a message by its id. |
|
||||
| `zulip.messages.getHistoryById()` | GET `/messages/<msg_id>/history` | return the history of a message |
|
||||
| `zulip.messages.deleteReactionById()` | DELETE `/messages/<msg_id>/reactions` | deletes reactions on a message by message id |
|
||||
| `zulip.messages.deleteById()` | DELETE `/messages/<msg_id>` | delete the message with the provided message id if the user has permission to do so. |
|
||||
| `zulip.queues.register()` | POST `/register` | registers a new queue. You can pass it a params object with the types of events you are interested in and whether you want to receive raw text or html (using markdown). |
|
||||
| `zulip.queues.deregister()` | DELETE `/events` | deletes a previously registered queue. |
|
||||
| `zulip.reactions.add()` | POST `/reactions` | add a reaction to a message. Accepts a params object with `message_id`, `emoji_name`, `emoji_code` and `reaction_type` (default is `unicode_emoji`). |
|
||||
| `zulip.reactions.remove()` | DELETE `/reactions` | remove a reaction from a message. Accepts a params object with `message_id` and `emoji_code` and `reaction_type` (default is `unicode_emoji`). |
|
||||
| `zulip.streams.retrieve()` | GET `/streams` | returns a promise that can be used to retrieve all streams. |
|
||||
| `zulip.streams.getStreamId()` | GET `/get_stream_id` | returns a promise that can be used to retrieve a stream's id. |
|
||||
| `zulip.streams.subscriptions.retrieve()` | GET `/users/me/subscriptions` | returns a promise that can be used to retrieve the user's subscriptions. |
|
||||
| `zulip.streams.deleteById()` | DELETE `/streams/<stream_id>` | delete the stream with the provided stream id if the user has permission to do so. |
|
||||
| `zulip.streams.topics.retrieve()` | GET `/users/me/<stream_id>/topics` | retrieves all the topics in a specific stream. |
|
||||
| `zulip.typing.send()` | POST `/typing` | can be used to send a typing notification. The parameters required are `to` (either a username or a list of usernames) and `op` (either `start` or `stop`). |
|
||||
| `zulip.users.retrieve()` | GET `/users` | retrieves all users for this realm. |
|
||||
| `zulip.users.me.pointer.retrieve()` | GET `/users/me/pointer` | retrieves a pointer for a user. The pointer is the id of the last message the user read. This can then be used as an anchor message id for subsequent API calls. |
|
||||
| `zulip.users.me.getProfile()` | GET `/users/me` | retrieves the profile of the user/bot. |
|
||||
| `zulip.users.me.subscriptions()` | POST `/users/me/subscriptions` | subscribes a user to a stream/streams. |
|
||||
| `zulip.users.create()` | POST `/users` | create a new user. |
|
||||
| `zulip.users.me.alertWords.retrieve()` | GET `/users/me/alert_words` | get array of a user's alert words. |
|
||||
| `zulip.users.me.subscriptions.remove()` | DELETE `/users/me/subscriptions` | remove subscriptions. |
|
||||
| `zulip.users.me.pointer.update()` | POST `users/me/pointer` | updates the pointer for the user, for moving the home view. Accepts a message id. This has the side effect of marking some messages as read. Will not return success if the message id is invalid. Will always succeed if the id is less than the current value of the pointer (the id of the last message read). |
|
||||
| `zulip.server.settings()` | GET `/server_settings` | returns a dictionary of server settings. |
|
||||
| `zulip.filters.retrieve()` | GET `realm/filters` | return a list of filters in a realm |
|
||||
|
||||
# Testing
|
||||
|
||||
Use `npm test` to run the tests.
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Currently, we have a simple testing framework which stubs our network requests and also allows us to test the input passed to it. This is what a sample test for an API endpoint looks like:
|
||||
|
||||
```js
|
||||
const chai = require('chai');
|
||||
const users = require('../../lib/resources/users'); // File to test.
|
||||
const common = require('../common'); // Common functions for tests.
|
||||
|
||||
chai.should();
|
||||
|
||||
describe('Users', () => {
|
||||
it('should fetch users', async () => {
|
||||
const params = {
|
||||
subject: 'test',
|
||||
content: 'sample test',
|
||||
};
|
||||
const validator = (url, options) => {
|
||||
// Function to test the network request parameters.
|
||||
url.should.equal(`${common.config.apiURL}/users`);
|
||||
Object.keys(options.body.data).length.should.equal(4);
|
||||
options.body.data.subject.should.equal(params.subject);
|
||||
options.body.data.content.should.equal(params.content);
|
||||
};
|
||||
const output = {
|
||||
// The data returned by the API in JSON format.
|
||||
already_subscribed: {},
|
||||
result: 'success',
|
||||
};
|
||||
common.stubNetwork(validator, output); // Stub the network modules.
|
||||
const data = await users(common.config).retrieve(params);
|
||||
data.should.have.property('result', 'success'); // Function call.
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Each pull request should contain relevant tests as well as example usage.
|
||||
311
full_diagnosis.js
Normal file
311
full_diagnosis.js
Normal file
@@ -0,0 +1,311 @@
|
||||
const io = require('socket.io-client');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
console.log('🔍 全面WebSocket连接诊断');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 1. 测试基础网络连接
|
||||
async function testBasicConnection() {
|
||||
console.log('\n1️⃣ 测试基础HTTPS连接...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(`✅ HTTPS连接成功 - 状态码: ${res.statusCode}`);
|
||||
console.log(`📋 服务器: ${res.headers.server || '未知'}`);
|
||||
resolve({ success: true, statusCode: res.statusCode });
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(`❌ HTTPS连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('❌ HTTPS连接超时');
|
||||
req.destroy();
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 测试本地服务器
|
||||
async function testLocalServer() {
|
||||
console.log('\n2️⃣ 测试本地服务器...');
|
||||
|
||||
const testPaths = [
|
||||
'http://localhost:3000/',
|
||||
'http://localhost:3000/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
for (const url of testPaths) {
|
||||
console.log(`🧪 测试: ${url}`);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const urlObj = new URL(url);
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 本地服务器正常');
|
||||
} else {
|
||||
console.log(` ⚠️ 本地服务器响应: ${res.statusCode}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 本地服务器连接失败: ${error.message}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 本地服务器超时');
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 测试远程Socket.IO路径
|
||||
async function testRemoteSocketIO() {
|
||||
console.log('\n3️⃣ 测试远程Socket.IO路径...');
|
||||
|
||||
const testPaths = [
|
||||
'/socket.io/?EIO=4&transport=polling',
|
||||
'/game/socket.io/?EIO=4&transport=polling',
|
||||
'/socket.io/?transport=polling',
|
||||
'/api/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const path of testPaths) {
|
||||
console.log(`🧪 测试路径: ${path}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: path,
|
||||
method: 'GET',
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'User-Agent': 'socket.io-diagnosis'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 路径可用');
|
||||
console.log(` 📄 响应: ${data.substring(0, 50)}...`);
|
||||
} else {
|
||||
console.log(` ❌ 路径不可用: ${res.statusCode}`);
|
||||
}
|
||||
resolve({ path, statusCode: res.statusCode, success: res.statusCode === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 请求失败: ${error.message}`);
|
||||
resolve({ path, error: error.message, success: false });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 请求超时');
|
||||
req.destroy();
|
||||
resolve({ path, error: 'timeout', success: false });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 4. 测试Socket.IO客户端连接
|
||||
async function testSocketIOClient() {
|
||||
console.log('\n4️⃣ 测试Socket.IO客户端连接...');
|
||||
|
||||
const configs = [
|
||||
{
|
||||
name: 'HTTPS + 所有传输方式',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['websocket', 'polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + 仅Polling',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + /game namespace',
|
||||
url: 'https://whaletownend.xinghangee.icu/game',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
console.log(`🧪 测试: ${config.name}`);
|
||||
console.log(` URL: ${config.url}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const socket = io(config.url, config.options);
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
socket.disconnect();
|
||||
console.log(' ❌ 连接超时');
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
}
|
||||
}, config.options.timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(' ✅ 连接成功');
|
||||
console.log(` 📡 Socket ID: ${socket.id}`);
|
||||
console.log(` 🚀 传输方式: ${socket.io.engine.transport.name}`);
|
||||
socket.disconnect();
|
||||
resolve({ success: true, transport: socket.io.engine.transport.name });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(` ❌ 连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
results.push({ config: config.name, ...result });
|
||||
|
||||
// 等待1秒再测试下一个
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 5. 检查DNS解析
|
||||
async function testDNS() {
|
||||
console.log('\n5️⃣ 检查DNS解析...');
|
||||
|
||||
const dns = require('dns');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup('whaletownend.xinghangee.icu', (err, address, family) => {
|
||||
if (err) {
|
||||
console.log(`❌ DNS解析失败: ${err.message}`);
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
console.log(`✅ DNS解析成功: ${address} (IPv${family})`);
|
||||
resolve({ success: true, address, family });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主诊断函数
|
||||
async function runFullDiagnosis() {
|
||||
console.log('开始全面诊断...\n');
|
||||
|
||||
try {
|
||||
const dnsResult = await testDNS();
|
||||
const basicResult = await testBasicConnection();
|
||||
await testLocalServer();
|
||||
const socketIOPaths = await testRemoteSocketIO();
|
||||
const clientResults = await testSocketIOClient();
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 诊断结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log(`1. DNS解析: ${dnsResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (dnsResult.address) {
|
||||
console.log(` IP地址: ${dnsResult.address}`);
|
||||
}
|
||||
|
||||
console.log(`2. HTTPS连接: ${basicResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (basicResult.error) {
|
||||
console.log(` 错误: ${basicResult.error}`);
|
||||
}
|
||||
|
||||
const workingPaths = socketIOPaths.filter(r => r.success);
|
||||
console.log(`3. Socket.IO路径: ${workingPaths.length}/${socketIOPaths.length} 个可用`);
|
||||
workingPaths.forEach(p => {
|
||||
console.log(` ✅ ${p.path}`);
|
||||
});
|
||||
|
||||
const workingClients = clientResults.filter(r => r.success);
|
||||
console.log(`4. Socket.IO客户端: ${workingClients.length}/${clientResults.length} 个成功`);
|
||||
workingClients.forEach(c => {
|
||||
console.log(` ✅ ${c.config} (${c.transport})`);
|
||||
});
|
||||
|
||||
console.log('\n💡 建议:');
|
||||
|
||||
if (!dnsResult.success) {
|
||||
console.log('❌ DNS解析失败 - 检查域名配置');
|
||||
} else if (!basicResult.success) {
|
||||
console.log('❌ 基础HTTPS连接失败 - 检查服务器状态和防火墙');
|
||||
} else if (workingPaths.length === 0) {
|
||||
console.log('❌ 所有Socket.IO路径都不可用 - 检查nginx配置和后端服务');
|
||||
} else if (workingClients.length === 0) {
|
||||
console.log('❌ Socket.IO客户端无法连接 - 可能是CORS或协议问题');
|
||||
} else {
|
||||
console.log('✅ 部分功能正常 - 使用可用的配置继续开发');
|
||||
|
||||
if (workingClients.length > 0) {
|
||||
const bestConfig = workingClients.find(c => c.transport === 'websocket') || workingClients[0];
|
||||
console.log(`💡 推荐使用: ${bestConfig.config}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('诊断过程中发生错误:', error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runFullDiagnosis();
|
||||
@@ -3,6 +3,12 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "../config/**/*",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
24
package.json
24
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixel-game-server",
|
||||
"version": "1.0.0",
|
||||
"description": "A 2D pixel art game server built with NestJS",
|
||||
"version": "1.1.1",
|
||||
"description": "A 2D pixel art game server built with NestJS - 支持验证码登录功能和邮箱冲突检测",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
@@ -10,7 +10,10 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts",
|
||||
"test:unit": "jest --testPathPattern=spec.ts --testPathIgnorePatterns=e2e.spec.ts",
|
||||
"test:all": "cross-env RUN_E2E_TESTS=true jest"
|
||||
},
|
||||
"keywords": [
|
||||
"game",
|
||||
@@ -25,26 +28,35 @@
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^10.4.20",
|
||||
"@nestjs/platform-socket.io": "^10.4.20",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^10.4.20",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pino": "^10.1.0",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.28"
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.18.3",
|
||||
"zulip-js": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
@@ -52,11 +64,15 @@
|
||||
"@nestjs/testing": "^10.4.20",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.27",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"fast-check": "^4.5.2",
|
||||
"jest": "^29.7.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
packages:
|
||||
- 'client'
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@scarf/scarf'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AppService } from './app.service';
|
||||
import { AppStatusResponseDto } from './dto/app.dto';
|
||||
import { ErrorResponseDto } from './dto/error_response.dto';
|
||||
import { AppStatusResponseDto, ErrorResponseDto } from './business/shared';
|
||||
|
||||
/**
|
||||
* 应用根控制器
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
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 { LoginModule } from './business/login/login.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 { 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
|
||||
@@ -60,9 +67,33 @@ function isDatabaseConfigured(): boolean {
|
||||
// 根据数据库配置选择用户模块模式
|
||||
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
||||
LoginCoreModule,
|
||||
LoginModule,
|
||||
AuthModule,
|
||||
ZulipModule,
|
||||
UserMgmtModule,
|
||||
AdminModule,
|
||||
SecurityCoreModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
// 注意:全局拦截器现在由SecurityModule提供
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
/**
|
||||
* 配置中间件
|
||||
*
|
||||
* @param consumer 中间件消费者
|
||||
*/
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// 1. 维护模式中间件 - 最高优先级
|
||||
consumer
|
||||
.apply(MaintenanceMiddleware)
|
||||
.forRoutes('*');
|
||||
|
||||
// 2. 内容类型检查中间件
|
||||
consumer
|
||||
.apply(ContentTypeMiddleware)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppStatusResponseDto } from './dto/app.dto';
|
||||
import { AppStatusResponseDto } from './business/shared';
|
||||
|
||||
/**
|
||||
* 应用服务类
|
||||
@@ -31,7 +31,7 @@ export class AppService {
|
||||
|
||||
return {
|
||||
service: 'Pixel Game Server',
|
||||
version: '1.0.0',
|
||||
version: '1.1.1',
|
||||
status: 'running',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
|
||||
184
src/business/admin/admin.controller.ts
Normal file
184
src/business/admin/admin.controller.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 管理员控制器
|
||||
*
|
||||
* API端点:
|
||||
* - POST /admin/auth/login 管理员登录
|
||||
* - GET /admin/users 用户列表(需要管理员Token)
|
||||
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||||
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||||
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminLoginDto, AdminResetPasswordDto } from './dto/admin-login.dto';
|
||||
import {
|
||||
AdminLoginResponseDto,
|
||||
AdminUsersResponseDto,
|
||||
AdminCommonResponseDto,
|
||||
AdminUserResponseDto,
|
||||
AdminRuntimeLogsResponseDto
|
||||
} from './dto/admin-response.dto';
|
||||
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';
|
||||
import { spawn } from 'child_process';
|
||||
import { pipeline } from 'stream';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||||
@ApiBody({ type: AdminLoginDto })
|
||||
@ApiResponse({ status: 200, description: '登录成功', type: AdminLoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: '登录失败' })
|
||||
@ApiResponse({ status: 403, description: '权限不足或账户被禁用' })
|
||||
@ApiResponse({ status: 429, description: '登录尝试过于频繁' })
|
||||
@Throttle(ThrottlePresets.LOGIN)
|
||||
@Post('auth/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async login(@Body() dto: AdminLoginDto) {
|
||||
return await this.adminService.login(dto.identifier, dto.password);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取用户列表', description: '后台用户管理:分页获取用户列表' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认100)' })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUsersResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('users')
|
||||
async listUsers(
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const parsedLimit = limit ? Number(limit) : 100;
|
||||
const parsedOffset = offset ? Number(offset) : 0;
|
||||
return await this.adminService.listUsers(parsedLimit, parsedOffset);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户ID' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUserResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('users/:id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return await this.adminService.getUser(BigInt(id));
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '重置用户密码', description: '管理员直接为用户设置新密码(需满足密码强度规则)' })
|
||||
@ApiParam({ name: 'id', description: '用户ID' })
|
||||
@ApiBody({ type: AdminResetPasswordDto })
|
||||
@ApiResponse({ status: 200, description: '重置成功', type: AdminCommonResponseDto })
|
||||
@ApiResponse({ status: 429, description: '操作过于频繁' })
|
||||
@UseGuards(AdminGuard)
|
||||
@Throttle(ThrottlePresets.ADMIN_OPERATION)
|
||||
@Post('users/:id/reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||||
return await this.adminService.resetPassword(BigInt(id), dto.new_password);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取运行日志尾部', description: '从 logs/ 目录读取最近的日志行(默认200行)' })
|
||||
@ApiQuery({ name: 'lines', required: false, description: '返回行数(默认200,最大2000)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminRuntimeLogsResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/runtime')
|
||||
async getRuntimeLogs(@Query('lines') lines?: string) {
|
||||
const parsedLines = lines ? Number(lines) : undefined;
|
||||
return await this.adminService.getRuntimeLogs(parsedLines);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '下载全部运行日志', description: '将 logs/ 目录打包为 tar.gz 并下载(需要管理员Token)' })
|
||||
@ApiProduces('application/gzip')
|
||||
@ApiResponse({ status: 200, description: '打包下载成功(tar.gz 二进制流)' })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/archive')
|
||||
async downloadLogsArchive(@Res() res: Response) {
|
||||
const logDir = this.adminService.getLogDirAbsolutePath();
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(logDir);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(logDir);
|
||||
const baseName = path.basename(logDir);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `logs-${ts}.tar.gz`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
tar.stderr.on('data', (chunk: Buffer) => {
|
||||
const msg = chunk.toString('utf8').trim();
|
||||
if (msg) {
|
||||
this.logger.warn(`tar stderr: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
tar.on('error', (err: any) => {
|
||||
this.logger.error('打包日志失败(tar 进程启动失败)', err?.stack || String(err));
|
||||
if (!res.headersSent) {
|
||||
const msg = err?.code === 'ENOENT' ? '服务器缺少 tar 命令,无法打包日志' : '日志打包失败';
|
||||
res.status(500).json({ success: false, message: msg });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
const pipelinePromise = new Promise<void>((resolve, reject) => {
|
||||
pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
|
||||
const exitPromise = new Promise<void>((resolve, reject) => {
|
||||
tar.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`tar exited with code ${code ?? 'unknown'}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await pipelinePromise;
|
||||
await exitPromise;
|
||||
} catch (err) {
|
||||
this.logger.error('打包日志失败(tar 执行或输出失败)', err instanceof Error ? err.stack : String(err));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: '日志打包失败' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/business/admin/admin.module.ts
Normal file
26
src/business/admin/admin.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 管理员业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供后台管理的HTTP API(管理员登录、用户管理、密码重置等)
|
||||
* - 仅负责HTTP层与业务流程编排
|
||||
* - 核心鉴权与密码策略由 AdminCoreService 提供
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AdminCoreModule, LoggerModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
exports: [AdminService], // 导出AdminService供其他模块使用
|
||||
})
|
||||
export class AdminModule {}
|
||||
159
src/business/admin/admin.service.spec.ts
Normal file
159
src/business/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
|
||||
describe('AdminService', () => {
|
||||
let service: AdminService;
|
||||
|
||||
const adminCoreServiceMock: Pick<AdminCoreService, 'login' | 'resetUserPassword'> = {
|
||||
login: jest.fn(),
|
||||
resetUserPassword: jest.fn(),
|
||||
};
|
||||
|
||||
const usersServiceMock = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const logManagementServiceMock: Pick<LogManagementService, 'getRuntimeLogTail' | 'getLogDirAbsolutePath'> = {
|
||||
getRuntimeLogTail: jest.fn(),
|
||||
getLogDirAbsolutePath: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new AdminService(
|
||||
adminCoreServiceMock as unknown as AdminCoreService,
|
||||
usersServiceMock as any,
|
||||
logManagementServiceMock as unknown as LogManagementService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should login admin successfully', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockResolvedValue({
|
||||
admin: { id: '1', username: 'admin', nickname: '管理员', role: 9 },
|
||||
access_token: 'token',
|
||||
expires_at: 123,
|
||||
});
|
||||
|
||||
const res = await service.login('admin', 'Admin123456');
|
||||
|
||||
expect(adminCoreServiceMock.login).toHaveBeenCalledWith({ identifier: 'admin', password: 'Admin123456' });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.admin?.role).toBe(9);
|
||||
expect(res.message).toBe('管理员登录成功');
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue(new Error('密码错误'));
|
||||
|
||||
const res = await service.login('admin', 'bad');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||
expect(res.message).toBe('密码错误');
|
||||
});
|
||||
|
||||
it('should handle non-Error login failure', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue('boom');
|
||||
|
||||
const res = await service.login('admin', 'bad');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||
expect(res.message).toBe('管理员登录失败');
|
||||
});
|
||||
|
||||
it('should list users with pagination', async () => {
|
||||
const user = {
|
||||
id: BigInt(1),
|
||||
username: 'u1',
|
||||
nickname: 'U1',
|
||||
email: 'u1@test.com',
|
||||
email_verified: true,
|
||||
phone: null,
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||
} as unknown as Users;
|
||||
|
||||
usersServiceMock.findAll.mockResolvedValue([user]);
|
||||
|
||||
const res = await service.listUsers(100, 0);
|
||||
|
||||
expect(usersServiceMock.findAll).toHaveBeenCalledWith(100, 0);
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.users).toHaveLength(1);
|
||||
expect(res.data?.users[0]).toMatchObject({
|
||||
id: '1',
|
||||
username: 'u1',
|
||||
nickname: 'U1',
|
||||
role: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get user by id', async () => {
|
||||
const user = {
|
||||
id: BigInt(3),
|
||||
username: 'u3',
|
||||
nickname: 'U3',
|
||||
email: null,
|
||||
email_verified: false,
|
||||
phone: '123',
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||
} as unknown as Users;
|
||||
|
||||
usersServiceMock.findOne.mockResolvedValue(user);
|
||||
|
||||
const res = await service.getUser(BigInt(3));
|
||||
|
||||
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(3));
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.user).toMatchObject({ id: '3', username: 'u3', nickname: 'U3' });
|
||||
});
|
||||
|
||||
it('should reset user password', async () => {
|
||||
usersServiceMock.findOne.mockResolvedValue({ id: BigInt(2) } as unknown as Users);
|
||||
(adminCoreServiceMock.resetUserPassword as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const res = await service.resetPassword(BigInt(2), 'NewPass1234');
|
||||
|
||||
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(2));
|
||||
expect(adminCoreServiceMock.resetUserPassword).toHaveBeenCalledWith(BigInt(2), 'NewPass1234');
|
||||
expect(res).toEqual({ success: true, message: '密码重置成功' });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when resetting password for missing user', async () => {
|
||||
usersServiceMock.findOne.mockRejectedValue(new Error('not found'));
|
||||
|
||||
await expect(service.resetPassword(BigInt(999), 'NewPass1234')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should get runtime logs', async () => {
|
||||
(logManagementServiceMock.getRuntimeLogTail as jest.Mock).mockResolvedValue({
|
||||
file: 'dev.log',
|
||||
updated_at: '2025-01-01T00:00:00.000Z',
|
||||
lines: ['a', 'b'],
|
||||
});
|
||||
|
||||
const res = await service.getRuntimeLogs(2);
|
||||
|
||||
expect(logManagementServiceMock.getRuntimeLogTail).toHaveBeenCalledWith({ lines: 2 });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.file).toBe('dev.log');
|
||||
expect(res.data?.lines).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should expose log dir absolute path', () => {
|
||||
(logManagementServiceMock.getLogDirAbsolutePath as jest.Mock).mockReturnValue('/abs/logs');
|
||||
|
||||
expect(service.getLogDirAbsolutePath()).toBe('/abs/logs');
|
||||
expect(logManagementServiceMock.getLogDirAbsolutePath).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
434
src/business/admin/admin.service.ts
Normal file
434
src/business/admin/admin.service.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 管理员业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 调用核心服务完成管理员登录
|
||||
* - 提供用户列表查询
|
||||
* - 提供用户密码重置能力
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
||||
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||
import { UserStatus, getUserStatusDescription } from '../user-mgmt/enums/user-status.enum';
|
||||
import { UserStatusDto, BatchUserStatusDto } from '../user-mgmt/dto/user-status.dto';
|
||||
import {
|
||||
UserStatusResponseDto,
|
||||
BatchUserStatusResponseDto,
|
||||
UserStatusStatsResponseDto,
|
||||
UserStatusInfoDto,
|
||||
BatchOperationResultDto
|
||||
} from '../user-mgmt/dto/user-status-response.dto';
|
||||
|
||||
export interface AdminApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(
|
||||
private readonly adminCoreService: AdminCoreService,
|
||||
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||
private readonly logManagementService: LogManagementService,
|
||||
) {}
|
||||
|
||||
getLogDirAbsolutePath(): string {
|
||||
return this.logManagementService.getLogDirAbsolutePath();
|
||||
}
|
||||
|
||||
async login(identifier: string, password: string): Promise<AdminApiResponse> {
|
||||
try {
|
||||
const result = await this.adminCoreService.login({ identifier, password });
|
||||
return { success: true, data: result, message: '管理员登录成功' };
|
||||
} catch (error) {
|
||||
this.logger.error(`管理员登录失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '管理员登录失败',
|
||||
error_code: 'ADMIN_LOGIN_FAILED',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers(limit: number, offset: number): Promise<AdminApiResponse<{ users: any[]; limit: number; offset: number }>> {
|
||||
const users = await this.usersService.findAll(limit, offset);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: users.map((u: Users) => this.formatUser(u)),
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
message: '用户列表获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(id: bigint): Promise<AdminApiResponse<{ user: any }>> {
|
||||
const user = await this.usersService.findOne(id);
|
||||
return {
|
||||
success: true,
|
||||
data: { user: this.formatUser(user) },
|
||||
message: '用户信息获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(id: bigint, newPassword: string): Promise<AdminApiResponse> {
|
||||
// 确认用户存在
|
||||
const user = await this.usersService.findOne(id).catch((): null => null);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
await this.adminCoreService.resetUserPassword(id, newPassword);
|
||||
|
||||
this.logger.log(`管理员重置密码成功: userId=${id.toString()}`);
|
||||
|
||||
return { success: true, message: '密码重置成功' };
|
||||
}
|
||||
|
||||
async getRuntimeLogs(lines?: number): Promise<AdminApiResponse<{ file: string; updated_at: string; lines: string[] }>> {
|
||||
const result = await this.logManagementService.getRuntimeLogTail({ lines });
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: '运行日志获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
private formatUser(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
email_verified: user.email_verified,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
status: user.status || UserStatus.ACTIVE, // 兼容旧数据
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户状态信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户状态信息
|
||||
*/
|
||||
private formatUserStatus(user: Users): UserStatusInfoDto {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
status: user.status || UserStatus.ACTIVE,
|
||||
status_description: getUserStatusDescription(user.status || UserStatus.ACTIVE),
|
||||
updated_at: user.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户状态
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员修改指定用户的账户状态,支持激活、锁定、禁用等操作
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 检查状态变更的合法性
|
||||
* 3. 更新用户状态
|
||||
* 4. 记录状态变更日志
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param userStatusDto 状态修改数据
|
||||
* @returns 修改结果
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
* @throws BadRequestException 当状态变更不合法时
|
||||
*/
|
||||
async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise<UserStatusResponseDto> {
|
||||
try {
|
||||
this.logger.log('开始修改用户状态', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 1. 验证用户是否存在
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
this.logger.warn('修改用户状态失败:用户不存在', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString()
|
||||
});
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 2. 检查状态变更的合法性
|
||||
if (user.status === userStatusDto.status) {
|
||||
this.logger.warn('修改用户状态失败:状态未发生变化', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
currentStatus: user.status,
|
||||
newStatus: userStatusDto.status
|
||||
});
|
||||
throw new BadRequestException('用户状态未发生变化');
|
||||
}
|
||||
|
||||
// 3. 更新用户状态
|
||||
const updatedUser = await this.usersService.update(userId, {
|
||||
status: userStatusDto.status
|
||||
});
|
||||
|
||||
// 4. 记录状态变更日志
|
||||
this.logger.log('用户状态修改成功', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
oldStatus: user.status,
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: this.formatUserStatus(updatedUser),
|
||||
reason: userStatusDto.reason
|
||||
},
|
||||
message: '用户状态修改成功'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('修改用户状态失败', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException || error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '用户状态修改失败',
|
||||
error_code: 'USER_STATUS_UPDATE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改用户状态
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员批量修改多个用户的账户状态
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户ID列表
|
||||
* 2. 逐个处理用户状态修改
|
||||
* 3. 收集成功和失败的结果
|
||||
* 4. 返回批量操作结果
|
||||
*
|
||||
* @param batchUserStatusDto 批量状态修改数据
|
||||
* @returns 批量修改结果
|
||||
*/
|
||||
async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise<BatchUserStatusResponseDto> {
|
||||
try {
|
||||
this.logger.log('开始批量修改用户状态', {
|
||||
operation: 'batch_update_user_status',
|
||||
userCount: batchUserStatusDto.user_ids.length,
|
||||
newStatus: batchUserStatusDto.status,
|
||||
reason: batchUserStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const successUsers: UserStatusInfoDto[] = [];
|
||||
const failedUsers: Array<{ user_id: string; error: string }> = [];
|
||||
|
||||
// 1. 逐个处理用户状态修改
|
||||
for (const userIdStr of batchUserStatusDto.user_ids) {
|
||||
try {
|
||||
const userId = BigInt(userIdStr);
|
||||
|
||||
// 2. 验证用户是否存在
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
failedUsers.push({
|
||||
user_id: userIdStr,
|
||||
error: '用户不存在'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 检查状态是否需要变更
|
||||
if (user.status === batchUserStatusDto.status) {
|
||||
failedUsers.push({
|
||||
user_id: userIdStr,
|
||||
error: '用户状态未发生变化'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 更新用户状态
|
||||
const updatedUser = await this.usersService.update(userId, {
|
||||
status: batchUserStatusDto.status
|
||||
});
|
||||
|
||||
successUsers.push(this.formatUserStatus(updatedUser));
|
||||
|
||||
} catch (error) {
|
||||
failedUsers.push({
|
||||
user_id: userIdStr,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 构建批量操作结果
|
||||
const result: BatchOperationResultDto = {
|
||||
success_users: successUsers,
|
||||
failed_users: failedUsers,
|
||||
success_count: successUsers.length,
|
||||
failed_count: failedUsers.length,
|
||||
total_count: batchUserStatusDto.user_ids.length
|
||||
};
|
||||
|
||||
this.logger.log('批量修改用户状态完成', {
|
||||
operation: 'batch_update_user_status',
|
||||
successCount: result.success_count,
|
||||
failedCount: result.failed_count,
|
||||
totalCount: result.total_count,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
result,
|
||||
reason: batchUserStatusDto.reason
|
||||
},
|
||||
message: `批量用户状态修改完成,成功:${result.success_count},失败:${result.failed_count}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('批量修改用户状态失败', {
|
||||
operation: 'batch_update_user_status',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '批量用户状态修改失败',
|
||||
error_code: 'BATCH_USER_STATUS_UPDATE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态统计
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取各种用户状态的数量统计信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 查询所有用户
|
||||
* 2. 按状态分组统计
|
||||
* 3. 计算各状态数量
|
||||
* 4. 返回统计结果
|
||||
*
|
||||
* @returns 状态统计信息
|
||||
*/
|
||||
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
|
||||
try {
|
||||
this.logger.log('开始获取用户状态统计', {
|
||||
operation: 'get_user_status_stats',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 1. 查询所有用户(这里可以优化为直接查询统计信息)
|
||||
const allUsers = await this.usersService.findAll(10000, 0); // 假设最多1万用户
|
||||
|
||||
// 2. 按状态分组统计
|
||||
const stats = {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
locked: 0,
|
||||
banned: 0,
|
||||
deleted: 0,
|
||||
pending: 0,
|
||||
total: allUsers.length
|
||||
};
|
||||
|
||||
// 3. 计算各状态数量
|
||||
allUsers.forEach((user: Users) => {
|
||||
const status = user.status || UserStatus.ACTIVE;
|
||||
switch (status) {
|
||||
case UserStatus.ACTIVE:
|
||||
stats.active++;
|
||||
break;
|
||||
case UserStatus.INACTIVE:
|
||||
stats.inactive++;
|
||||
break;
|
||||
case UserStatus.LOCKED:
|
||||
stats.locked++;
|
||||
break;
|
||||
case UserStatus.BANNED:
|
||||
stats.banned++;
|
||||
break;
|
||||
case UserStatus.DELETED:
|
||||
stats.deleted++;
|
||||
break;
|
||||
case UserStatus.PENDING:
|
||||
stats.pending++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('用户状态统计获取成功', {
|
||||
operation: 'get_user_status_stats',
|
||||
stats,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stats,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
message: '用户状态统计获取成功'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取用户状态统计失败', {
|
||||
operation: 'get_user_status_stats',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '用户状态统计获取失败',
|
||||
error_code: 'USER_STATUS_STATS_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/business/admin/dto/admin-login.dto.ts
Normal file
34
src/business/admin/dto/admin-login.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 管理员相关 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员登录与用户密码重置的请求结构
|
||||
* - 使用 class-validator 进行参数校验
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AdminLoginDto {
|
||||
@ApiProperty({ description: '登录标识符(用户名/邮箱/手机号)', example: 'admin' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Admin123456' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class AdminResetPasswordDto {
|
||||
@ApiProperty({ description: '新密码(至少8位,包含字母和数字)', example: 'NewPass1234' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
new_password: string;
|
||||
}
|
||||
104
src/business/admin/dto/admin-response.dto.ts
Normal file
104
src/business/admin/dto/admin-response.dto.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 管理员响应 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员相关接口的响应格式
|
||||
* - 提供 Swagger 文档生成支持
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AdminLoginResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '登录成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: 'JWT Token', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
|
||||
token?: string;
|
||||
|
||||
@ApiProperty({ description: '管理员信息', required: false })
|
||||
admin?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AdminUsersResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取用户列表成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '用户列表', type: 'array' })
|
||||
users?: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
|
||||
@ApiProperty({ description: '总数', example: 100 })
|
||||
total?: number;
|
||||
|
||||
@ApiProperty({ description: '偏移量', example: 0 })
|
||||
offset?: number;
|
||||
|
||||
@ApiProperty({ description: '限制数量', example: 100 })
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class AdminUserResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取用户详情成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '用户信息', required: false })
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AdminCommonResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '操作成功' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class AdminRuntimeLogsResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取日志成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '日志内容', type: 'array', items: { type: 'string' } })
|
||||
logs?: string[];
|
||||
|
||||
@ApiProperty({ description: '返回行数', example: 200 })
|
||||
lines?: number;
|
||||
}
|
||||
43
src/business/admin/guards/admin.guard.ts
Normal file
43
src/business/admin/guards/admin.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 管理员鉴权守卫
|
||||
*
|
||||
* 功能描述:
|
||||
* - 保护后台管理接口
|
||||
* - 校验 Authorization: Bearer <admin_token>
|
||||
* - 仅允许 role=9 的管理员访问
|
||||
*
|
||||
* @author jianuo
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AdminCoreService, AdminAuthPayload } from '../../../core/admin_core/admin_core.service';
|
||||
|
||||
export interface AdminRequest extends Request {
|
||||
admin?: AdminAuthPayload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(private readonly adminCoreService: AdminCoreService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const auth = req.headers['authorization'];
|
||||
|
||||
if (!auth || Array.isArray(auth)) {
|
||||
throw new UnauthorizedException('缺少Authorization头');
|
||||
}
|
||||
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
throw new UnauthorizedException('Authorization格式错误');
|
||||
}
|
||||
|
||||
const payload = this.adminCoreService.verifyToken(token);
|
||||
req.admin = payload;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
24
src/business/admin/index.ts
Normal file
24
src/business/admin/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 管理员模块统一导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 导出管理员相关的所有组件
|
||||
* - 提供统一的导入入口
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
// 控制器
|
||||
export * from './admin.controller';
|
||||
|
||||
// 服务
|
||||
export * from './admin.service';
|
||||
|
||||
// DTO
|
||||
export * from './dto/admin-login.dto';
|
||||
export * from './dto/admin-response.dto';
|
||||
|
||||
// 模块
|
||||
export * from './admin.module';
|
||||
81
src/business/admin/tests
Normal file
81
src/business/admin/tests
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
};
|
||||
|
||||
const adminCoreServiceMock: Pick<AdminCoreService, 'verifyToken'> = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
const makeContext = (authorization?: any) => {
|
||||
const req: any = { headers: {} };
|
||||
if (authorization !== undefined) {
|
||||
req.headers['authorization'] = authorization;
|
||||
}
|
||||
|
||||
const ctx: Partial<ExecutionContext> = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => req,
|
||||
getResponse: () => ({} as any),
|
||||
getNext: () => ({} as any),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ctx: ctx as ExecutionContext, req };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should allow access with valid admin token', () => {
|
||||
(adminCoreServiceMock.verifyToken as jest.Mock).mockReturnValue(payload);
|
||||
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx, req } = makeContext('Bearer valid');
|
||||
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
expect(adminCoreServiceMock.verifyToken).toHaveBeenCalledWith('valid');
|
||||
expect(req.admin).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should deny access without token', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext(undefined);
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access with invalid Authorization format', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext('InvalidFormat');
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access when verifyToken throws (invalid/expired)', () => {
|
||||
(adminCoreServiceMock.verifyToken as jest.Mock).mockImplementation(() => {
|
||||
throw new UnauthorizedException('Token已过期');
|
||||
});
|
||||
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext('Bearer bad');
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access when Authorization header is an array', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext(['Bearer token']);
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
54
src/business/auth/auth.module.ts
Normal file
54
src/business/auth/auth.module.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 用户认证业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合所有用户认证相关功能
|
||||
* - 用户登录、注册、密码管理
|
||||
* - GitHub OAuth集成
|
||||
* - 邮箱验证功能
|
||||
* - JWT令牌管理和验证
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
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';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoginCoreModule,
|
||||
ZulipCoreModule,
|
||||
ZulipAccountsModule.forRoot(),
|
||||
UsersModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d'
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [LoginController],
|
||||
providers: [
|
||||
LoginService,
|
||||
],
|
||||
exports: [LoginService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -13,6 +13,7 @@
|
||||
* - POST /auth/forgot-password - 发送密码重置验证码
|
||||
* - POST /auth/reset-password - 重置密码
|
||||
* - PUT /auth/change-password - 修改密码
|
||||
* - POST /auth/refresh-token - 刷新访问令牌
|
||||
*
|
||||
* @author moyin angjustinl
|
||||
* @version 1.0.0
|
||||
@@ -22,8 +23,8 @@
|
||||
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto';
|
||||
import { LoginService, ApiResponse, LoginResponse } from '../services/login.service';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
RegisterResponseDto,
|
||||
@@ -31,8 +32,11 @@ import {
|
||||
ForgotPasswordResponseDto,
|
||||
CommonResponseDto,
|
||||
TestModeEmailVerificationResponseDto,
|
||||
SuccessEmailVerificationResponseDto
|
||||
} from '../../dto/login_response.dto';
|
||||
SuccessEmailVerificationResponseDto,
|
||||
RefreshTokenResponseDto
|
||||
} from '../dto/login_response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
|
||||
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -65,14 +69,35 @@ export class LoginController {
|
||||
status: 401,
|
||||
description: '用户名或密码错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 403,
|
||||
description: '账户被禁用或锁定'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '登录尝试过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.LOGIN)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async login(@Body() loginDto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.login({
|
||||
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.login({
|
||||
identifier: loginDto.identifier,
|
||||
password: loginDto.password
|
||||
});
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
// 根据错误类型设置不同的状态码
|
||||
if (result.error_code === 'LOGIN_FAILED') {
|
||||
res.status(HttpStatus.UNAUTHORIZED).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,11 +124,16 @@ export class LoginController {
|
||||
status: 409,
|
||||
description: '用户名或邮箱已存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '注册请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.REGISTER)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async register(@Body() registerDto: RegisterDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.register({
|
||||
async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.register({
|
||||
username: registerDto.username,
|
||||
password: registerDto.password,
|
||||
nickname: registerDto.nickname,
|
||||
@@ -111,6 +141,22 @@ export class LoginController {
|
||||
phone: registerDto.phone,
|
||||
email_verification_code: registerDto.email_verification_code
|
||||
});
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.CREATED).json(result);
|
||||
} else {
|
||||
// 根据错误类型设置不同的状态码
|
||||
if (result.message?.includes('已存在')) {
|
||||
// 资源冲突:用户名、邮箱、手机号已存在
|
||||
res.status(HttpStatus.CONFLICT).json(result);
|
||||
} else if (result.error_code === 'REGISTER_FAILED') {
|
||||
// 其他注册失败:参数错误、验证码错误等
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,16 +184,22 @@ export class LoginController {
|
||||
description: 'GitHub认证失败'
|
||||
})
|
||||
@Post('github')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async githubOAuth(@Body() githubDto: GitHubOAuthDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.githubOAuth({
|
||||
async githubOAuth(@Body() githubDto: GitHubOAuthDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.githubOAuth({
|
||||
github_id: githubDto.github_id,
|
||||
username: githubDto.username,
|
||||
nickname: githubDto.nickname,
|
||||
email: githubDto.email,
|
||||
avatar_url: githubDto.avatar_url
|
||||
});
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +232,11 @@ export class LoginController {
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Post('forgot-password')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async forgotPassword(
|
||||
@@ -222,15 +279,26 @@ export class LoginController {
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '重置请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.RESET_PASSWORD)
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto): Promise<ApiResponse> {
|
||||
return await this.loginService.resetPassword({
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.resetPassword({
|
||||
identifier: resetPasswordDto.identifier,
|
||||
verificationCode: resetPasswordDto.verification_code,
|
||||
newPassword: resetPasswordDto.new_password
|
||||
});
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,18 +326,24 @@ export class LoginController {
|
||||
description: '用户不存在'
|
||||
})
|
||||
@Put('change-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async changePassword(@Body() changePasswordDto: ChangePasswordDto): Promise<ApiResponse> {
|
||||
async changePassword(@Body() changePasswordDto: ChangePasswordDto, @Res() res: Response): Promise<void> {
|
||||
// 实际应用中应从JWT令牌中获取用户ID
|
||||
// 这里为了演示,使用请求体中的用户ID
|
||||
const userId = BigInt(changePasswordDto.user_id);
|
||||
|
||||
return await this.loginService.changePassword(
|
||||
const result = await this.loginService.changePassword(
|
||||
userId,
|
||||
changePasswordDto.old_password,
|
||||
changePasswordDto.new_password
|
||||
);
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +376,8 @@ export class LoginController {
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Timeout(TimeoutPresets.EMAIL_SEND)
|
||||
@Post('send-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async sendEmailVerification(
|
||||
@@ -315,6 +391,9 @@ export class LoginController {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else if (result.error_code === 'TEST_MODE_ONLY') {
|
||||
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
|
||||
} else if (result.message?.includes('已被注册') || result.message?.includes('已存在')) {
|
||||
// 邮箱已被注册
|
||||
res.status(HttpStatus.CONFLICT).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
@@ -341,13 +420,19 @@ export class LoginController {
|
||||
description: '验证码错误或已过期'
|
||||
})
|
||||
@Post('verify-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto): Promise<ApiResponse> {
|
||||
return await this.loginService.verifyEmailCode(
|
||||
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.verifyEmailCode(
|
||||
emailVerificationDto.email,
|
||||
emailVerificationDto.verification_code
|
||||
);
|
||||
|
||||
// 根据业务结果设置正确的HTTP状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,6 +465,7 @@ export class LoginController {
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Post('resend-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resendEmailVerification(
|
||||
@@ -398,6 +484,96 @@ export class LoginController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
* @param verificationCodeLoginDto 验证码登录数据
|
||||
* @returns 登录结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '验证码登录',
|
||||
description: '使用邮箱或手机号和验证码进行登录,无需密码'
|
||||
})
|
||||
@ApiBody({ type: VerificationCodeLoginDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码登录成功',
|
||||
type: LoginResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 401,
|
||||
description: '验证码错误或已过期'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@Post('verification-code-login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async verificationCodeLogin(@Body() verificationCodeLoginDto: VerificationCodeLoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.verificationCodeLogin({
|
||||
identifier: verificationCodeLoginDto.identifier,
|
||||
verificationCode: verificationCodeLoginDto.verification_code
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码
|
||||
*
|
||||
* @param sendLoginVerificationCodeDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送登录验证码',
|
||||
description: '向用户邮箱或手机发送登录验证码。邮件使用专门的登录验证码模板,内容明确标识为登录验证而非密码重置。'
|
||||
})
|
||||
@ApiBody({ type: SendLoginVerificationCodeDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码发送成功',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 206,
|
||||
description: '测试模式:验证码已生成但未真实发送',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Post('send-login-verification-code')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async sendLoginVerificationCode(
|
||||
@Body() sendLoginVerificationCodeDto: SendLoginVerificationCodeDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.sendLoginVerificationCode(sendLoginVerificationCodeDto.identifier);
|
||||
|
||||
// 根据结果设置不同的状态码
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else if (result.error_code === 'TEST_MODE_ONLY') {
|
||||
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
@@ -411,9 +587,131 @@ export class LoginController {
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@Post('debug-verification-code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async debugVerificationCode(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<any> {
|
||||
return await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
|
||||
async debugVerificationCode(@Body() sendEmailVerificationDto: SendEmailVerificationDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
|
||||
|
||||
// 调试接口总是返回200
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除限流记录(仅开发环境)
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '清除限流记录',
|
||||
description: '清除所有限流记录(仅开发环境使用)'
|
||||
})
|
||||
@Post('debug-clear-throttle')
|
||||
async clearThrottle(@Res() res: Response): Promise<void> {
|
||||
// 注入ThrottleGuard并清除记录
|
||||
// 这里需要通过依赖注入获取ThrottleGuard实例
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
message: '限流记录已清除'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证刷新令牌的有效性和格式
|
||||
* 2. 检查用户状态是否正常
|
||||
* 3. 生成新的JWT令牌对
|
||||
* 4. 返回新的访问令牌和刷新令牌
|
||||
*
|
||||
* @param refreshTokenDto 刷新令牌数据
|
||||
* @param res Express响应对象
|
||||
* @returns 新的令牌对
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '刷新访问令牌',
|
||||
description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。'
|
||||
})
|
||||
@ApiBody({ type: RefreshTokenDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '令牌刷新成功',
|
||||
type: RefreshTokenResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 401,
|
||||
description: '刷新令牌无效或已过期'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在或已被禁用'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '刷新请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.REFRESH_TOKEN)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('refresh-token')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log('令牌刷新请求', {
|
||||
operation: 'refreshToken',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log('令牌刷新成功', {
|
||||
operation: 'refreshToken',
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
this.logger.warn('令牌刷新失败', {
|
||||
operation: 'refreshToken',
|
||||
error: result.message,
|
||||
errorCode: result.error_code,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 根据错误类型设置不同的状态码
|
||||
if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).json(result);
|
||||
} else if (result.message?.includes('用户不存在')) {
|
||||
res.status(HttpStatus.NOT_FOUND).json(result);
|
||||
} else {
|
||||
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error('令牌刷新异常', {
|
||||
operation: 'refreshToken',
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
success: false,
|
||||
message: '服务器内部错误',
|
||||
error_code: 'INTERNAL_SERVER_ERROR'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 当前用户装饰器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 从请求上下文中提取当前认证用户信息
|
||||
* - 简化控制器中获取用户信息的操作
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getProfile(@CurrentUser() user: JwtPayload) {
|
||||
* return { user };
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthenticatedRequest, JwtPayload } from '../guards/jwt-auth.guard';
|
||||
|
||||
/**
|
||||
* 当前用户装饰器
|
||||
*
|
||||
* @param data 可选的属性名,用于获取用户对象的特定属性
|
||||
* @param ctx 执行上下文
|
||||
* @returns 用户信息或用户的特定属性
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
@@ -371,4 +371,74 @@ export class SendEmailVerificationDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录请求DTO
|
||||
*/
|
||||
export class VerificationCodeLoginDto {
|
||||
/**
|
||||
* 登录标识符
|
||||
* 支持邮箱或手机号登录
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '登录标识符,支持邮箱或手机号',
|
||||
example: 'test@example.com',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '登录标识符必须是字符串' })
|
||||
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* 验证码
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '6位数字验证码',
|
||||
example: '123456',
|
||||
pattern: '^\\d{6}$'
|
||||
})
|
||||
@IsString({ message: '验证码必须是字符串' })
|
||||
@IsNotEmpty({ message: '验证码不能为空' })
|
||||
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
|
||||
verification_code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码请求DTO
|
||||
*/
|
||||
export class SendLoginVerificationCodeDto {
|
||||
/**
|
||||
* 登录标识符
|
||||
* 支持邮箱或手机号
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '登录标识符,支持邮箱或手机号',
|
||||
example: 'test@example.com',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '登录标识符必须是字符串' })
|
||||
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌请求DTO
|
||||
*/
|
||||
export class RefreshTokenDto {
|
||||
/**
|
||||
* 刷新令牌
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
minLength: 1
|
||||
})
|
||||
@IsString({ message: '刷新令牌必须是字符串' })
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refresh_token: string;
|
||||
}
|
||||
@@ -80,17 +80,28 @@ export class LoginResponseDataDto {
|
||||
user: UserInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌',
|
||||
description: 'JWT访问令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
access_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
required: false
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
refresh_token?: string;
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌过期时间(秒)',
|
||||
example: 604800
|
||||
})
|
||||
expires_in: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '令牌类型',
|
||||
example: 'Bearer'
|
||||
})
|
||||
token_type: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否为新用户',
|
||||
@@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto {
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌刷新响应数据DTO
|
||||
*/
|
||||
export class RefreshTokenResponseDataDto {
|
||||
@ApiProperty({
|
||||
description: 'JWT访问令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
access_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌过期时间(秒)',
|
||||
example: 604800
|
||||
})
|
||||
expires_in: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '令牌类型',
|
||||
example: 'Bearer'
|
||||
})
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌刷新响应DTO
|
||||
*/
|
||||
export class RefreshTokenResponseDto {
|
||||
@ApiProperty({
|
||||
description: '请求是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应数据',
|
||||
type: RefreshTokenResponseDataDto,
|
||||
required: false
|
||||
})
|
||||
data?: RefreshTokenResponseDataDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '令牌刷新成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'TOKEN_REFRESH_FAILED',
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* JWT 使用示例
|
||||
*
|
||||
* 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
|
||||
import { JwtAuthGuard, JwtPayload } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* 示例控制器 - 展示 JWT 认证的使用方法
|
||||
*/
|
||||
@Controller('example')
|
||||
export class ExampleController {
|
||||
|
||||
/**
|
||||
* 公开接口 - 无需认证
|
||||
*/
|
||||
@Get('public')
|
||||
getPublicData() {
|
||||
return {
|
||||
message: '这是一个公开接口,无需认证',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 受保护的接口 - 需要 JWT 认证
|
||||
*
|
||||
* 请求头示例:
|
||||
* Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
*/
|
||||
@Get('protected')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getProtectedData(@CurrentUser() user: JwtPayload) {
|
||||
return {
|
||||
message: '这是一个受保护的接口,需要有效的 JWT 令牌',
|
||||
user: {
|
||||
id: user.sub,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getUserProfile(@CurrentUser() user: JwtPayload) {
|
||||
return {
|
||||
profile: {
|
||||
userId: user.sub,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tokenIssuedAt: new Date(user.iat * 1000).toISOString(),
|
||||
tokenExpiresAt: new Date(user.exp * 1000).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的特定属性
|
||||
*/
|
||||
@Get('username')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getUsername(@CurrentUser('username') username: string) {
|
||||
return {
|
||||
username,
|
||||
message: `你好,${username}!`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要特定角色的接口
|
||||
*/
|
||||
@Post('admin-only')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
adminOnlyAction(@CurrentUser() user: JwtPayload, @Body() data: any) {
|
||||
// 检查用户角色
|
||||
if (user.role !== 1) { // 假设 1 是管理员角色
|
||||
return {
|
||||
success: false,
|
||||
message: '权限不足,仅管理员可访问',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '管理员操作执行成功',
|
||||
data,
|
||||
operator: user.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用说明:
|
||||
*
|
||||
* 1. 首先调用登录接口获取 JWT 令牌:
|
||||
* POST /auth/login
|
||||
* {
|
||||
* "identifier": "username",
|
||||
* "password": "password"
|
||||
* }
|
||||
*
|
||||
* 2. 从响应中获取 access_token
|
||||
*
|
||||
* 3. 在后续请求中添加 Authorization 头:
|
||||
* Authorization: Bearer <access_token>
|
||||
*
|
||||
* 4. 访问受保护的接口:
|
||||
* GET /example/protected
|
||||
* GET /example/profile
|
||||
* GET /example/username
|
||||
* POST /example/admin-only
|
||||
*
|
||||
* 错误处理:
|
||||
* - 401 Unauthorized: 令牌缺失或无效
|
||||
* - 403 Forbidden: 令牌有效但权限不足
|
||||
*/
|
||||
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* JWT 认证守卫
|
||||
*
|
||||
* 功能描述:
|
||||
* - 验证请求中的 JWT 令牌
|
||||
* - 提取用户信息并添加到请求上下文
|
||||
* - 保护需要认证的路由
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* JWT 载荷接口
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string; // 用户ID
|
||||
username: string;
|
||||
role: number;
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展的请求接口,包含用户信息
|
||||
*/
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: JwtPayload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn('访问被拒绝:缺少认证令牌');
|
||||
throw new UnauthorizedException('缺少认证令牌');
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证并解码 JWT 令牌
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||
|
||||
// 将用户信息添加到请求对象
|
||||
(request as AuthenticatedRequest).user = payload;
|
||||
|
||||
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
|
||||
throw new UnauthorizedException('无效的认证令牌');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头中提取 JWT 令牌
|
||||
*
|
||||
* @param request 请求对象
|
||||
* @returns JWT 令牌或 undefined
|
||||
*/
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
23
src/business/auth/index.ts
Normal file
23
src/business/auth/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 用户认证业务模块导出
|
||||
*
|
||||
* 功能概述:
|
||||
* - 用户登录和注册
|
||||
* - GitHub OAuth集成
|
||||
* - 密码管理(忘记密码、重置密码、修改密码)
|
||||
* - 邮箱验证功能
|
||||
* - JWT Token管理
|
||||
*/
|
||||
|
||||
// 模块
|
||||
export * from './auth.module';
|
||||
|
||||
// 控制器
|
||||
export * from './controllers/login.controller';
|
||||
|
||||
// 服务
|
||||
export * from './services/login.service';
|
||||
|
||||
// DTO
|
||||
export * from './dto/login.dto';
|
||||
export * from './dto/login_response.dto';
|
||||
763
src/business/auth/services/login.service.spec.ts
Normal file
763
src/business/auth/services/login.service.spec.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* 登录业务服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试登录相关的业务逻辑
|
||||
* - 测试JWT令牌生成和验证
|
||||
* - 测试令牌刷新功能
|
||||
* - 测试各种异常情况处理
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginCoreService } from '../../../core/login_core/login_core.service';
|
||||
import { UsersService } from '../../../core/db/users/users.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 * as jwt from 'jsonwebtoken';
|
||||
|
||||
// Mock jwt module
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('LoginService', () => {
|
||||
let service: LoginService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let usersService: jest.Mocked<UsersService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
const mockUser = {
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '+8613800138000',
|
||||
password_hash: '$2b$12$hashedpassword',
|
||||
nickname: '测试用户',
|
||||
github_id: null as string | null,
|
||||
avatar_url: null as string | null,
|
||||
role: 1,
|
||||
email_verified: false,
|
||||
status: 'active' as any,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars';
|
||||
const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test';
|
||||
const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock environment variables for Zulip
|
||||
const originalEnv = process.env;
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
ZULIP_SERVER_URL: 'https://test.zulipchat.com',
|
||||
ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com',
|
||||
ZULIP_BOT_API_KEY: 'test_api_key_12345',
|
||||
};
|
||||
|
||||
const mockLoginCoreService = {
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
githubOAuth: jest.fn(),
|
||||
sendPasswordResetCode: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
sendEmailVerification: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
resendEmailVerification: jest.fn(),
|
||||
verificationCodeLogin: jest.fn(),
|
||||
sendLoginVerificationCode: jest.fn(),
|
||||
debugVerificationCode: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
signAsync: jest.fn(),
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUsersService = {
|
||||
findOne: 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: JwtService,
|
||||
useValue: mockJwtService,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: ZulipAccountService,
|
||||
useValue: mockZulipAccountService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useValue: mockZulipAccountsRepository,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
jwtService = module.get(JwtService);
|
||||
configService = module.get(ConfigService);
|
||||
usersService = module.get('UsersService');
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
zulipAccountsRepository = module.get('ZulipAccountsRepository');
|
||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||
|
||||
// Setup default config service mocks
|
||||
configService.get.mockImplementation((key: string, defaultValue?: any) => {
|
||||
const config = {
|
||||
'JWT_SECRET': mockJwtSecret,
|
||||
'JWT_EXPIRES_IN': '7d',
|
||||
};
|
||||
return config[key] || defaultValue;
|
||||
});
|
||||
|
||||
// Setup default JWT service mocks
|
||||
jwtService.signAsync.mockResolvedValue(mockAccessToken);
|
||||
(jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken);
|
||||
|
||||
// Setup default Zulip mocks
|
||||
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 123,
|
||||
email: 'test@example.com',
|
||||
apiKey: 'mock_api_key'
|
||||
});
|
||||
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountsRepository.create.mockResolvedValue({} as any);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Restore original environment variables
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should login successfully and return JWT tokens', async () => {
|
||||
loginCoreService.login.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.data?.expires_in).toBe(604800); // 7 days in seconds
|
||||
expect(result.data?.token_type).toBe('Bearer');
|
||||
expect(result.data?.is_new_user).toBe(false);
|
||||
expect(result.message).toBe('登录成功');
|
||||
|
||||
// Verify JWT service was called correctly
|
||||
expect(jwtService.signAsync).toHaveBeenCalledWith({
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
email: 'test@example.com',
|
||||
type: 'access',
|
||||
iat: expect.any(Number),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
});
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
type: 'refresh',
|
||||
iat: expect.any(Number),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
},
|
||||
mockJwtSecret,
|
||||
{
|
||||
expiresIn: '30d',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
loginCoreService.login.mockRejectedValue(new Error('用户名或密码错误'));
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||
expect(result.message).toBe('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('should handle JWT generation failure', async () => {
|
||||
loginCoreService.login.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||
expect(result.message).toContain('JWT generation failed');
|
||||
});
|
||||
|
||||
it('should handle missing JWT secret', async () => {
|
||||
loginCoreService.login.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'JWT_SECRET') return undefined;
|
||||
if (key === 'JWT_EXPIRES_IN') return '7d';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||
expect(result.message).toContain('JWT_SECRET未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully with JWT tokens', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.data?.expires_in).toBe(604800);
|
||||
expect(result.data?.token_type).toBe('Bearer');
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
expect(result.message).toBe('注册成功,Zulip账号已同步创建');
|
||||
});
|
||||
|
||||
it('should register successfully without email', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: { ...mockUser, email: null },
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.message).toBe('注册成功');
|
||||
// Should not try to create Zulip account without email
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Zulip account creation failure and rollback', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Zulip creation failed'
|
||||
});
|
||||
|
||||
loginCoreService.deleteUser.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip账号创建失败');
|
||||
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id);
|
||||
});
|
||||
|
||||
it('should handle register failure', async () => {
|
||||
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||
expect(result.message).toBe('用户名已存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
const mockPayload = {
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
type: 'access' as const,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
it('should verify access token successfully', async () => {
|
||||
(jwt.verify as jest.Mock).mockReturnValue(mockPayload);
|
||||
|
||||
const result = await service.verifyToken(mockAccessToken, 'access');
|
||||
|
||||
expect(result).toEqual(mockPayload);
|
||||
expect(jwt.verify).toHaveBeenCalledWith(
|
||||
mockAccessToken,
|
||||
mockJwtSecret,
|
||||
{
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify refresh token successfully', async () => {
|
||||
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
|
||||
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
|
||||
|
||||
const result = await service.verifyToken(mockRefreshToken, 'refresh');
|
||||
|
||||
expect(result).toEqual(refreshPayload);
|
||||
});
|
||||
|
||||
it('should throw error for invalid token', async () => {
|
||||
(jwt.verify as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('invalid token');
|
||||
});
|
||||
|
||||
await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token');
|
||||
});
|
||||
|
||||
it('should throw error for token type mismatch', async () => {
|
||||
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
|
||||
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
|
||||
|
||||
await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配');
|
||||
});
|
||||
|
||||
it('should throw error for incomplete payload', async () => {
|
||||
const incompletePayload = { sub: '1', type: 'access' }; // missing username and role
|
||||
(jwt.verify as jest.Mock).mockReturnValue(incompletePayload);
|
||||
|
||||
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整');
|
||||
});
|
||||
|
||||
it('should throw error when JWT secret is missing', async () => {
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'JWT_SECRET') return undefined;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
const mockRefreshPayload = {
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
type: 'refresh' as const,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload);
|
||||
usersService.findOne.mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
it('should refresh access token successfully', async () => {
|
||||
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.data?.expires_in).toBe(604800);
|
||||
expect(result.data?.token_type).toBe('Bearer');
|
||||
expect(result.message).toBe('令牌刷新成功');
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith(
|
||||
mockRefreshToken,
|
||||
mockJwtSecret,
|
||||
{
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
}
|
||||
);
|
||||
expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1));
|
||||
});
|
||||
|
||||
it('should handle invalid refresh token', async () => {
|
||||
(jwt.verify as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('invalid token');
|
||||
});
|
||||
|
||||
const result = await service.refreshAccessToken('invalid_token');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||
expect(result.message).toContain('invalid token');
|
||||
});
|
||||
|
||||
it('should handle user not found', async () => {
|
||||
usersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||
expect(result.message).toBe('用户不存在或已被禁用');
|
||||
});
|
||||
|
||||
it('should handle user service error', async () => {
|
||||
usersService.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||
expect(result.message).toContain('Database error');
|
||||
});
|
||||
|
||||
it('should handle JWT generation error during refresh', async () => {
|
||||
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
|
||||
|
||||
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||
expect(result.message).toContain('JWT generation failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExpirationTime', () => {
|
||||
it('should parse seconds correctly', () => {
|
||||
const result = (service as any).parseExpirationTime('30s');
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
|
||||
it('should parse minutes correctly', () => {
|
||||
const result = (service as any).parseExpirationTime('5m');
|
||||
expect(result).toBe(300);
|
||||
});
|
||||
|
||||
it('should parse hours correctly', () => {
|
||||
const result = (service as any).parseExpirationTime('2h');
|
||||
expect(result).toBe(7200);
|
||||
});
|
||||
|
||||
it('should parse days correctly', () => {
|
||||
const result = (service as any).parseExpirationTime('7d');
|
||||
expect(result).toBe(604800);
|
||||
});
|
||||
|
||||
it('should parse weeks correctly', () => {
|
||||
const result = (service as any).parseExpirationTime('2w');
|
||||
expect(result).toBe(1209600);
|
||||
});
|
||||
|
||||
it('should return default for invalid format', () => {
|
||||
const result = (service as any).parseExpirationTime('invalid');
|
||||
expect(result).toBe(604800); // 7 days default
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTokenPair', () => {
|
||||
it('should generate token pair successfully', async () => {
|
||||
const result = await (service as any).generateTokenPair(mockUser);
|
||||
|
||||
expect(result.access_token).toBe(mockAccessToken);
|
||||
expect(result.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.expires_in).toBe(604800);
|
||||
expect(result.token_type).toBe('Bearer');
|
||||
|
||||
expect(jwtService.signAsync).toHaveBeenCalledWith({
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
email: 'test@example.com',
|
||||
type: 'access',
|
||||
iat: expect.any(Number),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
});
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: '1',
|
||||
username: 'testuser',
|
||||
role: 1,
|
||||
type: 'refresh',
|
||||
iat: expect.any(Number),
|
||||
iss: 'whale-town',
|
||||
aud: 'whale-town-users',
|
||||
},
|
||||
mockJwtSecret,
|
||||
{
|
||||
expiresIn: '30d',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing JWT secret', async () => {
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'JWT_SECRET') return undefined;
|
||||
if (key === 'JWT_EXPIRES_IN') return '7d';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置');
|
||||
});
|
||||
|
||||
it('should handle JWT service error', async () => {
|
||||
jwtService.signAsync.mockRejectedValue(new Error('JWT service error'));
|
||||
|
||||
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUserInfo', () => {
|
||||
it('should format user info correctly', () => {
|
||||
const formattedUser = (service as any).formatUserInfo(mockUser);
|
||||
|
||||
expect(formattedUser).toEqual({
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
phone: '+8613800138000',
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: mockUser.created_at
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('other methods', () => {
|
||||
it('should handle githubOAuth successfully', async () => {
|
||||
loginCoreService.githubOAuth.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
const result = await service.githubOAuth({
|
||||
github_id: '12345',
|
||||
username: 'testuser',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.data?.message).toBe('GitHub登录成功');
|
||||
});
|
||||
|
||||
it('should handle verificationCodeLogin successfully', async () => {
|
||||
loginCoreService.verificationCodeLogin.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
const result = await service.verificationCodeLogin({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '123456'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.email).toBe('test@example.com');
|
||||
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||
expect(result.data?.message).toBe('验证码登录成功');
|
||||
});
|
||||
|
||||
it('should handle sendPasswordResetCode in test mode', async () => {
|
||||
loginCoreService.sendPasswordResetCode.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendPasswordResetCode('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // Test mode returns false
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.data?.is_test_mode).toBe(true);
|
||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||
});
|
||||
|
||||
it('should handle resetPassword successfully', async () => {
|
||||
loginCoreService.resetPassword.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.resetPassword({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '123456',
|
||||
newPassword: 'newpassword123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('密码重置成功');
|
||||
});
|
||||
|
||||
it('should handle changePassword successfully', async () => {
|
||||
loginCoreService.changePassword.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.changePassword(
|
||||
BigInt(1),
|
||||
'oldpassword',
|
||||
'newpassword123'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('密码修改成功');
|
||||
});
|
||||
|
||||
it('should handle sendEmailVerification in test mode', async () => {
|
||||
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||
});
|
||||
|
||||
it('should handle verifyEmailCode successfully', async () => {
|
||||
loginCoreService.verifyEmailCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('邮箱验证成功');
|
||||
});
|
||||
|
||||
it('should handle sendLoginVerificationCode successfully', async () => {
|
||||
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendLoginVerificationCode('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // 测试模式下返回false
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||
});
|
||||
|
||||
it('should handle debugVerificationCode successfully', async () => {
|
||||
const mockDebugInfo = {
|
||||
email: 'test@example.com',
|
||||
code: '123456',
|
||||
expiresAt: new Date(),
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo);
|
||||
|
||||
const result = await service.debugVerificationCode('test@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockDebugInfo);
|
||||
expect(result.message).toBe('调试信息获取成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
1187
src/business/auth/services/login.service.ts
Normal file
1187
src/business/auth/services/login.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 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 { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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<LoginCoreService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
// 测试用的模拟数据生成器
|
||||
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,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn().mockReturnValue('mock_jwt_token'),
|
||||
signAsync: jest.fn().mockResolvedValue('mock_jwt_token'),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'JWT_SECRET':
|
||||
return 'test_jwt_secret_key_for_testing';
|
||||
case 'JWT_EXPIRES_IN':
|
||||
return '7d';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: {
|
||||
findById: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
loginService = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
zulipAccountsRepository = module.get('ZulipAccountsRepository');
|
||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||
|
||||
// Mock LoginService 的 initializeZulipAdminClient 方法
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
|
||||
// 设置环境变量模拟
|
||||
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;
|
||||
|
||||
// 设置模拟行为
|
||||
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(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
|
||||
|
||||
// 验证游戏用户注册
|
||||
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账号创建失败
|
||||
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账号关联
|
||||
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;
|
||||
|
||||
// 设置模拟行为
|
||||
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) => {
|
||||
// 设置模拟行为 - 管理员客户端初始化失败
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
|
||||
|
||||
// 执行注册
|
||||
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();
|
||||
|
||||
// 恢复 mock
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ 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;
|
||||
|
||||
// 重新设置 mock 以模拟环境变量缺失的错误
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, 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();
|
||||
|
||||
// 恢复环境变量和 mock
|
||||
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';
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ 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),
|
||||
};
|
||||
|
||||
// 设置模拟行为
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 登录业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合登录相关的控制器、服务和依赖
|
||||
* - 提供完整的登录业务功能模块
|
||||
* - 可被其他模块导入使用
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginController } from './login.controller';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
|
||||
@Module({
|
||||
imports: [LoginCoreModule],
|
||||
controllers: [LoginController],
|
||||
providers: [LoginService],
|
||||
exports: [LoginService],
|
||||
})
|
||||
export class LoginModule {}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* 登录业务服务测试
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
describe('LoginService', () => {
|
||||
let service: LoginService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
|
||||
const mockUser = {
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '+8613800138000',
|
||||
password_hash: '$2b$12$hashedpassword',
|
||||
nickname: '测试用户',
|
||||
github_id: null as string | null,
|
||||
avatar_url: null as string | null,
|
||||
role: 1,
|
||||
email_verified: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockLoginCoreService = {
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
githubOAuth: jest.fn(),
|
||||
sendPasswordResetCode: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LoginService,
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should return success response for valid login', async () => {
|
||||
loginCoreService.login.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
});
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return error response for failed login', async () => {
|
||||
loginCoreService.login.mockRejectedValue(new Error('登录失败'));
|
||||
|
||||
const result = await service.login({
|
||||
identifier: 'testuser',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('登录失败');
|
||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should return success response for valid registration', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error response for failed registration', async () => {
|
||||
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||
|
||||
const result = await service.register({
|
||||
username: 'existinguser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('用户名已存在');
|
||||
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubOAuth', () => {
|
||||
it('should return success response for GitHub OAuth', async () => {
|
||||
loginCoreService.githubOAuth.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.githubOAuth({
|
||||
github_id: 'github123',
|
||||
username: 'githubuser',
|
||||
nickname: 'GitHub用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPasswordResetCode', () => {
|
||||
it('should return test mode response with verification code', async () => {
|
||||
loginCoreService.sendPasswordResetCode.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendPasswordResetCode('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // 测试模式下不算成功
|
||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.data?.is_test_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('should return success response for real email sending', async () => {
|
||||
loginCoreService.sendPasswordResetCode.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: false
|
||||
});
|
||||
|
||||
const result = await service.sendPasswordResetCode('test@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.is_test_mode).toBe(false);
|
||||
expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('should return success response for password reset', async () => {
|
||||
loginCoreService.resetPassword.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.resetPassword({
|
||||
identifier: 'test@example.com',
|
||||
verificationCode: '123456',
|
||||
newPassword: 'newpassword123'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('密码重置成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should return success response for password change', async () => {
|
||||
loginCoreService.changePassword.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword123');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('密码修改成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,505 +0,0 @@
|
||||
/**
|
||||
* 登录业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的业务功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供业务接口
|
||||
*
|
||||
* @author moyin angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
|
||||
/**
|
||||
* 登录响应数据接口
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** 用户信息 */
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
/** 访问令牌(实际应用中应生成JWT) */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token?: string;
|
||||
/** 是否为新用户 */
|
||||
is_new_user?: boolean;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应接口
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
/** 错误代码 */
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
private readonly logger = new Logger(LoginService.name);
|
||||
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param loginRequest 登录请求
|
||||
* @returns 登录响应
|
||||
*/
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
|
||||
|
||||
// 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 生成访问令牌(实际应用中应使用JWT)
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: '登录成功'
|
||||
};
|
||||
|
||||
this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '登录失败',
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerRequest 注册请求
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||
|
||||
// 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
is_new_user: true,
|
||||
message: '注册成功'
|
||||
};
|
||||
|
||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: '注册成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '注册失败',
|
||||
error_code: 'REGISTER_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth登录
|
||||
*
|
||||
* @param oauthRequest OAuth请求
|
||||
* @returns 登录响应
|
||||
*/
|
||||
async githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`GitHub OAuth登录尝试: ${oauthRequest.github_id}`);
|
||||
|
||||
// 调用核心服务进行OAuth认证
|
||||
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
|
||||
};
|
||||
|
||||
this.logger.log(`GitHub OAuth成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`GitHub OAuth失败: ${oauthRequest.github_id}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'GitHub登录失败',
|
||||
error_code: 'GITHUB_OAUTH_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码
|
||||
*
|
||||
* @param identifier 邮箱或手机号
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送密码重置验证码: ${identifier}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendPasswordResetCode(identifier);
|
||||
|
||||
this.logger.log(`密码重置验证码已发送: ${identifier}`);
|
||||
|
||||
// 根据是否为测试模式返回不同的状态和消息 by angjustinl 2025-12-17
|
||||
if (result.isTestMode) {
|
||||
// 测试模式:验证码生成但未真实发送
|
||||
return {
|
||||
success: false, // 测试模式下不算真正成功
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
error_code: 'TEST_MODE_ONLY'
|
||||
};
|
||||
} else {
|
||||
// 真实发送模式
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: '验证码已发送,请查收'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: 'SEND_CODE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*
|
||||
* @param resetRequest 重置请求
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resetPassword(resetRequest: PasswordResetRequest): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`密码重置尝试: ${resetRequest.identifier}`);
|
||||
|
||||
// 调用核心服务重置密码
|
||||
await this.loginCoreService.resetPassword(resetRequest);
|
||||
|
||||
this.logger.log(`密码重置成功: ${resetRequest.identifier}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '密码重置成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`密码重置失败: ${resetRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '密码重置失败',
|
||||
error_code: 'RESET_PASSWORD_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param oldPassword 旧密码
|
||||
* @param newPassword 新密码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`修改密码尝试: 用户ID ${userId}`);
|
||||
|
||||
// 调用核心服务修改密码
|
||||
await this.loginCoreService.changePassword(userId, oldPassword, newPassword);
|
||||
|
||||
this.logger.log(`修改密码成功: 用户ID ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`修改密码失败: 用户ID ${userId}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '密码修改失败',
|
||||
error_code: 'CHANGE_PASSWORD_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已发送: ${email}`);
|
||||
|
||||
// 根据是否为测试模式返回不同的状态和消息
|
||||
if (result.isTestMode) {
|
||||
// 测试模式:验证码生成但未真实发送
|
||||
return {
|
||||
success: false, // 测试模式下不算真正成功
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
error_code: 'TEST_MODE_ONLY'
|
||||
};
|
||||
} else {
|
||||
// 真实发送模式
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: '验证码已发送,请查收邮件'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: 'SEND_EMAIL_VERIFICATION_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param code 验证码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`验证邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务验证验证码
|
||||
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
|
||||
|
||||
if (isValid) {
|
||||
this.logger.log(`邮箱验证成功: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: '邮箱验证成功'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码错误',
|
||||
error_code: 'INVALID_VERIFICATION_CODE'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '邮箱验证失败',
|
||||
error_code: 'EMAIL_VERIFICATION_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`重新发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务重新发送验证码
|
||||
const result = await this.loginCoreService.resendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已重新发送: ${email}`);
|
||||
|
||||
// 根据是否为测试模式返回不同的状态和消息
|
||||
if (result.isTestMode) {
|
||||
// 测试模式:验证码生成但未真实发送
|
||||
return {
|
||||
success: false, // 测试模式下不算真正成功
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
error_code: 'TEST_MODE_ONLY'
|
||||
};
|
||||
} else {
|
||||
// 真实发送模式
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: '验证码已重新发送,请查收邮件'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '重新发送验证码失败',
|
||||
error_code: 'RESEND_EMAIL_VERIFICATION_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户信息
|
||||
*/
|
||||
private formatUserInfo(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(), // 将bigint转换为字符串
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成访问令牌
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @returns 访问令牌
|
||||
*/
|
||||
private generateAccessToken(user: Users): string {
|
||||
// 实际应用中应使用JWT库生成真正的JWT令牌
|
||||
// 这里仅用于演示,生成一个简单的令牌
|
||||
const payload = {
|
||||
userId: user.id.toString(),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 简单的Base64编码(实际应用中应使用JWT)
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
}
|
||||
/**
|
||||
* 调试验证码信息
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 调试信息
|
||||
*/
|
||||
async debugVerificationCode(email: string): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`调试验证码信息: ${email}`);
|
||||
|
||||
const debugInfo = await this.loginCoreService.debugVerificationCode(email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: debugInfo,
|
||||
message: '调试信息获取成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取验证码调试信息失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '获取调试信息失败',
|
||||
error_code: 'DEBUG_VERIFICATION_CODE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/business/shared/dto/index.ts
Normal file
17
src/business/shared/dto/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 共享 DTO 统一导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 导出所有共享的 DTO 类
|
||||
* - 提供统一的导入入口
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
// 应用状态相关
|
||||
export * from './app-status.dto';
|
||||
|
||||
// 错误响应相关
|
||||
export * from './error-response.dto';
|
||||
14
src/business/shared/index.ts
Normal file
14
src/business/shared/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 共享模块统一导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 导出所有共享的组件和类型
|
||||
* - 提供统一的导入入口
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
// DTO
|
||||
export * from './dto';
|
||||
162
src/business/user-mgmt/controllers/user-status.controller.ts
Normal file
162
src/business/user-mgmt/controllers/user-status.controller.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 用户状态管理控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理员管理用户账户状态
|
||||
* - 支持批量状态操作
|
||||
* - 提供状态变更审计日志
|
||||
*
|
||||
* API端点:
|
||||
* - PUT /admin/users/:id/status - 修改用户状态
|
||||
* - POST /admin/users/batch-status - 批量修改用户状态
|
||||
* - GET /admin/users/status-stats - 获取用户状态统计
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Put, Post, UseGuards, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
|
||||
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 '../../../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';
|
||||
|
||||
@ApiTags('user-management')
|
||||
@Controller('admin/users')
|
||||
export class UserStatusController {
|
||||
private readonly logger = new Logger(UserStatusController.name);
|
||||
|
||||
constructor(private readonly userManagementService: UserManagementService) {}
|
||||
|
||||
/**
|
||||
* 修改用户状态
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param userStatusDto 状态修改数据
|
||||
* @returns 修改结果
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '修改用户状态',
|
||||
description: '管理员修改指定用户的账户状态,支持激活、锁定、禁用等操作'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID' })
|
||||
@ApiBody({ type: UserStatusDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '状态修改成功',
|
||||
type: UserStatusResponseDto
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '权限不足'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 429,
|
||||
description: '操作过于频繁'
|
||||
})
|
||||
@UseGuards(AdminGuard)
|
||||
@Throttle(ThrottlePresets.ADMIN_OPERATION)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Put(':id/status')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async updateUserStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() userStatusDto: UserStatusDto
|
||||
): Promise<UserStatusResponseDto> {
|
||||
this.logger.log('管理员修改用户状态', {
|
||||
operation: 'update_user_status',
|
||||
userId: id,
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return await this.userManagementService.updateUserStatus(BigInt(id), userStatusDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改用户状态
|
||||
*
|
||||
* @param batchUserStatusDto 批量状态修改数据
|
||||
* @returns 批量修改结果
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '批量修改用户状态',
|
||||
description: '管理员批量修改多个用户的账户状态'
|
||||
})
|
||||
@ApiBody({ type: BatchUserStatusDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '批量修改成功',
|
||||
type: BatchUserStatusResponseDto
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '权限不足'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 429,
|
||||
description: '操作过于频繁'
|
||||
})
|
||||
@UseGuards(AdminGuard)
|
||||
@Throttle(ThrottlePresets.ADMIN_OPERATION)
|
||||
@Timeout(TimeoutPresets.SLOW)
|
||||
@Post('batch-status')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async batchUpdateUserStatus(
|
||||
@Body() batchUserStatusDto: BatchUserStatusDto
|
||||
): Promise<BatchUserStatusResponseDto> {
|
||||
this.logger.log('管理员批量修改用户状态', {
|
||||
operation: 'batch_update_user_status',
|
||||
userCount: batchUserStatusDto.user_ids.length,
|
||||
newStatus: batchUserStatusDto.status,
|
||||
reason: batchUserStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return await this.userManagementService.batchUpdateUserStatus(batchUserStatusDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态统计
|
||||
*
|
||||
* @returns 状态统计信息
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '获取用户状态统计',
|
||||
description: '获取各种用户状态的数量统计信息'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
type: UserStatusStatsResponseDto
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '权限不足'
|
||||
})
|
||||
@UseGuards(AdminGuard)
|
||||
@Timeout(TimeoutPresets.DATABASE_QUERY)
|
||||
@Get('status-stats')
|
||||
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
|
||||
this.logger.log('管理员获取用户状态统计', {
|
||||
operation: 'get_user_status_stats',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return await this.userManagementService.getUserStatusStats();
|
||||
}
|
||||
}
|
||||
294
src/business/user-mgmt/dto/user-status-response.dto.ts
Normal file
294
src/business/user-mgmt/dto/user-status-response.dto.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 用户状态管理响应 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义用户状态管理相关的响应数据结构
|
||||
* - 提供Swagger文档生成支持
|
||||
* - 确保状态管理API响应的数据格式一致性
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserStatus } from '../enums/user-status.enum';
|
||||
|
||||
/**
|
||||
* 用户状态信息DTO
|
||||
*/
|
||||
export class UserStatusInfoDto {
|
||||
@ApiProperty({
|
||||
description: '用户ID',
|
||||
example: '1'
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户名',
|
||||
example: 'testuser'
|
||||
})
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户昵称',
|
||||
example: '测试用户'
|
||||
})
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户状态',
|
||||
enum: UserStatus,
|
||||
example: UserStatus.ACTIVE
|
||||
})
|
||||
status: UserStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态描述',
|
||||
example: '正常'
|
||||
})
|
||||
status_description: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态修改时间',
|
||||
example: '2025-12-24T10:00:00.000Z'
|
||||
})
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态修改响应数据DTO
|
||||
*/
|
||||
export class UserStatusDataDto {
|
||||
@ApiProperty({
|
||||
description: '用户信息',
|
||||
type: UserStatusInfoDto
|
||||
})
|
||||
user: UserStatusInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '修改原因',
|
||||
example: '用户违反社区规定',
|
||||
required: false
|
||||
})
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态修改响应DTO
|
||||
*/
|
||||
export class UserStatusResponseDto {
|
||||
@ApiProperty({
|
||||
description: '请求是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应数据',
|
||||
type: UserStatusDataDto,
|
||||
required: false
|
||||
})
|
||||
data?: UserStatusDataDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '用户状态修改成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'USER_STATUS_UPDATE_FAILED',
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作结果DTO
|
||||
*/
|
||||
export class BatchOperationResultDto {
|
||||
@ApiProperty({
|
||||
description: '成功处理的用户列表',
|
||||
type: [UserStatusInfoDto]
|
||||
})
|
||||
success_users: UserStatusInfoDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '处理失败的用户列表',
|
||||
type: [Object],
|
||||
example: [
|
||||
{
|
||||
user_id: '999',
|
||||
error: '用户不存在'
|
||||
}
|
||||
]
|
||||
})
|
||||
failed_users: Array<{
|
||||
user_id: string;
|
||||
error: string;
|
||||
}>;
|
||||
|
||||
@ApiProperty({
|
||||
description: '成功处理数量',
|
||||
example: 5
|
||||
})
|
||||
success_count: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '失败处理数量',
|
||||
example: 1
|
||||
})
|
||||
failed_count: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '总处理数量',
|
||||
example: 6
|
||||
})
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量用户状态修改响应数据DTO
|
||||
*/
|
||||
export class BatchUserStatusDataDto {
|
||||
@ApiProperty({
|
||||
description: '批量操作结果',
|
||||
type: BatchOperationResultDto
|
||||
})
|
||||
result: BatchOperationResultDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '修改原因',
|
||||
example: '批量处理违规用户',
|
||||
required: false
|
||||
})
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量用户状态修改响应DTO
|
||||
*/
|
||||
export class BatchUserStatusResponseDto {
|
||||
@ApiProperty({
|
||||
description: '请求是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应数据',
|
||||
type: BatchUserStatusDataDto,
|
||||
required: false
|
||||
})
|
||||
data?: BatchUserStatusDataDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '批量用户状态修改完成'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'BATCH_USER_STATUS_UPDATE_FAILED',
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态统计DTO
|
||||
*/
|
||||
export class UserStatusStatsDto {
|
||||
@ApiProperty({
|
||||
description: '正常用户数量',
|
||||
example: 1250
|
||||
})
|
||||
active: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '未激活用户数量',
|
||||
example: 45
|
||||
})
|
||||
inactive: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '锁定用户数量',
|
||||
example: 12
|
||||
})
|
||||
locked: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '禁用用户数量',
|
||||
example: 8
|
||||
})
|
||||
banned: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '已删除用户数量',
|
||||
example: 3
|
||||
})
|
||||
deleted: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '待审核用户数量',
|
||||
example: 15
|
||||
})
|
||||
pending: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '总用户数量',
|
||||
example: 1333
|
||||
})
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态统计响应数据DTO
|
||||
*/
|
||||
export class UserStatusStatsDataDto {
|
||||
@ApiProperty({
|
||||
description: '用户状态统计',
|
||||
type: UserStatusStatsDto
|
||||
})
|
||||
stats: UserStatusStatsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '统计时间',
|
||||
example: '2025-12-24T10:00:00.000Z'
|
||||
})
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态统计响应DTO
|
||||
*/
|
||||
export class UserStatusStatsResponseDto {
|
||||
@ApiProperty({
|
||||
description: '请求是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应数据',
|
||||
type: UserStatusStatsDataDto,
|
||||
required: false
|
||||
})
|
||||
data?: UserStatusStatsDataDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '用户状态统计获取成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'USER_STATUS_STATS_FAILED',
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
95
src/business/user-mgmt/dto/user-status.dto.ts
Normal file
95
src/business/user-mgmt/dto/user-status.dto.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 用户状态管理 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义用户状态管理相关的请求数据结构
|
||||
* - 提供数据验证规则和错误提示
|
||||
* - 确保状态管理操作的数据格式一致性
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { IsString, IsNotEmpty, IsEnum, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserStatus } from '../enums/user-status.enum';
|
||||
|
||||
/**
|
||||
* 用户状态修改请求DTO
|
||||
*/
|
||||
export class UserStatusDto {
|
||||
/**
|
||||
* 新的用户状态
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '用户状态',
|
||||
enum: UserStatus,
|
||||
example: UserStatus.ACTIVE,
|
||||
enumName: 'UserStatus'
|
||||
})
|
||||
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
|
||||
@IsNotEmpty({ message: '用户状态不能为空' })
|
||||
status: UserStatus;
|
||||
|
||||
/**
|
||||
* 状态修改原因
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '状态修改原因(可选)',
|
||||
example: '用户违反社区规定',
|
||||
required: false,
|
||||
maxLength: 200
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '修改原因必须是字符串' })
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量用户状态修改请求DTO
|
||||
*/
|
||||
export class BatchUserStatusDto {
|
||||
/**
|
||||
* 用户ID列表
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '用户ID列表',
|
||||
example: ['1', '2', '3'],
|
||||
type: [String],
|
||||
minItems: 1,
|
||||
maxItems: 100
|
||||
})
|
||||
@IsArray({ message: '用户ID列表必须是数组' })
|
||||
@ArrayMinSize(1, { message: '至少需要选择一个用户' })
|
||||
@ArrayMaxSize(100, { message: '一次最多只能操作100个用户' })
|
||||
@IsString({ each: true, message: '用户ID必须是字符串' })
|
||||
@IsNotEmpty({ each: true, message: '用户ID不能为空' })
|
||||
user_ids: string[];
|
||||
|
||||
/**
|
||||
* 新的用户状态
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '用户状态',
|
||||
enum: UserStatus,
|
||||
example: UserStatus.LOCKED,
|
||||
enumName: 'UserStatus'
|
||||
})
|
||||
@IsEnum(UserStatus, { message: '用户状态必须是有效的枚举值' })
|
||||
@IsNotEmpty({ message: '用户状态不能为空' })
|
||||
status: UserStatus;
|
||||
|
||||
/**
|
||||
* 状态修改原因
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '批量修改原因(可选)',
|
||||
example: '批量处理违规用户',
|
||||
required: false,
|
||||
maxLength: 200
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '修改原因必须是字符串' })
|
||||
reason?: string;
|
||||
}
|
||||
100
src/business/user-mgmt/enums/user-status.enum.ts
Normal file
100
src/business/user-mgmt/enums/user-status.enum.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 用户状态枚举
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义用户账户的各种状态
|
||||
* - 提供状态检查和描述功能
|
||||
* - 支持用户生命周期管理
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户状态枚举
|
||||
*
|
||||
* 状态说明:
|
||||
* - active: 正常状态,可以正常使用所有功能
|
||||
* - inactive: 未激活状态,通常是新注册用户需要邮箱验证
|
||||
* - locked: 临时锁定状态,可以解锁恢复
|
||||
* - banned: 永久禁用状态,需要管理员处理
|
||||
* - deleted: 软删除状态,数据保留但不可使用
|
||||
* - pending: 待审核状态,需要管理员审核后激活
|
||||
*/
|
||||
export enum UserStatus {
|
||||
ACTIVE = 'active', // 正常状态
|
||||
INACTIVE = 'inactive', // 未激活状态
|
||||
LOCKED = 'locked', // 锁定状态
|
||||
BANNED = 'banned', // 禁用状态
|
||||
DELETED = 'deleted', // 删除状态
|
||||
PENDING = 'pending' // 待审核状态
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态的中文描述
|
||||
*
|
||||
* @param status 用户状态
|
||||
* @returns 状态描述
|
||||
*/
|
||||
export function getUserStatusDescription(status: UserStatus): string {
|
||||
const descriptions = {
|
||||
[UserStatus.ACTIVE]: '正常',
|
||||
[UserStatus.INACTIVE]: '未激活',
|
||||
[UserStatus.LOCKED]: '已锁定',
|
||||
[UserStatus.BANNED]: '已禁用',
|
||||
[UserStatus.DELETED]: '已删除',
|
||||
[UserStatus.PENDING]: '待审核'
|
||||
};
|
||||
|
||||
return descriptions[status] || '未知状态';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否可以登录
|
||||
*
|
||||
* @param status 用户状态
|
||||
* @returns 是否可以登录
|
||||
*/
|
||||
export function canUserLogin(status: UserStatus): boolean {
|
||||
// 只有正常状态的用户可以登录
|
||||
return status === UserStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态对应的错误消息
|
||||
*
|
||||
* @param status 用户状态
|
||||
* @returns 错误消息
|
||||
*/
|
||||
export function getUserStatusErrorMessage(status: UserStatus): string {
|
||||
const errorMessages = {
|
||||
[UserStatus.ACTIVE]: '', // 正常状态无错误
|
||||
[UserStatus.INACTIVE]: '账户未激活,请先验证邮箱',
|
||||
[UserStatus.LOCKED]: '账户已被锁定,请联系管理员',
|
||||
[UserStatus.BANNED]: '账户已被禁用,请联系管理员',
|
||||
[UserStatus.DELETED]: '账户不存在',
|
||||
[UserStatus.PENDING]: '账户待审核,请等待管理员审核'
|
||||
};
|
||||
|
||||
return errorMessages[status] || '账户状态异常';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的用户状态
|
||||
*
|
||||
* @returns 用户状态数组
|
||||
*/
|
||||
export function getAllUserStatuses(): UserStatus[] {
|
||||
return Object.values(UserStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查状态值是否有效
|
||||
*
|
||||
* @param status 状态值
|
||||
* @returns 是否为有效状态
|
||||
*/
|
||||
export function isValidUserStatus(status: string): status is UserStatus {
|
||||
return Object.values(UserStatus).includes(status as UserStatus);
|
||||
}
|
||||
22
src/business/user-mgmt/index.ts
Normal file
22
src/business/user-mgmt/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 用户管理业务模块导出
|
||||
*
|
||||
* 功能概述:
|
||||
* - 用户状态管理(激活、锁定、禁用等)
|
||||
* - 批量用户操作
|
||||
* - 用户状态统计和分析
|
||||
* - 状态变更审计和历史记录
|
||||
*/
|
||||
|
||||
// 模块
|
||||
export * from './user-mgmt.module';
|
||||
|
||||
// 控制器
|
||||
export * from './controllers/user-status.controller';
|
||||
|
||||
// 服务
|
||||
export * from './services/user-management.service';
|
||||
|
||||
// DTO
|
||||
export * from './dto/user-status.dto';
|
||||
export * from './dto/user-status-response.dto';
|
||||
199
src/business/user-mgmt/services/user-management.service.ts
Normal file
199
src/business/user-mgmt/services/user-management.service.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 用户管理业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 用户状态管理业务逻辑
|
||||
* - 批量用户操作
|
||||
* - 用户状态统计
|
||||
* - 状态变更审计
|
||||
*
|
||||
* 职责分工:
|
||||
* - 专注于用户管理相关的业务逻辑
|
||||
* - 调用 AdminService 的底层方法
|
||||
* - 提供用户管理特定的业务规则
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AdminService } from '../../admin/admin.service';
|
||||
import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto';
|
||||
import {
|
||||
UserStatusResponseDto,
|
||||
BatchUserStatusResponseDto,
|
||||
UserStatusStatsResponseDto
|
||||
} from '../dto/user-status-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UserManagementService {
|
||||
private readonly logger = new Logger(UserManagementService.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
/**
|
||||
* 修改用户状态
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证状态变更的业务规则
|
||||
* 2. 记录状态变更原因
|
||||
* 3. 调用底层服务执行变更
|
||||
* 4. 记录业务审计日志
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param userStatusDto 状态修改数据
|
||||
* @returns 修改结果
|
||||
*/
|
||||
async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise<UserStatusResponseDto> {
|
||||
this.logger.log('用户管理:开始修改用户状态', {
|
||||
operation: 'user_mgmt_update_status',
|
||||
userId: userId.toString(),
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 调用底层管理员服务
|
||||
const result = await this.adminService.updateUserStatus(userId, userStatusDto);
|
||||
|
||||
// 记录业务层日志
|
||||
if (result.success) {
|
||||
this.logger.log('用户管理:用户状态修改成功', {
|
||||
operation: 'user_mgmt_update_status_success',
|
||||
userId: userId.toString(),
|
||||
newStatus: userStatusDto.status,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改用户状态
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证批量操作的业务规则
|
||||
* 2. 分批处理大量用户
|
||||
* 3. 提供批量操作的进度反馈
|
||||
* 4. 记录批量操作审计
|
||||
*
|
||||
* @param batchUserStatusDto 批量状态修改数据
|
||||
* @returns 批量修改结果
|
||||
*/
|
||||
async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise<BatchUserStatusResponseDto> {
|
||||
this.logger.log('用户管理:开始批量修改用户状态', {
|
||||
operation: 'user_mgmt_batch_update_status',
|
||||
userCount: batchUserStatusDto.user_ids.length,
|
||||
newStatus: batchUserStatusDto.status,
|
||||
reason: batchUserStatusDto.reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 业务规则:限制批量操作的数量
|
||||
if (batchUserStatusDto.user_ids.length > 100) {
|
||||
this.logger.warn('用户管理:批量操作数量超限', {
|
||||
operation: 'user_mgmt_batch_update_limit_exceeded',
|
||||
requestCount: batchUserStatusDto.user_ids.length,
|
||||
maxAllowed: 100
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '批量操作数量不能超过100个用户',
|
||||
error_code: 'BATCH_OPERATION_LIMIT_EXCEEDED'
|
||||
};
|
||||
}
|
||||
|
||||
// 调用底层管理员服务
|
||||
const result = await this.adminService.batchUpdateUserStatus(batchUserStatusDto);
|
||||
|
||||
// 记录业务层日志
|
||||
if (result.success) {
|
||||
this.logger.log('用户管理:批量用户状态修改完成', {
|
||||
operation: 'user_mgmt_batch_update_status_success',
|
||||
successCount: result.data?.result.success_count || 0,
|
||||
failedCount: result.data?.result.failed_count || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态统计
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 获取基础统计数据
|
||||
* 2. 计算业务相关的指标
|
||||
* 3. 提供状态分布分析
|
||||
* 4. 缓存统计结果
|
||||
*
|
||||
* @returns 状态统计信息
|
||||
*/
|
||||
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
|
||||
this.logger.log('用户管理:获取用户状态统计', {
|
||||
operation: 'user_mgmt_get_status_stats',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 调用底层管理员服务
|
||||
const result = await this.adminService.getUserStatusStats();
|
||||
|
||||
// 业务层可以在这里添加额外的统计分析
|
||||
if (result.success && result.data) {
|
||||
const stats = result.data.stats;
|
||||
|
||||
// 计算业务指标
|
||||
const activeRate = stats.total > 0 ? (stats.active / stats.total * 100).toFixed(2) : '0';
|
||||
const problemUserCount = stats.locked + stats.banned + stats.deleted;
|
||||
|
||||
this.logger.log('用户管理:用户状态统计分析', {
|
||||
operation: 'user_mgmt_status_analysis',
|
||||
totalUsers: stats.total,
|
||||
activeUsers: stats.active,
|
||||
activeRate: `${activeRate}%`,
|
||||
problemUsers: problemUserCount,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态变更历史
|
||||
*
|
||||
* 业务功能:
|
||||
* - 查询指定用户的状态变更记录
|
||||
* - 提供状态变更的审计追踪
|
||||
* - 支持时间范围查询
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param limit 返回数量限制
|
||||
* @returns 状态变更历史
|
||||
*/
|
||||
async getUserStatusHistory(userId: bigint, limit: number = 10) {
|
||||
this.logger.log('用户管理:获取用户状态变更历史', {
|
||||
operation: 'user_mgmt_get_status_history',
|
||||
userId: userId.toString(),
|
||||
limit,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// TODO: 实现状态变更历史查询
|
||||
// 这里可以调用专门的审计日志服务
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user_id: userId.toString(),
|
||||
history: [] as any[],
|
||||
total_count: 0
|
||||
},
|
||||
message: '状态变更历史获取成功(功能待实现)'
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/business/user-mgmt/user-mgmt.module.ts
Normal file
30
src/business/user-mgmt/user-mgmt.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 用户管理业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合用户状态管理相关的所有组件
|
||||
* - 提供用户生命周期管理功能
|
||||
* - 支持批量操作和状态统计
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 AdminModule 提供底层管理功能
|
||||
* - 依赖 Core 模块提供基础设施
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-24
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserStatusController } from './controllers/user-status.controller';
|
||||
import { UserManagementService } from './services/user-management.service';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||
|
||||
@Module({
|
||||
imports: [AdminModule, AdminCoreModule],
|
||||
controllers: [UserStatusController],
|
||||
providers: [UserManagementService],
|
||||
exports: [UserManagementService],
|
||||
})
|
||||
export class UserMgmtModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user