21 Commits

Author SHA1 Message Date
dd856b9ba6 Merge pull request 'fix/login-verification-email-template' (#26) from fix/login-verification-email-template into main
Reviewed-on: datawhale/whale-town-end#26
2025-12-25 20:57:25 +08:00
moyin
07601b6d79 docs:更新README中的测试说明
- 更新快速测试部分,使用新的综合测试脚本
- 添加测试脚本的参数说明(跳过限流测试、自定义服务器等)
- 更新测试内容列表,包含新增的功能测试
- 统一测试命令,简化开发者使用流程
2025-12-25 20:51:00 +08:00
moyin
7429de3cf4 chore:整理API测试脚本
- 移除分散的旧测试脚本(test-api.ps1, test-api.sh, test-register-fix.ps1, test-throttle.ps1)
- 添加统一的综合测试脚本(test-comprehensive.ps1)
- 新脚本支持更多功能:跳过限流测试、自定义服务器地址等
- 提供更完整的API功能测试覆盖
2025-12-25 20:50:29 +08:00
moyin
0192934c66 test:添加验证码冷却时间清除功能测试
为新增的验证码冷却时间清除功能添加全面的测试用例:

验证服务测试:
- 测试成功清除冷却时间
- 测试清除不存在的冷却时间
- 测试Redis操作错误处理
- 测试不同类型和标识符的冷却时间清除

登录核心服务测试:
- 测试注册成功后自动清除冷却时间
- 测试密码重置成功后自动清除冷却时间
- 测试验证码登录成功后自动清除冷却时间
- 测试冷却时间清除失败的优雅处理
2025-12-25 20:49:16 +08:00
moyin
64370c3206 feat:集成验证码冷却时间自动清除机制
在用户成功完成关键操作后自动清除验证码冷却时间:
- 用户注册成功后清除邮箱验证码冷却时间
- 密码重置成功后清除密码重置验证码冷却时间
- 验证码登录成功后清除登录验证码冷却时间

清除失败不影响主流程,只记录警告日志,确保用户体验。
2025-12-25 20:48:53 +08:00
moyin
a78df48101 feat:添加验证码冷却时间清除功能
新增 clearCooldown 方法,用于在用户成功完成操作后
清除验证码冷却时间,提升用户体验:
- 注册成功后清除邮箱验证码冷却时间
- 密码重置成功后清除重置验证码冷却时间
- 验证码登录成功后清除登录验证码冷却时间
2025-12-25 20:48:15 +08:00
moyin
0005dc773c api:更新登录验证码接口Swagger注解
更新发送登录验证码接口的ApiOperation描述,
明确说明邮件使用专门的登录验证码模板,
内容标识为登录验证而非密码重置。
2025-12-25 20:41:00 +08:00
moyin
946d328be6 docs:更新登录验证码邮件模板修复相关文档
- 在API文档重要提醒中添加邮件模板修复说明
- 更新OpenAPI文档版本号至1.1.3
- 增强发送登录验证码接口的描述,明确说明使用专门的登录验证码模板
2025-12-25 20:40:45 +08:00
moyin
841a58886e test:添加登录验证码邮件发送测试
为修复的登录验证码邮件模板功能添加专门的测试用例:
- 测试登录验证码邮件发送功能
- 验证邮件模板内容包含正确的登录验证码信息
- 确保邮件主题和内容符合预期
2025-12-25 20:40:27 +08:00
moyin
91565f716d fix:修复登录验证码邮件模板错误
登录验证码发送时错误地使用了密码重置邮件模板,
导致用户收到的邮件内容显示为'密码重置'而不是'登录验证码'。

修改 EmailService.sendVerificationCode 方法,
当 purpose 为 'login_verification' 时使用正确的
getLoginVerificationTemplate 方法而不是 getPasswordResetTemplate。
2025-12-25 20:40:08 +08:00
417b01323e Merge pull request 'feature/email-conflict-detection-v1.1.1' (#25) from feature/email-conflict-detection-v1.1.1 into main
Reviewed-on: datawhale/whale-town-end#25
2025-12-25 18:33:58 +08:00
b3de6dec5f Merge branch 'main' into feature/email-conflict-detection-v1.1.1 2025-12-25 18:33:47 +08:00
moyin
d683f0d5da feat: 邮箱冲突检测优化 v1.1.1
- 新增邮箱冲突检测:发送验证码前检查邮箱是否已被注册
- 优化用户体验:避免向已注册邮箱发送无用验证码
- 改进错误处理:返回409 Conflict状态码和明确错误信息
- 更新API文档:重新整理文档结构,突出前端开发要点
- 完善测试用例:添加邮箱冲突检测相关测试
- 版本升级:1.1.0  1.1.1

核心修改:
- src/core/login_core/login_core.service.ts: 在sendEmailVerification方法中添加邮箱存在性检查
- src/business/auth/controllers/login.controller.ts: 正确处理409冲突状态码
- docs/api/api-documentation.md: 重新整理为精简实用的前端开发文档
- docs/api/openapi.yaml: 更新版本和接口描述
- test-register-fix.ps1: 添加邮箱冲突检测测试用例
2025-12-25 18:31:36 +08:00
moyin
aae77866ac docs: 更新API文档,反映HTTP状态码修复
文档更新内容:
- 更新注册接口响应示例,区分400和409状态码
- 添加资源冲突响应示例(用户名、邮箱、手机号已存在)
- 完善OpenAPI文档,添加详细的响应示例
- 更新错误码表格,明确不同错误的状态码
- 添加HTTP状态码测试场景

 修复说明:
- 409 Conflict:用户名/邮箱/手机号已存在
- 400 Bad Request:验证码错误/参数格式错误
- 符合RESTful API规范

 测试验证:
- 邮箱冲突:HTTP 409
- 用户名冲突:HTTP 409
- 验证码错误:HTTP 400

 前端开发者现在可以:
- 根据HTTP状态码进行精确的错误处理
- 移除临时解决方案,使用标准状态码判断
- 提供更好的用户体验和错误提示
2025-12-25 16:32:51 +08:00
moyin
8a19bb7daa fix: 修复用户注册冲突错误的HTTP状态码问题
问题修复:
- 用户名冲突:400  409 Conflict
- 邮箱冲突:400  409 Conflict
- 手机号冲突:400  409 Conflict

 保持其他错误返回400:
- 验证码错误:400 Bad Request
- 参数格式错误:400 Bad Request

 符合RESTful API规范:
- 409 Conflict:资源冲突
- 400 Bad Request:请求参数错误

 测试验证:
- 邮箱冲突正确返回409
- 用户名冲突正确返回409
- 验证码错误正确返回400
2025-12-25 16:26:55 +08:00
a8e29c6a46 Merge pull request 'feature/verification-code-login-v1.1.0' (#24) from feature/verification-code-login-v1.1.0 into main
Reviewed-on: datawhale/whale-town-end#24
2025-12-25 16:19:23 +08:00
moyin
9f606abbb2 chore: 升级版本到1.1.0
版本升级:1.0.0  1.1.0

 新功能:
- 验证码登录功能完整实现
- 支持邮箱和手机号验证码登录
- 新增2个API接口(总计23个)

 文档更新:
- Swagger API文档版本更新
- OpenAPI规范文档更新
- 手动API文档版本更新
- 添加v1.1.0版本更新日志

 技术改进:
- 完善验证码相关错误处理
- 优化API响应格式一致性
- 增强测试覆盖率

 更新内容:
- package.json: 1.0.0  1.1.0
- Swagger配置: 1.0.0  1.1.0
- OpenAPI文档: 1.0.0  1.1.0
- 应用状态接口: 1.0.0  1.1.0
- API文档: 添加v1.1.0更新日志
2025-12-25 16:15:52 +08:00
moyin
7385c63ffd feat(docs): 更新OpenAPI文档,添加验证码登录和完整接口定义
- 添加验证码登录接口:/auth/verification-code-login
- 添加发送登录验证码接口:/auth/send-login-verification-code
- 添加邮箱验证相关接口:send/verify/resend-email-verification
- 添加调试接口:debug-verification-code, debug-clear-throttle
- 添加应用状态接口:GET /
- 完善所有Schema定义和响应格式
- 添加测试模式和限流错误响应
- 确保OpenAPI文档与实际API完全匹配
2025-12-25 16:11:07 +08:00
8d5a44d985 Merge pull request 'fix(docs): 修正API文档中的错误码和验证码说明' (#23) from docs-change-api-document into main
Reviewed-on: datawhale/whale-town-end#23
2025-12-25 16:06:44 +08:00
moyin
d59e9531e2 fix(docs): 修正API文档中的错误码和验证码说明
- 修正验证码登录错误码:VERIFICATION_CODE_INVALID -> VERIFICATION_CODE_LOGIN_FAILED
- 修正发送登录验证码错误码:USER_NOT_FOUND -> SEND_LOGIN_CODE_FAILED
- 添加验证码登录相关错误码到错误码表格
- 完善验证码使用说明和注意事项
- 确保文档与实际API响应完全一致
2025-12-25 16:04:34 +08:00
28a39935b7 Merge pull request 'feat(login): 添加验证码登录auth api' (#18) from ANGJustinl/whale-town-end:main into main
Reviewed-on: datawhale/whale-town-end#18
2025-12-25 15:48:13 +08:00
18 changed files with 1904 additions and 2500 deletions

View File

@@ -78,16 +78,25 @@ pnpm run dev
### 🧪 快速测试
```bash
# Windows
.\test-api.ps1
# 运行综合测试(推荐)
.\test-comprehensive.ps1
# Linux/macOS
./test-api.sh
# 跳过限流测试(更快)
.\test-comprehensive.ps1 -SkipThrottleTest
# 测试远程服务器
.\test-comprehensive.ps1 -BaseUrl "https://your-server.com"
```
**测试内容:**
- ✅ 应用状态检查
- ✅ 邮箱验证码发送与验证
- ✅ 用户注册与登录
- ✅ 验证码登录功能
- ✅ 密码重置流程
- ✅ 邮箱冲突检测
- ✅ 验证码冷却时间清除
- ✅ 限流保护机制
- ✅ Redis文件存储功能
- ✅ 邮件测试模式
@@ -323,9 +332,8 @@ pnpm run test:watch
# 生成测试覆盖率报告
pnpm run test:cov
# API功能测试
.\test-api.ps1 # Windows
./test-api.sh # Linux/macOS
# API功能测试(综合测试脚本)
.\test-comprehensive.ps1
```
### 📈 测试覆盖率

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Pixel Game Server - Auth API
description: 像素游戏服务器用户认证API接口文档
version: 1.0.0
description: 像素游戏服务器用户认证API接口文档 - 包含验证码登录功能、邮箱冲突检测、验证码冷却优化和邮件模板修复
version: 1.1.3
contact:
name: API Support
email: support@example.com
@@ -15,10 +15,39 @@ servers:
description: 开发环境
tags:
- name: app
description: 应用状态相关接口
- name: auth
description: 用户认证相关接口
- name: admin
description: 管理员后台相关接口
- name: user-management
description: 用户管理相关接口
paths:
/:
get:
tags:
- app
summary: 获取应用状态
description: 返回应用的基本运行状态信息,用于健康检查和监控
operationId: getAppStatus
responses:
'200':
description: 应用状态获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/AppStatusResponse'
example:
service: Pixel Game Server
version: 1.0.0
status: running
timestamp: "2025-12-25T08:00:00.000Z"
uptime: 3600
environment: development
storage_mode: database
/auth/login:
post:
tags:
@@ -77,7 +106,7 @@ paths:
tags:
- auth
summary: 用户注册
description: 创建新用户账户
description: 创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码。发送验证码接口会自动检查邮箱是否已被注册,避免向已存在邮箱发送验证码。
operationId: register
requestBody:
required: true
@@ -99,17 +128,49 @@ paths:
schema:
$ref: '#/components/schemas/RegisterResponse'
'400':
description: 请求参数错误
description: 请求参数错误或验证码错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
validation_error:
summary: 参数验证错误
value:
success: false
message: "密码必须包含字母和数字长度8-128字符"
error_code: "REGISTER_FAILED"
verification_code_error:
summary: 验证码错误
value:
success: false
message: "验证码不存在或已过期"
error_code: "REGISTER_FAILED"
'409':
description: 用户名邮箱已存在
description: 资源冲突 - 用户名邮箱或手机号已存在
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
username_exists:
summary: 用户名已存在
value:
success: false
message: "用户名已存在"
error_code: "REGISTER_FAILED"
email_exists:
summary: 邮箱已存在
value:
success: false
message: "邮箱已存在"
error_code: "REGISTER_FAILED"
phone_exists:
summary: 手机号已存在
value:
success: false
message: "手机号已存在"
error_code: "REGISTER_FAILED"
/auth/github:
post:
@@ -259,8 +320,290 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/send-email-verification:
post:
tags:
- auth
summary: 发送邮箱验证码
description: 向指定邮箱发送验证码。如果邮箱已被注册,将返回冲突错误。
operationId: sendEmailVerification
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendEmailVerificationDto'
example:
email: test@example.com
responses:
'200':
description: 验证码发送成功(真实发送模式)
content:
application/json:
schema:
$ref: '#/components/schemas/EmailVerificationResponse'
'206':
description: 测试模式:验证码已生成但未真实发送
content:
application/json:
schema:
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: 邮箱已被注册
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
message: "邮箱已被注册,请使用其他邮箱或直接登录"
error_code: "SEND_EMAIL_VERIFICATION_FAILED"
'429':
description: 发送频率过高
content:
application/json:
schema:
$ref: '#/components/schemas/ThrottleErrorResponse'
/auth/verify-email:
post:
tags:
- auth
summary: 验证邮箱验证码
description: 使用验证码验证邮箱
operationId: verifyEmail
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EmailVerificationDto'
example:
email: test@example.com
verification_code: "123456"
responses:
'200':
description: 邮箱验证成功
content:
application/json:
schema:
$ref: '#/components/schemas/CommonResponse'
'400':
description: 验证码错误或已过期
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/resend-email-verification:
post:
tags:
- auth
summary: 重新发送邮箱验证码
description: 重新向指定邮箱发送验证码
operationId: resendEmailVerification
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendEmailVerificationDto'
example:
email: test@example.com
responses:
'200':
description: 验证码重新发送成功
content:
application/json:
schema:
$ref: '#/components/schemas/EmailVerificationResponse'
'206':
description: 测试模式:验证码已生成但未真实发送
content:
application/json:
schema:
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
'400':
description: 邮箱已验证或用户不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 发送频率过高
content:
application/json:
schema:
$ref: '#/components/schemas/ThrottleErrorResponse'
/auth/verification-code-login:
post:
tags:
- auth
summary: 验证码登录
description: 使用邮箱或手机号和验证码进行登录,无需密码
operationId: verificationCodeLogin
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VerificationCodeLoginDto'
example:
identifier: test@example.com
verification_code: "123456"
responses:
'200':
description: 验证码登录成功
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: 验证码错误或已过期
content:
application/json:
schema:
$ref: '#/components/schemas/VerificationCodeLoginErrorResponse'
'404':
description: 用户不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/send-login-verification-code:
post:
tags:
- auth
summary: 发送登录验证码
description: 向用户邮箱或手机发送登录验证码。邮件内容使用专门的登录验证码模板,标题为"登录验证码",内容说明用于登录验证而非密码重置。
operationId: sendLoginVerificationCode
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendLoginVerificationCodeDto'
example:
identifier: test@example.com
responses:
'200':
description: 验证码发送成功
content:
application/json:
schema:
$ref: '#/components/schemas/EmailVerificationResponse'
'206':
description: 测试模式:验证码已生成但未真实发送
content:
application/json:
schema:
$ref: '#/components/schemas/TestModeEmailVerificationResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: 用户不存在
content:
application/json:
schema:
$ref: '#/components/schemas/SendLoginCodeErrorResponse'
'429':
description: 发送频率过高
content:
application/json:
schema:
$ref: '#/components/schemas/ThrottleErrorResponse'
/auth/debug-verification-code:
post:
tags:
- auth
summary: 调试验证码信息
description: 获取验证码的详细调试信息(仅开发环境)
operationId: debugVerificationCode
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SendEmailVerificationDto'
example:
email: test@example.com
responses:
'200':
description: 调试信息获取成功
content:
application/json:
schema:
$ref: '#/components/schemas/DebugVerificationCodeResponse'
/auth/debug-clear-throttle:
post:
tags:
- auth
summary: 清除限流记录
description: 清除所有限流记录(仅开发环境使用)
operationId: clearThrottle
responses:
'200':
description: 限流记录已清除
content:
application/json:
schema:
$ref: '#/components/schemas/CommonResponse'
components:
schemas:
AppStatusResponse:
type: object
properties:
service:
type: string
description: 服务名称
example: Pixel Game Server
version:
type: string
description: 版本号
example: 1.0.0
status:
type: string
description: 运行状态
example: running
timestamp:
type: string
format: date-time
description: 当前时间戳
example: "2025-12-25T08:00:00.000Z"
uptime:
type: integer
description: 运行时间(秒)
example: 3600
environment:
type: string
description: 运行环境
example: development
storage_mode:
type: string
description: 存储模式
example: database
LoginDto:
type: object
required:
@@ -415,6 +758,64 @@ components:
pattern: '^(?=.*[a-zA-Z])(?=.*\d)'
example: newpassword123
SendEmailVerificationDto:
type: object
required:
- email
properties:
email:
type: string
format: email
description: 邮箱地址
example: test@example.com
EmailVerificationDto:
type: object
required:
- email
- verification_code
properties:
email:
type: string
format: email
description: 邮箱地址
example: test@example.com
verification_code:
type: string
description: 6位数字验证码
pattern: '^\d{6}$'
example: "123456"
VerificationCodeLoginDto:
type: object
required:
- identifier
- verification_code
properties:
identifier:
type: string
description: 登录标识符(邮箱或手机号)
minLength: 1
maxLength: 100
example: test@example.com
verification_code:
type: string
description: 6位数字验证码
pattern: '^\d{6}$'
example: "123456"
SendLoginVerificationCodeDto:
type: object
required:
- identifier
properties:
identifier:
type: string
description: 邮箱或手机号
minLength: 1
maxLength: 100
example: test@example.com
UserInfo:
type: object
properties:
@@ -566,3 +967,174 @@ components:
type: string
description: 错误代码
example: OPERATION_FAILED
EmailVerificationResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: true
data:
type: object
properties:
sent_to:
type: string
description: 发送目标
example: test@example.com
expires_in:
type: integer
description: 过期时间(秒)
example: 300
is_test_mode:
type: boolean
description: 是否为测试模式
example: false
message:
type: string
description: 响应消息
example: 验证码已发送,请查收邮件
TestModeEmailVerificationResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: false
data:
type: object
properties:
verification_code:
type: string
description: 验证码(仅测试模式)
example: "123456"
sent_to:
type: string
description: 发送目标
example: test@example.com
expires_in:
type: integer
description: 过期时间(秒)
example: 300
is_test_mode:
type: boolean
description: 是否为测试模式
example: true
message:
type: string
description: 响应消息
example: 测试模式:验证码已生成但未真实发送
error_code:
type: string
description: 错误代码
example: TEST_MODE_ONLY
VerificationCodeLoginErrorResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: false
message:
type: string
description: 错误消息
example: 验证码错误或已过期
error_code:
type: string
description: 错误代码
example: VERIFICATION_CODE_LOGIN_FAILED
SendLoginCodeErrorResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: false
message:
type: string
description: 错误消息
example: 用户不存在
error_code:
type: string
description: 错误代码
example: SEND_LOGIN_CODE_FAILED
ThrottleErrorResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: false
message:
type: string
description: 错误消息
example: 请求过于频繁,请稍后再试
error_code:
type: string
description: 错误代码
example: TOO_MANY_REQUESTS
throttle_info:
type: object
properties:
limit:
type: integer
description: 限制次数
example: 1
window_seconds:
type: integer
description: 时间窗口(秒)
example: 60
current_requests:
type: integer
description: 当前请求次数
example: 1
reset_time:
type: string
format: date-time
description: 重置时间
example: "2025-12-25T08:01:00.000Z"
DebugVerificationCodeResponse:
type: object
properties:
success:
type: boolean
description: 请求是否成功
example: true
data:
type: object
properties:
key:
type: string
description: Redis键名
example: verification_code:email_verification:test@example.com
exists:
type: boolean
description: 是否存在
example: true
ttl:
type: integer
description: 剩余生存时间(秒)
example: 290
rawData:
type: string
description: 原始数据
example: '{"code":"123456","createdAt":1766649341250}'
parsedData:
type: object
description: 解析后的数据
properties:
code:
type: string
example: "123456"
createdAt:
type: integer
example: 1766649341250
currentTime:
type: integer
description: 当前时间戳
example: 1766649341250

View File

@@ -1,7 +1,7 @@
{
"name": "pixel-game-server",
"version": "1.0.0",
"description": "A 2D pixel art game server built with NestJS",
"version": "1.1.1",
"description": "A 2D pixel art game server built with NestJS - 支持验证码登录功能和邮箱冲突检测",
"main": "dist/main.js",
"scripts": {
"dev": "nest start --watch",

View File

@@ -31,7 +31,7 @@ export class AppService {
return {
service: 'Pixel Game Server',
version: '1.0.0',
version: '1.1.1',
status: 'running',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),

View File

@@ -145,7 +145,11 @@ export class LoginController {
res.status(HttpStatus.CREATED).json(result);
} else {
// 根据错误类型设置不同的状态码
if (result.error_code === 'REGISTER_FAILED') {
if (result.message?.includes('已存在')) {
// 资源冲突:用户名、邮箱、手机号已存在
res.status(HttpStatus.CONFLICT).json(result);
} else if (result.error_code === 'REGISTER_FAILED') {
// 其他注册失败:参数错误、验证码错误等
res.status(HttpStatus.BAD_REQUEST).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
@@ -385,6 +389,9 @@ export class LoginController {
res.status(HttpStatus.OK).json(result);
} else if (result.error_code === 'TEST_MODE_ONLY') {
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
} else if (result.message?.includes('已被注册') || result.message?.includes('已存在')) {
// 邮箱已被注册
res.status(HttpStatus.CONFLICT).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
@@ -522,7 +529,7 @@ export class LoginController {
*/
@ApiOperation({
summary: '发送登录验证码',
description: '向用户邮箱或手机发送登录验证码'
description: '向用户邮箱或手机发送登录验证码。邮件使用专门的登录验证码模板,内容明确标识为登录验证而非密码重置。'
})
@ApiBody({ type: SendLoginVerificationCodeDto })
@SwaggerApiResponse({

View File

@@ -51,6 +51,7 @@ describe('LoginCoreService', () => {
const mockVerificationService = {
generateCode: jest.fn(),
verifyCode: jest.fn(),
clearCooldown: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
@@ -146,6 +147,69 @@ describe('LoginCoreService', () => {
expect(result.isNewUser).toBe(true);
});
it('should register with email and clear cooldown', async () => {
const email = 'test@example.com';
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.create.mockResolvedValue({ ...mockUser, email });
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
emailService.sendWelcomeEmail.mockResolvedValue(undefined);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('hashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email,
email_verification_code: '123456'
});
expect(result.user.email).toBe(email);
expect(result.isNewUser).toBe(true);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
email,
VerificationCodeType.EMAIL_VERIFICATION
);
});
it('should handle cooldown clearing failure gracefully', async () => {
const email = 'test@example.com';
usersService.findByUsername.mockResolvedValue(null);
usersService.findByEmail.mockResolvedValue(null);
usersService.create.mockResolvedValue({ ...mockUser, email });
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
emailService.sendWelcomeEmail.mockResolvedValue(undefined);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('hashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email,
email_verification_code: '123456'
});
expect(result.user.email).toBe(email);
expect(result.isNewUser).toBe(true);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
email,
VerificationCodeType.EMAIL_VERIFICATION
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${email}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should validate password strength', async () => {
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {
throw new BadRequestException('密码长度至少8位');
@@ -231,6 +295,59 @@ describe('LoginCoreService', () => {
expect(result).toEqual(mockUser);
});
it('should reset password and clear cooldown', async () => {
const identifier = 'test@example.com';
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
usersService.findByEmail.mockResolvedValue(mockUser);
usersService.update.mockResolvedValue(mockUser);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
const result = await service.resetPassword({
identifier,
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result).toEqual(mockUser);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.PASSWORD_RESET
);
});
it('should handle cooldown clearing failure gracefully during password reset', async () => {
const identifier = 'test@example.com';
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
usersService.findByEmail.mockResolvedValue(mockUser);
usersService.update.mockResolvedValue(mockUser);
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.resetPassword({
identifier,
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result).toEqual(mockUser);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.PASSWORD_RESET
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${identifier}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should throw BadRequestException for invalid verification code', async () => {
verificationService.verifyCode.mockResolvedValue(false);
@@ -336,82 +453,75 @@ describe('LoginCoreService', () => {
);
});
it('should successfully login with phone verification code', async () => {
const phoneUser = { ...mockUser, phone: '+8613800138000' };
usersService.findAll.mockResolvedValue([phoneUser]);
verificationService.verifyCode.mockResolvedValue(true);
const result = await service.verificationCodeLogin({
identifier: '+8613800138000',
verificationCode: '123456'
});
expect(result.user).toEqual(phoneUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'+8613800138000',
VerificationCodeType.SMS_VERIFICATION,
'123456'
);
});
it('should reject unverified email user', async () => {
usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
})).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录');
});
it('should reject non-existent user', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(service.verificationCodeLogin({
identifier: 'nonexistent@example.com',
verificationCode: '123456'
})).rejects.toThrow('用户不存在,请先注册账户');
});
it('should reject invalid verification code', async () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(false);
await expect(service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '999999'
})).rejects.toThrow('验证码验证失败');
});
it('should reject invalid identifier format', async () => {
await expect(service.verificationCodeLogin({
identifier: 'invalid-identifier',
verificationCode: '123456'
})).rejects.toThrow('请提供有效的邮箱或手机号');
});
});
describe('verificationCodeLogin', () => {
it('should successfully login with email verification code', async () => {
it('should successfully login with email verification code and clear cooldown', async () => {
const identifier = 'test@example.com';
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(verifiedUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.verifyCode).toHaveBeenCalledWith(
'test@example.com',
VerificationCodeType.EMAIL_VERIFICATION,
'123456'
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.EMAIL_VERIFICATION
);
});
it('should successfully login with phone verification code and clear cooldown', async () => {
const identifier = '+8613800138000';
const phoneUser = { ...mockUser, phone: identifier };
usersService.findAll.mockResolvedValue([phoneUser]);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockResolvedValue(undefined);
const result = await service.verificationCodeLogin({
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(phoneUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.SMS_VERIFICATION
);
});
it('should handle cooldown clearing failure gracefully during verification code login', async () => {
const identifier = 'test@example.com';
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.verifyCode.mockResolvedValue(true);
verificationService.clearCooldown.mockRejectedValue(new Error('Redis error'));
// Mock console.warn to avoid test output noise
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.verificationCodeLogin({
identifier,
verificationCode: '123456'
});
expect(result.user).toEqual(verifiedUser);
expect(result.isNewUser).toBe(false);
expect(verificationService.clearCooldown).toHaveBeenCalledWith(
identifier,
VerificationCodeType.EMAIL_VERIFICATION
);
expect(consoleSpy).toHaveBeenCalledWith(
`清除验证码冷却时间失败: ${identifier}`,
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('should successfully login with phone verification code', async () => {
const phoneUser = { ...mockUser, phone: '+8613800138000' };
usersService.findAll.mockResolvedValue([phoneUser]);

View File

@@ -239,6 +239,19 @@ export class LoginCoreService {
email_verified: email ? true : false // 如果提供了邮箱且验证码验证通过,则标记为已验证
});
// 注册成功后清除验证码冷却时间,方便用户后续操作
if (email) {
try {
await this.verificationService.clearCooldown(
email,
VerificationCodeType.EMAIL_VERIFICATION
);
} catch (error) {
// 清除冷却时间失败不影响注册流程,只记录日志
console.warn(`清除验证码冷却时间失败: ${email}`, error);
}
}
// 如果提供了邮箱,发送欢迎邮件
if (email) {
try {
@@ -417,9 +430,22 @@ export class LoginCoreService {
const passwordHash = await this.hashPassword(newPassword);
// 更新密码
return await this.usersService.update(user.id, {
const updatedUser = await this.usersService.update(user.id, {
password_hash: passwordHash
});
// 密码重置成功后清除验证码冷却时间
try {
await this.verificationService.clearCooldown(
identifier,
VerificationCodeType.PASSWORD_RESET
);
} catch (error) {
// 清除冷却时间失败不影响重置流程,只记录日志
console.warn(`清除验证码冷却时间失败: ${identifier}`, error);
}
return updatedUser;
}
/**
@@ -516,6 +542,12 @@ export class LoginCoreService {
* @returns 验证码结果
*/
async sendEmailVerification(email: string, nickname?: string): Promise<VerificationCodeResult> {
// 首先检查邮箱是否已经被注册,避免发送无用的验证码
const existingUser = await this.usersService.findByEmail(email);
if (existingUser) {
throw new ConflictException('邮箱已被注册,请使用其他邮箱或直接登录');
}
// 生成验证码
const verificationCode = await this.verificationService.generateCode(
email,
@@ -695,7 +727,15 @@ export class LoginCoreService {
throw error;
}
// 5. 验证成功,返回用户信息
// 5. 验证成功后清除验证码冷却时间
try {
await this.verificationService.clearCooldown(identifier, verificationType);
} catch (error) {
// 清除冷却时间失败不影响登录流程,只记录日志
console.warn(`清除验证码冷却时间失败: ${identifier}`, error);
}
// 6. 验证成功,返回用户信息
return {
user,
isNewUser: false

View File

@@ -221,6 +221,30 @@ describe('EmailService', () => {
);
});
it('应该成功发送登录验证码', async () => {
const options: VerificationEmailOptions = {
email: 'test@example.com',
code: '789012',
nickname: '测试用户',
purpose: 'login_verification'
};
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
configService.get.mockReturnValue('"Test Sender" <noreply@test.com>');
const result = await service.sendVerificationCode(options);
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
subject: '【Whale Town】登录验证码',
text: '您的验证码是7890125分钟内有效请勿泄露给他人。'
})
);
});
it('应该在发送失败时返回false', async () => {
const options: VerificationEmailOptions = {
email: 'test@example.com',
@@ -323,6 +347,26 @@ describe('EmailService', () => {
await service.sendVerificationCode(options);
});
it('应该生成包含验证码的登录验证模板', async () => {
const options: VerificationEmailOptions = {
email: 'test@example.com',
code: '789012',
nickname: '测试用户',
purpose: 'login_verification'
};
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
expect(mailOptions.html).toContain('789012');
expect(mailOptions.html).toContain('测试用户');
expect(mailOptions.html).toContain('登录验证码');
expect(mailOptions.html).toContain('您正在使用验证码登录');
expect(mailOptions.html).toContain('🔐');
return Promise.resolve({ messageId: 'test-id' });
});
await service.sendVerificationCode(options);
});
it('应该生成包含用户昵称的欢迎邮件模板', async () => {
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
expect(mailOptions.html).toContain('测试用户');

View File

@@ -172,7 +172,7 @@ export class EmailService {
template = this.getPasswordResetTemplate(code, nickname);
} else if (purpose === 'login_verification') {
subject = '【Whale Town】登录验证码';
template = this.getPasswordResetTemplate(code, nickname); // 复用密码重置模板
template = this.getLoginVerificationTemplate(code, nickname);
} else {
subject = '【Whale Town】验证码';
template = this.getEmailVerificationTemplate(code, nickname);

View File

@@ -587,4 +587,69 @@ describe('VerificationService', () => {
expect(code).toMatch(/^\d{6}$/);
});
});
describe('clearCooldown', () => {
it('应该成功清除验证码冷却时间', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(true);
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
});
it('应该处理清除不存在的冷却时间', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(false);
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
});
it('应该处理Redis删除操作错误', async () => {
const email = 'test@example.com';
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockRejectedValue(new Error('Redis delete failed'));
await expect(service.clearCooldown(email, type)).rejects.toThrow('Redis delete failed');
});
it('应该为不同类型的验证码清除对应的冷却时间', async () => {
const email = 'test@example.com';
const types = [
VerificationCodeType.EMAIL_VERIFICATION,
VerificationCodeType.PASSWORD_RESET,
VerificationCodeType.SMS_VERIFICATION,
];
mockRedis.del.mockResolvedValue(true);
for (const type of types) {
await service.clearCooldown(email, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${email}`);
}
expect(mockRedis.del).toHaveBeenCalledTimes(types.length);
});
it('应该为不同标识符清除对应的冷却时间', async () => {
const identifiers = ['test1@example.com', 'test2@example.com', '+8613800138000'];
const type = VerificationCodeType.EMAIL_VERIFICATION;
mockRedis.del.mockResolvedValue(true);
for (const identifier of identifiers) {
await service.clearCooldown(identifier, type);
expect(mockRedis.del).toHaveBeenCalledWith(`verification_cooldown:${type}:${identifier}`);
}
expect(mockRedis.del).toHaveBeenCalledTimes(identifiers.length);
});
});
});

