diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e971bf --- /dev/null +++ b/.env.example @@ -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" + +# 生产环境设置(生产环境取消注释) +# NODE_ENV=production +# LOG_LEVEL=info \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..a887343 --- /dev/null +++ b/CONTRIBUTORS.md @@ -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更新此文件。* \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 06c6431..e1f3207 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -40,9 +40,8 @@ sudo mkdir -p /var/www cd /var/www # 克隆项目(替换为你的实际仓库地址) -sudo git clone https://your-gitea-server.com/username/pixel-game-server.git -sudo chown -R $USER:$USER pixel-game-server -cd pixel-game-server +git clone https://gitea.xinghangee.icu/datawhale/whale-town-end.git +cd whale-town-end ``` ### 3. 配置环境 @@ -111,7 +110,7 @@ pm2 startup 创建 Nginx 配置文件: ```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 -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 systemctl reload nginx ``` @@ -171,7 +170,7 @@ sudo systemctl reload nginx pm2 status # 查看日志 -pm2 logs pixel-game-server +pm2 logs whale-town-end pm2 logs webhook-handler # 测试 API @@ -183,10 +182,10 @@ curl http://localhost:3000/api-docs ```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 @@ -195,7 +194,7 @@ bash deploy.sh git pull origin main pnpm install pnpm run build -pm2 reload pixel-game-server +pm2 reload whale-town-end ``` ## 故障排除 diff --git a/Dockerfile b/Dockerfile index 772b078..ac314d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,22 @@ # 使用官方 Node.js 镜像 -FROM node:18-alpine +FROM node:lts-alpine # 设置工作目录 WORKDIR /app -# 安装 pnpm -RUN npm install -g pnpm +# 设置构建参数 +ARG NPM_REGISTRY=https://registry.npmmirror.com -# 复制 package.json 和 pnpm-lock.yaml -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# 设置 npm 和 pnpm 镜像源 +RUN npm config set registry ${NPM_REGISTRY} && \ + npm install -g pnpm && \ + pnpm config set registry ${NPM_REGISTRY} + +# 复制 package.json +COPY package.json pnpm-workspace.yaml ./ # 安装依赖 -RUN pnpm install --frozen-lockfile +RUN pnpm install # 复制源代码 COPY . . diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49f498d --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index eaa2366..8208fb4 100644 --- a/README.md +++ b/README.md @@ -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)** +### 📋 环境要求 -**📢 重要提醒:** -- 🚫 **未阅读 AI 辅助指南的代码将无法通过审查** -- 🤖 **学会使用 AI 助手可以让你的开发效率提升 300%** -- 📝 **AI 可以帮你自动生成符合规范的代码和注释** -- 🔍 **AI 可以实时检查你的代码质量** +- **Node.js** >= 18.0.0 (推荐 24.7.0) +- **pnpm** >= 8.0.0 (推荐 10.25.0) -**👉 [立即阅读 AI 辅助开发指南](./docs/AI辅助开发规范指南.md)** - ---- - -## 技术栈 - -- **NestJS** `^10.4.20` - 渐进式 Node.js 框架 -- **TypeScript** `^5.9.3` - 类型安全 -- **Socket.IO** - WebSocket 实时通信支持 -- **RxJS** `^7.8.2` - 响应式编程库 -- **Pino** `^10.1.0` - 高性能日志库 -- **Jest** `^29.7.0` - 测试框架 - -### 核心依赖 - -**生产环境:** -- `@nestjs/common` `^10.4.20` - NestJS 核心功能 -- `@nestjs/core` `^10.4.20` - NestJS 核心模块 -- `@nestjs/config` `^4.0.2` - 配置管理 -- `@nestjs/platform-express` `^10.4.20` - Express 平台适配器 -- `@nestjs/websockets` `^10.4.20` - WebSocket 支持 -- `@nestjs/platform-socket.io` `^10.4.20` - Socket.IO 适配器 -- `nestjs-pino` `^4.5.0` - Pino 日志集成 -- `pino` `^10.1.0` - 高性能日志库 -- `reflect-metadata` `^0.1.14` - 装饰器元数据支持 -- `rxjs` `^7.8.2` - 响应式编程 - -**开发环境:** -- `@nestjs/cli` `^10.4.9` - NestJS 命令行工具 -- `@nestjs/schematics` `^10.2.3` - NestJS 代码生成器 -- `@nestjs/testing` `^10.4.20` - 测试工具 -- `@types/jest` `^29.5.14` - Jest 类型定义 -- `@types/node` `^20.19.26` - Node.js 类型定义 -- `jest` `^29.7.0` - 测试框架 -- `ts-jest` `^29.2.5` - TypeScript Jest 支持 -- `ts-node` `^10.9.2` - TypeScript 运行时 -- `typescript` `^5.9.3` - TypeScript 编译器 -- `pino-pretty` `^13.1.3` - Pino 美化输出 - -## 🚨 重要:开发前必读 - -### ⚠️ 所有开发者必须先阅读 AI 辅助开发指南 - -**在开始任何开发工作之前,请务必阅读:[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)** - -这个指南将教你如何: -- 🤖 使用 AI 助手遵循项目规范 -- 📝 自动生成规范的代码和注释 -- 🔍 实时检查代码质量 -- 🚀 显著提高开发效率和代码质量 - -**不阅读此指南直接开发,代码审查将无法通过!** - ---- - -## 开发规范 - -### 命名规范 - -项目采用统一的命名规范,确保代码风格一致: - -- **文件/文件夹**:下划线分隔(如 `order_controller.ts`) -- **变量/函数**:小驼峰命名(如 `userName`、`async queryUserInfo()`) -- **类/构造函数**:大驼峰命名(如 `UserModel`、`OrderService`) -- **常量**:全大写 + 下划线(如 `PORT`、`DB_HOST`) -- **接口路由**:全小写 + 短横线(如 `/user/get-info`、`/order/create-order`) - -详细规范请查看:[命名规范文档](./docs/naming_convention.md) - -### Git 提交规范 - -项目采用约定式提交规范,提交信息格式:`<类型>:<简短描述>` - -**常用提交类型:** - -- `feat` - 新增功能 -- `fix` - 修复 Bug -- `docs` - 文档更新 -- `style` - 代码格式调整 -- `refactor` - 代码重构 -- `perf` - 性能优化 -- `test` - 测试相关 -- `chore` - 构建/工具变动 - -**后端特定类型:** - -- `api` - API 接口 -- `db` - 数据库 -- `websocket` - WebSocket -- `auth` - 认证授权 -- `dto` - 数据传输对象 -- `service` - 服务层 - -**核心原则:** - -- ⭐ 一次提交只做一件事 -- 使用中文冒号 `:` -- 简短明确(不超过 50 字符) -- 能拆分就拆分,保持提交历史清晰 - -**示例:** +### 🛠️ 安装与运行 ```bash -git commit -m "feat:实现玩家注册和登录功能" -git commit -m "fix:修复房间加入时的并发问题" -git commit -m "api:添加玩家信息查询接口" -``` +# 1. 克隆项目 +git clone +cd whale-town-end -详细规范请查看:[Git 提交规范文档](./docs/git_commit_guide.md) - -### 后端开发规范 - -项目要求严格的代码质量和可维护性标准: - -**核心要求:** - -- **完整注释**:每个模块、类、方法都必须有详细注释 -- **全面业务逻辑**:考虑所有可能情况,包括异常和边界条件 -- **关键日志记录**:重要操作必须记录日志,便于问题排查 -- **防御性编程**:对所有输入进行验证,实现健壮的错误处理 - -**注释要求:** - -```typescript -/** - * 玩家服务类 - * - * 职责:处理玩家相关的业务逻辑 - * 主要方法:createPlayer(), updatePlayerInfo(), getPlayerById() - * 使用场景:玩家注册登录流程、个人陈列室数据管理 - */ -@Injectable() -export class PlayerService { - /** - * 创建新玩家 - * @param email 玩家邮箱地址 - * @param nickname 玩家昵称 - * @returns Promise 创建成功的玩家对象 - * @throws BadRequestException 当邮箱格式错误时 - */ - async createPlayer(email: string, nickname: string): Promise { - // 详细的业务逻辑实现... - } -} -``` - -详细规范请查看:[后端开发规范指南](./docs/backend_development_guide.md) - -## 📚 开发文档 - -### 🔥 必读文档 -- **[AI 辅助开发规范指南](./docs/AI辅助开发规范指南.md)** - 🚨 **所有开发者必读!** 教你如何使用 AI 遵循项目规范 - -### 📋 规范文档 -- [后端开发规范](./docs/backend_development_guide.md) - 注释标准、业务逻辑设计和日志记录要求 -- [Git 提交规范](./docs/git_commit_guide.md) - Git 提交信息格式和最佳实践 -- [命名规范](./docs/naming_convention.md) - 项目命名规范和最佳实践 -- [NestJS 使用指南](./docs/nestjs_guide.md) - 详细的 NestJS 开发指南,包含实战案例 - -### 📖 API 文档 -- **[API 文档总览](./docs/api/README.md)** - API 文档使用指南和快速开始 -- **[Swagger UI](http://localhost:3000/api-docs)** - 交互式 API 文档(需启动服务器) -- [详细接口文档](./docs/api/api-documentation.md) - 完整的 API 接口说明 -- [OpenAPI 规范](./docs/api/openapi.yaml) - 标准化的 API 描述文件 -- [Postman 集合](./docs/api/postman-collection.json) - 可导入的 API 测试集合 - -### 💡 使用建议 -1. **开发前**:先读 AI 辅助指南,了解如何用 AI 帮助遵循规范 -2. **开发中**:参考具体规范文档,使用 AI 实时检查代码质量 -3. **API 开发**:使用 Swagger UI 进行接口测试,参考 API 文档进行开发 -4. **提交前**:用 AI 检查代码和提交信息是否符合规范 - -## 前置要求 - -- **Node.js** >= 18.0.0 默认:24.7.0 -- **pnpm** >= 8.0.0(推荐)默认:10.25.0 - -如果还没有安装 pnpm,请先安装: - -```bash -npm install -g pnpm -``` - -检查版本: - -```bash -node --version -pnpm --version -``` - -## 安装依赖 - -```bash +# 2. 安装依赖 pnpm install + +# 3. 配置环境(测试模式,无需数据库和邮件服务器) +cp .env.example .env + +# 4. 启动开发服务器 +pnpm run dev ``` -## 开发 +🎉 **服务启动成功!** 访问 http://localhost:3000 -启动开发服务器(支持热重载): +### 🧪 快速测试 ```bash -pnpm dev +# Windows +.\test-api.ps1 + +# Linux/macOS +./test-api.sh ``` -服务器将运行在 `http://localhost:3000` +**测试内容:** +- ✅ 邮箱验证码发送与验证 +- ✅ 用户注册与登录 +- ✅ Redis文件存储功能 +- ✅ 邮件测试模式 -## 测试 +--- -运行测试: +## 🎓 新开发者指南 -```bash -pnpm test -``` +### 第一步:了解项目规范 📚 -运行测试并监听文件变化: +**⚠️ 重要:在开始开发前,请务必阅读以下文档** -```bash -pnpm test:watch -``` +1. **[AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)** 🤖 + - 学会使用AI助手提升开发效率300% + - 自动生成符合规范的代码和注释 + - 实时检查代码质量 -运行测试并生成覆盖率报告: +2. **[后端开发规范](./docs/backend_development_guide.md)** 📝 + - 代码注释标准 + - 业务逻辑设计原则 + - 日志记录要求 -```bash -pnpm test:cov -``` +3. **[Git提交规范](./docs/git_commit_guide.md)** 🔄 + - 提交信息格式 + - 分支管理策略 -## 构建 - -```bash -pnpm build -``` - -## 生产环境运行 - -```bash -pnpm start:prod -``` - -## 项目结构 +### 第二步:熟悉项目架构 🏗️ ``` -src/ -├── api/ # API 接口层(控制器、网关) -├── business/ # 业务逻辑层 -├── core/ # 核心功能模块 -│ ├── db/ # 数据库相关 -│ └── utils/ # 工具函数 -│ └── logger/ # 日志系统 -├── main.ts # 应用入口 -├── app.module.ts # 根模块 -├── app.controller.ts # 根控制器 -└── app.service.ts # 根服务 -test/ -├── api/ # API 测试 -└── service/ # 服务测试 -docs/ # 项目文档 -├── api/ # API 接口文档 -│ ├── README.md # API 文档使用指南 -│ ├── api-documentation.md # 详细接口文档 -│ ├── openapi.yaml # OpenAPI 规范文件 -│ └── postman-collection.json # Postman 测试集合 -├── systems/ # 系统设计文档 -│ ├── logger/ # 日志系统文档 -│ └── user-auth/ # 用户认证系统文档 -├── backend_development_guide.md # 后端开发规范 -├── git_commit_guide.md # Git 提交规范 -├── naming_convention.md # 命名规范 -├── nestjs_guide.md # NestJS 使用指南 -└── AI辅助开发规范指南.md # AI 辅助开发指南 +项目根目录/ +├── src/ # 源代码目录 +│ ├── api/ # API接口层(预留,用于游戏相关控制器) +│ ├── business/ # 业务逻辑层 +│ │ └── login/ # 登录业务模块 +│ ├── core/ # 核心功能模块 +│ │ ├── db/ # 数据库层 +│ │ │ └── users/ # 用户数据模型(支持MySQL/内存双模式) +│ │ ├── redis/ # Redis缓存服务(支持真实Redis/文件存储) +│ │ ├── login_core/ # 登录核心服务 +│ │ └── utils/ # 工具服务 +│ │ ├── email/ # 邮件服务(支持SMTP/测试模式) +│ │ ├── verification/ # 验证码服务 +│ │ └── logger/ # 日志系统 +│ ├── dto/ # 数据传输对象 +│ ├── types/ # TypeScript类型定义 +│ ├── app.module.ts # 应用主模块 +│ └── main.ts # 应用入口 +├── docs/ # 项目文档 +│ ├── api/ # API文档 +│ └── systems/ # 系统设计文档 +├── test/ # 测试文件 +├── redis-data/ # Redis文件存储数据 +├── logs/ # 日志文件 +└── 配置文件 # .env, package.json, tsconfig.json等 ``` -## 核心功能 +**架构特点:** +- 🏗️ **分层架构** - 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 第三方登录 -- 密码重置和修改功能 -- bcrypt 密码加密 -- 基于角色的权限控制 +### 🗄️ 灵活存储方案 +- **Redis文件存储** - 开发测试无需Redis服务器 +- **内存数据库** - 无需MySQL即可运行 +- **生产就绪** - 支持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 -# 启动服务器 +# 开发服务器(热重载) 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. **� Bug修复** - 发现并修复问题 +2. **✨ 新功能** - 添加有价值的功能 +3. **� 文档改馈进** - 完善项目文档 +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) 开源协议。 + +--- + +
+ +**🐋 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) + +
\ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7846627 --- /dev/null +++ b/TESTING.md @@ -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" + +# 生产环境设置 +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) 提高开发效率 \ No newline at end of file diff --git a/Test-Verification-Debug.ps1 b/Test-Verification-Debug.ps1 deleted file mode 100644 index 00c43e4..0000000 --- a/Test-Verification-Debug.ps1 +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/API_STATUS_CODES.md b/docs/API_STATUS_CODES.md new file mode 100644 index 0000000..1042acb --- /dev/null +++ b/docs/API_STATUS_CODES.md @@ -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 + +
+ ✅ 验证码已发送,请查收邮件 +
+ + +
+ ⚠️ 测试模式:验证码是 123456 +
+ 请配置邮件服务以启用真实发送 +
+ + +
+ ❌ 发送失败:邮箱格式错误 +
+``` + +## 📝 开发建议 + +### 1. 状态码检查 + +```javascript +// 推荐:明确检查状态码 +if (response.status === 206) { + // 处理测试模式 +} else if (response.status === 200) { + // 处理真实发送 +} + +// 不推荐:只检查 success 字段 +if (data.success) { + // 可能遗漏测试模式的情况 +} +``` + +### 2. 错误处理 + +```javascript +// 推荐:根据 error_code 进行精确处理 +switch (data.error_code) { + case 'TEST_MODE_ONLY': + handleTestMode(data); + break; + case 'SEND_CODE_FAILED': + handleSendFailure(data); + break; + default: + handleGenericError(data); +} +``` + +### 3. 用户体验 + +- **测试模式**:清晰提示用户当前处于测试模式 +- **配置引导**:提供配置邮件服务的链接或说明 +- **验证码显示**:在测试模式下直接显示验证码 +- **状态区分**:用不同的颜色和图标区分不同状态 + +## 🔗 相关文档 + +- [邮件服务配置指南](./EMAIL_CONFIGURATION.md) +- [快速启动指南](./QUICK_START.md) +- [API 文档](./api/README.md) \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b1b471e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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请求频率限制 \ No newline at end of file diff --git a/package.json b/package.json index 129c528..ac9307f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.20", + "@types/express": "^5.0.6", "@types/jest": "^29.5.14", "@types/node": "^20.19.27", "@types/nodemailer": "^6.4.14", diff --git a/src/app.controller.ts b/src/app.controller.ts index 1d102bb..5b66006 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,12 +1,49 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AppService } from './app.service'; +import { AppStatusResponseDto } from './dto/app.dto'; +import { ErrorResponseDto } from './dto/error_response.dto'; +/** + * 应用根控制器 + * + * 功能描述: + * - 提供应用基础信息和健康检查接口 + * - 用于监控服务运行状态 + * + * @author moyin + * @version 1.0.0 + * @since 2025-12-17 + */ +@ApiTags('App') @Controller() export class AppController { constructor(private readonly appService: AppService) {} + /** + * 获取应用状态 + * + * 功能描述: + * 返回应用的基本运行状态信息,用于健康检查和监控 + * + * @returns 应用状态信息 + */ @Get() - getStatus(): string { + @ApiOperation({ + summary: '获取应用状态', + description: '返回应用的基本运行状态信息,包括服务名称、版本、运行时间等。用于健康检查和服务监控。' + }) + @ApiResponse({ + status: 200, + description: '成功获取应用状态', + type: AppStatusResponseDto + }) + @ApiResponse({ + status: 500, + description: '服务器内部错误', + type: ErrorResponseDto + }) + getStatus(): AppStatusResponseDto { return this.appService.getStatus(); } } diff --git a/src/app.module.ts b/src/app.module.ts index ec8112e..12d727f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,29 @@ import { LoginCoreModule } from './core/login_core/login_core.module'; import { LoginModule } from './business/login/login.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({ imports: [ ConfigModule.forRoot({ @@ -17,17 +40,25 @@ import { RedisModule } from './core/redis/redis.module'; }), LoggerModule, RedisModule, - TypeOrmModule.forRoot({ - type: 'mysql', - host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT), - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - entities: [__dirname + '/**/*.entity{.ts,.js}'], - synchronize: false, - }), - UsersModule, + // 条件导入TypeORM模块 + ...(isDatabaseConfigured() ? [ + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: false, + // 添加连接超时和重试配置 + connectTimeout: 10000, + retryAttempts: 3, + retryDelay: 3000, + }), + ] : []), + // 根据数据库配置选择用户模块模式 + isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(), LoginCoreModule, LoginModule, ], diff --git a/src/app.service.ts b/src/app.service.ts index c93fae9..59dea0d 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,52 @@ 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() export class AppService { - getStatus(): string { - return 'Pixel Game Server is running!'; + private readonly startTime: number; + + 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('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(varName)); } } diff --git a/src/business/login/login.controller.ts b/src/business/login/login.controller.ts index ba16f86..dc117c5 100644 --- a/src/business/login/login.controller.ts +++ b/src/business/login/login.controller.ts @@ -14,22 +14,25 @@ * - POST /auth/reset-password - 重置密码 * - PUT /auth/change-password - 修改密码 * - * @author moyin + * @author moyin angjustinl * @version 1.0.0 * @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 { Response } from 'express'; 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 { LoginResponseDto, RegisterResponseDto, GitHubOAuthResponseDto, ForgotPasswordResponseDto, - CommonResponseDto -} from './login-response.dto'; + CommonResponseDto, + TestModeEmailVerificationResponseDto, + SuccessEmailVerificationResponseDto +} from '../../dto/login_response.dto'; @ApiTags('auth') @Controller('auth') @@ -151,6 +154,7 @@ export class LoginController { * 发送密码重置验证码 * * @param forgotPasswordDto 忘记密码数据 + * @param res Express响应对象 * @returns 发送结果 */ @ApiOperation({ @@ -163,6 +167,11 @@ export class LoginController { description: '验证码发送成功', type: ForgotPasswordResponseDto }) + @SwaggerApiResponse({ + status: 206, + description: '测试模式:验证码已生成但未真实发送', + type: ForgotPasswordResponseDto + }) @SwaggerApiResponse({ status: 400, description: '请求参数错误' @@ -172,10 +181,21 @@ export class LoginController { description: '用户不存在' }) @Post('forgot-password') - @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true })) - async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto): Promise> { - return await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier); + async forgotPassword( + @Body() forgotPasswordDto: ForgotPasswordDto, + @Res() res: Response + ): Promise { + 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 res Express响应对象 * @returns 发送结果 */ @ApiOperation({ @@ -265,8 +286,13 @@ export class LoginController { @ApiBody({ type: SendEmailVerificationDto }) @SwaggerApiResponse({ status: 200, - description: '验证码发送成功', - type: ForgotPasswordResponseDto + description: '验证码发送成功(真实发送模式)', + type: SuccessEmailVerificationResponseDto + }) + @SwaggerApiResponse({ + status: 206, + description: '测试模式:验证码已生成但未真实发送', + type: TestModeEmailVerificationResponseDto }) @SwaggerApiResponse({ status: 400, @@ -277,10 +303,21 @@ export class LoginController { description: '发送频率过高' }) @Post('send-email-verification') - @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true })) - async sendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise> { - return await this.loginService.sendEmailVerification(sendEmailVerificationDto.email); + async sendEmailVerification( + @Body() sendEmailVerificationDto: SendEmailVerificationDto, + @Res() res: Response + ): Promise { + 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 res Express响应对象 * @returns 发送结果 */ @ApiOperation({ @@ -329,6 +367,11 @@ export class LoginController { description: '验证码重新发送成功', type: ForgotPasswordResponseDto }) + @SwaggerApiResponse({ + status: 206, + description: '测试模式:验证码已生成但未真实发送', + type: ForgotPasswordResponseDto + }) @SwaggerApiResponse({ status: 400, description: '邮箱已验证或用户不存在' @@ -338,10 +381,21 @@ export class LoginController { description: '发送频率过高' }) @Post('resend-email-verification') - @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ transform: true })) - async resendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise> { - return await this.loginService.resendEmailVerification(sendEmailVerificationDto.email); + async resendEmailVerification( + @Body() sendEmailVerificationDto: SendEmailVerificationDto, + @Res() res: Response + ): Promise { + 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); + } } /** diff --git a/src/business/login/login.service.spec.ts b/src/business/login/login.service.spec.ts index f3ac346..e1495e3 100644 --- a/src/business/login/login.service.spec.ts +++ b/src/business/login/login.service.spec.ts @@ -137,13 +137,31 @@ describe('LoginService', () => { }); describe('sendPasswordResetCode', () => { - it('should return success response with verification code', async () => { - loginCoreService.sendPasswordResetCode.mockResolvedValue('123456'); + it('should return test mode response with verification code', async () => { + loginCoreService.sendPasswordResetCode.mockResolvedValue({ + code: '123456', + isTestMode: true + }); + + const result = await service.sendPasswordResetCode('test@example.com'); + + expect(result.success).toBe(false); // 测试模式下不算成功 + expect(result.error_code).toBe('TEST_MODE_ONLY'); + expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(true); + }); + + it('should return success response for real email sending', async () => { + loginCoreService.sendPasswordResetCode.mockResolvedValue({ + code: '123456', + isTestMode: false + }); const result = await service.sendPasswordResetCode('test@example.com'); expect(result.success).toBe(true); - expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(false); + expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码 }); }); diff --git a/src/business/login/login.service.ts b/src/business/login/login.service.ts index f17b838..6c23fe4 100644 --- a/src/business/login/login.service.ts +++ b/src/business/login/login.service.ts @@ -11,7 +11,7 @@ * - 调用核心服务完成具体功能 * - 为控制器层提供业务接口 * - * @author moyin + * @author moyin angjustinl * @version 1.0.0 * @since 2025-12-17 */ @@ -199,21 +199,37 @@ export class LoginService { * @param identifier 邮箱或手机号 * @returns 响应结果 */ - async sendPasswordResetCode(identifier: string): Promise> { + async sendPasswordResetCode(identifier: string): Promise> { try { this.logger.log(`发送密码重置验证码: ${identifier}`); // 调用核心服务发送验证码 - const verificationCode = await this.loginCoreService.sendPasswordResetCode(identifier); + const result = await this.loginCoreService.sendPasswordResetCode(identifier); this.logger.log(`密码重置验证码已发送: ${identifier}`); - // 实际应用中不应返回验证码,这里仅用于演示 - return { - success: true, - data: { verification_code: verificationCode }, - message: '验证码已发送,请查收' - }; + // 根据是否为测试模式返回不同的状态和消息 by angjustinl 2025-12-17 + if (result.isTestMode) { + // 测试模式:验证码生成但未真实发送 + return { + success: false, // 测试模式下不算真正成功 + data: { + verification_code: result.code, + is_test_mode: true + }, + message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + error_code: 'TEST_MODE_ONLY' + }; + } else { + // 真实发送模式 + return { + success: true, + data: { + is_test_mode: false + }, + message: '验证码已发送,请查收' + }; + } } catch (error) { this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error)); @@ -293,21 +309,37 @@ export class LoginService { * @param email 邮箱地址 * @returns 响应结果 */ - async sendEmailVerification(email: string): Promise> { + async sendEmailVerification(email: string): Promise> { try { this.logger.log(`发送邮箱验证码: ${email}`); // 调用核心服务发送验证码 - const verificationCode = await this.loginCoreService.sendEmailVerification(email); + const result = await this.loginCoreService.sendEmailVerification(email); this.logger.log(`邮箱验证码已发送: ${email}`); - // 实际应用中不应返回验证码,这里仅用于演示 - return { - success: true, - data: { verification_code: verificationCode }, - message: '验证码已发送,请查收邮件' - }; + // 根据是否为测试模式返回不同的状态和消息 + if (result.isTestMode) { + // 测试模式:验证码生成但未真实发送 + return { + success: false, // 测试模式下不算真正成功 + data: { + verification_code: result.code, + is_test_mode: true + }, + message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + error_code: 'TEST_MODE_ONLY' + }; + } else { + // 真实发送模式 + return { + success: true, + data: { + is_test_mode: false + }, + message: '验证码已发送,请查收邮件' + }; + } } catch (error) { this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); @@ -363,21 +395,37 @@ export class LoginService { * @param email 邮箱地址 * @returns 响应结果 */ - async resendEmailVerification(email: string): Promise> { + async resendEmailVerification(email: string): Promise> { try { this.logger.log(`重新发送邮箱验证码: ${email}`); // 调用核心服务重新发送验证码 - const verificationCode = await this.loginCoreService.resendEmailVerification(email); + const result = await this.loginCoreService.resendEmailVerification(email); this.logger.log(`邮箱验证码已重新发送: ${email}`); - // 实际应用中不应返回验证码,这里仅用于演示 - return { - success: true, - data: { verification_code: verificationCode }, - message: '验证码已重新发送,请查收邮件' - }; + // 根据是否为测试模式返回不同的状态和消息 + if (result.isTestMode) { + // 测试模式:验证码生成但未真实发送 + return { + success: false, // 测试模式下不算真正成功 + data: { + verification_code: result.code, + is_test_mode: true + }, + message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + error_code: 'TEST_MODE_ONLY' + }; + } else { + // 真实发送模式 + return { + success: true, + data: { + is_test_mode: false + }, + message: '验证码已重新发送,请查收邮件' + }; + } } catch (error) { this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); diff --git a/src/core/db/users/users.module.ts b/src/core/db/users/users.module.ts index 2987a3c..530446a 100644 --- a/src/core/db/users/users.module.ts +++ b/src/core/db/users/users.module.ts @@ -4,23 +4,61 @@ * 功能描述: * - 整合用户相关的实体、服务和控制器 * - 配置TypeORM实体和Repository + * - 支持数据库和内存存储的动态切换 by angjustinl 2025-12-17 * - 导出用户服务供其他模块使用 * - * @author moyin - * @version 1.0.0 + * 存储模式:by angjustinl 2025-12-17 + * - 数据库模式:使用TypeORM连接MySQL数据库 + * - 内存模式:使用Map存储,适用于开发和测试 + * + * @author moyin angjustinl + * @version 1.0.1 * @since 2025-12-17 */ -import { Module } from '@nestjs/common'; +import { Module, DynamicModule, Global } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from './users.entity'; import { UsersService } from './users.service'; +import { UsersMemoryService } from './users_memory.service'; -@Module({ - imports: [ - TypeOrmModule.forFeature([Users]) - ], - providers: [UsersService], - exports: [UsersService, TypeOrmModule], -}) -export class UsersModule {} \ No newline at end of file +@Global() +@Module({}) +export class UsersModule { + /** + * 创建数据库模式的用户模块 + * + * @returns 配置了TypeORM的动态模块 + */ + static forDatabase(): DynamicModule { + return { + module: UsersModule, + imports: [TypeOrmModule.forFeature([Users])], + providers: [ + { + provide: 'UsersService', + useClass: UsersService, + }, + ], + exports: ['UsersService', TypeOrmModule], + }; + } + + /** + * 创建内存模式的用户模块 + * + * @returns 配置了内存存储的动态模块 + */ + static forMemory(): DynamicModule { + return { + module: UsersModule, + providers: [ + { + provide: 'UsersService', + useClass: UsersMemoryService, + }, + ], + exports: ['UsersService'], + }; + } +} \ No newline at end of file diff --git a/src/core/db/users/users_memory.service.ts b/src/core/db/users/users_memory.service.ts new file mode 100644 index 0000000..cb515b0 --- /dev/null +++ b/src/core/db/users/users_memory.service.ts @@ -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 = new Map(); + private currentId: bigint = BigInt(1); + + /** + * 创建新用户 + * + * @param createUserDto 创建用户的数据传输对象 + * @returns 创建的用户实体 + * @throws ConflictException 当用户名、邮箱或手机号已存在时 + * @throws BadRequestException 当数据验证失败时 + */ + async create(createUserDto: CreateUserDto): Promise { + // 验证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 { + 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 { + const user = this.users.get(id); + + if (!user) { + throw new NotFoundException(`ID为 ${id} 的用户不存在`); + } + + return user; + } + + /** + * 根据用户名查询用户 + * + * @param username 用户名 + * @returns 用户实体或null + */ + async findByUsername(username: string): Promise { + const user = Array.from(this.users.values()).find( + u => u.username === username + ); + return user || null; + } + + /** + * 根据邮箱查询用户 + * + * @param email 邮箱 + * @returns 用户实体或null + */ + async findByEmail(email: string): Promise { + 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 { + 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): Promise { + // 检查用户是否存在 + 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 { + const user = await this.findOne(id); + this.users.delete(id); + return user; + } + + /** + * 统计用户数量 + * + * @param conditions 查询条件(内存模式下简化处理) + * @returns 用户数量 + */ + async count(conditions?: any): Promise { + 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 { + return this.users.has(id); + } + + /** + * 批量创建用户 + * + * @param createUserDtos 用户数据数组 + * @returns 创建的用户列表 + */ + async createBatch(createUserDtos: CreateUserDto[]): Promise { + 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 { + 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 { + 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); + } +} diff --git a/src/core/login_core/login_core.service.spec.ts b/src/core/login_core/login_core.service.spec.ts index 2fb5514..6d7c47a 100644 --- a/src/core/login_core/login_core.service.spec.ts +++ b/src/core/login_core/login_core.service.spec.ts @@ -180,11 +180,15 @@ describe('LoginCoreService', () => { const verifiedUser = { ...mockUser, email_verified: true }; usersService.findByEmail.mockResolvedValue(verifiedUser); 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 () => { diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index d288a49..7499a4d 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -16,10 +16,10 @@ * @since 2025-12-17 */ -import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; -import { UsersService } from '../db/users/users.service'; +import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, Inject } from '@nestjs/common'; 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 * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; @@ -90,10 +90,20 @@ export interface AuthResult { isNewUser?: boolean; } +/** + * 验证码发送结果接口 by angjustinl 2025-12-17 + */ +export interface VerificationCodeResult { + /** 验证码 */ + code: string; + /** 是否为测试模式 */ + isTestMode: boolean; +} + @Injectable() export class LoginCoreService { constructor( - private readonly usersService: UsersService, + @Inject('UsersService') private readonly usersService: UsersService, private readonly emailService: EmailService, private readonly verificationService: VerificationService, ) {} @@ -122,7 +132,7 @@ export class LoginCoreService { // 如果邮箱未找到,尝试手机号查找(简单验证) if (!user && this.isPhoneNumber(identifier)) { 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 邮箱或手机号 - * @returns 验证码(实际应用中应发送到用户邮箱/手机) + * @returns 验证码结果 * @throws NotFoundException 用户不存在时 */ - async sendPasswordResetCode(identifier: string): Promise { + async sendPasswordResetCode(identifier: string): Promise { // 查找用户 let user: Users | null = null; @@ -285,7 +295,7 @@ export class LoginCoreService { } } else if (this.isPhoneNumber(identifier)) { 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) { @@ -299,23 +309,28 @@ export class LoginCoreService { ); // 发送验证码 + let isTestMode = false; + if (this.isEmail(identifier)) { - const success = await this.emailService.sendVerificationCode({ + const result = await this.emailService.sendVerificationCode({ email: identifier, code: verificationCode, nickname: user.nickname, purpose: 'password_reset' }); - if (!success) { + if (!result.success) { throw new BadRequestException('验证码发送失败,请稍后重试'); } + + isTestMode = result.isTestMode; } else { // TODO: 实现短信发送 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); } else if (this.isPhoneNumber(identifier)) { 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) { @@ -457,9 +472,9 @@ export class LoginCoreService { * * @param email 邮箱地址 * @param nickname 用户昵称 - * @returns 验证码 + * @returns 验证码结果 */ - async sendEmailVerification(email: string, nickname?: string): Promise { + async sendEmailVerification(email: string, nickname?: string): Promise { // 生成验证码 const verificationCode = await this.verificationService.generateCode( email, @@ -467,18 +482,18 @@ export class LoginCoreService { ); // 发送验证邮件 - const success = await this.emailService.sendVerificationCode({ + const result = await this.emailService.sendVerificationCode({ email, code: verificationCode, nickname, purpose: 'email_verification' }); - if (!success) { + if (!result.success) { throw new BadRequestException('验证邮件发送失败,请稍后重试'); } - return verificationCode; // 实际应用中不应返回验证码 + return { code: verificationCode, isTestMode: result.isTestMode }; } /** @@ -520,9 +535,9 @@ export class LoginCoreService { * 重新发送邮箱验证码 * * @param email 邮箱地址 - * @returns 验证码 + * @returns 验证码结果 */ - async resendEmailVerification(email: string): Promise { + async resendEmailVerification(email: string): Promise { const user = await this.usersService.findByEmail(email); if (!user) { diff --git a/src/core/utils/email/email.service.spec.ts b/src/core/utils/email/email.service.spec.ts index b16eb96..1cbbf59 100644 --- a/src/core/utils/email/email.service.spec.ts +++ b/src/core/utils/email/email.service.spec.ts @@ -72,7 +72,7 @@ describe('EmailService', () => { configService.get.mockReturnValue(undefined); // 重新创建服务实例来测试测试模式 - const testService = new EmailService(configService); + new EmailService(configService); expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ streamTransport: true, @@ -89,7 +89,7 @@ describe('EmailService', () => { .mockReturnValueOnce('test@gmail.com') // EMAIL_USER .mockReturnValueOnce('password'); // EMAIL_PASS - const testService = new EmailService(configService); + new EmailService(configService); expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.gmail.com', @@ -117,7 +117,8 @@ describe('EmailService', () => { const result = await service.sendEmail(emailOptions); - expect(result).toBe(true); + expect(result.success).toBe(true); + expect(result.isTestMode).toBe(false); expect(mockTransporter.sendMail).toHaveBeenCalledWith({ from: '"Test Sender" ', to: 'test@example.com', @@ -138,7 +139,8 @@ describe('EmailService', () => { const result = await service.sendEmail(emailOptions); - expect(result).toBe(false); + expect(result.success).toBe(false); + expect(result.error).toBe('发送失败'); }); it('应该在测试模式下输出邮件内容', async () => { @@ -157,13 +159,14 @@ describe('EmailService', () => { }; // 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; const result = await service.sendEmail(emailOptions); - expect(result).toBe(true); - expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式) ==='); + expect(result.success).toBe(true); + expect(result.isTestMode).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式 - 邮件未真实发送) ==='); loggerSpy.mockRestore(); }); @@ -183,7 +186,8 @@ describe('EmailService', () => { 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.objectContaining({ to: 'test@example.com', @@ -206,7 +210,8 @@ describe('EmailService', () => { 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.objectContaining({ to: 'test@example.com', @@ -227,7 +232,8 @@ describe('EmailService', () => { 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', '测试用户'); - expect(result).toBe(true); + expect(result.success).toBe(true); + expect(result.isTestMode).toBe(false); expect(mockTransporter.sendMail).toHaveBeenCalledWith( expect.objectContaining({ to: 'test@example.com', @@ -253,7 +260,8 @@ describe('EmailService', () => { 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); - expect(result).toBe(false); + expect(result.success).toBe(false); + expect(result.error).toBe('ECONNREFUSED'); }); it('应该正确处理认证错误', async () => { @@ -372,7 +381,8 @@ describe('EmailService', () => { const result = await service.sendEmail(emailOptions); - expect(result).toBe(false); + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid login'); }); it('应该正确处理连接验证错误', async () => { @@ -393,7 +403,7 @@ describe('EmailService', () => { .mockReturnValueOnce(undefined) // EMAIL_USER .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_PORT', 587); @@ -408,7 +418,7 @@ describe('EmailService', () => { .mockReturnValueOnce('custom@163.com') // EMAIL_USER .mockReturnValueOnce('custompass'); // EMAIL_PASS - const testService = new EmailService(configService); + new EmailService(configService); expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.163.com', diff --git a/src/core/utils/email/email.service.ts b/src/core/utils/email/email.service.ts index 2fe85c1..f6f2a4f 100644 --- a/src/core/utils/email/email.service.ts +++ b/src/core/utils/email/email.service.ts @@ -50,6 +50,18 @@ export interface VerificationEmailOptions { purpose: 'email_verification' | 'password_reset'; } +/** + * 邮件发送结果接口 by angjustinl 2025-12-17 + */ +export interface EmailSendResult { + /** 是否成功 */ + success: boolean; + /** 是否为测试模式 */ + isTestMode: boolean; + /** 错误信息(如果失败) */ + error?: string; +} + @Injectable() export class EmailService { 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 邮件选项 * @returns 发送结果 */ - async sendEmail(options: EmailOptions): Promise { + async sendEmail(options: EmailOptions): Promise { try { const mailOptions = { from: this.configService.get('EMAIL_FROM', '"Whale Town Game" '), @@ -103,22 +124,31 @@ export class EmailService { text: options.text, }; - const result = await this.transporter.sendMail(mailOptions); - + const isTestMode = this.isTestMode(); + // 如果是测试模式,输出邮件内容到控制台 - if ((this.transporter.options as any).streamTransport) { - this.logger.log('=== 邮件发送(测试模式) ==='); - this.logger.log(`收件人: ${options.to}`); - this.logger.log(`主题: ${options.subject}`); - this.logger.log(`内容: ${options.text || '请查看HTML内容'}`); - this.logger.log('========================'); + if (isTestMode) { + this.logger.warn('=== 邮件发送(测试模式 - 邮件未真实发送) ==='); + this.logger.warn(`收件人: ${options.to}`); + this.logger.warn(`主题: ${options.subject}`); + this.logger.warn(`内容: ${options.text || '请查看HTML内容'}`); + 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) { - this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error)); - return false; + this.logger.error(`❌ 邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error)); + return { + success: false, + isTestMode: this.isTestMode(), + error: error instanceof Error ? error.message : String(error) + }; } } @@ -128,7 +158,7 @@ export class EmailService { * @param options 验证码邮件选项 * @returns 发送结果 */ - async sendVerificationCode(options: VerificationEmailOptions): Promise { + async sendVerificationCode(options: VerificationEmailOptions): Promise { const { email, code, nickname, purpose } = options; let subject: string; @@ -157,7 +187,7 @@ export class EmailService { * @param nickname 用户昵称 * @returns 发送结果 */ - async sendWelcomeEmail(email: string, nickname: string): Promise { + async sendWelcomeEmail(email: string, nickname: string): Promise { const subject = '🎮 欢迎加入 Whale Town!'; const template = this.getWelcomeTemplate(nickname); diff --git a/src/core/utils/verification/verification.service.spec.ts b/src/core/utils/verification/verification.service.spec.ts index 298f21a..72f218b 100644 --- a/src/core/utils/verification/verification.service.spec.ts +++ b/src/core/utils/verification/verification.service.spec.ts @@ -272,6 +272,7 @@ describe('VerificationService', () => { }; mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo)); + mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒 await expect(service.verifyCode(email, type, '654321')).rejects.toThrow( new BadRequestException('验证码错误,剩余尝试次数: 1') @@ -285,7 +286,7 @@ describe('VerificationService', () => { expect(mockRedis.set).toHaveBeenCalledWith( `verification_code:${type}:${email}`, JSON.stringify(updatedCodeInfo), - 300 + 240 ); }); @@ -298,6 +299,7 @@ describe('VerificationService', () => { }; mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo)); + mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒 await expect(service.verifyCode(email, type, '654321')).rejects.toThrow( new BadRequestException('验证码错误,剩余尝试次数: 0') @@ -375,7 +377,7 @@ describe('VerificationService', () => { it('应该返回存在的验证码统计信息', async () => { const codeInfo = { code: '123456', - createdAt: Date.now(), + createdAt: 1766035834340, attempts: 1, maxAttempts: 3, }; @@ -391,18 +393,20 @@ describe('VerificationService', () => { ttl: 240, attempts: 1, maxAttempts: 3, + code: '123456', + createdAt: expect.any(Number), }); }); it('应该在验证码不存在时返回基本信息', async () => { mockRedis.exists.mockResolvedValue(false); - mockRedis.ttl.mockResolvedValue(-1); + mockRedis.ttl.mockResolvedValue(-2); // -2 表示键不存在 const result = await service.getCodeStats(email, type); expect(result).toEqual({ exists: false, - ttl: -1, + ttl: -2, // 修改为 -2 }); }); diff --git a/src/dto/app.dto.ts b/src/dto/app.dto.ts new file mode 100644 index 0000000..498f5c5 --- /dev/null +++ b/src/dto/app.dto.ts @@ -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'; +} \ No newline at end of file diff --git a/src/dto/error_response.dto.ts b/src/dto/error_response.dto.ts new file mode 100644 index 0000000..595fc42 --- /dev/null +++ b/src/dto/error_response.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/business/login/login.dto.ts b/src/dto/login.dto.ts similarity index 100% rename from src/business/login/login.dto.ts rename to src/dto/login.dto.ts diff --git a/src/business/login/login-response.dto.ts b/src/dto/login_response.dto.ts similarity index 62% rename from src/business/login/login-response.dto.ts rename to src/dto/login_response.dto.ts index d9f2a18..ef853f2 100644 --- a/src/business/login/login-response.dto.ts +++ b/src/dto/login_response.dto.ts @@ -209,6 +209,13 @@ export class ForgotPasswordResponseDataDto { required: false }) verification_code?: string; + + @ApiProperty({ + description: '是否为测试模式', + example: true, + required: false + }) + is_test_mode?: boolean; } /** @@ -217,26 +224,76 @@ export class ForgotPasswordResponseDataDto { export class ForgotPasswordResponseDto { @ApiProperty({ description: '请求是否成功', - example: true + example: false, + examples: { + success: { + summary: '真实发送成功', + value: true + }, + testMode: { + summary: '测试模式', + value: false + } + } }) success: boolean; @ApiProperty({ description: '响应数据', 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; @ApiProperty({ description: '响应消息', - example: '验证码已发送,请查收' + example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + examples: { + success: { + summary: '真实发送成功', + value: '验证码已发送,请查收' + }, + testMode: { + summary: '测试模式', + value: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。' + } + } }) message: string; @ApiProperty({ 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 }) error_code?: string; @@ -264,4 +321,75 @@ export class CommonResponseDto { required: false }) 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; } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 4f1028d..279f830 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,42 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; 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() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log'], + }); // 全局启用校验管道(核心配置) app.useGlobalPipes( diff --git a/test-api.ps1 b/test-api.ps1 new file mode 100644 index 0000000..cd432c9 --- /dev/null +++ b/test-api.ps1 @@ -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 \ No newline at end of file diff --git a/test-api.sh b/test-api.sh new file mode 100644 index 0000000..a4fb3c0 --- /dev/null +++ b/test-api.sh @@ -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" \ No newline at end of file