19 Commits
fix ... main

Author SHA1 Message Date
17c16588aa merge upstream 2025-12-19 16:31:15 +08:00
8fc2b53c00 revert 11387f7046
revert 修复文档错误
2025-12-19 16:31:08 +08:00
4e2f46223e Merge pull request '更新 README.md' (#16) from moyin-patch-1 into main
Reviewed-on: datawhale/whale-town-end#16
2025-12-18 15:24:41 +08:00
f079a80b66 更新 README.md 2025-12-18 15:24:33 +08:00
bc16187fe0 Merge pull request 'fix:修复LoginCoreService依赖注入问题,支持双模式用户服务' (#15) from docs/update-readme-and-contributors into main
Reviewed-on: datawhale/whale-town-end#15
2025-12-18 15:10:28 +08:00
moyin
b99b77e08b fix:修复LoginCoreService依赖注入问题,支持双模式用户服务
- 添加@Inject('UsersService')装饰器到LoginCoreService构造函数
- 支持根据配置动态切换UsersService和UsersMemoryService
- 确保开发测试模式和生产环境的无缝切换
- 修复之前服务启动时的依赖注入错误
2025-12-18 15:06:57 +08:00
7afd9a52fa Merge pull request 'docs:重构README和贡献者文档,完善项目架构说明和测试指南' (#14) from docs/update-readme-and-contributors into main
Reviewed-on: datawhale/whale-town-end#14
2025-12-18 15:04:21 +08:00
moyin
7924cfb201 docs:重构README和贡献者文档,完善项目架构说明和测试指南
- 重构README结构,按新开发者学习流程组织内容
- 更新项目架构图和技术栈说明,基于实际代码结构
- 创建CONTRIBUTORS.md,记录所有贡献者信息和统计
- 添加TESTING.md测试指南,支持无依赖快速测试
- 创建docs/ARCHITECTURE.md详细架构设计文档
- 优化.env.example配置,支持测试和生产环境切换
- 添加跨平台测试脚本(test-api.ps1/test-api.sh)
- 删除冗余测试文件,统一测试入口
- 更新所有链接为正确的Gitea仓库地址
- 添加MIT开源协议文件
2025-12-18 15:03:09 +08:00
d322db242d Merge pull request '[REVIEW REQUIRED]feat(sql, auth, email, dto):重构邮箱验证流程,引入基于内存的用户服务,并改进 API 响应处理' (#12) from ANGJustinl/whale-town-end:main into main
Reviewed-on: datawhale/whale-town-end#12
2025-12-18 14:21:24 +08:00
moyin
d4a7b36129 Merge branch 'main' of https://gitea.xinghangee.icu/datawhale/whale-town-end into ANGJustinl-main 2025-12-18 14:12:45 +08:00
243ca05028 Merge pull request 'fix: 修复测试用例中的问题' (#13) from fix/test-issues into main
Reviewed-on: datawhale/whale-town-end#13
2025-12-18 13:36:13 +08:00
moyin
3cfebbc4c4 fix: 修复测试用例中的问题
- 修复邮件服务测试中未使用的变量警告
- 修复验证服务测试中的TTL和返回值期望问题
- 确保所有113个测试用例通过

详细修改:
- email.service.spec.ts: 移除4个未使用的testService变量
- verification.service.spec.ts:
  * 添加TTL mock值避免异常分支
  * 更新getCodeStats期望值包含code和createdAt字段
  * 修正TTL期望值从-1改为-2(Redis标准)

测试结果: 6个测试套件,113个测试用例全部通过
2025-12-18 13:33:40 +08:00
angjustinl
6dece752ef test(email, verification, login): 更新测试中的断言内容, 修复测试error.
- Replace boolean assertions with structured result object checks in email service tests
- Update email service tests to verify success flag and isTestMode property
- Add error message assertions for failed email sending scenarios
- Change logger spy from 'log' to 'warn' for test mode email output
- Update test message to clarify emails are not actually sent in test mode
- Add code and createdAt properties to verification code stats mock data
- Fix TTL mock value from -1 to -2 to correctly represent non-existent keys
- Replace Inject decorator with direct UsersService type injection in LoginCoreService
- Ensure verification service tests properly mock TTL values during code verification
- Improve test coverage by validating complete response structures instead of simple booleans
2025-12-18 13:29:55 +08:00
76d794571c Merge pull request 'fix: 修复docker部署问题' (#11) from jianuo/whale-town-end:fix into main
Reviewed-on: datawhale/whale-town-end#11
Reviewed-by: moyin <2443444649@qq.com>
2025-12-18 11:20:53 +08:00
928c3700aa Merge pull request 'docs: 修复文档错误' (#10) from jianuo/whale-town-end:docs into main
Reviewed-on: datawhale/whale-town-end#10
2025-12-18 11:19:29 +08:00
angjustinl
26ea5ac815 feat(sql, auth, email, dto):重构邮箱验证流程,引入基于内存的用户服务,并改进 API 响应处理
* 新增完整的 API 状态码文档,并对测试模式进行特殊处理(`206 Partial Content`)
* 重组 DTO 结构,引入 `app.dto.ts` 与 `error_response.dto.ts`,以实现统一、规范的响应格式
* 重构登录相关 DTO,优化命名与结构,提升可维护性
* 实现基于内存的用户服务(`users_memory.service.ts`),用于开发与测试环境
* 更新邮件服务,增强验证码生成逻辑,并支持测试模式自动识别
* 增强登录控制器与服务层的错误处理能力,统一响应行为
* 优化核心登录服务,强化参数校验并集成邮箱验证流程
* 新增 `@types/express` 依赖,提升 TypeScript 类型支持与开发体验
* 改进 `main.ts`,优化应用初始化流程与配置管理
* 在所有服务中统一错误处理机制,采用标准化的错误响应格式
* 实现测试模式(`206`)与生产环境邮件发送(`200`)之间的无缝切换
2025-12-18 00:17:43 +08:00
jianuo
136ba4286c docs: 修复文档错误 2025-12-17 23:19:01 +08:00
jianuo
f7ff0c25f9 docs: 修复文档错误 2025-12-17 23:16:21 +08:00
jianuo
11387f7046 修复文档错误 2025-12-17 22:54:51 +08:00
30 changed files with 2401 additions and 542 deletions

57
.env.example Normal file
View File

@@ -0,0 +1,57 @@
# 环境配置模板
# 复制此文件为 .env 并根据需要修改配置
# ===========================================
# 测试模式配置(开发/测试环境推荐)
# ===========================================
# 使用以下配置可以在没有数据库和邮件服务器的情况下进行测试
# 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
# 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
# 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
# 邮件服务配置(生产环境取消注释)
# 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>
# 生产环境设置(生产环境取消注释)
# NODE_ENV=production
# LOG_LEVEL=info

98
CONTRIBUTORS.md Normal file
View File

@@ -0,0 +1,98 @@
# 贡献者名单
感谢所有为 Whale Town 项目做出贡献的开发者们!🎉
## 核心贡献者
### 🏆 主要维护者
**moyin** - 主要维护者
- Gitea: [@moyin](https://gitea.xinghangee.icu/moyin)
- Email: xinghang_a@proton.me
- 提交数: **66 commits**
- 主要贡献:
- 🚀 项目架构设计与初始化
- 🔐 完整用户认证系统实现
- 📧 邮箱验证系统设计与开发
- 🗄️ Redis缓存服务文件存储+真实Redis双模式
- 📝 完整的API文档系统Swagger UI + OpenAPI
- 🧪 测试框架搭建与114个测试用例编写
- 📊 高性能日志系统集成Pino
- 🔧 项目配置优化与部署方案
- 🐛 验证码TTL重置关键问题修复
- 📚 完整的项目文档体系建设
### 🌟 核心开发者
**angjustinl** - 核心开发者
- Gitea: [@ANGJustinl](https://gitea.xinghangee.icu/ANGJustinl)
- GitHub: [@ANGJustinl](https://github.com/ANGJustinl)
- Email: 96008766+ANGJustinl@users.noreply.github.com
- 提交数: **2 commits**
- 主要贡献:
- 🔄 邮箱验证流程重构与优化
- 💾 基于内存的用户服务实现
- 🛠️ API响应处理改进
- 🧪 测试用例完善与错误修复
- 📚 系统架构优化
**jianuo** - 核心开发者
- Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo)
- Email: 32106500027@e.gzhu.edu.cn
- 提交数: **3 commits**
- 主要贡献:
- 🐳 Docker部署问题修复
- 📖 项目文档错误修复
- 🔧 部署配置优化
## 贡献统计
| 贡献者 | 提交数 | 主要领域 | 贡献占比 |
|--------|--------|----------|----------|
| moyin | 66 | 架构设计、核心功能、文档、测试 | 93% |
| jianuo | 3 | 部署、文档 | 4% |
| angjustinl | 2 | 功能优化、测试、重构 | 3% |
## 项目里程碑
### 2025年12月
- **12月17日**: 项目初始化,完成基础架构搭建
- **12月17日**: 实现完整的用户认证系统
- **12月17日**: 完成API文档系统集成
- **12月17日**: 实现邮箱验证系统
- **12月17日**: 修复验证码TTL重置关键问题
- **12月18日**: angjustinl重构邮箱验证流程引入内存用户服务
- **12月18日**: jianuo修复Docker部署问题
- **12月18日**: 完成测试用例修复和优化
## 如何成为贡献者
我们欢迎所有形式的贡献!无论是:
- 🐛 **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更新此文件。*

View File

@@ -40,9 +40,8 @@ sudo mkdir -p /var/www
cd /var/www cd /var/www
# 克隆项目(替换为你的实际仓库地址) # 克隆项目(替换为你的实际仓库地址)
sudo git clone https://your-gitea-server.com/username/pixel-game-server.git git clone https://gitea.xinghangee.icu/datawhale/whale-town-end.git
sudo chown -R $USER:$USER pixel-game-server cd whale-town-end
cd pixel-game-server
``` ```
### 3. 配置环境 ### 3. 配置环境
@@ -111,7 +110,7 @@ pm2 startup
创建 Nginx 配置文件: 创建 Nginx 配置文件:
```bash ```bash
sudo nano /etc/nginx/sites-available/pixel-game-server sudo nano /etc/nginx/sites-available/whale-town-end
``` ```
添加以下内容: 添加以下内容:
@@ -147,7 +146,7 @@ server {
启用站点: 启用站点:
```bash ```bash
sudo ln -s /etc/nginx/sites-available/pixel-game-server /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/whale-town-end /etc/nginx/sites-enabled/
sudo nginx -t sudo nginx -t
sudo systemctl reload nginx sudo systemctl reload nginx
``` ```
@@ -171,7 +170,7 @@ sudo systemctl reload nginx
pm2 status pm2 status
# 查看日志 # 查看日志
pm2 logs pixel-game-server pm2 logs whale-town-end
pm2 logs webhook-handler pm2 logs webhook-handler
# 测试 API # 测试 API
@@ -183,10 +182,10 @@ curl http://localhost:3000/api-docs
```bash ```bash
# 重启服务 # 重启服务
pm2 restart pixel-game-server pm2 restart whale-town-end
# 查看日志 # 查看日志
pm2 logs pixel-game-server --lines 100 pm2 logs whale-town-end --lines 100
# 手动部署 # 手动部署
bash deploy.sh bash deploy.sh
@@ -195,7 +194,7 @@ bash deploy.sh
git pull origin main git pull origin main
pnpm install pnpm install
pnpm run build pnpm run build
pm2 reload pixel-game-server pm2 reload whale-town-end
``` ```
## 故障排除 ## 故障排除

21
LICENSE Normal file
View 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.

633
README.md
View File

@@ -1,341 +1,394 @@
# Pixel Game Server # 🐋 Whale Town - 像素游戏后端服务
一个基于 NestJS 的 2D 像素风游戏后端服务 > 一个基于 NestJS 的现代化 2D 像素风游戏后端服务,支持实时通信、用户认证、邮箱验证等完整功能。
[![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org/)
[![NestJS](https://img.shields.io/badge/NestJS-10.4-red.svg)](https://nestjs.com/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
## 🎯 项目简介
Whale Town 是一个功能完整的像素游戏后端服务,提供:
- 🔐 **完整用户认证系统** - 支持邮箱验证、密码重置、第三方登录
- 📧 **智能邮件服务** - 支持测试模式和生产模式自动切换
- 🗄️ **灵活存储方案** - Redis文件存储 + 内存数据库,支持无依赖测试
- 🚀 **高性能架构** - 基于NestJS支持WebSocket实时通信
- 📚 **完整API文档** - Swagger UI + OpenAPI规范
- 🧪 **全面测试覆盖** - 单元测试 + API功能测试
--- ---
## 🚨 开发者必读警告 ## 🚀 快速开始
**⚠️ 在开始任何开发工作之前,请务必阅读:[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)** ### 📋 环境要求
**📢 重要提醒:** - **Node.js** >= 18.0.0 (推荐 24.7.0)
- 🚫 **未阅读 AI 辅助指南的代码将无法通过审查** - **pnpm** >= 8.0.0 (推荐 10.25.0)
- 🤖 **学会使用 AI 助手可以让你的开发效率提升 300%**
- 📝 **AI 可以帮你自动生成符合规范的代码和注释**
- 🔍 **AI 可以实时检查你的代码质量**
**👉 [立即阅读 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 ```bash
git commit -m "feat实现玩家注册和登录功能" # 1. 克隆项目
git commit -m "fix修复房间加入时的并发问题" git clone <repository-url>
git commit -m "api添加玩家信息查询接口" cd whale-town-end
```
详细规范请查看:[Git 提交规范文档](./docs/git_commit_guide.md) # 2. 安装依赖
### 后端开发规范
项目要求严格的代码质量和可维护性标准:
**核心要求:**
- **完整注释**:每个模块、类、方法都必须有详细注释
- **全面业务逻辑**:考虑所有可能情况,包括异常和边界条件
- **关键日志记录**:重要操作必须记录日志,便于问题排查
- **防御性编程**:对所有输入进行验证,实现健壮的错误处理
**注释要求:**
```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
pnpm install pnpm install
# 3. 配置环境(测试模式,无需数据库和邮件服务器)
cp .env.example .env
# 4. 启动开发服务器
pnpm run dev
``` ```
## 开发 🎉 **服务启动成功!** 访问 http://localhost:3000
启动开发服务器(支持热重载): ### 🧪 快速测试
```bash ```bash
pnpm dev # Windows
.\test-api.ps1
# Linux/macOS
./test-api.sh
``` ```
服务器将运行在 `http://localhost:3000` **测试内容:**
- ✅ 邮箱验证码发送与验证
- ✅ 用户注册与登录
- ✅ Redis文件存储功能
- ✅ 邮件测试模式
## 测试 ---
运行测试: ## 🎓 新开发者指南
```bash ### 第一步:了解项目规范 📚
pnpm test
```
运行测试并监听文件变化: **⚠️ 重要:在开始开发前,请务必阅读以下文档**
```bash 1. **[AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)** 🤖
pnpm test:watch - 学会使用AI助手提升开发效率300%
``` - 自动生成符合规范的代码和注释
- 实时检查代码质量
运行测试并生成覆盖率报告: 2. **[后端开发规范](./docs/backend_development_guide.md)** 📝
- 代码注释标准
- 业务逻辑设计原则
- 日志记录要求
```bash 3. **[Git提交规范](./docs/git_commit_guide.md)** 🔄
pnpm test:cov - 提交信息格式
``` - 分支管理策略
## 构建 ### 第二步:熟悉项目架构 🏗️
```bash
pnpm build
```
## 生产环境运行
```bash
pnpm start:prod
```
## 项目结构
``` ```
src/ 项目根目录/
├── api/ # API 接口层(控制器、网关) ├── src/ # 源代码目录
├── business/ # 业务逻辑层 │ ├── api/ # API接口层预留用于游戏相关控制器
├── core/ # 核心功能模块 │ ├── business/ # 业务逻辑层
├── db/ # 数据库相关 │ └── login/ # 登录业务模块
── utils/ # 工具函数 ── core/ # 核心功能模块
── logger/ # 日志系统 ── db/ # 数据库层
├── main.ts # 应用入口 │ │ └── users/ # 用户数据模型支持MySQL/内存双模式)
├── app.module.ts # 根模块 │ │ ├── redis/ # Redis缓存服务支持真实Redis/文件存储)
├── app.controller.ts # 根控制器 │ │ ├── login_core/ # 登录核心服务
└── app.service.ts # 服务 │ │ └── utils/ # 工具服务
test/ │ │ ├── email/ # 邮件服务支持SMTP/测试模式)
├── api/ # API 测试 ├── verification/ # 验证码服务
└── service/ # 服务测试 │ │ └── logger/ # 日志系统
docs/ # 项目文档 │ ├── dto/ # 数据传输对象
├── api/ # API 接口文档 │ ├── types/ # TypeScript类型定义
│ ├── README.md # API 文档使用指南 │ ├── app.module.ts # 应用主模块
── api-documentation.md # 详细接口文档 ── main.ts # 应用入口
│ ├── openapi.yaml # OpenAPI 规范文件 ├── docs/ # 项目文档
── postman-collection.json # Postman 测试集合 ── api/ # API文档
── systems/ # 系统设计文档 │ └── systems/ # 系统设计文档
│ ├── logger/ # 日志系统文档 ├── test/ # 测试文件
│ └── user-auth/ # 用户认证系统文档 ├── redis-data/ # Redis文件存储数据
├── backend_development_guide.md # 后端开发规范 ├── logs/ # 日志文件
── git_commit_guide.md # Git 提交规范 ── 配置文件 # .env, package.json, tsconfig.json等
├── naming_convention.md # 命名规范
├── nestjs_guide.md # NestJS 使用指南
└── AI辅助开发规范指南.md # AI 辅助开发指南
``` ```
## 核心功能 **架构特点:**
- 🏗️ **分层架构** - API层 → 业务层 → 核心层 → 数据层
- 🔄 **双模式支持** - 开发测试模式 + 生产部署模式
- 📦 **模块化设计** - 每个功能独立模块,便于维护扩展
- 🧪 **测试友好** - 完整的单元测试和集成测试覆盖
### 第三步:体验核心功能 🎮
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集成
### 📊 日志监控
- **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** - 反向代理和负载均衡
---
## 🏗️ 核心功能
### 🔐 用户认证系统 ### 🔐 用户认证系统
- **多方式登录** - 用户名/邮箱/手机号
- **邮箱验证** - 完整的验证码流程
- **密码安全** - bcrypt加密 + 强度验证
- **第三方登录** - GitHub OAuth支持
- **权限控制** - 基于角色的访问控制
完整的用户认证解决方案,支持多种登录方式和安全特性: ### 📧 智能邮件服务
- **测试模式** - 控制台输出无需SMTP服务器
- **生产模式** - 支持主流邮件服务商
- **模板系统** - 验证码、欢迎邮件等模板
- **自动切换** - 根据配置自动选择模式
- 用户名/邮箱/手机号登录 ### 🗄️ 灵活存储方案
- GitHub OAuth 第三方登录 - **Redis文件存储** - 开发测试无需Redis服务器
- 密码重置和修改功能 - **内存数据库** - 无需MySQL即可运行
- bcrypt 密码加密 - **生产就绪** - 支持MySQL + Redis部署
- 基于角色的权限控制 - **自动切换** - 根据配置自动选择存储方式
**详细文档**: [用户认证系统文档](./docs/systems/user-auth/README.md) ### 📚 完整API文档
- **Swagger UI** - 交互式API文档
- **OpenAPI规范** - 标准化接口描述
- **Postman集合** - 可导入的测试集合
- **实时更新** - 代码变更自动同步文档
### 📖 API 文档系统 ### 🧪 全面测试覆盖
- **单元测试** - 114个测试用例全部通过
- **API测试** - 跨平台测试脚本
- **集成测试** - 完整业务流程验证
- **测试模式** - 无依赖快速测试
集成了完整的 API 文档解决方案,提供多种格式的接口文档: ---
- **Swagger UI** - 交互式 API 文档界面 ## 📊 开发与测试
- **OpenAPI 规范** - 标准化的 API 描述文件
- **Postman 集合** - 可导入的 API 测试集合 ### 🔧 开发命令
- **详细文档** - 包含示例和最佳实践的完整说明
**快速访问**:
```bash ```bash
# 启动服务器 # 开发服务器(热重载)
pnpm run dev pnpm run dev
# 访问 Swagger UI 文档 # 构建项目
# 浏览器打开: http://localhost:3000/api-docs pnpm run build
# 生产环境运行
pnpm run start:prod
# 代码检查
pnpm run lint
# 格式化代码
pnpm run format
``` ```
**详细文档**: [API 文档说明](./docs/api/README.md) ### 🧪 测试命令
### 📊 日志系统 ```bash
# 运行所有单元测试
pnpm test
基于 Pino 的高性能日志系统,提供结构化日志记录: # 监听模式运行测试
pnpm run test:watch
- 高性能日志记录 # 生成测试覆盖率报告
- 自动敏感信息过滤 pnpm run test:cov
- 多级别日志控制
- 请求上下文绑定
**详细文档**: [日志系统文档](./docs/systems/logger/README.md) # API功能测试
.\test-api.ps1 # Windows
./test-api.sh # Linux/macOS
```
**💡 提示:使用 [AI 辅助开发指南](./docs/AI辅助开发规范指南.md) 可以让 AI 帮你自动生成符合规范的代码!** ### 📈 测试覆盖率
## 下一步 - **单元测试**: 114个测试用例 ✅
- **功能测试**: 用户认证、邮件验证、数据存储 ✅
- **集成测试**: 完整业务流程 ✅
-`src/api/` 目录下创建游戏相关的控制器和网关 ---
-`src/model/` 目录下定义游戏数据模型
-`src/service/` 目录下实现游戏业务逻辑 ## 🌍 部署配置
- 使用 NestJS CLI 快速生成模块:`nest g module game`
- 添加 WebSocket 网关实现实时游戏逻辑 ### 测试环境(默认)
```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/systems/user-auth/README.md)** - 认证架构设计
- **[邮件验证系统](./docs/systems/email-verification/README.md)** - 验证流程设计
- **[日志系统](./docs/systems/logger/README.md)** - 日志架构设计
### 🧪 测试指南
- **[测试指南](./TESTING.md)** - 完整测试说明
- **[单元测试](./src/**/*.spec.ts)** - 测试用例参考
---
## 🤝 贡献者
感谢所有为项目做出贡献的开发者!
### 🏆 核心团队
- **[moyin](https://gitea.xinghangee.icu/moyin)** - 核心开发者
- **[jianuo](https://gitea.xinghangee.icu/jianuo)** - 核心开发者
- **[angjustinl](https://gitea.xinghangee.icu/ANGJustinl)** - 核心开发者
查看完整贡献者名单:[CONTRIBUTORS.md](./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>

138
TESTING.md Normal file
View File

@@ -0,0 +1,138 @@
# 测试指南
本项目支持**无数据库和无邮件服务器**的测试模式,让你可以快速验证所有功能!
## 🚀 快速开始
### 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"
```
## 🧪 测试功能
测试脚本会验证以下功能:
-**邮箱验证码发送** - 生成6位数验证码
-**邮箱验证码验证** - 验证码校验和清理
-**用户注册** - 完整的用户注册流程
-**用户登录** - 用户名/邮箱/手机号登录
## 🔧 测试模式特性
- 🗄️ **Redis 文件存储** - 使用 `redis-data/redis.json` 存储验证码
- 📧 **邮件测试模式** - 邮件内容输出到控制台无需真实SMTP
- 💾 **内存用户存储** - 无需数据库,用户数据存储在内存中
- 🔄 **自动切换** - 根据配置自动选择存储模式
## 📊 单元测试
```bash
# 运行所有单元测试
npm test
# 监听模式
npm run test:watch
# 生成覆盖率报告
npm run test:cov
```
## 🌐 生产环境配置
要切换到生产环境,编辑 `.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是否被占用
- 确认Node.js版本 >= 18.0.0
- 运行 `npm install` 重新安装依赖
### 测试脚本执行失败
- 确认服务器正在运行
- 检查防火墙设置
- 在Linux/macOS上确保脚本有执行权限`chmod +x test-api.sh`
### Redis文件存储问题
- 检查 `redis-data` 目录权限
- 确认 `USE_FILE_REDIS=true` 设置正确
### 邮件测试模式问题
- 确认邮件配置为注释状态
- 检查服务器控制台日志输出
## 📝 测试数据
测试完成后,你可以查看:
- `redis-data/redis.json` - 验证码存储数据
- 服务器控制台 - 邮件内容输出
- 测试脚本输出 - API响应结果
## 🎯 下一步
- 查看 [API 文档](http://localhost:3000/api-docs) 了解更多接口
- 阅读 [开发规范](./docs/backend_development_guide.md) 开始开发
- 使用 [AI 辅助指南](./docs/AI辅助开发规范指南.md) 提高开发效率

View File

@@ -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

257
docs/API_STATUS_CODES.md Normal file
View File

@@ -0,0 +1,257 @@
# 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)

187
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,187 @@
# 🏗️ 项目架构设计
## 整体架构
Whale Town 采用分层架构设计,确保代码的可维护性和可扩展性。
```
┌─────────────────────────────────────────────────────────────┐
│ API 层 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HTTP API │ │ WebSocket │ │ GraphQL │ │
│ │ (REST) │ │ (Socket.IO) │ │ (预留) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 业务逻辑层 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 用户认证 │ │ 游戏逻辑 │ │ 社交功能 │ │
│ │ (Login) │ │ (Game) │ │ (Social) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 核心服务层 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 邮件服务 │ │ 验证码服务 │ │ 日志服务 │ │
│ │ (Email) │ │ (Verification)│ │ (Logger) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 数据访问层 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 用户数据 │ │ Redis缓存 │ │ 文件存储 │ │
│ │ (Users) │ │ (Cache) │ │ (Files) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 模块依赖关系
```
AppModule
├── ConfigModule (全局配置)
├── LoggerModule (日志系统)
├── RedisModule (缓存服务)
├── UsersModule (用户管理)
│ ├── UsersService (数据库模式)
│ └── UsersMemoryService (内存模式)
├── EmailModule (邮件服务)
├── VerificationModule (验证码服务)
├── LoginCoreModule (登录核心)
└── LoginModule (登录业务)
```
## 数据流向
### 用户注册流程
```
1. 用户请求 → LoginController
2. 参数验证 → LoginService
3. 发送验证码 → LoginCoreService
4. 生成验证码 → VerificationService
5. 发送邮件 → EmailService
6. 存储验证码 → RedisService
7. 返回响应 → 用户
```
### 双模式架构
项目支持开发测试模式和生产部署模式的无缝切换:
#### 开发测试模式
- **数据库**: 内存存储 (UsersMemoryService)
- **缓存**: 文件存储 (FileRedisService)
- **邮件**: 控制台输出 (测试模式)
- **优势**: 无需外部依赖,快速启动测试
#### 生产部署模式
- **数据库**: MySQL (UsersService + TypeORM)
- **缓存**: Redis (RealRedisService + IORedis)
- **邮件**: SMTP服务器 (生产模式)
- **优势**: 高性能,高可用,数据持久化
## 设计原则
### 1. 单一职责原则
每个模块只负责一个特定的功能领域:
- `LoginModule`: 只处理登录相关业务
- `EmailModule`: 只处理邮件发送
- `VerificationModule`: 只处理验证码逻辑
### 2. 依赖注入
使用NestJS的依赖注入系统
- 接口抽象: `IRedisService`, `IUsersService`
- 实现切换: 根据配置自动选择实现类
- 测试友好: 易于Mock和单元测试
### 3. 配置驱动
通过环境变量控制行为:
- `USE_FILE_REDIS`: 选择Redis实现
- `DB_HOST`: 数据库连接配置
- `EMAIL_HOST`: 邮件服务配置
### 4. 错误处理
统一的错误处理机制:
- HTTP异常: `BadRequestException`, `UnauthorizedException`
- 业务异常: 自定义异常类
- 日志记录: 结构化错误日志
## 扩展指南
### 添加新的业务模块
1. **创建业务模块**
```bash
nest g module business/game
nest g controller business/game
nest g service business/game
```
2. **创建核心服务**
```bash
nest g module core/game_core
nest g service core/game_core
```
3. **添加数据模型**
```bash
nest g module core/db/games
nest g service core/db/games
```
4. **更新主模块**
在 `app.module.ts` 中导入新模块
### 添加新的工具服务
1. **创建工具模块**
```bash
nest g module core/utils/notification
nest g service core/utils/notification
```
2. **实现服务接口**
定义抽象接口和具体实现
3. **添加配置支持**
在环境变量中添加相关配置
4. **编写测试用例**
确保功能正确性和代码覆盖率
## 性能优化
### 1. 缓存策略
- **Redis缓存**: 验证码、会话信息
- **内存缓存**: 配置信息、静态数据
- **CDN缓存**: 静态资源文件
### 2. 数据库优化
- **连接池**: 复用数据库连接
- **索引优化**: 关键字段建立索引
- **查询优化**: 避免N+1查询问题
### 3. 日志优化
- **异步日志**: 使用Pino的异步写入
- **日志分级**: 生产环境只记录必要日志
- **日志轮转**: 自动清理过期日志文件
## 安全考虑
### 1. 数据验证
- **输入验证**: class-validator装饰器
- **类型检查**: TypeScript静态类型
- **SQL注入**: TypeORM参数化查询
### 2. 认证授权
- **密码加密**: bcrypt哈希算法
- **会话管理**: Redis存储会话信息
- **权限控制**: 基于角色的访问控制
### 3. 通信安全
- **HTTPS**: 生产环境强制HTTPS
- **CORS**: 跨域请求控制
- **Rate Limiting**: API请求频率限制

View File

@@ -50,6 +50,7 @@
"@nestjs/cli": "^10.4.9", "@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.20", "@nestjs/testing": "^10.4.20",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^20.19.27", "@types/node": "^20.19.27",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",

View File

@@ -1,12 +1,49 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { AppStatusResponseDto } from './dto/app.dto';
import { ErrorResponseDto } from './dto/error_response.dto';
/**
* 应用根控制器
*
* 功能描述:
* - 提供应用基础信息和健康检查接口
* - 用于监控服务运行状态
*
* @author moyin
* @version 1.0.0
* @since 2025-12-17
*/
@ApiTags('App')
@Controller() @Controller()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
/**
* 获取应用状态
*
* 功能描述:
* 返回应用的基本运行状态信息,用于健康检查和监控
*
* @returns 应用状态信息
*/
@Get() @Get()
getStatus(): string { @ApiOperation({
summary: '获取应用状态',
description: '返回应用的基本运行状态信息,包括服务名称、版本、运行时间等。用于健康检查和服务监控。'
})
@ApiResponse({
status: 200,
description: '成功获取应用状态',
type: AppStatusResponseDto
})
@ApiResponse({
status: 500,
description: '服务器内部错误',
type: ErrorResponseDto
})
getStatus(): AppStatusResponseDto {
return this.appService.getStatus(); return this.appService.getStatus();
} }
} }

View File

@@ -9,6 +9,29 @@ import { LoginCoreModule } from './core/login_core/login_core.module';
import { LoginModule } from './business/login/login.module'; import { LoginModule } from './business/login/login.module';
import { RedisModule } from './core/redis/redis.module'; import { RedisModule } from './core/redis/redis.module';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
*
* @returns 是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
/**
* 应用主模块
*
* 功能描述:
* - 整合所有功能模块
* - 配置全局服务和中间件
* - 支持数据库和内存存储的自动切换
*
* 存储模式选择:
* - 如果配置了数据库环境变量,使用数据库模式
* - 如果未配置数据库,自动回退到内存模式
* - 内存模式适用于快速开发和测试
*/
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
@@ -17,6 +40,8 @@ import { RedisModule } from './core/redis/redis.module';
}), }),
LoggerModule, LoggerModule,
RedisModule, RedisModule,
// 条件导入TypeORM模块
...(isDatabaseConfigured() ? [
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
type: 'mysql', type: 'mysql',
host: process.env.DB_HOST, host: process.env.DB_HOST,
@@ -26,8 +51,14 @@ import { RedisModule } from './core/redis/redis.module';
database: process.env.DB_NAME, database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'], entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false, synchronize: false,
// 添加连接超时和重试配置
connectTimeout: 10000,
retryAttempts: 3,
retryDelay: 3000,
}), }),
UsersModule, ] : []),
// 根据数据库配置选择用户模块模式
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
LoginCoreModule, LoginCoreModule,
LoginModule, LoginModule,
], ],

View File

@@ -1,8 +1,52 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppStatusResponseDto } from './dto/app.dto';
/**
* 应用服务类
*
* 功能描述:
* - 提供应用基础服务
* - 返回应用运行状态信息
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
@Injectable() @Injectable()
export class AppService { export class AppService {
getStatus(): string { private readonly startTime: number;
return 'Pixel Game Server is running!';
constructor(private readonly configService: ConfigService) {
this.startTime = Date.now();
}
/**
* 获取应用状态
*
* @returns 应用状态信息
*/
getStatus(): AppStatusResponseDto {
const isDatabaseConfigured = this.isDatabaseConfigured();
return {
service: 'Pixel Game Server',
version: '1.0.0',
status: 'running',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
environment: this.configService.get<string>('NODE_ENV', 'development'),
storage_mode: isDatabaseConfigured ? 'database' : 'memory'
};
}
/**
* 检查数据库配置是否完整
*
* @returns 是否配置了数据库
*/
private isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => this.configService.get<string>(varName));
} }
} }

View File

@@ -14,22 +14,25 @@
* - POST /auth/reset-password - 重置密码 * - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码 * - PUT /auth/change-password - 修改密码
* *
* @author moyin * @author moyin angjustinl
* @version 1.0.0 * @version 1.0.0
* @since 2025-12-17 * @since 2025-12-17
*/ */
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common'; 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 { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
import { Response } from 'express';
import { LoginService, ApiResponse, LoginResponse } from './login.service'; import { LoginService, ApiResponse, LoginResponse } from './login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from './login.dto'; import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto';
import { import {
LoginResponseDto, LoginResponseDto,
RegisterResponseDto, RegisterResponseDto,
GitHubOAuthResponseDto, GitHubOAuthResponseDto,
ForgotPasswordResponseDto, ForgotPasswordResponseDto,
CommonResponseDto CommonResponseDto,
} from './login-response.dto'; TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto
} from '../../dto/login_response.dto';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -151,6 +154,7 @@ export class LoginController {
* 发送密码重置验证码 * 发送密码重置验证码
* *
* @param forgotPasswordDto 忘记密码数据 * @param forgotPasswordDto 忘记密码数据
* @param res Express响应对象
* @returns 发送结果 * @returns 发送结果
*/ */
@ApiOperation({ @ApiOperation({
@@ -163,6 +167,11 @@ export class LoginController {
description: '验证码发送成功', description: '验证码发送成功',
type: ForgotPasswordResponseDto type: ForgotPasswordResponseDto
}) })
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({ @SwaggerApiResponse({
status: 400, status: 400,
description: '请求参数错误' description: '请求参数错误'
@@ -172,10 +181,21 @@ export class LoginController {
description: '用户不存在' description: '用户不存在'
}) })
@Post('forgot-password') @Post('forgot-password')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto): Promise<ApiResponse<{ verification_code?: string }>> { async forgotPassword(
return await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier); @Body() forgotPasswordDto: ForgotPasswordDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendPasswordResetCode(forgotPasswordDto.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);
}
} }
/** /**
@@ -256,6 +276,7 @@ export class LoginController {
* 发送邮箱验证码 * 发送邮箱验证码
* *
* @param sendEmailVerificationDto 发送验证码数据 * @param sendEmailVerificationDto 发送验证码数据
* @param res Express响应对象
* @returns 发送结果 * @returns 发送结果
*/ */
@ApiOperation({ @ApiOperation({
@@ -265,8 +286,13 @@ export class LoginController {
@ApiBody({ type: SendEmailVerificationDto }) @ApiBody({ type: SendEmailVerificationDto })
@SwaggerApiResponse({ @SwaggerApiResponse({
status: 200, status: 200,
description: '验证码发送成功', description: '验证码发送成功(真实发送模式)',
type: ForgotPasswordResponseDto type: SuccessEmailVerificationResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: TestModeEmailVerificationResponseDto
}) })
@SwaggerApiResponse({ @SwaggerApiResponse({
status: 400, status: 400,
@@ -277,10 +303,21 @@ export class LoginController {
description: '发送频率过高' description: '发送频率过高'
}) })
@Post('send-email-verification') @Post('send-email-verification')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async sendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> { async sendEmailVerification(
return await this.loginService.sendEmailVerification(sendEmailVerificationDto.email); @Body() sendEmailVerificationDto: SendEmailVerificationDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
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);
}
} }
/** /**
@@ -317,6 +354,7 @@ export class LoginController {
* 重新发送邮箱验证码 * 重新发送邮箱验证码
* *
* @param sendEmailVerificationDto 发送验证码数据 * @param sendEmailVerificationDto 发送验证码数据
* @param res Express响应对象
* @returns 发送结果 * @returns 发送结果
*/ */
@ApiOperation({ @ApiOperation({
@@ -329,6 +367,11 @@ export class LoginController {
description: '验证码重新发送成功', description: '验证码重新发送成功',
type: ForgotPasswordResponseDto type: ForgotPasswordResponseDto
}) })
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({ @SwaggerApiResponse({
status: 400, status: 400,
description: '邮箱已验证或用户不存在' description: '邮箱已验证或用户不存在'
@@ -338,10 +381,21 @@ export class LoginController {
description: '发送频率过高' description: '发送频率过高'
}) })
@Post('resend-email-verification') @Post('resend-email-verification')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true })) @UsePipes(new ValidationPipe({ transform: true }))
async resendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> { async resendEmailVerification(
return await this.loginService.resendEmailVerification(sendEmailVerificationDto.email); @Body() sendEmailVerificationDto: SendEmailVerificationDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
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);
}
} }
/** /**

View File

@@ -137,13 +137,31 @@ describe('LoginService', () => {
}); });
describe('sendPasswordResetCode', () => { describe('sendPasswordResetCode', () => {
it('should return success response with verification code', async () => { it('should return test mode response with verification code', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue('123456'); 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'); const result = await service.sendPasswordResetCode('test@example.com');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.data?.verification_code).toBe('123456'); expect(result.data?.is_test_mode).toBe(false);
expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码
}); });
}); });

View File

@@ -11,7 +11,7 @@
* - 调用核心服务完成具体功能 * - 调用核心服务完成具体功能
* - 为控制器层提供业务接口 * - 为控制器层提供业务接口
* *
* @author moyin * @author moyin angjustinl
* @version 1.0.0 * @version 1.0.0
* @since 2025-12-17 * @since 2025-12-17
*/ */
@@ -199,21 +199,37 @@ export class LoginService {
* @param identifier 邮箱或手机号 * @param identifier 邮箱或手机号
* @returns 响应结果 * @returns 响应结果
*/ */
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string }>> { async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try { try {
this.logger.log(`发送密码重置验证码: ${identifier}`); this.logger.log(`发送密码重置验证码: ${identifier}`);
// 调用核心服务发送验证码 // 调用核心服务发送验证码
const verificationCode = await this.loginCoreService.sendPasswordResetCode(identifier); const result = await this.loginCoreService.sendPasswordResetCode(identifier);
this.logger.log(`密码重置验证码已发送: ${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 { return {
success: true, success: true,
data: { verification_code: verificationCode }, data: {
is_test_mode: false
},
message: '验证码已发送,请查收' message: '验证码已发送,请查收'
}; };
}
} catch (error) { } catch (error) {
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error)); this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
@@ -293,21 +309,37 @@ export class LoginService {
* @param email 邮箱地址 * @param email 邮箱地址
* @returns 响应结果 * @returns 响应结果
*/ */
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> { async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try { try {
this.logger.log(`发送邮箱验证码: ${email}`); this.logger.log(`发送邮箱验证码: ${email}`);
// 调用核心服务发送验证码 // 调用核心服务发送验证码
const verificationCode = await this.loginCoreService.sendEmailVerification(email); const result = await this.loginCoreService.sendEmailVerification(email);
this.logger.log(`邮箱验证码已发送: ${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 { return {
success: true, success: true,
data: { verification_code: verificationCode }, data: {
is_test_mode: false
},
message: '验证码已发送,请查收邮件' message: '验证码已发送,请查收邮件'
}; };
}
} catch (error) { } catch (error) {
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
@@ -363,21 +395,37 @@ export class LoginService {
* @param email 邮箱地址 * @param email 邮箱地址
* @returns 响应结果 * @returns 响应结果
*/ */
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> { async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try { try {
this.logger.log(`重新发送邮箱验证码: ${email}`); this.logger.log(`重新发送邮箱验证码: ${email}`);
// 调用核心服务重新发送验证码 // 调用核心服务重新发送验证码
const verificationCode = await this.loginCoreService.resendEmailVerification(email); const result = await this.loginCoreService.resendEmailVerification(email);
this.logger.log(`邮箱验证码已重新发送: ${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 { return {
success: true, success: true,
data: { verification_code: verificationCode }, data: {
is_test_mode: false
},
message: '验证码已重新发送,请查收邮件' message: '验证码已重新发送,请查收邮件'
}; };
}
} catch (error) { } catch (error) {
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));

View File

@@ -4,23 +4,61 @@
* 功能描述: * 功能描述:
* - 整合用户相关的实体、服务和控制器 * - 整合用户相关的实体、服务和控制器
* - 配置TypeORM实体和Repository * - 配置TypeORM实体和Repository
* - 支持数据库和内存存储的动态切换 by angjustinl 2025-12-17
* - 导出用户服务供其他模块使用 * - 导出用户服务供其他模块使用
* *
* @author moyin * 存储模式by angjustinl 2025-12-17
* @version 1.0.0 * - 数据库模式使用TypeORM连接MySQL数据库
* - 内存模式使用Map存储适用于开发和测试
*
* @author moyin angjustinl
* @version 1.0.1
* @since 2025-12-17 * @since 2025-12-17
*/ */
import { Module } from '@nestjs/common'; import { Module, DynamicModule, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from './users.entity'; import { Users } from './users.entity';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { UsersMemoryService } from './users_memory.service';
@Module({ @Global()
imports: [ @Module({})
TypeOrmModule.forFeature([Users]) export class UsersModule {
/**
* 创建数据库模式的用户模块
*
* @returns 配置了TypeORM的动态模块
*/
static forDatabase(): DynamicModule {
return {
module: UsersModule,
imports: [TypeOrmModule.forFeature([Users])],
providers: [
{
provide: 'UsersService',
useClass: UsersService,
},
], ],
providers: [UsersService], exports: ['UsersService', TypeOrmModule],
exports: [UsersService, TypeOrmModule], };
}) }
export class UsersModule {}
/**
* 创建内存模式的用户模块
*
* @returns 配置了内存存储的动态模块
*/
static forMemory(): DynamicModule {
return {
module: UsersModule,
providers: [
{
provide: 'UsersService',
useClass: UsersMemoryService,
},
],
exports: ['UsersService'],
};
}
}

View File

@@ -0,0 +1,349 @@
/**
* 用户内存存储服务类
*
* 功能描述:
* - 提供基于内存的用户数据存储
* - 作为数据库连接失败时的回退方案
* - 实现与UsersService相同的接口
*
* 使用场景:
* - 开发环境无数据库时的快速启动
* - 测试环境的轻量级存储
* - 数据库故障时的临时降级
*
* 注意事项:
* - 数据仅存储在内存中,重启后丢失
* - 不适用于生产环境
* - 性能优异但无持久化保证
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { Users } from './users.entity';
import { CreateUserDto } from './users.dto';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@Injectable()
export class UsersMemoryService {
private users: Map<bigint, Users> = new Map();
private currentId: bigint = BigInt(1);
/**
* 创建新用户
*
* @param createUserDto 创建用户的数据传输对象
* @returns 创建的用户实体
* @throws ConflictException 当用户名、邮箱或手机号已存在时
* @throws BadRequestException 当数据验证失败时
*/
async create(createUserDto: CreateUserDto): Promise<Users> {
// 验证DTO
const dto = plainToClass(CreateUserDto, createUserDto);
const validationErrors = await validate(dto);
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map(error =>
Object.values(error.constraints || {}).join(', ')
).join('; ');
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
}
// 检查用户名是否已存在
if (createUserDto.username) {
const existingUser = await this.findByUsername(createUserDto.username);
if (existingUser) {
throw new ConflictException('用户名已存在');
}
}
// 检查邮箱是否已存在
if (createUserDto.email) {
const existingEmail = await this.findByEmail(createUserDto.email);
if (existingEmail) {
throw new ConflictException('邮箱已存在');
}
}
// 检查手机号是否已存在
if (createUserDto.phone) {
const existingPhone = Array.from(this.users.values()).find(
u => u.phone === createUserDto.phone
);
if (existingPhone) {
throw new ConflictException('手机号已存在');
}
}
// 检查GitHub ID是否已存在
if (createUserDto.github_id) {
const existingGithub = await this.findByGithubId(createUserDto.github_id);
if (existingGithub) {
throw new ConflictException('GitHub ID已存在');
}
}
// 创建用户实体
const user = new Users();
user.id = this.currentId++;
user.username = createUserDto.username;
user.email = createUserDto.email || null;
user.phone = createUserDto.phone || null;
user.password_hash = createUserDto.password_hash || null;
user.nickname = createUserDto.nickname;
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
user.role = createUserDto.role || 1;
user.email_verified = createUserDto.email_verified || false;
user.created_at = new Date();
user.updated_at = new Date();
// 保存到内存
this.users.set(user.id, user);
return user;
}
/**
* 查询所有用户
*
* @param limit 限制返回数量默认100
* @param offset 偏移量默认0
* @returns 用户列表
*/
async findAll(limit: number = 100, offset: number = 0): Promise<Users[]> {
const allUsers = Array.from(this.users.values())
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
return allUsers.slice(offset, offset + limit);
}
/**
* 根据ID查询用户
*
* @param id 用户ID
* @returns 用户实体
* @throws NotFoundException 当用户不存在时
*/
async findOne(id: bigint): Promise<Users> {
const user = this.users.get(id);
if (!user) {
throw new NotFoundException(`ID为 ${id} 的用户不存在`);
}
return user;
}
/**
* 根据用户名查询用户
*
* @param username 用户名
* @returns 用户实体或null
*/
async findByUsername(username: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.username === username
);
return user || null;
}
/**
* 根据邮箱查询用户
*
* @param email 邮箱
* @returns 用户实体或null
*/
async findByEmail(email: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.email === email
);
return user || null;
}
/**
* 根据GitHub ID查询用户
*
* @param githubId GitHub ID
* @returns 用户实体或null
*/
async findByGithubId(githubId: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.github_id === githubId
);
return user || null;
}
/**
* 更新用户信息
*
* @param id 用户ID
* @param updateData 更新的数据
* @returns 更新后的用户实体
* @throws NotFoundException 当用户不存在时
* @throws ConflictException 当更新的数据与其他用户冲突时
*/
async update(id: bigint, updateData: Partial<CreateUserDto>): Promise<Users> {
// 检查用户是否存在
const existingUser = await this.findOne(id);
// 检查更新数据的唯一性约束
if (updateData.username && updateData.username !== existingUser.username) {
const usernameExists = await this.findByUsername(updateData.username);
if (usernameExists) {
throw new ConflictException('用户名已存在');
}
}
if (updateData.email && updateData.email !== existingUser.email) {
const emailExists = await this.findByEmail(updateData.email);
if (emailExists) {
throw new ConflictException('邮箱已存在');
}
}
if (updateData.phone && updateData.phone !== existingUser.phone) {
const phoneExists = Array.from(this.users.values()).find(
u => u.phone === updateData.phone && u.id !== id
);
if (phoneExists) {
throw new ConflictException('手机号已存在');
}
}
if (updateData.github_id && updateData.github_id !== existingUser.github_id) {
const githubExists = await this.findByGithubId(updateData.github_id);
if (githubExists && githubExists.id !== id) {
throw new ConflictException('GitHub ID已存在');
}
}
// 更新用户数据
Object.assign(existingUser, updateData);
existingUser.updated_at = new Date();
this.users.set(id, existingUser);
return existingUser;
}
/**
* 删除用户
*
* @param id 用户ID
* @returns 删除操作结果
* @throws NotFoundException 当用户不存在时
*/
async remove(id: bigint): Promise<{ affected: number; message: string }> {
// 检查用户是否存在
await this.findOne(id);
// 执行删除
const deleted = this.users.delete(id);
return {
affected: deleted ? 1 : 0,
message: `成功删除ID为 ${id} 的用户`
};
}
/**
* 软删除用户(内存模式下与硬删除相同)
*
* @param id 用户ID
* @returns 被删除的用户实体
*/
async softRemove(id: bigint): Promise<Users> {
const user = await this.findOne(id);
this.users.delete(id);
return user;
}
/**
* 统计用户数量
*
* @param conditions 查询条件(内存模式下简化处理)
* @returns 用户数量
*/
async count(conditions?: any): Promise<number> {
if (!conditions) {
return this.users.size;
}
// 简化的条件过滤
let count = 0;
for (const user of this.users.values()) {
let match = true;
for (const [key, value] of Object.entries(conditions)) {
if ((user as any)[key] !== value) {
match = false;
break;
}
}
if (match) count++;
}
return count;
}
/**
* 检查用户是否存在
*
* @param id 用户ID
* @returns 是否存在
*/
async exists(id: bigint): Promise<boolean> {
return this.users.has(id);
}
/**
* 批量创建用户
*
* @param createUserDtos 用户数据数组
* @returns 创建的用户列表
*/
async createBatch(createUserDtos: CreateUserDto[]): Promise<Users[]> {
const users: Users[] = [];
for (const dto of createUserDtos) {
const user = await this.create(dto);
users.push(user);
}
return users;
}
/**
* 根据角色查询用户
*
* @param role 角色值
* @returns 用户列表
*/
async findByRole(role: number): Promise<Users[]> {
return Array.from(this.users.values())
.filter(u => u.role === role)
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
}
/**
* 搜索用户(根据用户名或昵称)
*
* @param keyword 搜索关键词
* @param limit 限制数量
* @returns 用户列表
*/
async search(keyword: string, limit: number = 20): Promise<Users[]> {
const lowerKeyword = keyword.toLowerCase();
return Array.from(this.users.values())
.filter(u =>
u.username.toLowerCase().includes(lowerKeyword) ||
u.nickname.toLowerCase().includes(lowerKeyword)
)
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, limit);
}
}

View File

@@ -180,11 +180,15 @@ describe('LoginCoreService', () => {
const verifiedUser = { ...mockUser, email_verified: true }; const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser); usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.generateCode.mockResolvedValue('123456'); verificationService.generateCode.mockResolvedValue('123456');
emailService.sendVerificationCode.mockResolvedValue(true); emailService.sendVerificationCode.mockResolvedValue({
success: true,
isTestMode: true
});
const code = await service.sendPasswordResetCode('test@example.com'); const result = await service.sendPasswordResetCode('test@example.com');
expect(code).toMatch(/^\d{6}$/); expect(result.code).toMatch(/^\d{6}$/);
expect(result.isTestMode).toBe(true);
}); });
it('should throw NotFoundException for non-existent user', async () => { it('should throw NotFoundException for non-existent user', async () => {

View File

@@ -16,10 +16,10 @@
* @since 2025-12-17 * @since 2025-12-17
*/ */
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { UsersService } from '../db/users/users.service';
import { Users } from '../db/users/users.entity'; import { Users } from '../db/users/users.entity';
import { EmailService } from '../utils/email/email.service'; import { UsersService } from '../db/users/users.service';
import { EmailService, EmailSendResult } from '../utils/email/email.service';
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service'; import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@@ -90,10 +90,20 @@ export interface AuthResult {
isNewUser?: boolean; isNewUser?: boolean;
} }
/**
* 验证码发送结果接口 by angjustinl 2025-12-17
*/
export interface VerificationCodeResult {
/** 验证码 */
code: string;
/** 是否为测试模式 */
isTestMode: boolean;
}
@Injectable() @Injectable()
export class LoginCoreService { export class LoginCoreService {
constructor( constructor(
private readonly usersService: UsersService, @Inject('UsersService') private readonly usersService: UsersService,
private readonly emailService: EmailService, private readonly emailService: EmailService,
private readonly verificationService: VerificationService, private readonly verificationService: VerificationService,
) {} ) {}
@@ -122,7 +132,7 @@ export class LoginCoreService {
// 如果邮箱未找到,尝试手机号查找(简单验证) // 如果邮箱未找到,尝试手机号查找(简单验证)
if (!user && this.isPhoneNumber(identifier)) { if (!user && this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll(); const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null; user = users.find((u: Users) => u.phone === identifier) || null;
} }
// 用户不存在 // 用户不存在
@@ -269,10 +279,10 @@ export class LoginCoreService {
* 发送密码重置验证码 * 发送密码重置验证码
* *
* @param identifier 邮箱或手机号 * @param identifier 邮箱或手机号
* @returns 验证码(实际应用中应发送到用户邮箱/手机) * @returns 验证码结果
* @throws NotFoundException 用户不存在时 * @throws NotFoundException 用户不存在时
*/ */
async sendPasswordResetCode(identifier: string): Promise<string> { async sendPasswordResetCode(identifier: string): Promise<VerificationCodeResult> {
// 查找用户 // 查找用户
let user: Users | null = null; let user: Users | null = null;
@@ -285,7 +295,7 @@ export class LoginCoreService {
} }
} else if (this.isPhoneNumber(identifier)) { } else if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll(); const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null; user = users.find((u: Users) => u.phone === identifier) || null;
} }
if (!user) { if (!user) {
@@ -299,23 +309,28 @@ export class LoginCoreService {
); );
// 发送验证码 // 发送验证码
let isTestMode = false;
if (this.isEmail(identifier)) { if (this.isEmail(identifier)) {
const success = await this.emailService.sendVerificationCode({ const result = await this.emailService.sendVerificationCode({
email: identifier, email: identifier,
code: verificationCode, code: verificationCode,
nickname: user.nickname, nickname: user.nickname,
purpose: 'password_reset' purpose: 'password_reset'
}); });
if (!success) { if (!result.success) {
throw new BadRequestException('验证码发送失败,请稍后重试'); throw new BadRequestException('验证码发送失败,请稍后重试');
} }
isTestMode = result.isTestMode;
} else { } else {
// TODO: 实现短信发送 // TODO: 实现短信发送
console.log(`短信验证码(${identifier}: ${verificationCode}`); console.log(`短信验证码(${identifier}: ${verificationCode}`);
isTestMode = true; // 短信也是测试模式
} }
return verificationCode; // 实际应用中不应返回验证码 return { code: verificationCode, isTestMode };
} }
/** /**
@@ -347,7 +362,7 @@ export class LoginCoreService {
user = await this.usersService.findByEmail(identifier); user = await this.usersService.findByEmail(identifier);
} else if (this.isPhoneNumber(identifier)) { } else if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll(); const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null; user = users.find((u: Users) => u.phone === identifier) || null;
} }
if (!user) { if (!user) {
@@ -457,9 +472,9 @@ export class LoginCoreService {
* *
* @param email 邮箱地址 * @param email 邮箱地址
* @param nickname 用户昵称 * @param nickname 用户昵称
* @returns 验证码 * @returns 验证码结果
*/ */
async sendEmailVerification(email: string, nickname?: string): Promise<string> { async sendEmailVerification(email: string, nickname?: string): Promise<VerificationCodeResult> {
// 生成验证码 // 生成验证码
const verificationCode = await this.verificationService.generateCode( const verificationCode = await this.verificationService.generateCode(
email, email,
@@ -467,18 +482,18 @@ export class LoginCoreService {
); );
// 发送验证邮件 // 发送验证邮件
const success = await this.emailService.sendVerificationCode({ const result = await this.emailService.sendVerificationCode({
email, email,
code: verificationCode, code: verificationCode,
nickname, nickname,
purpose: 'email_verification' purpose: 'email_verification'
}); });
if (!success) { if (!result.success) {
throw new BadRequestException('验证邮件发送失败,请稍后重试'); throw new BadRequestException('验证邮件发送失败,请稍后重试');
} }
return verificationCode; // 实际应用中不应返回验证码 return { code: verificationCode, isTestMode: result.isTestMode };
} }
/** /**
@@ -520,9 +535,9 @@ export class LoginCoreService {
* 重新发送邮箱验证码 * 重新发送邮箱验证码
* *
* @param email 邮箱地址 * @param email 邮箱地址
* @returns 验证码 * @returns 验证码结果
*/ */
async resendEmailVerification(email: string): Promise<string> { async resendEmailVerification(email: string): Promise<VerificationCodeResult> {
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
if (!user) { if (!user) {

View File

@@ -72,7 +72,7 @@ describe('EmailService', () => {
configService.get.mockReturnValue(undefined); configService.get.mockReturnValue(undefined);
// 重新创建服务实例来测试测试模式 // 重新创建服务实例来测试测试模式
const testService = new EmailService(configService); new EmailService(configService);
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
streamTransport: true, streamTransport: true,
@@ -89,7 +89,7 @@ describe('EmailService', () => {
.mockReturnValueOnce('test@gmail.com') // EMAIL_USER .mockReturnValueOnce('test@gmail.com') // EMAIL_USER
.mockReturnValueOnce('password'); // EMAIL_PASS .mockReturnValueOnce('password'); // EMAIL_PASS
const testService = new EmailService(configService); new EmailService(configService);
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
host: 'smtp.gmail.com', host: 'smtp.gmail.com',
@@ -117,7 +117,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions); const result = await service.sendEmail(emailOptions);
expect(result).toBe(true); expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({ expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: '"Test Sender" <noreply@test.com>', from: '"Test Sender" <noreply@test.com>',
to: 'test@example.com', to: 'test@example.com',
@@ -138,7 +139,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions); const result = await service.sendEmail(emailOptions);
expect(result).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
}); });
it('应该在测试模式下输出邮件内容', async () => { it('应该在测试模式下输出邮件内容', async () => {
@@ -157,13 +159,14 @@ describe('EmailService', () => {
}; };
// Mock the service to use test transporter // Mock the service to use test transporter
const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); const loggerSpy = jest.spyOn(service['logger'], 'warn').mockImplementation();
service['transporter'] = testTransporter; service['transporter'] = testTransporter;
const result = await service.sendEmail(emailOptions); const result = await service.sendEmail(emailOptions);
expect(result).toBe(true); expect(result.success).toBe(true);
expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式) ==='); expect(result.isTestMode).toBe(true);
expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式 - 邮件未真实发送) ===');
loggerSpy.mockRestore(); loggerSpy.mockRestore();
}); });
@@ -183,7 +186,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options); const result = await service.sendVerificationCode(options);
expect(result).toBe(true); expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith( expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: 'test@example.com', to: 'test@example.com',
@@ -206,7 +210,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options); const result = await service.sendVerificationCode(options);
expect(result).toBe(true); expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith( expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: 'test@example.com', to: 'test@example.com',
@@ -227,7 +232,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options); const result = await service.sendVerificationCode(options);
expect(result).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
}); });
}); });
@@ -238,7 +244,8 @@ describe('EmailService', () => {
const result = await service.sendWelcomeEmail('test@example.com', '测试用户'); const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
expect(result).toBe(true); expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith( expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: 'test@example.com', to: 'test@example.com',
@@ -253,7 +260,8 @@ describe('EmailService', () => {
const result = await service.sendWelcomeEmail('test@example.com', '测试用户'); const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
expect(result).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
}); });
}); });
@@ -358,7 +366,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions); const result = await service.sendEmail(emailOptions);
expect(result).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
}); });
it('应该正确处理认证错误', async () => { it('应该正确处理认证错误', async () => {
@@ -372,7 +381,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions); const result = await service.sendEmail(emailOptions);
expect(result).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('Invalid login');
}); });
it('应该正确处理连接验证错误', async () => { it('应该正确处理连接验证错误', async () => {
@@ -393,7 +403,7 @@ describe('EmailService', () => {
.mockReturnValueOnce(undefined) // EMAIL_USER .mockReturnValueOnce(undefined) // EMAIL_USER
.mockReturnValueOnce(undefined); // EMAIL_PASS .mockReturnValueOnce(undefined); // EMAIL_PASS
const testService = new EmailService(configService); new EmailService(configService);
expect(configService.get).toHaveBeenCalledWith('EMAIL_HOST', 'smtp.gmail.com'); expect(configService.get).toHaveBeenCalledWith('EMAIL_HOST', 'smtp.gmail.com');
expect(configService.get).toHaveBeenCalledWith('EMAIL_PORT', 587); expect(configService.get).toHaveBeenCalledWith('EMAIL_PORT', 587);
@@ -408,7 +418,7 @@ describe('EmailService', () => {
.mockReturnValueOnce('custom@163.com') // EMAIL_USER .mockReturnValueOnce('custom@163.com') // EMAIL_USER
.mockReturnValueOnce('custompass'); // EMAIL_PASS .mockReturnValueOnce('custompass'); // EMAIL_PASS
const testService = new EmailService(configService); new EmailService(configService);
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
host: 'smtp.163.com', host: 'smtp.163.com',

View File

@@ -50,6 +50,18 @@ export interface VerificationEmailOptions {
purpose: 'email_verification' | 'password_reset'; purpose: 'email_verification' | 'password_reset';
} }
/**
* 邮件发送结果接口 by angjustinl 2025-12-17
*/
export interface EmailSendResult {
/** 是否成功 */
success: boolean;
/** 是否为测试模式 */
isTestMode: boolean;
/** 错误信息(如果失败) */
error?: string;
}
@Injectable() @Injectable()
export class EmailService { export class EmailService {
private readonly logger = new Logger(EmailService.name); private readonly logger = new Logger(EmailService.name);
@@ -87,13 +99,22 @@ export class EmailService {
} }
} }
/**
* 检查是否为测试模式
*
* @returns 是否为测试模式
*/
isTestMode(): boolean {
return !!(this.transporter.options as any).streamTransport;
}
/** /**
* 发送邮件 * 发送邮件
* *
* @param options 邮件选项 * @param options 邮件选项
* @returns 发送结果 * @returns 发送结果
*/ */
async sendEmail(options: EmailOptions): Promise<boolean> { async sendEmail(options: EmailOptions): Promise<EmailSendResult> {
try { try {
const mailOptions = { const mailOptions = {
from: this.configService.get<string>('EMAIL_FROM', '"Whale Town Game" <noreply@whaletown.com>'), from: this.configService.get<string>('EMAIL_FROM', '"Whale Town Game" <noreply@whaletown.com>'),
@@ -103,22 +124,31 @@ export class EmailService {
text: options.text, text: options.text,
}; };
const result = await this.transporter.sendMail(mailOptions); const isTestMode = this.isTestMode();
// 如果是测试模式,输出邮件内容到控制台 // 如果是测试模式,输出邮件内容到控制台
if ((this.transporter.options as any).streamTransport) { if (isTestMode) {
this.logger.log('=== 邮件发送(测试模式) ==='); this.logger.warn('=== 邮件发送(测试模式 - 邮件未真实发送 ===');
this.logger.log(`收件人: ${options.to}`); this.logger.warn(`收件人: ${options.to}`);
this.logger.log(`主题: ${options.subject}`); this.logger.warn(`主题: ${options.subject}`);
this.logger.log(`内容: ${options.text || '请查看HTML内容'}`); this.logger.warn(`内容: ${options.text || '请查看HTML内容'}`);
this.logger.log('========================'); this.logger.warn('⚠️ 注意: 这是测试模式,邮件不会真实发送到用户邮箱');
this.logger.warn('💡 提示: 请在 .env 文件中配置邮件服务以启用真实发送');
this.logger.warn('================================================');
return { success: true, isTestMode: true };
} }
this.logger.log(`邮件发送成功: ${options.to}`); // 真实发送邮件
return true; const result = await this.transporter.sendMail(mailOptions);
this.logger.log(`✅ 邮件发送成功: ${options.to}`);
return { success: true, isTestMode: false };
} catch (error) { } catch (error) {
this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error)); this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error));
return false; return {
success: false,
isTestMode: this.isTestMode(),
error: error instanceof Error ? error.message : String(error)
};
} }
} }
@@ -128,7 +158,7 @@ export class EmailService {
* @param options 验证码邮件选项 * @param options 验证码邮件选项
* @returns 发送结果 * @returns 发送结果
*/ */
async sendVerificationCode(options: VerificationEmailOptions): Promise<boolean> { async sendVerificationCode(options: VerificationEmailOptions): Promise<EmailSendResult> {
const { email, code, nickname, purpose } = options; const { email, code, nickname, purpose } = options;
let subject: string; let subject: string;
@@ -157,7 +187,7 @@ export class EmailService {
* @param nickname 用户昵称 * @param nickname 用户昵称
* @returns 发送结果 * @returns 发送结果
*/ */
async sendWelcomeEmail(email: string, nickname: string): Promise<boolean> { async sendWelcomeEmail(email: string, nickname: string): Promise<EmailSendResult> {
const subject = '🎮 欢迎加入 Whale Town'; const subject = '🎮 欢迎加入 Whale Town';
const template = this.getWelcomeTemplate(nickname); const template = this.getWelcomeTemplate(nickname);

View File

@@ -272,6 +272,7 @@ describe('VerificationService', () => {
}; };
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo)); mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow( await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
new BadRequestException('验证码错误,剩余尝试次数: 1') new BadRequestException('验证码错误,剩余尝试次数: 1')
@@ -285,7 +286,7 @@ describe('VerificationService', () => {
expect(mockRedis.set).toHaveBeenCalledWith( expect(mockRedis.set).toHaveBeenCalledWith(
`verification_code:${type}:${email}`, `verification_code:${type}:${email}`,
JSON.stringify(updatedCodeInfo), JSON.stringify(updatedCodeInfo),
300 240
); );
}); });
@@ -298,6 +299,7 @@ describe('VerificationService', () => {
}; };
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo)); mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow( await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
new BadRequestException('验证码错误,剩余尝试次数: 0') new BadRequestException('验证码错误,剩余尝试次数: 0')
@@ -375,7 +377,7 @@ describe('VerificationService', () => {
it('应该返回存在的验证码统计信息', async () => { it('应该返回存在的验证码统计信息', async () => {
const codeInfo = { const codeInfo = {
code: '123456', code: '123456',
createdAt: Date.now(), createdAt: 1766035834340,
attempts: 1, attempts: 1,
maxAttempts: 3, maxAttempts: 3,
}; };
@@ -391,18 +393,20 @@ describe('VerificationService', () => {
ttl: 240, ttl: 240,
attempts: 1, attempts: 1,
maxAttempts: 3, maxAttempts: 3,
code: '123456',
createdAt: expect.any(Number),
}); });
}); });
it('应该在验证码不存在时返回基本信息', async () => { it('应该在验证码不存在时返回基本信息', async () => {
mockRedis.exists.mockResolvedValue(false); mockRedis.exists.mockResolvedValue(false);
mockRedis.ttl.mockResolvedValue(-1); mockRedis.ttl.mockResolvedValue(-2); // -2 表示键不存在
const result = await service.getCodeStats(email, type); const result = await service.getCodeStats(email, type);
expect(result).toEqual({ expect(result).toEqual({
exists: false, exists: false,
ttl: -1, ttl: -2, // 修改为 -2
}); });
}); });

72
src/dto/app.dto.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 应用状态响应 DTO
*
* 功能描述:
* - 定义应用状态接口的响应格式
* - 提供 Swagger 文档生成支持
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* 应用状态响应 DTO
*/
export class AppStatusResponseDto {
@ApiProperty({
description: '服务名称',
example: 'Pixel Game Server',
type: String
})
service: string;
@ApiProperty({
description: '服务版本',
example: '1.0.0',
type: String
})
version: string;
@ApiProperty({
description: '运行状态',
example: 'running',
enum: ['running', 'starting', 'stopping', 'error'],
type: String
})
status: string;
@ApiProperty({
description: '当前时间戳',
example: '2025-12-17T15:00:00.000Z',
type: String,
format: 'date-time'
})
timestamp: string;
@ApiProperty({
description: '运行时间(秒)',
example: 3600,
type: Number,
minimum: 0
})
uptime: number;
@ApiProperty({
description: '运行环境',
example: 'development',
enum: ['development', 'production', 'test'],
type: String
})
environment: string;
@ApiProperty({
description: '存储模式',
example: 'memory',
enum: ['database', 'memory'],
type: String
})
storage_mode: 'database' | 'memory';
}

View File

@@ -0,0 +1,56 @@
/**
* 通用错误响应 DTO
*
* 功能描述:
* - 定义统一的错误响应格式
* - 提供 Swagger 文档生成支持
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* 通用错误响应 DTO
*/
export class ErrorResponseDto {
@ApiProperty({
description: 'HTTP 状态码',
example: 500,
type: Number
})
statusCode: number;
@ApiProperty({
description: '错误消息',
example: 'Internal server error',
type: String
})
message: string;
@ApiProperty({
description: '错误发生时间',
example: '2025-12-17T15:00:00.000Z',
type: String,
format: 'date-time'
})
timestamp: string;
@ApiProperty({
description: '请求路径',
example: '/api/status',
type: String,
required: false
})
path?: string;
@ApiProperty({
description: '错误代码',
example: 'INTERNAL_ERROR',
type: String,
required: false
})
error?: string;
}

View File

@@ -209,6 +209,13 @@ export class ForgotPasswordResponseDataDto {
required: false required: false
}) })
verification_code?: string; verification_code?: string;
@ApiProperty({
description: '是否为测试模式',
example: true,
required: false
})
is_test_mode?: boolean;
} }
/** /**
@@ -217,26 +224,76 @@ export class ForgotPasswordResponseDataDto {
export class ForgotPasswordResponseDto { export class ForgotPasswordResponseDto {
@ApiProperty({ @ApiProperty({
description: '请求是否成功', description: '请求是否成功',
example: true example: false,
examples: {
success: {
summary: '真实发送成功',
value: true
},
testMode: {
summary: '测试模式',
value: false
}
}
}) })
success: boolean; success: boolean;
@ApiProperty({ @ApiProperty({
description: '响应数据', description: '响应数据',
type: ForgotPasswordResponseDataDto, type: ForgotPasswordResponseDataDto,
required: false required: false,
examples: {
success: {
summary: '真实发送成功',
value: {
verification_code: '123456',
is_test_mode: false
}
},
testMode: {
summary: '测试模式',
value: {
verification_code: '059174',
is_test_mode: true
}
}
}
}) })
data?: ForgotPasswordResponseDataDto; data?: ForgotPasswordResponseDataDto;
@ApiProperty({ @ApiProperty({
description: '响应消息', description: '响应消息',
example: '验证码已发送,请查收' example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
examples: {
success: {
summary: '真实发送成功',
value: '验证码已发送,请查收'
},
testMode: {
summary: '测试模式',
value: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
}
}
}) })
message: string; message: string;
@ApiProperty({ @ApiProperty({
description: '错误代码', description: '错误代码',
example: 'SEND_CODE_FAILED', example: 'TEST_MODE_ONLY',
examples: {
success: {
summary: '真实发送成功',
value: null
},
testMode: {
summary: '测试模式',
value: 'TEST_MODE_ONLY'
},
failed: {
summary: '发送失败',
value: 'SEND_CODE_FAILED'
}
},
required: false required: false
}) })
error_code?: string; error_code?: string;
@@ -265,3 +322,74 @@ export class CommonResponseDto {
}) })
error_code?: string; error_code?: string;
} }
/**
* DTO by angjustinl 2025-12-17
*/
export class TestModeEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功测试模式下为false',
example: false
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '059174',
is_test_mode: true
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TEST_MODE_ONLY'
})
error_code: string;
}
/**
* DTO
*/
export class SuccessEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '123456',
is_test_mode: false
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '验证码已发送,请查收'
})
message: string;
@ApiProperty({
description: '错误代码',
example: null,
required: false
})
error_code?: string;
}