View File

@@ -294,6 +294,18 @@ export class VerificationService {
return `verification_hourly:${type}:${identifier}:${date}:${hour}`;
}
/**
* 清除验证码冷却时间
*
* @param identifier 标识符
* @param type 验证码类型
*/
async clearCooldown(identifier: string, type: VerificationCodeType): Promise<void> {
const cooldownKey = this.buildCooldownKey(identifier, type);
await this.redis.del(cooldownKey);
this.logger.log(`验证码冷却时间已清除: ${identifier} (${type})`);
}
/**
* 清理过期的验证码(可选的定时任务)
*/

View File

@@ -58,8 +58,8 @@ async function bootstrap() {
// 配置Swagger文档
const config = new DocumentBuilder()
.setTitle('Pixel Game Server API')
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册等功能')
.setVersion('1.0.0')
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册、验证码登录、邮箱冲突检测等功能')
.setVersion('1.1.1')
.addTag('auth', '用户认证相关接口')
.addTag('admin', '管理员后台相关接口')
.addBearerAuth(

View File

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

View File

@@ -1,95 +0,0 @@
#!/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"

333
test-comprehensive.ps1 Normal file
View File

@@ -0,0 +1,333 @@
# Comprehensive API Test Script
# 综合API测试脚本 - 完整的后端功能测试
#
# 🧪 测试内容:
# 1. 基础API功能应用状态、注册、登录
# 2. 邮箱验证码流程(发送、验证、冲突检测)
# 3. 验证码冷却时间清除功能
# 4. 限流保护机制
# 5. 密码重置流程
# 6. 验证码登录功能
# 7. 错误处理和边界条件
#
# 🚀 使用方法:
# .\test-comprehensive.ps1 # 运行完整测试
# .\test-comprehensive.ps1 -SkipThrottleTest # 跳过限流测试
# .\test-comprehensive.ps1 -SkipCooldownTest # 跳过冷却测试
# .\test-comprehensive.ps1 -BaseUrl "https://your-server.com" # 测试远程服务器
param(
[string]$BaseUrl = "http://localhost:3000",
[switch]$SkipThrottleTest = $false,
[switch]$SkipCooldownTest = $false
)
$ErrorActionPreference = "Continue"
Write-Host "🧪 Comprehensive API Test Suite" -ForegroundColor Green
Write-Host "===============================" -ForegroundColor Green
Write-Host "Base URL: $BaseUrl" -ForegroundColor Yellow
Write-Host "Skip Throttle Test: $SkipThrottleTest" -ForegroundColor Yellow
Write-Host "Skip Cooldown Test: $SkipCooldownTest" -ForegroundColor Yellow
# Helper function to handle API responses
function Test-ApiCall {
param(
[string]$TestName,
[string]$Url,
[string]$Body,
[string]$Method = "POST",
[int]$ExpectedStatus = 200,
[switch]$Silent = $false
)
if (-not $Silent) {
Write-Host "`n📋 $TestName" -ForegroundColor Yellow
}
try {
$response = Invoke-RestMethod -Uri $Url -Method $Method -Body $Body -ContentType "application/json" -ErrorAction Stop
if (-not $Silent) {
Write-Host "✅ SUCCESS ($(if ($response.success) { 'true' } else { 'false' }))" -ForegroundColor Green
Write-Host "Message: $($response.message)" -ForegroundColor Cyan
}
return $response
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
if (-not $Silent) {
Write-Host "❌ FAILED ($statusCode)" -ForegroundColor $(if ($statusCode -eq $ExpectedStatus) { "Yellow" } else { "Red" })
}
if ($_.Exception.Response) {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
$responseBody = $reader.ReadToEnd()
$reader.Close()
$stream.Close()
if ($responseBody) {
try {
$errorResponse = $responseBody | ConvertFrom-Json
if (-not $Silent) {
Write-Host "Message: $($errorResponse.message)" -ForegroundColor Cyan
Write-Host "Error Code: $($errorResponse.error_code)" -ForegroundColor Gray
}
return $errorResponse
} catch {
if (-not $Silent) {
Write-Host "Raw Response: $responseBody" -ForegroundColor Gray
}
}
}
}
return $null
}
}
# Clear throttle first
Write-Host "`n🔄 Clearing throttle records..." -ForegroundColor Blue
try {
Invoke-RestMethod -Uri "$BaseUrl/auth/debug-clear-throttle" -Method POST | Out-Null
Write-Host "✅ Throttle cleared" -ForegroundColor Green
} catch {
Write-Host "⚠️ Could not clear throttle" -ForegroundColor Yellow
}
# Test Results Tracking
$testResults = @{
AppStatus = $false
BasicAPI = $false
EmailConflict = $false
VerificationCodeLogin = $false
CooldownClearing = $false
ThrottleProtection = $false
PasswordReset = $false
}
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 0: Application Status" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
# Test application status
$result0 = Test-ApiCall -TestName "Check application status" -Url "$BaseUrl" -Method "GET" -Body ""
if ($result0 -and $result0.service -eq "Pixel Game Server") {
$testResults.AppStatus = $true
Write-Host "✅ PASS: Application is running" -ForegroundColor Green
Write-Host " Service: $($result0.service)" -ForegroundColor Cyan
Write-Host " Version: $($result0.version)" -ForegroundColor Cyan
Write-Host " Environment: $($result0.environment)" -ForegroundColor Cyan
} else {
Write-Host "❌ FAIL: Application status check failed" -ForegroundColor Red
}
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 1: Basic API Functionality" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
# Generate unique test data
$testEmail = "comprehensive_test_$(Get-Random)@example.com"
$testUsername = "comp_test_$(Get-Random)"
# Test 1: Send verification code
$result1 = Test-ApiCall -TestName "Send email verification code" -Url "$BaseUrl/auth/send-email-verification" -Body (@{
email = $testEmail
} | ConvertTo-Json)
if ($result1 -and $result1.data.verification_code) {
$verificationCode = $result1.data.verification_code
Write-Host "Got verification code: $verificationCode" -ForegroundColor Green
# Test 2: Register user
$result2 = Test-ApiCall -TestName "Register new user" -Url "$BaseUrl/auth/register" -Body (@{
username = $testUsername
password = "password123"
nickname = "Comprehensive Test User"
email = $testEmail
email_verification_code = $verificationCode
} | ConvertTo-Json)
if ($result2 -and $result2.success) {
# Test 3: Login user
$result3 = Test-ApiCall -TestName "Login with registered user" -Url "$BaseUrl/auth/login" -Body (@{
identifier = $testUsername
password = "password123"
} | ConvertTo-Json)
if ($result3 -and $result3.success) {
$testResults.BasicAPI = $true
Write-Host "✅ PASS: Basic API functionality working" -ForegroundColor Green
}
}
}
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 2: Email Conflict Detection" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
# Test email conflict detection
$result4 = Test-ApiCall -TestName "Test email conflict detection" -Url "$BaseUrl/auth/send-email-verification" -Body (@{
email = $testEmail
} | ConvertTo-Json) -ExpectedStatus 409
if ($result4 -and $result4.message -like "*已被注册*") {
$testResults.EmailConflict = $true
Write-Host "✅ PASS: Email conflict detection working" -ForegroundColor Green
} else {
Write-Host "❌ FAIL: Email conflict detection not working" -ForegroundColor Red
}
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 3: Verification Code Login" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
# Test verification code login
if ($result2 -and $result2.success) {
$userEmail = $result2.data.user.email
# Send login verification code
$result4a = Test-ApiCall -TestName "Send login verification code" -Url "$BaseUrl/auth/send-login-verification-code" -Body (@{
identifier = $userEmail
} | ConvertTo-Json)
if ($result4a -and $result4a.data.verification_code) {
$loginCode = $result4a.data.verification_code
# Login with verification code
$result4b = Test-ApiCall -TestName "Login with verification code" -Url "$BaseUrl/auth/verification-code-login" -Body (@{
identifier = $userEmail
verification_code = $loginCode
} | ConvertTo-Json)
if ($result4b -and $result4b.success) {
$testResults.VerificationCodeLogin = $true
Write-Host "✅ PASS: Verification code login working" -ForegroundColor Green
} else {
Write-Host "❌ FAIL: Verification code login failed" -ForegroundColor Red
}
}
}
if (-not $SkipCooldownTest) {
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 4: Cooldown Clearing & Password Reset" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
# Test cooldown clearing with password reset
if ($result2 -and $result2.success) {
$userEmail = $result2.data.user.email
# Send password reset code
$result5 = Test-ApiCall -TestName "Send password reset code" -Url "$BaseUrl/auth/forgot-password" -Body (@{
identifier = $userEmail
} | ConvertTo-Json)
if ($result5 -and $result5.data.verification_code) {
$resetCode = $result5.data.verification_code
# Reset password
$result6 = Test-ApiCall -TestName "Reset password (should clear cooldown)" -Url "$BaseUrl/auth/reset-password" -Body (@{
identifier = $userEmail
verification_code = $resetCode
new_password = "newpassword123"
} | ConvertTo-Json)
if ($result6 -and $result6.success) {
$testResults.PasswordReset = $true
Write-Host "✅ PASS: Password reset working" -ForegroundColor Green
# Test immediate code sending (should work if cooldown cleared)
Start-Sleep -Seconds 1
$result7 = Test-ApiCall -TestName "Send reset code immediately (test cooldown clearing)" -Url "$BaseUrl/auth/forgot-password" -Body (@{
identifier = $userEmail
} | ConvertTo-Json)
if ($result7 -and $result7.success) {
$testResults.CooldownClearing = $true
Write-Host "✅ PASS: Cooldown clearing working" -ForegroundColor Green
} else {
Write-Host "❌ FAIL: Cooldown not cleared properly" -ForegroundColor Red
}
} else {
Write-Host "❌ FAIL: Password reset failed" -ForegroundColor Red
}
}
}
}
if (-not $SkipThrottleTest) {
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "🧪 Test Suite 5: Throttle Protection" -ForegroundColor Cyan
Write-Host "="*60 -ForegroundColor Cyan
$successCount = 0
$throttleCount = 0
Write-Host "Testing throttle limits (making 12 registration requests)..." -ForegroundColor Yellow
for ($i = 1; $i -le 12; $i++) {
$result = Test-ApiCall -TestName "Registration attempt $i" -Url "$BaseUrl/auth/register" -Body (@{
username = "throttle_test_$i"
password = "password123"
nickname = "Throttle Test $i"
} | ConvertTo-Json) -Silent
if ($result -and $result.success) {
$successCount++
Write-Host " Request $i`: ✅ Success" -ForegroundColor Green
} else {
$throttleCount++
Write-Host " Request $i`: 🚦 Throttled" -ForegroundColor Yellow
}
Start-Sleep -Milliseconds 100
}
Write-Host "`nThrottle Results: $successCount success, $throttleCount throttled" -ForegroundColor Cyan
if ($successCount -ge 8 -and $throttleCount -ge 1) {
$testResults.ThrottleProtection = $true
Write-Host "✅ PASS: Throttle protection working" -ForegroundColor Green
} else {
Write-Host "❌ FAIL: Throttle protection not working properly" -ForegroundColor Red
}
}
Write-Host "`n🎯 Test Results Summary" -ForegroundColor Green
Write-Host "=======================" -ForegroundColor Green
$passCount = 0
$totalTests = 0
foreach ($test in $testResults.GetEnumerator()) {
$totalTests++
if ($test.Value) {
$passCount++
Write-Host "$($test.Key): PASS" -ForegroundColor Green
} else {
Write-Host "$($test.Key): FAIL" -ForegroundColor Red
}
}
Write-Host "`n📊 Overall Result: $passCount/$totalTests tests passed" -ForegroundColor $(if ($passCount -eq $totalTests) { "Green" } else { "Yellow" })
if ($passCount -eq $totalTests) {
Write-Host "🎉 All tests passed! API is working correctly." -ForegroundColor Green
} else {
Write-Host "⚠️ Some tests failed. Please check the implementation." -ForegroundColor Yellow
}
Write-Host "`n💡 Usage Tips:" -ForegroundColor Cyan
Write-Host " • Use -SkipThrottleTest to skip throttle testing" -ForegroundColor White
Write-Host " • Use -SkipCooldownTest to skip cooldown testing" -ForegroundColor White
Write-Host " • Check server logs for detailed error information" -ForegroundColor White
Write-Host " • For production testing: .\test-comprehensive.ps1 -BaseUrl 'https://your-server.com'" -ForegroundColor White
Write-Host "`n📋 Test Coverage:" -ForegroundColor Cyan
Write-Host " ✓ Application Status & Health Check" -ForegroundColor White
Write-Host " ✓ User Registration & Login Flow" -ForegroundColor White
Write-Host " ✓ Email Verification & Conflict Detection" -ForegroundColor White
Write-Host " ✓ Verification Code Login" -ForegroundColor White
Write-Host " ✓ Password Reset Flow" -ForegroundColor White
Write-Host " ✓ Cooldown Time Clearing" -ForegroundColor White
Write-Host " ✓ Rate Limiting & Throttle Protection" -ForegroundColor White

View File

@@ -1,133 +0,0 @@
# Test register API fix - Core functionality test
# 测试注册API修复 - 核心功能测试
#
# 主要测试内容:
# 1. 用户注册(无邮箱)- 应该成功
# 2. 用户注册(有邮箱但无验证码)- 应该失败并返回正确错误信息
# 3. 用户存在性检查 - 应该在验证码验证之前进行,返回"用户名已存在"
# 4. 邮箱验证码完整流程 - 验证码生成、注册、重复邮箱检查
#
# 修复验证:
# - 用户存在检查现在在验证码验证之前执行
# - 验证码不会因为用户已存在而被无效消费
# - 错误信息更加准确和用户友好
$baseUrl = "http://localhost:3000"
Write-Host "🧪 Testing Register API Fix" -ForegroundColor Green
Write-Host "============================" -ForegroundColor Green
# Helper function to handle API responses
function Test-ApiCall {
param(
[string]$TestName,
[string]$Url,
[string]$Body,
[int]$ExpectedStatus = 200
)
Write-Host "`n📋 $TestName" -ForegroundColor Yellow
try {
$response = Invoke-RestMethod -Uri $Url -Method POST -Body $Body -ContentType "application/json" -ErrorAction Stop
Write-Host "✅ SUCCESS ($(if ($response.success) { 'true' } else { 'false' }))" -ForegroundColor Green
Write-Host "Message: $($response.message)" -ForegroundColor Cyan
return $response
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
Write-Host "❌ FAILED ($statusCode)" -ForegroundColor $(if ($statusCode -eq $ExpectedStatus) { "Yellow" } else { "Red" })
if ($_.Exception.Response) {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
$responseBody = $reader.ReadToEnd()
$reader.Close()
$stream.Close()
if ($responseBody) {
try {
$errorResponse = $responseBody | ConvertFrom-Json
Write-Host "Message: $($errorResponse.message)" -ForegroundColor Cyan
Write-Host "Error Code: $($errorResponse.error_code)" -ForegroundColor Gray
return $errorResponse
} catch {
Write-Host "Raw Response: $responseBody" -ForegroundColor Gray
}
} else {
Write-Host "Empty response body" -ForegroundColor Gray
}
}
return $null
}
}
# Clear throttle first
Write-Host "`n🔄 Clearing throttle records..." -ForegroundColor Blue
try {
Invoke-RestMethod -Uri "$baseUrl/auth/debug-clear-throttle" -Method POST | Out-Null
Write-Host "✅ Throttle cleared" -ForegroundColor Green
} catch {
Write-Host "⚠️ Could not clear throttle" -ForegroundColor Yellow
}
# Test 1: Register without email (should succeed)
$result1 = Test-ApiCall -TestName "Register without email" -Url "$baseUrl/auth/register" -Body (@{
username = "testuser_$(Get-Random)"
password = "password123"
nickname = "Test User"
} | ConvertTo-Json)
# Test 2: Register with email but no verification code (should fail)
$result2 = Test-ApiCall -TestName "Register with email but no verification code" -Url "$baseUrl/auth/register" -Body (@{
username = "testuser_$(Get-Random)"
password = "password123"
nickname = "Test User"
email = "test@example.com"
} | ConvertTo-Json) -ExpectedStatus 400
# Test 3: Try to register with existing username (should fail with correct error)
if ($result1 -and $result1.success) {
$existingUsername = ($result1.data.user.username)
$result3 = Test-ApiCall -TestName "Register with existing username ($existingUsername)" -Url "$baseUrl/auth/register" -Body (@{
username = $existingUsername
password = "password123"
nickname = "Duplicate User"
} | ConvertTo-Json) -ExpectedStatus 400
if ($result3 -and $result3.message -like "*用户名已存在*") {
Write-Host "✅ PASS: Correct error message for existing user" -ForegroundColor Green
} else {
Write-Host "❌ FAIL: Wrong error message for existing user" -ForegroundColor Red
}
}
# Test 4: Get verification code and register with email
Write-Host "`n📋 Get verification code and register with email" -ForegroundColor Yellow
try {
$emailResponse = Invoke-RestMethod -Uri "$baseUrl/auth/send-email-verification" -Method POST -Body (@{email = "newuser@test.com"} | ConvertTo-Json) -ContentType "application/json"
if ($emailResponse.data.verification_code) {
$verificationCode = $emailResponse.data.verification_code
Write-Host "Got verification code: $verificationCode" -ForegroundColor Green
$result4 = Test-ApiCall -TestName "Register with valid email and verification code" -Url "$baseUrl/auth/register" -Body (@{
username = "emailuser_$(Get-Random)"
password = "password123"
nickname = "Email User"
email = "newuser@test.com"
email_verification_code = $verificationCode
} | ConvertTo-Json)
if ($result4 -and $result4.success) {
Write-Host "✅ PASS: Email registration successful" -ForegroundColor Green
}
}
} catch {
Write-Host "⚠️ Could not test email verification (email service may not be configured)" -ForegroundColor Yellow
}
Write-Host "`n🎯 Test Summary" -ForegroundColor Green
Write-Host "===============" -ForegroundColor Green
Write-Host "✅ Registration logic has been fixed:" -ForegroundColor White
Write-Host " • User existence checked BEFORE verification code validation" -ForegroundColor White
Write-Host " • Proper error messages for different scenarios" -ForegroundColor White
Write-Host " • Verification codes not wasted on existing users" -ForegroundColor White

View File

@@ -1,111 +0,0 @@
# Test throttle functionality
# 测试限流功能
#
# 主要测试内容:
# 1. 限流记录清除功能
# 2. 正常注册请求(在限流范围内)
# 3. 批量请求测试限流阈值
# 4. 验证限流配置是否正确生效
#
# 当前限流配置:
# - 注册接口10次/5分钟开发环境已放宽
# - 登录接口5次/分钟
# - 发送验证码1次/分钟
# - 密码重置3次/小时
$baseUrl = "http://localhost:3000"
Write-Host "🚦 Testing Throttle Functionality" -ForegroundColor Green
Write-Host "==================================" -ForegroundColor Green
# Clear throttle first
Write-Host "`n🔄 Clearing throttle records..." -ForegroundColor Blue
try {
$clearResponse = Invoke-RestMethod -Uri "$baseUrl/auth/debug-clear-throttle" -Method POST
Write-Host "$($clearResponse.message)" -ForegroundColor Green
} catch {
Write-Host "⚠️ Could not clear throttle records" -ForegroundColor Yellow
}
# Test normal registration (should work with increased limit)
Write-Host "`n📋 Test 1: Normal registration with increased throttle limit" -ForegroundColor Yellow
$registerData = @{
username = "testuser_throttle_$(Get-Random)"
password = "password123"
nickname = "Test User Throttle"
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri "$baseUrl/auth/register" -Method POST -Body $registerData -ContentType "application/json" -ErrorAction Stop
Write-Host "✅ SUCCESS: Registration completed" -ForegroundColor Green
Write-Host "Message: $($response.message)" -ForegroundColor Cyan
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
Write-Host "❌ FAILED ($statusCode)" -ForegroundColor $(if ($statusCode -eq 429) { "Yellow" } else { "Red" })
if ($_.Exception.Response) {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$responseBody = $reader.ReadToEnd()
$reader.Close()
try {
$errorResponse = $responseBody | ConvertFrom-Json
Write-Host "Message: $($errorResponse.message)" -ForegroundColor Cyan
if ($errorResponse.throttle_info) {
Write-Host "Throttle Info:" -ForegroundColor Gray
Write-Host " Limit: $($errorResponse.throttle_info.limit)" -ForegroundColor Gray
Write-Host " Window: $($errorResponse.throttle_info.window_seconds)s" -ForegroundColor Gray
Write-Host " Current: $($errorResponse.throttle_info.current_requests)" -ForegroundColor Gray
Write-Host " Reset: $($errorResponse.throttle_info.reset_time)" -ForegroundColor Gray
}
} catch {
Write-Host "Raw Response: $responseBody" -ForegroundColor Gray
}
}
}
# Test throttle limits by making multiple requests
Write-Host "`n📋 Test 2: Testing throttle limits (register endpoint: 10 requests/5min)" -ForegroundColor Yellow
$successCount = 0
$throttleCount = 0
for ($i = 1; $i -le 12; $i++) {
$testData = @{
username = "throttletest_$i"
password = "password123"
nickname = "Throttle Test $i"
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri "$baseUrl/auth/register" -Method POST -Body $testData -ContentType "application/json" -ErrorAction Stop
$successCount++
Write-Host " Request $i`: ✅ Success" -ForegroundColor Green
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
if ($statusCode -eq 429) {
$throttleCount++
Write-Host " Request $i`: 🚦 Throttled (429)" -ForegroundColor Yellow
} else {
Write-Host " Request $i`: ❌ Failed ($statusCode)" -ForegroundColor Red
}
}
# Small delay between requests
Start-Sleep -Milliseconds 100
}
Write-Host "`n📊 Results:" -ForegroundColor Cyan
Write-Host " Successful requests: $successCount" -ForegroundColor Green
Write-Host " Throttled requests: $throttleCount" -ForegroundColor Yellow
Write-Host " Expected behavior: ~10 success, ~2 throttled" -ForegroundColor Gray
if ($successCount -ge 8 -and $throttleCount -ge 1) {
Write-Host "✅ PASS: Throttle is working correctly" -ForegroundColor Green
} else {
Write-Host "⚠️ WARNING: Throttle behavior may need adjustment" -ForegroundColor Yellow
}
Write-Host "`n🎯 Throttle Configuration:" -ForegroundColor Green
Write-Host " Register: 10 requests / 5 minutes" -ForegroundColor White
Write-Host " Login: 5 requests / 1 minute" -ForegroundColor White
Write-Host " Send Code: 1 request / 1 minute" -ForegroundColor White
Write-Host " Password Reset: 3 requests / 1 hour" -ForegroundColor White