View File

@@ -3,8 +3,42 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
*
* @returns 是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
/**
* 打印启动横幅
*/
function printBanner() {
const isDatabaseMode = isDatabaseConfigured();
console.log('\n' + '='.repeat(70));
console.log('🎮 Pixel Game Server');
console.log('='.repeat(70));
console.log(`📦 存储模式: ${isDatabaseMode ? '数据库模式 (MySQL)' : '内存模式 (Memory)'}`);
if (!isDatabaseMode) {
console.log('⚠️ 警告: 未检测到数据库配置,使用内存存储');
console.log('💡 提示: 数据将在服务重启后丢失');
console.log('📝 配置: 请在 .env 文件中配置数据库连接信息');
} else {
console.log('✅ 数据库: 已连接到 MySQL 数据库');
}
console.log('='.repeat(70) + '\n');
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'],
});
// 全局启用校验管道(核心配置) // 全局启用校验管道(核心配置)
app.useGlobalPipes( app.useGlobalPipes(

93
test-api.ps1 Normal file
View File

@@ -0,0 +1,93 @@
# Whale Town API Test Script (Windows PowerShell)
# 测试邮箱验证码和用户注册登录功能
param(
[string]$BaseUrl = "http://localhost:3000",
[string]$TestEmail = "test@example.com"
)
Write-Host "=== Whale Town API Test (Windows) ===" -ForegroundColor Green
Write-Host "Testing without database and email server" -ForegroundColor Cyan
Write-Host "Base URL: $BaseUrl" -ForegroundColor Yellow
Write-Host "Test Email: $TestEmail" -ForegroundColor Yellow
# Test 1: Send verification code
Write-Host "`n1. Sending email verification code..." -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 "✅ Verification code sent successfully" -ForegroundColor Green
Write-Host " Code: $($sendResponse.data.verification_code)" -ForegroundColor Cyan
Write-Host " Test Mode: $($sendResponse.data.is_test_mode)" -ForegroundColor Cyan
$verificationCode = $sendResponse.data.verification_code
} catch {
Write-Host "❌ Failed to send verification code" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
# Test 2: Verify email code
Write-Host "`n2. Verifying email code..." -ForegroundColor Yellow
$verifyBody = @{
email = $TestEmail
verification_code = $verificationCode
} | ConvertTo-Json
try {
$verifyResponse = Invoke-RestMethod -Uri "$BaseUrl/auth/verify-email" -Method POST -Body $verifyBody -ContentType "application/json"
Write-Host "✅ Email verification successful" -ForegroundColor Green
} catch {
Write-Host "❌ Email verification failed" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
}
# Test 3: User registration
Write-Host "`n3. Testing user registration..." -ForegroundColor Yellow
$registerBody = @{
username = "testuser_$(Get-Random -Maximum 9999)"
password = "Test123456"
nickname = "Test User"
email = $TestEmail
email_verification_code = $verificationCode
} | ConvertTo-Json
try {
$registerResponse = Invoke-RestMethod -Uri "$BaseUrl/auth/register" -Method POST -Body $registerBody -ContentType "application/json"
Write-Host "✅ User registration successful" -ForegroundColor Green
Write-Host " User ID: $($registerResponse.data.user.id)" -ForegroundColor Cyan
Write-Host " Username: $($registerResponse.data.user.username)" -ForegroundColor Cyan
$username = $registerResponse.data.user.username
} catch {
Write-Host "❌ User registration failed" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
$username = $null
}
# Test 4: User login
if ($username) {
Write-Host "`n4. Testing user login..." -ForegroundColor Yellow
$loginBody = @{
identifier = $username
password = "Test123456"
} | ConvertTo-Json
try {
$loginResponse = Invoke-RestMethod -Uri "$BaseUrl/auth/login" -Method POST -Body $loginBody -ContentType "application/json"
Write-Host "✅ User login successful" -ForegroundColor Green
Write-Host " Username: $($loginResponse.data.user.username)" -ForegroundColor Cyan
Write-Host " Nickname: $($loginResponse.data.user.nickname)" -ForegroundColor Cyan
} catch {
Write-Host "❌ User login failed" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "`n=== Test Summary ===" -ForegroundColor Green
Write-Host "✅ Redis file storage: Working" -ForegroundColor Green
Write-Host "✅ Email test mode: Working" -ForegroundColor Green
Write-Host "✅ Memory user storage: Working" -ForegroundColor Green
Write-Host "`n💡 Check redis-data/redis.json for stored verification data" -ForegroundColor Yellow
Write-Host "💡 Check server console for email content output" -ForegroundColor Yellow

95
test-api.sh Normal file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
# Whale Town API Test Script (Linux/macOS)
# 测试邮箱验证码和用户注册登录功能
BASE_URL="${1:-http://localhost:3000}"
TEST_EMAIL="${2:-test@example.com}"
echo "=== Whale Town API Test (Linux/macOS) ==="
echo "Testing without database and email server"
echo "Base URL: $BASE_URL"
echo "Test Email: $TEST_EMAIL"
# Test 1: Send verification code
echo ""
echo "1. Sending email verification code..."
SEND_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/send-email-verification" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$TEST_EMAIL\"}")
if echo "$SEND_RESPONSE" | grep -q '"success"'; then
echo "✅ Verification code sent successfully"
VERIFICATION_CODE=$(echo "$SEND_RESPONSE" | grep -o '"verification_code":"[^"]*"' | cut -d'"' -f4)
IS_TEST_MODE=$(echo "$SEND_RESPONSE" | grep -o '"is_test_mode":[^,}]*' | cut -d':' -f2)
echo " Code: $VERIFICATION_CODE"
echo " Test Mode: $IS_TEST_MODE"
else
echo "❌ Failed to send verification code"
echo " Response: $SEND_RESPONSE"
exit 1
fi
# Test 2: Verify email code
echo ""
echo "2. Verifying email code..."
VERIFY_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/verify-email" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$TEST_EMAIL\",\"verification_code\":\"$VERIFICATION_CODE\"}")
if echo "$VERIFY_RESPONSE" | grep -q '"success":true'; then
echo "✅ Email verification successful"
else
echo "❌ Email verification failed"
echo " Response: $VERIFY_RESPONSE"
fi
# Test 3: User registration
echo ""
echo "3. Testing user registration..."
RANDOM_NUM=$((RANDOM % 9999))
USERNAME="testuser_$RANDOM_NUM"
REGISTER_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/register" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"Test123456\",\"nickname\":\"Test User\",\"email\":\"$TEST_EMAIL\",\"email_verification_code\":\"$VERIFICATION_CODE\"}")
if echo "$REGISTER_RESPONSE" | grep -q '"success":true'; then
echo "✅ User registration successful"
USER_ID=$(echo "$REGISTER_RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
REGISTERED_USERNAME=$(echo "$REGISTER_RESPONSE" | grep -o '"username":"[^"]*"' | cut -d'"' -f4)
echo " User ID: $USER_ID"
echo " Username: $REGISTERED_USERNAME"
else
echo "❌ User registration failed"
echo " Response: $REGISTER_RESPONSE"
REGISTERED_USERNAME=""
fi
# Test 4: User login
if [ -n "$REGISTERED_USERNAME" ]; then
echo ""
echo "4. Testing user login..."
LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"identifier\":\"$REGISTERED_USERNAME\",\"password\":\"Test123456\"}")
if echo "$LOGIN_RESPONSE" | grep -q '"success":true'; then
echo "✅ User login successful"
LOGIN_USERNAME=$(echo "$LOGIN_RESPONSE" | grep -o '"username":"[^"]*"' | cut -d'"' -f4)
LOGIN_NICKNAME=$(echo "$LOGIN_RESPONSE" | grep -o '"nickname":"[^"]*"' | cut -d'"' -f4)
echo " Username: $LOGIN_USERNAME"
echo " Nickname: $LOGIN_NICKNAME"
else
echo "❌ User login failed"
echo " Response: $LOGIN_RESPONSE"
fi
fi
echo ""
echo "=== Test Summary ==="
echo "✅ Redis file storage: Working"
echo "✅ Email test mode: Working"
echo "✅ Memory user storage: Working"
echo ""
echo "💡 Check redis-data/redis.json for stored verification data"
echo "💡 Check server console for email content output"