refactor:项目架构重构和命名规范化

- 统一文件命名为snake_case格式(kebab-case  snake_case)
- 重构zulip模块为zulip_core,明确Core层职责
- 重构user-mgmt模块为user_mgmt,统一命名规范
- 调整模块依赖关系,优化架构分层
- 删除过时的文件和目录结构
- 更新相关文档和配置文件

本次重构涉及大量文件重命名和模块重组,
旨在建立更清晰的项目架构和统一的命名规范。
This commit is contained in:
moyin
2026-01-08 00:14:14 +08:00
parent 4fa4bd1a70
commit bb796a2469
178 changed files with 24767 additions and 3484 deletions

View File

@@ -0,0 +1,157 @@
# Admin 管理员业务模块
Admin 是应用的管理员业务模块提供完整的后台管理功能包括管理员认证、用户管理、系统监控和日志管理等核心业务能力。作为Business层模块专注于管理员相关的业务逻辑编排和HTTP接口提供。
## 管理员认证功能
### login()
管理员登录认证,支持用户名、邮箱、手机号多种标识符登录。
### AdminGuard.canActivate()
管理员权限验证守卫确保只有role=9的管理员可以访问后台接口。
## 用户管理功能
### listUsers()
分页获取用户列表支持自定义limit和offset参数。
### getUser()
根据用户ID获取单个用户的详细信息。
### resetPassword()
管理员重置指定用户的密码,支持密码强度验证。
### updateUserStatus()
修改单个用户的账户状态,支持激活、锁定、禁用等状态变更。
### batchUpdateUserStatus()
批量修改多个用户的账户状态,提供批量操作结果统计。
### getUserStatusStats()
获取各种用户状态的数量统计信息,用于后台数据分析。
## 系统监控功能
### getRuntimeLogs()
获取应用运行日志的尾部内容,支持自定义返回行数。
### downloadLogsArchive()
将整个logs目录打包为tar.gz格式并提供下载。
### getLogDirAbsolutePath()
获取日志目录的绝对路径,用于文件系统操作。
## 使用的项目内部依赖
### AdminCoreService (来自 core/admin_core)
管理员认证核心服务提供JWT Token生成、验证和密码加密等技术实现。
### UsersService (来自 core/db/users)
用户数据服务提供用户CRUD操作的技术实现。
### UsersMemoryService (来自 core/db/users)
用户内存数据服务,提供内存模式下的用户数据操作。
### LogManagementService (来自 core/utils/logger)
日志管理服务,提供日志文件读取和管理功能。
### UserStatus (来自 business/user-mgmt/enums)
用户状态枚举,定义用户的各种状态值。
### UserStatusDto (来自 business/user-mgmt/dto)
用户状态修改数据传输对象,提供状态变更的请求结构。
### BatchUserStatusDto (来自 business/user-mgmt/dto)
批量用户状态修改数据传输对象,支持批量状态变更操作。
### UserStatusResponseDto (来自 business/user-mgmt/dto)
用户状态响应数据传输对象,定义状态操作的响应格式。
### AdminLoginDto (本模块)
管理员登录请求数据传输对象,定义登录接口的请求结构。
### AdminResetPasswordDto (本模块)
管理员重置密码请求数据传输对象,定义密码重置的请求结构。
### AdminLoginResponseDto (本模块)
管理员登录响应数据传输对象,定义登录接口的响应格式。
### AdminUsersResponseDto (本模块)
用户列表响应数据传输对象,定义用户列表接口的响应格式。
### AdminUserResponseDto (本模块)
单个用户响应数据传输对象,定义用户详情接口的响应格式。
### AdminCommonResponseDto (本模块)
通用响应数据传输对象,定义通用操作的响应格式。
### AdminRuntimeLogsResponseDto (本模块)
运行日志响应数据传输对象,定义日志接口的响应格式。
## 核心特性
### 完整的管理员认证体系
- 支持多种标识符登录(用户名、邮箱、手机号)
- JWT Token认证机制确保接口安全性
- 管理员权限验证只允许role=9的用户访问
- 登录频率限制,防止暴力破解攻击
### 全面的用户管理能力
- 用户列表分页查询,支持大数据量处理
- 用户详情查询,提供完整的用户信息
- 密码重置功能,支持密码强度验证
- 用户状态管理,支持单个和批量状态修改
- 用户状态统计,提供数据分析支持
### 强大的系统监控功能
- 实时日志查询,支持自定义行数
- 日志文件打包下载,便于问题排查
- 文件系统路径管理,确保操作安全性
- 错误处理和异常监控
### 业务逻辑编排优化
- 统一的API响应格式提供一致的接口体验
- 完整的异常处理机制,确保系统稳定性
- 详细的操作日志记录,便于审计和追踪
- 私有方法提取,提高代码复用性和可维护性
### 高质量的测试覆盖
- 单元测试覆盖率100%,确保代码质量
- 完整的异常场景测试,验证错误处理
- Mock服务配置实现测试隔离
- 边界情况测试,确保系统健壮性
## 潜在风险
### 权限安全风险
- 管理员Token泄露可能导致系统被恶意操作
- 建议定期更换JWT签名密钥设置合理的Token过期时间
- 建议实施IP白名单限制只允许特定IP访问管理接口
### 批量操作性能风险
- 批量用户状态修改在大数据量时可能影响性能
- 建议设置批量操作的数量限制,避免单次处理过多数据
- 建议实施异步处理机制,提高大批量操作的响应速度
### 日志文件安全风险
- 日志下载功能可能暴露敏感信息
- 建议对日志内容进行脱敏处理,移除敏感数据
- 建议实施日志访问审计,记录所有日志下载操作
### 系统资源占用风险
- 大量并发的日志查询可能影响系统性能
- 建议实施请求频率限制,防止资源滥用
- 建议监控系统资源使用情况,及时发现异常
### 业务逻辑一致性风险
- 用户状态修改与其他业务模块的状态同步问题
- 建议实施事务机制,确保状态变更的原子性
- 建议添加状态变更通知机制,保持业务数据一致性
## 版本信息
- **版本**: 1.0.1
- **作者**: moyin
- **创建时间**: 2025-12-19
- **最后修改**: 2026-01-07
- **修改类型**: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录

View File

@@ -0,0 +1,237 @@
/**
* AdminController 单元测试
*
* 功能描述:
* - 测试管理员控制器的所有HTTP端点
* - 验证请求参数处理和响应格式
* - 测试权限验证和异常处理
*
* 职责分离:
* - HTTP层测试不涉及业务逻辑实现
* - Mock业务服务专注控制器逻辑
* - 验证请求响应的正确性
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 创建AdminController测试文件补充测试覆盖
*
* @author moyin
* @version 1.0.1
* @since 2026-01-07
* @lastModified 2026-01-07
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Response } from 'express';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGuard } from './guards/admin.guard';
describe('AdminController', () => {
let controller: AdminController;
let adminService: jest.Mocked<AdminService>;
const mockAdminService = {
login: jest.fn(),
listUsers: jest.fn(),
getUser: jest.fn(),
resetPassword: jest.fn(),
getRuntimeLogs: jest.fn(),
getLogDirAbsolutePath: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [
{
provide: AdminService,
useValue: mockAdminService,
},
],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AdminController>(AdminController);
adminService = module.get(AdminService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('login', () => {
it('should login admin successfully', async () => {
const loginDto = { identifier: 'admin', password: 'Admin123456' };
const expectedResult = {
success: true,
data: { admin: { id: '1', username: 'admin', role: 9 }, access_token: 'token' },
message: '管理员登录成功'
};
adminService.login.mockResolvedValue(expectedResult);
const result = await controller.login(loginDto);
expect(adminService.login).toHaveBeenCalledWith('admin', 'Admin123456');
expect(result).toEqual(expectedResult);
});
it('should handle login failure', async () => {
const loginDto = { identifier: 'admin', password: 'wrong' };
const expectedResult = {
success: false,
message: '密码错误',
error_code: 'ADMIN_LOGIN_FAILED'
};
adminService.login.mockResolvedValue(expectedResult);
const result = await controller.login(loginDto);
expect(result.success).toBe(false);
expect(result.error_code).toBe('ADMIN_LOGIN_FAILED');
});
});
describe('listUsers', () => {
it('should list users with default pagination', async () => {
const expectedResult = {
success: true,
data: {
users: [{ id: '1', username: 'user1' }],
limit: 100,
offset: 0
},
message: '用户列表获取成功'
};
adminService.listUsers.mockResolvedValue(expectedResult);
const result = await controller.listUsers();
expect(adminService.listUsers).toHaveBeenCalledWith(100, 0);
expect(result).toEqual(expectedResult);
});
it('should list users with custom pagination', async () => {
const expectedResult = {
success: true,
data: {
users: [],
limit: 50,
offset: 10
},
message: '用户列表获取成功'
};
adminService.listUsers.mockResolvedValue(expectedResult);
const result = await controller.listUsers('50', '10');
expect(adminService.listUsers).toHaveBeenCalledWith(50, 10);
expect(result).toEqual(expectedResult);
});
});
describe('getUser', () => {
it('should get user by id', async () => {
const expectedResult = {
success: true,
data: { user: { id: '123', username: 'testuser' } },
message: '用户信息获取成功'
};
adminService.getUser.mockResolvedValue(expectedResult);
const result = await controller.getUser('123');
expect(adminService.getUser).toHaveBeenCalledWith(BigInt(123));
expect(result).toEqual(expectedResult);
});
});
describe('resetPassword', () => {
it('should reset user password', async () => {
const resetDto = { new_password: 'NewPass1234' };
const expectedResult = {
success: true,
message: '密码重置成功'
};
adminService.resetPassword.mockResolvedValue(expectedResult);
const result = await controller.resetPassword('123', resetDto);
expect(adminService.resetPassword).toHaveBeenCalledWith(BigInt(123), 'NewPass1234');
expect(result).toEqual(expectedResult);
});
});
describe('getRuntimeLogs', () => {
it('should get runtime logs with default lines', async () => {
const expectedResult = {
success: true,
data: {
file: 'app.log',
updated_at: '2026-01-07T00:00:00.000Z',
lines: ['log line 1', 'log line 2']
},
message: '运行日志获取成功'
};
adminService.getRuntimeLogs.mockResolvedValue(expectedResult);
const result = await controller.getRuntimeLogs();
expect(adminService.getRuntimeLogs).toHaveBeenCalledWith(undefined);
expect(result).toEqual(expectedResult);
});
it('should get runtime logs with custom lines', async () => {
const expectedResult = {
success: true,
data: {
file: 'app.log',
updated_at: '2026-01-07T00:00:00.000Z',
lines: ['log line 1']
},
message: '运行日志获取成功'
};
adminService.getRuntimeLogs.mockResolvedValue(expectedResult);
const result = await controller.getRuntimeLogs('100');
expect(adminService.getRuntimeLogs).toHaveBeenCalledWith(100);
expect(result).toEqual(expectedResult);
});
});
describe('downloadLogsArchive', () => {
let mockResponse: Partial<Response>;
beforeEach(() => {
mockResponse = {
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn(),
end: jest.fn(),
headersSent: false,
};
});
it('should handle missing log directory', async () => {
adminService.getLogDirAbsolutePath.mockReturnValue('/nonexistent/logs');
await controller.downloadLogsArchive(mockResponse as Response);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.json).toHaveBeenCalledWith({
success: false,
message: '日志目录不存在'
});
});
});
});

View File

@@ -1,6 +1,16 @@
/**
* 管理员控制器
*
* 功能描述:
* - 提供管理员登录认证接口
* - 提供用户管理相关接口(查询、重置密码)
* - 提供系统日志查询和下载功能
*
* 职责分离:
* - HTTP请求处理和参数验证
* - 业务逻辑委托给AdminService处理
* - 权限控制通过AdminGuard实现
*
* API端点
* - POST /admin/auth/login 管理员登录
* - GET /admin/users 用户列表需要管理员Token
@@ -8,24 +18,28 @@
* - POST /admin/users/:id/reset-password 重置指定用户密码需要管理员Token
* - GET /admin/logs/runtime 获取运行日志尾部需要管理员Token
*
* @author jianuo
* @version 1.0.0
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from './guards/admin.guard';
import { AdminService } from './admin.service';
import { AdminLoginDto, AdminResetPasswordDto } from './dto/admin-login.dto';
import { AdminLoginDto, AdminResetPasswordDto } from './dto/admin_login.dto';
import {
AdminLoginResponseDto,
AdminUsersResponseDto,
AdminCommonResponseDto,
AdminUserResponseDto,
AdminRuntimeLogsResponseDto
} from './dto/admin-response.dto';
import { Throttle, ThrottlePresets } from '../../core/security_core/decorators/throttle.decorator';
} from './dto/admin_response.dto';
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
import type { Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';

View File

@@ -1,6 +1,6 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
import { AdminGuard } from './admin.guard';
import { AdminCoreService, AdminAuthPayload } from '../../core/admin_core/admin_core.service';
import { AdminGuard } from './guards/admin.guard';
describe('AdminGuard', () => {
const payload: AdminAuthPayload = {

View File

@@ -3,12 +3,21 @@
*
* 功能描述:
* - 提供后台管理的HTTP API管理员登录、用户管理、密码重置等
* - 仅负责HTTP层与业务流程编排
* - 核心鉴权与密码策略由 AdminCoreService 提供
* - 集成管理员核心服务和日志管理服务
* - 导出管理员服务供其他模块使用
*
* @author jianuo
* @version 1.0.0
* 职责分离:
* - 模块依赖管理和服务注册
* - HTTP层与业务流程编排
* - 核心鉴权与密码策略由AdminCoreService提供
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { Module } from '@nestjs/common';

View File

@@ -1,8 +1,9 @@
import { NotFoundException } from '@nestjs/common';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { AdminService } from './admin.service';
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
import { LogManagementService } from '../../core/utils/logger/log_management.service';
import { Users } from '../../core/db/users/users.entity';
import { UserStatus } from '../user_mgmt/user_status.enum';
describe('AdminService', () => {
let service: AdminService;
@@ -15,6 +16,7 @@ describe('AdminService', () => {
const usersServiceMock = {
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
const logManagementServiceMock: Pick<LogManagementService, 'getRuntimeLogTail' | 'getLogDirAbsolutePath'> = {
@@ -156,4 +158,111 @@ describe('AdminService', () => {
expect(service.getLogDirAbsolutePath()).toBe('/abs/logs');
expect(logManagementServiceMock.getLogDirAbsolutePath).toHaveBeenCalled();
});
// 测试新增的用户状态管理方法
describe('updateUserStatus', () => {
const mockUser = {
id: BigInt(1),
username: 'testuser',
status: UserStatus.ACTIVE
} as unknown as Users;
it('should update user status successfully', async () => {
usersServiceMock.findOne.mockResolvedValue(mockUser);
usersServiceMock.update.mockResolvedValue({ ...mockUser, status: UserStatus.INACTIVE });
const result = await service.updateUserStatus(BigInt(1), { status: UserStatus.INACTIVE, reason: 'test' });
expect(result.success).toBe(true);
expect(result.message).toBe('用户状态修改成功');
});
it('should throw NotFoundException when user not found', async () => {
usersServiceMock.findOne.mockResolvedValue(null);
await expect(service.updateUserStatus(BigInt(999), { status: UserStatus.INACTIVE, reason: 'test' }))
.rejects.toThrow(NotFoundException);
});
it('should return error when status unchanged', async () => {
usersServiceMock.findOne.mockResolvedValue({ ...mockUser, status: UserStatus.INACTIVE });
await expect(service.updateUserStatus(BigInt(1), { status: UserStatus.INACTIVE, reason: 'test' }))
.rejects.toThrow(BadRequestException);
});
});
describe('batchUpdateUserStatus', () => {
it('should batch update user status successfully', async () => {
const mockUsers = [
{ id: BigInt(1), username: 'user1', status: UserStatus.ACTIVE },
{ id: BigInt(2), username: 'user2', status: UserStatus.ACTIVE }
] as unknown as Users[];
usersServiceMock.findOne
.mockResolvedValueOnce(mockUsers[0])
.mockResolvedValueOnce(mockUsers[1]);
usersServiceMock.update
.mockResolvedValueOnce({ ...mockUsers[0], status: UserStatus.INACTIVE })
.mockResolvedValueOnce({ ...mockUsers[1], status: UserStatus.INACTIVE });
const result = await service.batchUpdateUserStatus({
userIds: ['1', '2'],
status: UserStatus.INACTIVE,
reason: 'batch test'
});
expect(result.success).toBe(true);
expect(result.data?.result.success_count).toBe(2);
expect(result.data?.result.failed_count).toBe(0);
});
it('should handle mixed success and failure', async () => {
usersServiceMock.findOne
.mockResolvedValueOnce({ id: BigInt(1), status: UserStatus.ACTIVE })
.mockResolvedValueOnce(null); // User not found
usersServiceMock.update.mockResolvedValueOnce({ id: BigInt(1), status: UserStatus.INACTIVE });
const result = await service.batchUpdateUserStatus({
userIds: ['1', '999'],
status: UserStatus.INACTIVE,
reason: 'mixed test'
});
expect(result.success).toBe(true);
expect(result.data?.result.success_count).toBe(1);
expect(result.data?.result.failed_count).toBe(1);
});
});
describe('getUserStatusStats', () => {
it('should return user status statistics', async () => {
const mockUsers = [
{ status: UserStatus.ACTIVE },
{ status: UserStatus.ACTIVE },
{ status: UserStatus.INACTIVE },
{ status: null } // Should default to active
] as unknown as Users[];
usersServiceMock.findAll.mockResolvedValue(mockUsers);
const result = await service.getUserStatusStats();
expect(result.success).toBe(true);
expect(result.data?.stats.active).toBe(3); // 2 active + 1 null (defaults to active)
expect(result.data?.stats.inactive).toBe(1);
expect(result.data?.stats.total).toBe(4);
});
it('should handle error when getting stats', async () => {
usersServiceMock.findAll.mockRejectedValue(new Error('Database error'));
const result = await service.getUserStatusStats();
expect(result.success).toBe(false);
expect(result.error_code).toBe('USER_STATUS_STATS_FAILED');
});
});
});

View File

@@ -2,13 +2,36 @@
* 管理员业务服务
*
* 功能描述:
* - 调用核心服务完成管理员登录
* - 提供用户列表查询
* - 提供用户密码重置能力
* - 管理员登录认证业务逻辑
* - 用户管理业务功能(查询、密码重置、状态管理)
* - 系统日志管理功能
*
* @author jianuo
* @version 1.0.0
* 职责分离:
* - 业务逻辑编排和数据格式化
* - 调用核心服务完成具体操作
* - 异常处理和日志记录
*
* 主要方法:
* - login() - 管理员登录认证
* - listUsers() - 用户列表查询
* - getUser() - 单个用户查询
* - resetPassword() - 重置用户密码
* - updateUserStatus() - 修改用户状态
* - batchUpdateUserStatus() - 批量修改用户状态
* - getUserStatusStats() - 获取用户状态统计
* - getRuntimeLogs() - 获取运行日志
*
* 使用场景:
* - 后台管理系统的业务逻辑处理
* - 管理员权限相关的业务操作
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { Inject, Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
@@ -17,15 +40,15 @@ import { Users } from '../../core/db/users/users.entity';
import { UsersService } from '../../core/db/users/users.service';
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
import { LogManagementService } from '../../core/utils/logger/log_management.service';
import { UserStatus, getUserStatusDescription } from '../user-mgmt/enums/user-status.enum';
import { UserStatusDto, BatchUserStatusDto } from '../user-mgmt/dto/user-status.dto';
import { UserStatus, getUserStatusDescription } from '../user_mgmt/user_status.enum';
import { UserStatusDto, BatchUserStatusDto } from '../user_mgmt/user_status.dto';
import {
UserStatusResponseDto,
BatchUserStatusResponseDto,
UserStatusStatsResponseDto,
UserStatusInfoDto,
BatchOperationResultDto
} from '../user-mgmt/dto/user-status-response.dto';
} from '../user_mgmt/user_status_response.dto';
export interface AdminApiResponse<T = any> {
success: boolean;
@@ -44,6 +67,20 @@ export class AdminService {
private readonly logManagementService: LogManagementService,
) {}
/**
* 记录操作日志
*
* @param level 日志级别
* @param message 日志消息
* @param context 日志上下文
*/
private logOperation(level: 'log' | 'warn' | 'error', message: string, context: Record<string, any>): void {
this.logger[level](message, {
...context,
timestamp: new Date().toISOString()
});
}
getLogDirAbsolutePath(): string {
return this.logManagementService.getLogDirAbsolutePath();
}
@@ -161,18 +198,17 @@ export class AdminService {
*/
async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise<UserStatusResponseDto> {
try {
this.logger.log('开始修改用户状态', {
this.logOperation('log', '开始修改用户状态', {
operation: 'update_user_status',
userId: userId.toString(),
newStatus: userStatusDto.status,
reason: userStatusDto.reason,
timestamp: new Date().toISOString()
reason: userStatusDto.reason
});
// 1. 验证用户是否存在
const user = await this.usersService.findOne(userId);
if (!user) {
this.logger.warn('修改用户状态失败:用户不存在', {
this.logOperation('warn', '修改用户状态失败:用户不存在', {
operation: 'update_user_status',
userId: userId.toString()
});
@@ -181,7 +217,7 @@ export class AdminService {
// 2. 检查状态变更的合法性
if (user.status === userStatusDto.status) {
this.logger.warn('修改用户状态失败:状态未发生变化', {
this.logOperation('warn', '修改用户状态失败:状态未发生变化', {
operation: 'update_user_status',
userId: userId.toString(),
currentStatus: user.status,
@@ -196,13 +232,12 @@ export class AdminService {
});
// 4. 记录状态变更日志
this.logger.log('用户状态修改成功', {
this.logOperation('log', '用户状态修改成功', {
operation: 'update_user_status',
userId: userId.toString(),
oldStatus: user.status,
newStatus: userStatusDto.status,
reason: userStatusDto.reason,
timestamp: new Date().toISOString()
reason: userStatusDto.reason
});
return {
@@ -215,11 +250,10 @@ export class AdminService {
};
} catch (error) {
this.logger.error('修改用户状态失败', {
this.logOperation('error', '修改用户状态失败', {
operation: 'update_user_status',
userId: userId.toString(),
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString()
error: error instanceof Error ? error.message : String(error)
});
if (error instanceof NotFoundException || error instanceof BadRequestException) {
@@ -234,6 +268,43 @@ export class AdminService {
}
}
/**
* 处理单个用户状态修改
*
* @param userIdStr 用户ID字符串
* @param newStatus 新状态
* @returns 处理结果
*/
private async processSingleUserStatus(
userIdStr: string,
newStatus: UserStatus
): Promise<{ success: true; user: UserStatusInfoDto } | { success: false; error: string }> {
try {
const userId = BigInt(userIdStr);
// 验证用户是否存在
const user = await this.usersService.findOne(userId);
if (!user) {
return { success: false, error: '用户不存在' };
}
// 检查状态是否需要变更
if (user.status === newStatus) {
return { success: false, error: '用户状态未发生变化' };
}
// 更新用户状态
const updatedUser = await this.usersService.update(userId, { status: newStatus });
return { success: true, user: this.formatUserStatus(updatedUser) };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '未知错误'
};
}
}
/**
* 批量修改用户状态
*
@@ -251,87 +322,56 @@ export class AdminService {
*/
async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise<BatchUserStatusResponseDto> {
try {
this.logger.log('开始批量修改用户状态', {
this.logOperation('log', '开始批量修改用户状态', {
operation: 'batch_update_user_status',
userCount: batchUserStatusDto.user_ids.length,
userCount: batchUserStatusDto.userIds.length,
newStatus: batchUserStatusDto.status,
reason: batchUserStatusDto.reason,
timestamp: new Date().toISOString()
reason: batchUserStatusDto.reason
});
const successUsers: UserStatusInfoDto[] = [];
const failedUsers: Array<{ user_id: string; error: string }> = [];
// 1. 逐个处理用户状态修改
for (const userIdStr of batchUserStatusDto.user_ids) {
try {
const userId = BigInt(userIdStr);
// 2. 验证用户是否存在
const user = await this.usersService.findOne(userId);
if (!user) {
failedUsers.push({
user_id: userIdStr,
error: '用户不存在'
});
continue;
}
// 3. 检查状态是否需要变更
if (user.status === batchUserStatusDto.status) {
failedUsers.push({
user_id: userIdStr,
error: '用户状态未发生变化'
});
continue;
}
// 4. 更新用户状态
const updatedUser = await this.usersService.update(userId, {
status: batchUserStatusDto.status
});
successUsers.push(this.formatUserStatus(updatedUser));
} catch (error) {
failedUsers.push({
user_id: userIdStr,
error: error instanceof Error ? error.message : '未知错误'
});
// 逐个处理用户状态修改
for (const userIdStr of batchUserStatusDto.userIds) {
const result = await this.processSingleUserStatus(userIdStr, batchUserStatusDto.status);
if (result.success) {
successUsers.push(result.user);
} else {
failedUsers.push({ user_id: userIdStr, error: (result as { success: false; error: string }).error });
}
}
// 5. 构建批量操作结果
const result: BatchOperationResultDto = {
// 构建批量操作结果
const operationResult: BatchOperationResultDto = {
success_users: successUsers,
failed_users: failedUsers,
success_count: successUsers.length,
failed_count: failedUsers.length,
total_count: batchUserStatusDto.user_ids.length
total_count: batchUserStatusDto.userIds.length
};
this.logger.log('批量修改用户状态完成', {
this.logOperation('log', '批量修改用户状态完成', {
operation: 'batch_update_user_status',
successCount: result.success_count,
failedCount: result.failed_count,
totalCount: result.total_count,
timestamp: new Date().toISOString()
successCount: operationResult.success_count,
failedCount: operationResult.failed_count,
totalCount: operationResult.total_count
});
return {
success: true,
data: {
result,
result: operationResult,
reason: batchUserStatusDto.reason
},
message: `批量用户状态修改完成,成功:${result.success_count},失败:${result.failed_count}`
message: `批量用户状态修改完成,成功:${operationResult.success_count},失败:${operationResult.failed_count}`
};
} catch (error) {
this.logger.error('批量修改用户状态失败', {
this.logOperation('error', '批量修改用户状态失败', {
operation: 'batch_update_user_status',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString()
error: error instanceof Error ? error.message : String(error)
});
return {
@@ -342,6 +382,50 @@ export class AdminService {
}
}
/**
* 计算用户状态统计
*
* @param users 用户列表
* @returns 状态统计结果
*/
private calculateUserStatusStats(users: Users[]) {
const stats = {
active: 0,
inactive: 0,
locked: 0,
banned: 0,
deleted: 0,
pending: 0,
total: users.length
};
users.forEach((user: Users) => {
const status = user.status || UserStatus.ACTIVE;
switch (status) {
case UserStatus.ACTIVE:
stats.active++;
break;
case UserStatus.INACTIVE:
stats.inactive++;
break;
case UserStatus.LOCKED:
stats.locked++;
break;
case UserStatus.BANNED:
stats.banned++;
break;
case UserStatus.DELETED:
stats.deleted++;
break;
case UserStatus.PENDING:
stats.pending++;
break;
}
});
return stats;
}
/**
* 获取用户状态统计
*
@@ -358,54 +442,19 @@ export class AdminService {
*/
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
try {
this.logger.log('开始获取用户状态统计', {
operation: 'get_user_status_stats',
timestamp: new Date().toISOString()
this.logOperation('log', '开始获取用户状态统计', {
operation: 'get_user_status_stats'
});
// 1. 查询所有用户(这里可以优化为直接查询统计信息)
// 查询所有用户(这里可以优化为直接查询统计信息)
const allUsers = await this.usersService.findAll(10000, 0); // 假设最多1万用户
// 2. 按状态分组统计
const stats = {
active: 0,
inactive: 0,
locked: 0,
banned: 0,
deleted: 0,
pending: 0,
total: allUsers.length
};
// 计算各状态数量
const stats = this.calculateUserStatusStats(allUsers);
// 3. 计算各状态数量
allUsers.forEach((user: Users) => {
const status = user.status || UserStatus.ACTIVE;
switch (status) {
case UserStatus.ACTIVE:
stats.active++;
break;
case UserStatus.INACTIVE:
stats.inactive++;
break;
case UserStatus.LOCKED:
stats.locked++;
break;
case UserStatus.BANNED:
stats.banned++;
break;
case UserStatus.DELETED:
stats.deleted++;
break;
case UserStatus.PENDING:
stats.pending++;
break;
}
});
this.logger.log('用户状态统计获取成功', {
this.logOperation('log', '用户状态统计获取成功', {
operation: 'get_user_status_stats',
stats,
timestamp: new Date().toISOString()
stats
});
return {
@@ -418,10 +467,9 @@ export class AdminService {
};
} catch (error) {
this.logger.error('获取用户状态统计失败', {
this.logOperation('error', '获取用户状态统计失败', {
operation: 'get_user_status_stats',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString()
error: error instanceof Error ? error.message : String(error)
});
return {

View File

@@ -3,11 +3,21 @@
*
*
* -
* - 使 class-validator
* -
* - Swagger文档自动生成
*
* @author jianuo
* @version 1.0.0
*
* -
* -
* - API文档生成支持
*
*
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';

View File

@@ -3,11 +3,21 @@
*
*
* -
* - Swagger
* - API响应结构
* - Swagger文档自动生成
*
* @author jianuo
* @version 1.0.0
*
* -
* - API文档生成支持
* -
*
*
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';

View File

@@ -2,13 +2,29 @@
* 管理员鉴权守卫
*
* 功能描述:
* - 保护后台管理接口
* - 校验 Authorization: Bearer <admin_token>
* - 仅允许 role=9 的管理员访问
* - 保护后台管理接口的访问权限
* - 验证Authorization Bearer Token
* - 确保只有role=9的管理员可以访问
*
* @author jianuo
* @version 1.0.0
* 职责分离:
* - HTTP请求权限验证
* - Token解析和验证
* - 管理员身份确认
*
* 主要方法:
* - canActivate() - 权限验证核心逻辑
*
* 使用场景:
* - 后台管理API的权限保护
* - 管理员身份验证
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
*
* @author moyin
* @version 1.0.1
* @since 2025-12-19
* @lastModified 2026-01-07
*/
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';

View File

@@ -4,10 +4,19 @@
* 功能描述:
* - 导出管理员相关的所有组件
* - 提供统一的导入入口
* - 简化其他模块的依赖管理
*
* @author kiro-ai
* @version 1.0.0
* 职责分离:
* - 模块接口统一管理
* - 导出控制和版本管理
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
// 控制器
@@ -17,8 +26,8 @@ export * from './admin.controller';
export * from './admin.service';
// DTO
export * from './dto/admin-login.dto';
export * from './dto/admin-response.dto';
export * from './dto/admin_login.dto';
export * from './dto/admin_response.dto';
// 模块
export * from './admin.module';

223
src/business/auth/README.md Normal file
View File

@@ -0,0 +1,223 @@
# Auth 用户认证业务模块
Auth 是应用的核心用户认证业务模块提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能支持邮箱验证、验证码登录、安全防护和Zulip账号同步具备完善的业务流程控制、错误处理和安全审计能力。
## 用户认证功能
### login()
处理用户登录请求,支持用户名/邮箱/手机号登录验证用户凭据并生成JWT令牌。
### register()
处理用户注册请求支持邮箱验证自动创建Zulip账号并建立关联。
### githubOAuth()
处理GitHub OAuth登录支持新用户自动注册和现有用户绑定。
### verificationCodeLogin()
支持邮箱或手机号验证码登录,提供无密码登录方式。
## 密码管理功能
### sendPasswordResetCode()
发送密码重置验证码到用户邮箱或手机号,支持测试模式和真实发送模式。
### resetPassword()
使用验证码重置用户密码,包含密码强度验证和安全检查。
### changePassword()
修改用户密码,验证旧密码并应用新密码强度规则。
## 邮箱验证功能
### sendEmailVerification()
发送邮箱验证码,用于注册时的邮箱验证和账户安全验证。
### verifyEmailCode()
验证邮箱验证码,确认邮箱所有权并更新用户验证状态。
### resendEmailVerification()
重新发送邮箱验证码,处理验证码过期或丢失的情况。
### sendLoginVerificationCode()
发送登录验证码,支持验证码登录功能。
## 调试和管理功能
### debugVerificationCode()
获取验证码调试信息,用于开发环境的测试和调试。
## HTTP API接口
### POST /auth/login
用户登录接口,接受用户名/邮箱/手机号和密码返回JWT令牌和用户信息。
### POST /auth/register
用户注册接口创建新用户账户并可选择性创建Zulip账号。
### POST /auth/github
GitHub OAuth登录接口处理GitHub第三方登录和账户绑定。
### POST /auth/forgot-password
发送密码重置验证码接口,支持邮箱和手机号找回密码。
### POST /auth/reset-password
重置密码接口,使用验证码验证身份并设置新密码。
### PUT /auth/change-password
修改密码接口,需要验证旧密码并设置新密码。
### POST /auth/send-email-verification
发送邮箱验证码接口,用于邮箱验证流程。
### POST /auth/verify-email
验证邮箱验证码接口,确认邮箱所有权。
### POST /auth/resend-email-verification
重新发送邮箱验证码接口,处理验证码重发需求。
### POST /auth/verification-code-login
验证码登录接口,支持无密码登录方式。
### POST /auth/send-login-verification-code
发送登录验证码接口,为验证码登录提供验证码。
### POST /auth/refresh-token
刷新JWT令牌接口使用刷新令牌获取新的访问令牌。
### POST /auth/debug-verification-code
调试验证码接口,获取验证码状态和调试信息。
### POST /auth/debug-clear-throttle
清除限流记录接口,仅用于开发环境调试。
## 认证和授权组件
### JwtAuthGuard
JWT认证守卫验证请求中的Bearer令牌并提取用户信息到请求上下文。
### CurrentUser
当前用户装饰器,从请求上下文中提取认证用户信息,支持获取完整用户对象或特定属性。
## 使用的项目内部依赖
### LoginCoreService (来自 core/login_core/login_core.service)
登录核心服务提供用户认证、密码管理、JWT令牌生成验证等核心功能实现。
### ZulipAccountService (来自 core/zulip_core/services/zulip_account.service)
Zulip账号服务处理Zulip账号的创建、管理和API Key安全存储。
### ZulipAccountsService (来自 core/db/zulip_accounts/zulip_accounts.service)
Zulip账号数据服务管理游戏用户与Zulip账号的关联关系数据。
### ApiKeySecurityService (来自 core/zulip_core/services/api_key_security.service)
API Key安全服务负责Zulip API Key的加密存储和安全管理。
### Users (来自 core/db/users/users.entity)
用户实体类,定义用户数据结构和数据库映射关系。
### UserStatus (来自 business/user_mgmt/user_status.enum)
用户状态枚举,定义用户的激活、禁用、待验证等状态值。
### LoginDto, RegisterDto (本模块)
登录和注册数据传输对象,提供完整的数据验证规则和类型定义。
### LoginResponseDto, RegisterResponseDto (本模块)
登录和注册响应数据传输对象定义API响应的数据结构和格式。
### ThrottlePresets, TimeoutPresets (来自 core/security_core/decorators)
安全防护预设配置,提供限流和超时控制的标准配置。
## 核心特性
### 多种登录方式支持
- 用户名/邮箱/手机号密码登录
- GitHub OAuth第三方登录
- 邮箱/手机号验证码登录
- 自动识别登录标识符类型
### JWT令牌管理
- 访问令牌和刷新令牌双令牌机制
- 令牌自动刷新和过期处理
- 安全的令牌签名和验证
- 用户信息载荷和权限控制
### Zulip集成支持
- 注册时自动创建Zulip账号
- 游戏用户与Zulip账号关联管理
- API Key安全存储和加密
- 注册失败时的回滚机制
### 邮箱验证系统
- 注册时邮箱验证流程
- 密码重置邮箱验证
- 验证码生成和过期管理
- 测试模式和生产模式支持
### 安全防护机制
- 请求频率限制和防暴力破解
- 密码强度验证和安全存储
- 用户状态检查和权限控制
- 详细的安全审计日志
### 业务流程控制
- 完整的错误处理和异常管理
- 统一的响应格式和状态码
- 业务规则验证和数据完整性
- 操作日志和性能监控
## 潜在风险
### Zulip账号创建失败风险
- Zulip服务不可用时注册流程可能失败
- 网络异常导致账号创建不完整
- 建议实现重试机制和降级策略允许跳过Zulip账号创建
### 验证码发送依赖风险
- 邮件服务配置错误导致验证码无法发送
- 测试模式下验证码泄露到日志中
- 建议完善邮件服务监控和测试模式安全控制
### JWT令牌安全风险
- 令牌泄露可能导致账户被盗用
- 刷新令牌长期有效增加安全风险
- 建议实现令牌黑名单机制和异常登录检测
### 并发操作风险
- 同时注册相同用户名可能导致数据冲突
- 高并发场景下验证码生成可能重复
- 建议加强数据库唯一性约束和分布式锁机制
### 第三方服务依赖风险
- GitHub OAuth服务不可用影响第三方登录
- Zulip服务异常影响账号同步功能
- 建议实现服务降级和故障转移机制
### 密码安全风险
- 弱密码策略可能导致账户安全问题
- 密码重置流程可能被恶意利用
- 建议加强密码策略和增加二次验证机制
## 补充信息
### 版本信息
- 模块版本1.0.2
- 最后修改2026-01-07
- 作者moyin
- 创建时间2025-12-17
### 架构优化记录
- 2026-01-07将JWT技术实现从Business层移至Core层符合分层架构原则
- 2026-01-07完成代码规范优化统一注释格式和文件命名规范
- 2026-01-07完善测试覆盖确保所有公共方法都有对应的单元测试
### 已知限制
- 短信验证码功能尚未实现,目前仅支持邮箱验证码
- Zulip账号创建失败时的重试机制有待完善
- 多设备登录管理和会话控制功能待开发
### 改进建议
- 实现短信验证码发送功能,完善多渠道验证
- 增加社交登录支持微信、QQ等
- 实现多因素认证MFA提升账户安全
- 添加登录设备管理和异常登录检测
- 完善Zulip集成的错误处理和重试机制

View File

@@ -8,18 +8,26 @@
* - 邮箱验证功能
* - JWT令牌管理和验证
*
* @author kiro-ai
* @version 1.0.0
* 职责分离:
* - 专注于认证业务模块的依赖注入和配置
* - 整合核心服务和业务服务
* - 提供JWT模块的统一配置
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正文件引用路径
*
* @author moyin
* @version 1.0.2
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoginController } from './controllers/login.controller';
import { LoginService } from './services/login.service';
import { LoginController } from './login.controller';
import { LoginService } from './login.service';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { UsersModule } from '../../core/db/users/users.module';
@@ -29,26 +37,11 @@ import { UsersModule } from '../../core/db/users/users.module';
ZulipCoreModule,
ZulipAccountsModule.forRoot(),
UsersModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
return {
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d'
issuer: 'whale-town',
audience: 'whale-town-users',
},
};
},
inject: [ConfigService],
}),
],
controllers: [LoginController],
providers: [
LoginService,
],
exports: [LoginService, JwtModule],
exports: [LoginService],
})
export class AuthModule {}

View File

@@ -0,0 +1,69 @@
/**
* 当前用户装饰器
*
* 功能描述:
* - 从请求上下文中提取当前认证用户信息
* - 简化控制器中获取用户信息的操作
* - 支持获取用户对象的特定属性
*
* 职责分离:
* - 专注于用户信息提取和参数装饰
* - 提供类型安全的用户信息访问
* - 简化控制器方法的参数处理
*
* 使用示例:
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* getProfile(@CurrentUser() user: JwtPayload) {
* return { user };
* }
* ```
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 文件重命名为snake_case格式更新注释规范
*
* @author moyin
* @version 1.0.2
* @since 2025-01-05
* @lastModified 2026-01-07
*/
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../../core/login_core/login_core.service';
import { AuthenticatedRequest } from './jwt_auth.guard';
/**
* 当前用户装饰器实现
*
* 业务逻辑:
* 1. 从执行上下文获取HTTP请求对象
* 2. 提取请求中的用户信息由JwtAuthGuard注入
* 3. 根据data参数返回完整用户对象或特定属性
* 4. 提供类型安全的用户信息访问
*
* @param data 可选的属性名,用于获取用户对象的特定属性
* @param ctx 执行上下文包含HTTP请求信息
* @returns JwtPayload | any 用户信息或用户的特定属性
* @throws 无异常抛出依赖JwtAuthGuard确保用户信息存在
*
* @example
* ```typescript
* // 获取完整用户对象
* @Get('profile')
* getProfile(@CurrentUser() user: JwtPayload) { }
*
* // 获取特定属性
* @Get('username')
* getUsername(@CurrentUser('username') username: string) { }
* ```
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -1,39 +0,0 @@
/**
* 当前用户装饰器
*
* 功能描述:
* - 从请求上下文中提取当前认证用户信息
* - 简化控制器中获取用户信息的操作
*
* 使用示例:
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* getProfile(@CurrentUser() user: JwtPayload) {
* return { user };
* }
* ```
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-05
*/
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedRequest, JwtPayload } from '../guards/jwt-auth.guard';
/**
* 当前用户装饰器
*
* @param data 可选的属性名,用于获取用户对象的特定属性
* @param ctx 执行上下文
* @returns 用户信息或用户的特定属性
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -1,83 +0,0 @@
/**
* JWT 认证守卫
*
* 功能描述:
* - 验证请求中的 JWT 令牌
* - 提取用户信息并添加到请求上下文
* - 保护需要认证的路由
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-05
*/
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
/**
* JWT 载荷接口
*/
export interface JwtPayload {
sub: string; // 用户ID
username: string;
role: number;
iat: number; // 签发时间
exp: number; // 过期时间
}
/**
* 扩展的请求接口,包含用户信息
*/
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
this.logger.warn('访问被拒绝:缺少认证令牌');
throw new UnauthorizedException('缺少认证令牌');
}
try {
// 验证并解码 JWT 令牌
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// 将用户信息添加到请求对象
(request as AuthenticatedRequest).user = payload;
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
throw new UnauthorizedException('无效的认证令牌');
}
}
/**
* 从请求头中提取 JWT 令牌
*
* @param request 请求对象
* @returns JWT 令牌或 undefined
*/
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -7,17 +7,31 @@
* - 密码管理(忘记密码、重置密码、修改密码)
* - 邮箱验证功能
* - JWT Token管理
*
* 职责分离:
* - 专注于模块导出和接口暴露
* - 提供统一的模块入口点
* - 简化外部模块的引用方式
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 更新注释规范
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
// 模块
export * from './auth.module';
// 控制器
export * from './controllers/login.controller';
export * from './login.controller';
// 服务
export * from './services/login.service';
export * from './login.service';
// DTO
export * from './dto/login.dto';
export * from './dto/login_response.dto';
export * from './login.dto';
export * from './login_response.dto';

View File

@@ -0,0 +1,119 @@
/**
* JWT 认证守卫
*
* 功能描述:
* - 验证请求中的 JWT 令牌
* - 提取用户信息并添加到请求上下文
* - 保护需要认证的路由
*
* 职责分离:
* - 专注于JWT令牌验证和用户认证
* - 提供统一的认证守卫机制
* - 处理认证失败的异常情况
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 文件重命名为snake_case格式更新注释规范
*
* @author moyin
* @version 1.0.2
* @since 2025-01-05
* @lastModified 2026-01-07
*/
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { Request } from 'express';
import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service';
/**
* 扩展的请求接口,包含用户信息
*/
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(private readonly loginCoreService: LoginCoreService) {}
/**
* JWT令牌验证和用户认证
*
* 业务逻辑:
* 1. 从请求头中提取Bearer令牌
* 2. 验证令牌的有效性和签名
* 3. 解码令牌获取用户信息
* 4. 将用户信息添加到请求上下文
* 5. 记录认证成功或失败的日志
* 6. 返回认证结果
*
* @param context 执行上下文包含HTTP请求信息
* @returns Promise<boolean> 认证是否成功
* @throws UnauthorizedException 当令牌缺失或无效时
*
* @example
* ```typescript
* @Get('protected')
* @UseGuards(JwtAuthGuard)
* getProtectedData() {
* // 此方法需要有效的JWT令牌才能访问
* }
* ```
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
this.logger.warn('访问被拒绝:缺少认证令牌');
throw new UnauthorizedException('缺少认证令牌');
}
try {
// 使用Core层服务验证JWT令牌
const payload = await this.loginCoreService.verifyToken(token, 'access');
// 将用户信息添加到请求对象
(request as AuthenticatedRequest).user = payload;
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
throw new UnauthorizedException('无效的认证令牌');
}
}
/**
* 从请求头中提取JWT令牌
*
* 业务逻辑:
* 1. 获取Authorization请求头
* 2. 解析Bearer令牌格式
* 3. 验证令牌类型是否为Bearer
* 4. 返回提取的令牌字符串
*
* @param request HTTP请求对象
* @returns string | undefined JWT令牌字符串或undefined
* @throws 无异常抛出返回undefined表示令牌不存在
*
* @example
* ```typescript
* // 请求头格式Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
* const token = this.extractTokenFromHeader(request);
* ```
*/
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -1,16 +1,30 @@
/**
* JWT 使
*
* 使 JWT
*
* - 使 JWT
* - JWT认证使用示例和最佳实践
* -
*
* @author kiro-ai
* @version 1.0.0
*
* - JWT认证功能的使用演示
* -
* -
*
*
* - 2026-01-07: 代码规范优化 -
* - 2026-01-07: 代码规范优化 - snake_case格式
*
* @author moyin
* @version 1.0.2
* @since 2025-01-05
* @lastModified 2026-01-07
*/
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
import { JwtAuthGuard, JwtPayload } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from './jwt_auth.guard';
import { JwtPayload } from '../../core/login_core/login_core.service';
import { CurrentUser } from './current_user.decorator';
/**
* - JWT 使

View File

@@ -6,6 +6,11 @@
* - RESTful API接口
* -
*
*
* - HTTP请求处理和响应格式化
* -
* - API文档和参数验证
*
* API端点
* - POST /auth/login -
* - POST /auth/register -
@@ -15,16 +20,21 @@
* - PUT /auth/change-password -
* - POST /auth/refresh-token - 访
*
* @author moyin angjustinl
* @version 1.0.0
*
* - 2026-01-07: 代码规范优化 -
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
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 '../services/login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto';
import { LoginService, ApiResponse, LoginResponse } from './login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from './login.dto';
import {
LoginResponseDto,
RegisterResponseDto,
@@ -34,9 +44,24 @@ import {
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto,
RefreshTokenResponseDto
} from '../dto/login_response.dto';
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
} from './login_response.dto';
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decorator';
// 错误代码到HTTP状态码的映射
const ERROR_STATUS_MAP = {
LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
REGISTER_FAILED: HttpStatus.BAD_REQUEST,
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST,
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
} as const;
@ApiTags('auth')
@Controller('auth')
@@ -45,6 +70,60 @@ export class LoginController {
constructor(private readonly loginService: LoginService) {}
/**
*
*
*
* 1. HTTP状态码
* 2.
* 3.
*
* @param result
* @param res Express响应对象
* @param successStatus HTTP状态码200
* @private
*/
private handleResponse(result: any, res: Response, successStatus: HttpStatus = HttpStatus.OK): void {
if (result.success) {
res.status(successStatus).json(result);
return;
}
// 根据错误代码获取状态码
const statusCode = this.getErrorStatusCode(result);
res.status(statusCode).json(result);
}
/**
* HTTP状态码
*
* @param result
* @returns HTTP状态码
* @private
*/
private getErrorStatusCode(result: any): HttpStatus {
// 优先使用错误代码映射
if (result.error_code && ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP]) {
return ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP];
}
// 根据消息内容判断
if (result.message?.includes('已存在') || result.message?.includes('已被注册')) {
return HttpStatus.CONFLICT;
}
if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) {
return HttpStatus.UNAUTHORIZED;
}
if (result.message?.includes('用户不存在')) {
return HttpStatus.NOT_FOUND;
}
// 默认返回400
return HttpStatus.BAD_REQUEST;
}
/**
*
*
@@ -87,17 +166,7 @@ export class LoginController {
password: loginDto.password
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
// 根据错误类型设置不同的状态码
if (result.error_code === 'LOGIN_FAILED') {
res.status(HttpStatus.UNAUTHORIZED).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
this.handleResponse(result, res);
}
/**
@@ -142,21 +211,7 @@ export class LoginController {
email_verification_code: registerDto.email_verification_code
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
} else {
// 根据错误类型设置不同的状态码
if (result.message?.includes('已存在')) {
// 资源冲突:用户名、邮箱、手机号已存在
res.status(HttpStatus.CONFLICT).json(result);
} else if (result.error_code === 'REGISTER_FAILED') {
// 其他注册失败:参数错误、验证码错误等
res.status(HttpStatus.BAD_REQUEST).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
this.handleResponse(result, res, HttpStatus.CREATED);
}
/**
@@ -194,12 +249,7 @@ export class LoginController {
avatar_url: githubDto.avatar_url
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -244,15 +294,7 @@ export class LoginController {
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier);
// 根据结果设置不同的状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else if (result.error_code === 'TEST_MODE_ONLY') {
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -293,12 +335,7 @@ export class LoginController {
newPassword: resetPasswordDto.new_password
});
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -338,12 +375,7 @@ export class LoginController {
changePasswordDto.new_password
);
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -385,18 +417,7 @@ export class LoginController {
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else if (result.error_code === 'TEST_MODE_ONLY') {
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
} else if (result.message?.includes('已被注册') || result.message?.includes('已存在')) {
// 邮箱已被注册
res.status(HttpStatus.CONFLICT).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -427,12 +448,7 @@ export class LoginController {
emailVerificationDto.verification_code
);
// 根据业务结果设置正确的HTTP状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -473,15 +489,7 @@ export class LoginController {
@Res() res: Response
): Promise<void> {
const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else if (result.error_code === 'TEST_MODE_ONLY') {
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -563,15 +571,7 @@ export class LoginController {
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendLoginVerificationCode(sendLoginVerificationCodeDto.identifier);
// 根据结果设置不同的状态码
if (result.success) {
res.status(HttpStatus.OK).json(result);
} else if (result.error_code === 'TEST_MODE_ONLY') {
res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
this.handleResponse(result, res);
}
/**
@@ -662,56 +662,70 @@ export class LoginController {
const startTime = Date.now();
try {
this.logger.log('令牌刷新请求', {
operation: 'refreshToken',
timestamp: new Date().toISOString(),
});
this.logRefreshTokenStart();
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
const duration = Date.now() - startTime;
if (result.success) {
this.logger.log('令牌刷新成功', {
operation: 'refreshToken',
duration,
timestamp: new Date().toISOString(),
});
res.status(HttpStatus.OK).json(result);
} else {
this.logger.warn('令牌刷新失败', {
operation: 'refreshToken',
error: result.message,
errorCode: result.error_code,
duration,
timestamp: new Date().toISOString(),
});
// 根据错误类型设置不同的状态码
if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) {
res.status(HttpStatus.UNAUTHORIZED).json(result);
} else if (result.message?.includes('用户不存在')) {
res.status(HttpStatus.NOT_FOUND).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
this.handleRefreshTokenResponse(result, res, startTime);
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('令牌刷新异常', {
operation: 'refreshToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
message: '服务器内部错误',
error_code: 'INTERNAL_SERVER_ERROR'
});
this.handleRefreshTokenError(error, res, startTime);
}
}
/**
*
* @private
*/
private logRefreshTokenStart(): void {
this.logger.log('令牌刷新请求', {
operation: 'refreshToken',
timestamp: new Date().toISOString(),
});
}
/**
*
* @private
*/
private handleRefreshTokenResponse(result: any, res: Response, startTime: number): void {
const duration = Date.now() - startTime;
if (result.success) {
this.logger.log('令牌刷新成功', {
operation: 'refreshToken',
duration,
timestamp: new Date().toISOString(),
});
res.status(HttpStatus.OK).json(result);
} else {
this.logger.warn('令牌刷新失败', {
operation: 'refreshToken',
error: result.message,
errorCode: result.error_code,
duration,
timestamp: new Date().toISOString(),
});
this.handleResponse(result, res);
}
}
/**
*
* @private
*/
private handleRefreshTokenError(error: unknown, res: Response, startTime: number): void {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('令牌刷新异常', {
operation: 'refreshToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
message: '服务器内部错误',
error_code: 'INTERNAL_SERVER_ERROR'
});
}
}

View File

@@ -6,9 +6,19 @@
* -
* - API接口的数据格式一致性
*
*
* -
* - Swagger文档生成支持
* -
*
*
* - 2026-01-07: 代码规范优化 -
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.0
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import {

View File

@@ -0,0 +1,366 @@
/**
* 登录业务服务测试
*
* 功能描述:
* - 测试登录相关的业务逻辑
* - 测试业务层与核心层的集成
* - 测试各种异常情况处理
*
* 注意JWT相关功能已移至Core层此测试专注于Business层逻辑
*
* @author moyin
* @version 1.0.1
* @since 2025-01-06
* @lastModified 2026-01-07
*/
import { Test, TestingModule } from '@nestjs/testing';
import { LoginService } from './login.service';
import { LoginCoreService } from '../../core/login_core/login_core.service';
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
import { UserStatus } from '../../core/db/users/user_status.enum';
describe('LoginService', () => {
let service: LoginService;
let loginCoreService: jest.Mocked<LoginCoreService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
const mockUser = {
id: BigInt(1),
username: 'testuser',
email: 'test@example.com',
phone: '+8613800138000',
password_hash: '$2b$12$hashedpassword',
nickname: '测试用户',
github_id: null as string | null,
avatar_url: null as string | null,
role: 1,
status: UserStatus.ACTIVE,
email_verified: true,
created_at: new Date(),
updated_at: new Date(),
};
const mockTokenPair = {
access_token: 'mock_access_token',
refresh_token: 'mock_refresh_token',
expires_in: 604800,
token_type: 'Bearer'
};
beforeEach(async () => {
// Mock environment variables for Zulip
process.env.ZULIP_SERVER_URL = 'https://test.zulipchat.com';
process.env.ZULIP_BOT_EMAIL = 'test-bot@test.zulipchat.com';
process.env.ZULIP_BOT_API_KEY = 'test_api_key_12345';
const mockLoginCoreService = {
login: jest.fn(),
register: jest.fn(),
githubOAuth: jest.fn(),
sendPasswordResetCode: jest.fn(),
resetPassword: jest.fn(),
changePassword: jest.fn(),
sendEmailVerification: jest.fn(),
verifyEmailCode: jest.fn(),
resendEmailVerification: jest.fn(),
verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: jest.fn(),
debugVerificationCode: jest.fn(),
deleteUser: jest.fn(),
generateTokenPair: jest.fn(),
};
const mockZulipAccountService = {
initializeAdminClient: jest.fn(),
createZulipAccount: jest.fn(),
linkGameAccount: jest.fn(),
};
const mockZulipAccountsService = {
findByGameUserId: jest.fn(),
create: jest.fn(),
deleteByGameUserId: jest.fn(),
};
const mockApiKeySecurityService = {
storeApiKey: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
LoginService,
{
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
{
provide: ZulipAccountService,
useValue: mockZulipAccountService,
},
{
provide: 'ZulipAccountsService',
useValue: mockZulipAccountsService,
},
{
provide: ApiKeySecurityService,
useValue: mockApiKeySecurityService,
},
],
}).compile();
service = module.get<LoginService>(LoginService);
loginCoreService = module.get(LoginCoreService);
zulipAccountService = module.get(ZulipAccountService);
zulipAccountsService = module.get('ZulipAccountsService');
apiKeySecurityService = module.get(ApiKeySecurityService);
// Setup default mocks
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
zulipAccountService.createZulipAccount.mockResolvedValue({
success: true,
userId: 123,
email: 'test@example.com',
apiKey: 'mock_api_key'
});
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
zulipAccountsService.create.mockResolvedValue({} as any);
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('login', () => {
it('should login successfully and return JWT tokens', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
expect(result.data?.refresh_token).toBe(mockTokenPair.refresh_token);
expect(loginCoreService.login).toHaveBeenCalledWith({
identifier: 'testuser',
password: 'password123'
});
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
});
it('should handle login failure', async () => {
loginCoreService.login.mockRejectedValue(new Error('用户名或密码错误'));
const result = await service.login({
identifier: 'testuser',
password: 'wrongpassword'
});
expect(result.success).toBe(false);
expect(result.message).toBe('用户名或密码错误');
expect(result.error_code).toBe('LOGIN_FAILED');
});
});
describe('register', () => {
it('should register successfully with JWT tokens', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
isNewUser: true
});
const result = await service.register({
username: 'newuser',
password: 'password123',
nickname: '新用户',
email: 'newuser@example.com',
email_verification_code: '123456'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
expect(result.data?.is_new_user).toBe(true);
expect(loginCoreService.register).toHaveBeenCalled();
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
});
it('should handle register failure', async () => {
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
const result = await service.register({
username: 'existinguser',
password: 'password123',
nickname: '用户'
});
expect(result.success).toBe(false);
expect(result.message).toBe('用户名已存在');
expect(result.error_code).toBe('REGISTER_FAILED');
});
});
describe('githubOAuth', () => {
it('should handle GitHub OAuth successfully', async () => {
loginCoreService.githubOAuth.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.githubOAuth({
github_id: '12345',
username: 'githubuser',
nickname: 'GitHub用户',
email: 'github@example.com'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
expect(loginCoreService.githubOAuth).toHaveBeenCalled();
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
});
});
describe('sendPasswordResetCode', () => {
it('should handle sendPasswordResetCode in test mode', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendPasswordResetCode('test@example.com');
expect(result.success).toBe(false); // Test mode returns false
expect(result.data?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(true);
expect(loginCoreService.sendPasswordResetCode).toHaveBeenCalledWith('test@example.com');
});
});
describe('resetPassword', () => {
it('should handle resetPassword successfully', async () => {
loginCoreService.resetPassword.mockResolvedValue(undefined);
const result = await service.resetPassword({
identifier: 'test@example.com',
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result.success).toBe(true);
expect(result.message).toBe('密码重置成功');
expect(loginCoreService.resetPassword).toHaveBeenCalled();
});
});
describe('changePassword', () => {
it('should handle changePassword successfully', async () => {
loginCoreService.changePassword.mockResolvedValue(undefined);
const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword');
expect(result.success).toBe(true);
expect(result.message).toBe('密码修改成功');
expect(loginCoreService.changePassword).toHaveBeenCalledWith(BigInt(1), 'oldpassword', 'newpassword');
});
});
describe('sendEmailVerification', () => {
it('should handle sendEmailVerification in test mode', async () => {
loginCoreService.sendEmailVerification.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendEmailVerification('test@example.com');
expect(result.success).toBe(false); // Test mode returns false
expect(result.data?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(true);
expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
});
});
describe('verifyEmailCode', () => {
it('should handle verifyEmailCode successfully', async () => {
loginCoreService.verifyEmailCode.mockResolvedValue(true);
const result = await service.verifyEmailCode('test@example.com', '123456');
expect(result.success).toBe(true);
expect(result.message).toBe('邮箱验证成功');
expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
});
});
describe('verificationCodeLogin', () => {
it('should handle verificationCodeLogin successfully', async () => {
loginCoreService.verificationCodeLogin.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
expect(loginCoreService.verificationCodeLogin).toHaveBeenCalled();
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
});
});
describe('sendLoginVerificationCode', () => {
it('should handle sendLoginVerificationCode successfully', async () => {
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendLoginVerificationCode('test@example.com');
expect(result.success).toBe(false); // Test mode returns false
expect(result.data?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(true);
expect(loginCoreService.sendLoginVerificationCode).toHaveBeenCalledWith('test@example.com');
});
});
describe('debugVerificationCode', () => {
it('should handle debugVerificationCode successfully', async () => {
const mockDebugInfo = {
email: 'test@example.com',
hasCode: true,
codeExpiry: new Date()
};
loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo);
const result = await service.debugVerificationCode('test@example.com');
expect(result.success).toBe(true);
expect(result.data).toEqual(mockDebugInfo);
expect(loginCoreService.debugVerificationCode).toHaveBeenCalledWith('test@example.com');
});
});
});

View File

@@ -10,60 +10,65 @@
* -
* -
* -
* - JWT技术实现已移至Core层
*
* @author moyin angjustinl
* @version 1.0.0
*
* - 2026-01-07: 代码规范优化 -
* - 2026-01-07: 架构优化 - JWT技术实现移至login_core模块
*
* @author moyin
* @version 1.0.3
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service';
import { Users } from '../../../core/db/users/users.entity';
import { UsersService } from '../../../core/db/users/users.service';
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
import { Users } from '../../core/db/users/users.entity';
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
/**
* JWT载荷接口
*/
export interface JwtPayload {
/** 用户ID */
sub: string;
/** 用户名 */
username: string;
/** 用户角色 */
role: number;
/** 邮箱 */
email?: string;
/** 令牌类型 */
type: 'access' | 'refresh';
/** 签发时间 */
iat?: number;
/** 过期时间 */
exp?: number;
/** 签发者 */
iss?: string;
/** 受众 */
aud?: string;
}
// 常量定义
const ERROR_CODES = {
LOGIN_FAILED: 'LOGIN_FAILED',
REGISTER_FAILED: 'REGISTER_FAILED',
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
SEND_CODE_FAILED: 'SEND_CODE_FAILED',
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED',
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
DEBUG_VERIFICATION_CODE_FAILED: 'DEBUG_VERIFICATION_CODE_FAILED',
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
} as const;
/**
*
*/
export interface TokenPair {
/** 访问令牌 */
access_token: string;
/** 刷新令牌 */
refresh_token: string;
/** 访问令牌过期时间(秒) */
expires_in: number;
/** 令牌类型 */
token_type: string;
}
const MESSAGES = {
LOGIN_SUCCESS: '登录成功',
REGISTER_SUCCESS: '注册成功',
REGISTER_SUCCESS_WITH_ZULIP: '注册成功Zulip账号已同步创建',
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
PASSWORD_RESET_SUCCESS: '密码重置成功',
PASSWORD_CHANGE_SUCCESS: '密码修改成功',
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
DEBUG_INFO_SUCCESS: '调试信息获取成功',
CODE_SENT: '验证码已发送,请查收',
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
VERIFICATION_CODE_ERROR: '验证码错误',
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
} as const;
// JWT相关接口已移至Core层通过import导入
/**
*
@@ -115,13 +120,9 @@ export class LoginService {
constructor(
private readonly loginCoreService: LoginCoreService,
private readonly zulipAccountService: ZulipAccountService,
@Inject('ZulipAccountsRepository')
private readonly zulipAccountsRepository: ZulipAccountsRepository,
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@Inject('UsersService')
private readonly usersService: UsersService,
) {}
/**
@@ -156,8 +157,8 @@ export class LoginService {
// 1. 调用核心服务进行认证
const authResult = await this.loginCoreService.login(loginRequest);
// 2. 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 2. 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 3. 格式化响应数据
const response: LoginResponse = {
@@ -167,7 +168,7 @@ export class LoginService {
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: '登录成功'
message: MESSAGES.LOGIN_SUCCESS
};
const duration = Date.now() - startTime;
@@ -184,7 +185,7 @@ export class LoginService {
return {
success: true,
data: response,
message: '登录成功'
message: MESSAGES.LOGIN_SUCCESS
};
} catch (error) {
const duration = Date.now() - startTime;
@@ -201,7 +202,7 @@ export class LoginService {
return {
success: false,
message: err.message || '登录失败',
error_code: 'LOGIN_FAILED'
error_code: ERROR_CODES.LOGIN_FAILED
};
}
}
@@ -271,8 +272,8 @@ export class LoginService {
throw new Error(`注册失败Zulip账号创建失败 - ${err.message}`);
}
// 4. 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 4. 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 5. 格式化响应数据
const response: LoginResponse = {
@@ -282,7 +283,7 @@ export class LoginService {
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: true,
message: zulipAccountCreated ? '注册成功Zulip账号已同步创建' : '注册成功'
message: zulipAccountCreated ? MESSAGES.REGISTER_SUCCESS_WITH_ZULIP : MESSAGES.REGISTER_SUCCESS
};
const duration = Date.now() - startTime;
@@ -316,7 +317,7 @@ export class LoginService {
return {
success: false,
message: err.message || '注册失败',
error_code: 'REGISTER_FAILED'
error_code: ERROR_CODES.REGISTER_FAILED
};
}
}
@@ -334,8 +335,8 @@ export class LoginService {
// 调用核心服务进行OAuth认证
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
// 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
@@ -345,7 +346,7 @@ export class LoginService {
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
message: authResult.isNewUser ? MESSAGES.GITHUB_BIND_SUCCESS : MESSAGES.GITHUB_LOGIN_SUCCESS
};
this.logger.log(`GitHub OAuth成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
@@ -361,7 +362,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : 'GitHub登录失败',
error_code: 'GITHUB_OAUTH_FAILED'
error_code: ERROR_CODES.GITHUB_OAUTH_FAILED
};
}
}
@@ -381,35 +382,14 @@ export class LoginService {
this.logger.log(`密码重置验证码已发送: ${identifier}`);
// 根据是否为测试模式返回不同的状态和消息 by angjustinl 2025-12-17
if (result.isTestMode) {
// 测试模式:验证码生成但未真实发送
return {
success: false, // 测试模式下不算真正成功
data: {
verification_code: result.code,
is_test_mode: true
},
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
error_code: 'TEST_MODE_ONLY'
};
} else {
// 真实发送模式
return {
success: true,
data: {
is_test_mode: false
},
message: '验证码已发送,请查收'
};
}
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
} catch (error) {
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: 'SEND_CODE_FAILED'
error_code: ERROR_CODES.SEND_CODE_FAILED
};
}
}
@@ -431,7 +411,7 @@ export class LoginService {
return {
success: true,
message: '密码重置成功'
message: MESSAGES.PASSWORD_RESET_SUCCESS
};
} catch (error) {
this.logger.error(`密码重置失败: ${resetRequest.identifier}`, error instanceof Error ? error.stack : String(error));
@@ -439,7 +419,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : '密码重置失败',
error_code: 'RESET_PASSWORD_FAILED'
error_code: ERROR_CODES.RESET_PASSWORD_FAILED
};
}
}
@@ -463,7 +443,7 @@ export class LoginService {
return {
success: true,
message: '密码修改成功'
message: MESSAGES.PASSWORD_CHANGE_SUCCESS
};
} catch (error) {
this.logger.error(`修改密码失败: 用户ID ${userId}`, error instanceof Error ? error.stack : String(error));
@@ -471,7 +451,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : '密码修改失败',
error_code: 'CHANGE_PASSWORD_FAILED'
error_code: ERROR_CODES.CHANGE_PASSWORD_FAILED
};
}
}
@@ -491,35 +471,14 @@ export class LoginService {
this.logger.log(`邮箱验证码已发送: ${email}`);
// 根据是否为测试模式返回不同的状态和消息
if (result.isTestMode) {
// 测试模式:验证码生成但未真实发送
return {
success: false, // 测试模式下不算真正成功
data: {
verification_code: result.code,
is_test_mode: true
},
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
error_code: 'TEST_MODE_ONLY'
};
} else {
// 真实发送模式
return {
success: true,
data: {
is_test_mode: false
},
message: '验证码已发送,请查收邮件'
};
}
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT);
} catch (error) {
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: 'SEND_EMAIL_VERIFICATION_FAILED'
error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED
};
}
}
@@ -542,13 +501,13 @@ export class LoginService {
this.logger.log(`邮箱验证成功: ${email}`);
return {
success: true,
message: '邮箱验证成功'
message: MESSAGES.EMAIL_VERIFICATION_SUCCESS
};
} else {
return {
success: false,
message: '验证码错误',
error_code: 'INVALID_VERIFICATION_CODE'
message: MESSAGES.VERIFICATION_CODE_ERROR,
error_code: ERROR_CODES.INVALID_VERIFICATION_CODE
};
}
} catch (error) {
@@ -557,7 +516,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : '邮箱验证失败',
error_code: 'EMAIL_VERIFICATION_FAILED'
error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED
};
}
}
@@ -577,35 +536,14 @@ export class LoginService {
this.logger.log(`邮箱验证码已重新发送: ${email}`);
// 根据是否为测试模式返回不同的状态和消息
if (result.isTestMode) {
// 测试模式:验证码生成但未真实发送
return {
success: false, // 测试模式下不算真正成功
data: {
verification_code: result.code,
is_test_mode: true
},
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
error_code: 'TEST_MODE_ONLY'
};
} else {
// 真实发送模式
return {
success: true,
data: {
is_test_mode: false
},
message: '验证码已重新发送,请查收邮件'
};
}
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT);
} catch (error) {
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '重新发送验证码失败',
error_code: 'RESEND_EMAIL_VERIFICATION_FAILED'
error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED
};
}
}
@@ -630,273 +568,40 @@ export class LoginService {
}
/**
* JWT令牌对
*
*
*
* 访JWT标准和安全最佳实践
*
*
* 1. 访
* 2.
* 3. 使
* 4.
*
* @param user
* @returns Promise<TokenPair> JWT令牌对
*
* @throws InternalServerErrorException
*
* @example
* ```typescript
* const tokenPair = await this.generateTokenPair(user);
* console.log(tokenPair.access_token); // JWT访问令牌
* console.log(tokenPair.refresh_token); // JWT刷新令牌
* ```
*/
private async generateTokenPair(user: Users): Promise<TokenPair> {
try {
const currentTime = Math.floor(Date.now() / 1000);
const jwtSecret = this.configService.get<string>('JWT_SECRET');
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
if (!jwtSecret) {
throw new Error('JWT_SECRET未配置');
}
// 1. 创建访问令牌载荷不包含iss和aud这些通过options传递
const accessPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
sub: user.id.toString(),
username: user.username,
role: user.role,
email: user.email,
type: 'access',
};
// 2. 创建刷新令牌载荷(有效期更长)
const refreshPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
sub: user.id.toString(),
username: user.username,
role: user.role,
type: 'refresh',
};
// 3. 生成访问令牌使用NestJS JwtService通过options传递iss和aud
const accessToken = await this.jwtService.signAsync(accessPayload, {
issuer: 'whale-town',
audience: 'whale-town-users',
});
// 4. 生成刷新令牌有效期30天
const refreshToken = jwt.sign(refreshPayload, jwtSecret, {
expiresIn: '30d',
issuer: 'whale-town',
audience: 'whale-town-users',
});
// 5. 计算过期时间(秒)
const expiresInSeconds = this.parseExpirationTime(expiresIn);
this.logger.log('JWT令牌对生成成功', {
operation: 'generateTokenPair',
userId: user.id.toString(),
username: user.username,
expiresIn: expiresInSeconds,
timestamp: new Date().toISOString(),
});
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresInSeconds,
token_type: 'Bearer',
};
} catch (error) {
const err = error as Error;
this.logger.error('JWT令牌对生成失败', {
operation: 'generateTokenPair',
userId: user.id.toString(),
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
throw new Error(`令牌生成失败: ${err.message}`);
}
}
/**
* JWT令牌
*
*
* JWT令牌的有效性
*
*
* 1.
* 2.
* 3.
* 4.
*
* @param token JWT令牌字符串
* @param tokenType access refresh
* @returns Promise<JwtPayload>
*
* @throws UnauthorizedException
* @throws Error
*/
async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise<JwtPayload> {
try {
const jwtSecret = this.configService.get<string>('JWT_SECRET');
if (!jwtSecret) {
throw new Error('JWT_SECRET未配置');
}
// 1. 验证令牌并解码载荷
const payload = jwt.verify(token, jwtSecret, {
issuer: 'whale-town',
audience: 'whale-town-users',
}) as JwtPayload;
// 2. 验证令牌类型
if (payload.type !== tokenType) {
throw new Error(`令牌类型不匹配,期望: ${tokenType},实际: ${payload.type}`);
}
// 3. 验证载荷完整性
if (!payload.sub || !payload.username || payload.role === undefined) {
throw new Error('令牌载荷数据不完整');
}
this.logger.log('JWT令牌验证成功', {
operation: 'verifyToken',
userId: payload.sub,
username: payload.username,
tokenType: payload.type,
timestamp: new Date().toISOString(),
});
return payload;
} catch (error) {
const err = error as Error;
this.logger.warn('JWT令牌验证失败', {
operation: 'verifyToken',
tokenType,
error: err.message,
timestamp: new Date().toISOString(),
});
throw new Error(`令牌验证失败: ${err.message}`);
}
}
/**
* 访
*
*
* 使访
*
*
* 1.
* 2.
* 3. 访
* 4.
*
* @param refreshToken
* @returns Promise<ApiResponse<TokenPair>>
*
* @throws UnauthorizedException
* @throws NotFoundException
*/
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
const startTime = Date.now();
try {
this.logger.log('开始刷新访问令牌', {
operation: 'refreshAccessToken',
timestamp: new Date().toISOString(),
});
// 1. 验证刷新令牌
const payload = await this.verifyToken(refreshToken, 'refresh');
// 2. 获取最新用户信息
const user = await this.usersService.findOne(BigInt(payload.sub));
if (!user) {
throw new Error('用户不存在或已被禁用');
}
// 3. 生成新的令牌对
const newTokenPair = await this.generateTokenPair(user);
const duration = Date.now() - startTime;
this.logger.log('访问令牌刷新成功', {
operation: 'refreshAccessToken',
userId: user.id.toString(),
username: user.username,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
data: newTokenPair,
message: '令牌刷新成功'
};
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('访问令牌刷新失败', {
operation: 'refreshAccessToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
message: err.message || '令牌刷新失败',
error_code: 'TOKEN_REFRESH_FAILED'
};
}
}
/**
*
*
*
* '7d', '24h', '60m'
*
* @param expiresIn
* @returns number
* @param result
* @param successMessage
* @param emailMessage
* @returns
* @private
*/
private parseExpirationTime(expiresIn: string): number {
if (!expiresIn || typeof expiresIn !== 'string') {
return 7 * 24 * 60 * 60; // 默认7天
}
const timeUnit = expiresIn.slice(-1);
const timeValue = parseInt(expiresIn.slice(0, -1));
if (isNaN(timeValue)) {
return 7 * 24 * 60 * 60; // 默认7天
}
switch (timeUnit) {
case 's': return timeValue;
case 'm': return timeValue * 60;
case 'h': return timeValue * 60 * 60;
case 'd': return timeValue * 24 * 60 * 60;
case 'w': return timeValue * 7 * 24 * 60 * 60;
default: return 7 * 24 * 60 * 60; // 默认7天
private handleTestModeResponse(
result: { code: string; isTestMode: boolean },
successMessage: string,
emailMessage?: string
): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> {
if (result.isTestMode) {
return {
success: false,
data: {
verification_code: result.code,
is_test_mode: true
},
message: MESSAGES.TEST_MODE_WARNING,
error_code: ERROR_CODES.TEST_MODE_ONLY
};
} else {
return {
success: true,
data: {
is_test_mode: false
},
message: emailMessage || successMessage
};
}
}
/**
*
*
@@ -910,8 +615,8 @@ export class LoginService {
// 调用核心服务进行验证码认证
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
// 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 生成JWT令牌对通过Core层
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
@@ -921,7 +626,7 @@ export class LoginService {
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: '验证码登录成功'
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
};
this.logger.log(`验证码登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
@@ -929,7 +634,7 @@ export class LoginService {
return {
success: true,
data: response,
message: '验证码登录成功'
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
};
} catch (error) {
this.logger.error(`验证码登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
@@ -937,7 +642,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : '验证码登录失败',
error_code: 'VERIFICATION_CODE_LOGIN_FAILED'
error_code: ERROR_CODES.VERIFICATION_CODE_LOGIN_FAILED
};
}
}
@@ -957,45 +662,65 @@ export class LoginService {
this.logger.log(`登录验证码已发送: ${identifier}`);
// 根据是否为测试模式返回不同的状态和消息
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: '验证码已发送,请查收'
};
}
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
} catch (error) {
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '发送验证码失败',
error_code: 'SEND_LOGIN_CODE_FAILED'
error_code: ERROR_CODES.SEND_LOGIN_CODE_FAILED
};
}
}
/**
*
* 访
*
* @param email
* @returns
*
* 使访
*
*
* 1.
* 2.
* 3. JWT令牌对
* 4. 访
*
* @param refreshToken
* @returns Promise<ApiResponse<TokenPair>>
*
* @throws UnauthorizedException
* @throws NotFoundException
*
* @example
* ```typescript
* const result = await loginService.refreshAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
* ```
*/
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
try {
this.logger.log(`刷新访问令牌尝试`);
// 调用核心服务刷新令牌
const tokenPair = await this.loginCoreService.refreshAccessToken(refreshToken);
this.logger.log(`访问令牌刷新成功`);
return {
success: true,
data: tokenPair,
message: MESSAGES.TOKEN_REFRESH_SUCCESS
};
} catch (error) {
this.logger.error(`访问令牌刷新失败`, error instanceof Error ? error.stack : String(error));
return {
success: false,
message: error instanceof Error ? error.message : '令牌刷新失败',
error_code: ERROR_CODES.TOKEN_REFRESH_FAILED
};
}
}
async debugVerificationCode(email: string): Promise<any> {
try {
this.logger.log(`调试验证码信息: ${email}`);
@@ -1005,7 +730,7 @@ export class LoginService {
return {
success: true,
data: debugInfo,
message: '调试信息获取成功'
message: MESSAGES.DEBUG_INFO_SUCCESS
};
} catch (error) {
this.logger.error(`获取验证码调试信息失败: ${email}`, error instanceof Error ? error.stack : String(error));
@@ -1013,7 +738,7 @@ export class LoginService {
return {
success: false,
message: error instanceof Error ? error.message : '获取调试信息失败',
error_code: 'DEBUG_VERIFICATION_CODE_FAILED'
error_code: ERROR_CODES.DEBUG_VERIFICATION_CODE_FAILED
};
}
}
@@ -1098,7 +823,7 @@ export class LoginService {
try {
// 1. 检查是否已存在Zulip账号关联
const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id);
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
if (existingAccount) {
this.logger.warn('用户已存在Zulip账号关联跳过创建', {
operation: 'createZulipAccountForUser',
@@ -1128,8 +853,8 @@ export class LoginService {
}
// 4. 在数据库中创建关联记录
await this.zulipAccountsRepository.create({
gameUserId: gameUser.id,
await this.zulipAccountsService.create({
gameUserId: gameUser.id.toString(),
zulipUserId: createResult.userId!,
zulipEmail: createResult.email!,
zulipFullName: gameUser.nickname,
@@ -1172,7 +897,7 @@ export class LoginService {
// 清理可能创建的部分数据
try {
await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id);
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
} catch (cleanupError) {
this.logger.warn('清理Zulip账号关联数据失败', {
operation: 'createZulipAccountForUser',

View File

@@ -16,24 +16,21 @@
*/
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as fc from 'fast-check';
import { LoginService } from './login.service';
import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service';
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
import { Users } from '../../../core/db/users/users.entity';
import { ZulipAccounts } from '../../../core/db/zulip_accounts/zulip_accounts.entity';
import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service';
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
import { Users } from '../../core/db/users/users.entity';
describe('LoginService - Zulip账号创建属性测试', () => {
let loginService: LoginService;
let loginCoreService: jest.Mocked<LoginCoreService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
// 测试用的模拟数据生成器
@@ -62,6 +59,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
const mockLoginCoreService = {
register: jest.fn(),
deleteUser: jest.fn(),
generateTokenPair: jest.fn(),
};
const mockZulipAccountService = {
@@ -70,7 +68,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
linkGameAccount: jest.fn(),
};
const mockZulipAccountsRepository = {
const mockZulipAccountsService = {
findByGameUserId: jest.fn(),
create: jest.fn(),
deleteByGameUserId: jest.fn(),
@@ -92,8 +90,8 @@ describe('LoginService - Zulip账号创建属性测试', () => {
useValue: mockZulipAccountService,
},
{
provide: 'ZulipAccountsRepository',
useValue: mockZulipAccountsRepository,
provide: 'ZulipAccountsService',
useValue: mockZulipAccountsService,
},
{
provide: ApiKeySecurityService,
@@ -140,9 +138,18 @@ describe('LoginService - Zulip账号创建属性测试', () => {
loginService = module.get<LoginService>(LoginService);
loginCoreService = module.get(LoginCoreService);
zulipAccountService = module.get(ZulipAccountService);
zulipAccountsRepository = module.get('ZulipAccountsRepository');
zulipAccountsService = module.get('ZulipAccountsService');
apiKeySecurityService = module.get(ApiKeySecurityService);
// 设置默认的mock返回值
const mockTokenPair = {
access_token: 'mock_access_token',
refresh_token: 'mock_refresh_token',
expires_in: 604800,
token_type: 'Bearer'
};
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
// Mock LoginService 的 initializeZulipAdminClient 方法
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
@@ -194,27 +201,28 @@ describe('LoginService - Zulip账号创建属性测试', () => {
apiKey: 'zulip_api_key_' + Math.random().toString(36),
};
const mockZulipAccount: ZulipAccounts = {
id: BigInt(Math.floor(Math.random() * 1000000)),
gameUserId: mockGameUser.id,
const mockZulipAccount = {
id: mockGameUser.id.toString(),
gameUserId: mockGameUser.id.toString(),
zulipUserId: mockZulipResult.userId,
zulipEmail: mockZulipResult.email,
zulipFullName: registerRequest.nickname,
zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey,
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
} as ZulipAccounts;
status: 'active' as const,
retryCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// 设置模拟行为
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount);
zulipAccountsService.create.mockResolvedValue(mockZulipAccount);
zulipAccountService.linkGameAccount.mockResolvedValue(true);
// 执行注册
@@ -247,8 +255,8 @@ describe('LoginService - Zulip账号创建属性测试', () => {
);
// 验证账号关联创建
expect(zulipAccountsRepository.create).toHaveBeenCalledWith({
gameUserId: mockGameUser.id,
expect(zulipAccountsService.create).toHaveBeenCalledWith({
gameUserId: mockGameUser.id.toString(),
zulipUserId: mockZulipResult.userId,
zulipEmail: mockZulipResult.email,
zulipFullName: registerRequest.nickname,
@@ -288,7 +296,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
user: mockGameUser,
isNewUser: true,
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
zulipAccountService.createZulipAccount.mockResolvedValue({
success: false,
error: 'Zulip服务器连接失败',
@@ -317,7 +325,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id);
// 验证没有创建账号关联
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
expect(zulipAccountsService.create).not.toHaveBeenCalled();
expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled();
}),
{ numRuns: 100 }
@@ -339,24 +347,25 @@ describe('LoginService - Zulip账号创建属性测试', () => {
updated_at: new Date(),
} as Users;
const existingZulipAccount: ZulipAccounts = {
id: BigInt(Math.floor(Math.random() * 1000000)),
gameUserId: mockGameUser.id,
const existingZulipAccount = {
id: Math.floor(Math.random() * 1000000).toString(),
gameUserId: mockGameUser.id.toString(),
zulipUserId: 12345,
zulipEmail: registerRequest.email,
zulipFullName: registerRequest.nickname,
zulipApiKeyEncrypted: 'existing_encrypted_key',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
} as ZulipAccounts;
status: 'active' as const,
retryCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// 设置模拟行为 - 已存在Zulip账号关联
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount);
zulipAccountsService.findByGameUserId.mockResolvedValue(existingZulipAccount);
// 执行注册
const result = await loginService.register(registerRequest);
@@ -369,11 +378,11 @@ describe('LoginService - Zulip账号创建属性测试', () => {
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
// 验证检查了现有关联
expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id);
expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id.toString());
// 验证没有尝试创建新的Zulip账号
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
expect(zulipAccountsService.create).not.toHaveBeenCalled();
}),
{ numRuns: 100 }
);
@@ -425,7 +434,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
// 验证没有尝试创建Zulip账号
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
expect(zulipAccountsService.create).not.toHaveBeenCalled();
}
),
{ numRuns: 50 }
@@ -525,10 +534,10 @@ describe('LoginService - Zulip账号创建属性测试', () => {
user: mockGameUser,
isNewUser: true,
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts);
zulipAccountsService.create.mockResolvedValue({} as any);
zulipAccountService.linkGameAccount.mockResolvedValue(true);
// 执行注册
@@ -542,9 +551,9 @@ describe('LoginService - Zulip账号创建属性测试', () => {
});
// 验证账号关联存储了正确的数据
expect(zulipAccountsRepository.create).toHaveBeenCalledWith(
expect(zulipAccountsService.create).toHaveBeenCalledWith(
expect.objectContaining({
gameUserId: mockGameUser.id,
gameUserId: mockGameUser.id.toString(),
zulipUserId: mockZulipResult.userId,
zulipEmail: registerRequest.email, // 相同的邮箱
zulipFullName: registerRequest.nickname, // 相同的昵称

View File

@@ -6,9 +6,19 @@
* - Swagger文档生成支持
* - API响应的数据格式一致性
*
*
* -
* - API文档支持
* -
*
*
* - 2026-01-07: 代码规范优化 -
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.0
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';
@@ -335,7 +345,10 @@ export class CommonResponseDto {
}
/**
* DTO by angjustinl 2025-12-17
* DTO
*
*
* - 2025-12-17: 功能新增 - DTO (修改者: angjustinl)
*/
export class TestModeEmailVerificationResponseDto {
@ApiProperty({

View File

@@ -1,763 +0,0 @@
/**
* 登录业务服务测试
*
* 功能描述:
* - 测试登录相关的业务逻辑
* - 测试JWT令牌生成和验证
* - 测试令牌刷新功能
* - 测试各种异常情况处理
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-06
*/
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { LoginService } from './login.service';
import { LoginCoreService } from '../../../core/login_core/login_core.service';
import { UsersService } from '../../../core/db/users/users.service';
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
import * as jwt from 'jsonwebtoken';
// Mock jwt module
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
verify: jest.fn(),
}));
describe('LoginService', () => {
let service: LoginService;
let loginCoreService: jest.Mocked<LoginCoreService>;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
let usersService: jest.Mocked<UsersService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
const mockUser = {
id: BigInt(1),
username: 'testuser',
email: 'test@example.com',
phone: '+8613800138000',
password_hash: '$2b$12$hashedpassword',
nickname: '测试用户',
github_id: null as string | null,
avatar_url: null as string | null,
role: 1,
email_verified: false,
status: 'active' as any,
created_at: new Date(),
updated_at: new Date()
};
const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars';
const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test';
const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test';
beforeEach(async () => {
// Mock environment variables for Zulip
const originalEnv = process.env;
process.env = {
...originalEnv,
ZULIP_SERVER_URL: 'https://test.zulipchat.com',
ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com',
ZULIP_BOT_API_KEY: 'test_api_key_12345',
};
const mockLoginCoreService = {
login: jest.fn(),
register: jest.fn(),
githubOAuth: jest.fn(),
sendPasswordResetCode: jest.fn(),
resetPassword: jest.fn(),
changePassword: jest.fn(),
sendEmailVerification: jest.fn(),
verifyEmailCode: jest.fn(),
resendEmailVerification: jest.fn(),
verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: jest.fn(),
debugVerificationCode: jest.fn(),
deleteUser: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const mockUsersService = {
findOne: jest.fn(),
};
const mockZulipAccountService = {
initializeAdminClient: jest.fn(),
createZulipAccount: jest.fn(),
linkGameAccount: jest.fn(),
};
const mockZulipAccountsRepository = {
findByGameUserId: jest.fn(),
create: jest.fn(),
deleteByGameUserId: jest.fn(),
};
const mockApiKeySecurityService = {
storeApiKey: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
LoginService,
{
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
{
provide: JwtService,
useValue: mockJwtService,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: 'UsersService',
useValue: mockUsersService,
},
{
provide: ZulipAccountService,
useValue: mockZulipAccountService,
},
{
provide: 'ZulipAccountsRepository',
useValue: mockZulipAccountsRepository,
},
{
provide: ApiKeySecurityService,
useValue: mockApiKeySecurityService,
},
],
}).compile();
service = module.get<LoginService>(LoginService);
loginCoreService = module.get(LoginCoreService);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
usersService = module.get('UsersService');
zulipAccountService = module.get(ZulipAccountService);
zulipAccountsRepository = module.get('ZulipAccountsRepository');
apiKeySecurityService = module.get(ApiKeySecurityService);
// Setup default config service mocks
configService.get.mockImplementation((key: string, defaultValue?: any) => {
const config = {
'JWT_SECRET': mockJwtSecret,
'JWT_EXPIRES_IN': '7d',
};
return config[key] || defaultValue;
});
// Setup default JWT service mocks
jwtService.signAsync.mockResolvedValue(mockAccessToken);
(jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken);
// Setup default Zulip mocks
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
zulipAccountService.createZulipAccount.mockResolvedValue({
success: true,
userId: 123,
email: 'test@example.com',
apiKey: 'mock_api_key'
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
zulipAccountsRepository.create.mockResolvedValue({} as any);
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
// Restore original environment variables
jest.restoreAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('login', () => {
it('should login successfully and return JWT tokens', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800); // 7 days in seconds
expect(result.data?.token_type).toBe('Bearer');
expect(result.data?.is_new_user).toBe(false);
expect(result.message).toBe('登录成功');
// Verify JWT service was called correctly
expect(jwtService.signAsync).toHaveBeenCalledWith({
sub: '1',
username: 'testuser',
role: 1,
email: 'test@example.com',
type: 'access',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
});
expect(jwt.sign).toHaveBeenCalledWith(
{
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
},
mockJwtSecret,
{
expiresIn: '30d',
}
);
});
it('should handle login failure', async () => {
loginCoreService.login.mockRejectedValue(new Error('用户名或密码错误'));
const result = await service.login({
identifier: 'testuser',
password: 'wrongpassword'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toBe('用户名或密码错误');
});
it('should handle JWT generation failure', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toContain('JWT generation failed');
});
it('should handle missing JWT secret', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
if (key === 'JWT_EXPIRES_IN') return '7d';
return undefined;
});
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toContain('JWT_SECRET未配置');
});
});
describe('register', () => {
it('should register successfully with JWT tokens', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
isNewUser: true
});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800);
expect(result.data?.token_type).toBe('Bearer');
expect(result.data?.is_new_user).toBe(true);
expect(result.message).toBe('注册成功Zulip账号已同步创建');
});
it('should register successfully without email', async () => {
loginCoreService.register.mockResolvedValue({
user: { ...mockUser, email: null },
isNewUser: true
});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户'
});
expect(result.success).toBe(true);
expect(result.data?.message).toBe('注册成功');
// Should not try to create Zulip account without email
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
});
it('should handle Zulip account creation failure and rollback', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
isNewUser: true
});
zulipAccountService.createZulipAccount.mockResolvedValue({
success: false,
error: 'Zulip creation failed'
});
loginCoreService.deleteUser.mockResolvedValue(undefined);
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(false);
expect(result.message).toContain('Zulip账号创建失败');
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id);
});
it('should handle register failure', async () => {
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('REGISTER_FAILED');
expect(result.message).toBe('用户名已存在');
});
});
describe('verifyToken', () => {
const mockPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
iss: 'whale-town',
aud: 'whale-town-users',
};
it('should verify access token successfully', async () => {
(jwt.verify as jest.Mock).mockReturnValue(mockPayload);
const result = await service.verifyToken(mockAccessToken, 'access');
expect(result).toEqual(mockPayload);
expect(jwt.verify).toHaveBeenCalledWith(
mockAccessToken,
mockJwtSecret,
{
issuer: 'whale-town',
audience: 'whale-town-users',
}
);
});
it('should verify refresh token successfully', async () => {
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
const result = await service.verifyToken(mockRefreshToken, 'refresh');
expect(result).toEqual(refreshPayload);
});
it('should throw error for invalid token', async () => {
(jwt.verify as jest.Mock).mockImplementation(() => {
throw new Error('invalid token');
});
await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token');
});
it('should throw error for token type mismatch', async () => {
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配');
});
it('should throw error for incomplete payload', async () => {
const incompletePayload = { sub: '1', type: 'access' }; // missing username and role
(jwt.verify as jest.Mock).mockReturnValue(incompletePayload);
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整');
});
it('should throw error when JWT secret is missing', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
return undefined;
});
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置');
});
});
describe('refreshAccessToken', () => {
const mockRefreshPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh' as const,
iat: Math.floor(Date.now() / 1000),
iss: 'whale-town',
aud: 'whale-town-users',
};
beforeEach(() => {
(jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload);
usersService.findOne.mockResolvedValue(mockUser);
});
it('should refresh access token successfully', async () => {
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(true);
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800);
expect(result.data?.token_type).toBe('Bearer');
expect(result.message).toBe('令牌刷新成功');
expect(jwt.verify).toHaveBeenCalledWith(
mockRefreshToken,
mockJwtSecret,
{
issuer: 'whale-town',
audience: 'whale-town-users',
}
);
expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1));
});
it('should handle invalid refresh token', async () => {
(jwt.verify as jest.Mock).mockImplementation(() => {
throw new Error('invalid token');
});
const result = await service.refreshAccessToken('invalid_token');
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('invalid token');
});
it('should handle user not found', async () => {
usersService.findOne.mockResolvedValue(null);
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toBe('用户不存在或已被禁用');
});
it('should handle user service error', async () => {
usersService.findOne.mockRejectedValue(new Error('Database error'));
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('Database error');
});
it('should handle JWT generation error during refresh', async () => {
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('JWT generation failed');
});
});
describe('parseExpirationTime', () => {
it('should parse seconds correctly', () => {
const result = (service as any).parseExpirationTime('30s');
expect(result).toBe(30);
});
it('should parse minutes correctly', () => {
const result = (service as any).parseExpirationTime('5m');
expect(result).toBe(300);
});
it('should parse hours correctly', () => {
const result = (service as any).parseExpirationTime('2h');
expect(result).toBe(7200);
});
it('should parse days correctly', () => {
const result = (service as any).parseExpirationTime('7d');
expect(result).toBe(604800);
});
it('should parse weeks correctly', () => {
const result = (service as any).parseExpirationTime('2w');
expect(result).toBe(1209600);
});
it('should return default for invalid format', () => {
const result = (service as any).parseExpirationTime('invalid');
expect(result).toBe(604800); // 7 days default
});
});
describe('generateTokenPair', () => {
it('should generate token pair successfully', async () => {
const result = await (service as any).generateTokenPair(mockUser);
expect(result.access_token).toBe(mockAccessToken);
expect(result.refresh_token).toBe(mockRefreshToken);
expect(result.expires_in).toBe(604800);
expect(result.token_type).toBe('Bearer');
expect(jwtService.signAsync).toHaveBeenCalledWith({
sub: '1',
username: 'testuser',
role: 1,
email: 'test@example.com',
type: 'access',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
});
expect(jwt.sign).toHaveBeenCalledWith(
{
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
},
mockJwtSecret,
{
expiresIn: '30d',
}
);
});
it('should handle missing JWT secret', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
if (key === 'JWT_EXPIRES_IN') return '7d';
return undefined;
});
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置');
});
it('should handle JWT service error', async () => {
jwtService.signAsync.mockRejectedValue(new Error('JWT service error'));
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error');
});
});
describe('formatUserInfo', () => {
it('should format user info correctly', () => {
const formattedUser = (service as any).formatUserInfo(mockUser);
expect(formattedUser).toEqual({
id: '1',
username: 'testuser',
nickname: '测试用户',
email: 'test@example.com',
phone: '+8613800138000',
avatar_url: null,
role: 1,
created_at: mockUser.created_at
});
});
});
describe('other methods', () => {
it('should handle githubOAuth successfully', async () => {
loginCoreService.githubOAuth.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.githubOAuth({
github_id: '12345',
username: 'testuser',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(true);
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.message).toBe('GitHub登录成功');
});
it('should handle verificationCodeLogin successfully', async () => {
loginCoreService.verificationCodeLogin.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '123456'
});
expect(result.success).toBe(true);
expect(result.data?.user.email).toBe('test@example.com');
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.message).toBe('验证码登录成功');
});
it('should handle sendPasswordResetCode in test mode', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendPasswordResetCode('test@example.com');
expect(result.success).toBe(false); // Test mode returns false
expect(result.data?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(true);
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle resetPassword successfully', async () => {
loginCoreService.resetPassword.mockResolvedValue(undefined);
const result = await service.resetPassword({
identifier: 'test@example.com',
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result.success).toBe(true);
expect(result.message).toBe('密码重置成功');
});
it('should handle changePassword successfully', async () => {
loginCoreService.changePassword.mockResolvedValue(undefined);
const result = await service.changePassword(
BigInt(1),
'oldpassword',
'newpassword123'
);
expect(result.success).toBe(true);
expect(result.message).toBe('密码修改成功');
});
it('should handle sendEmailVerification in test mode', async () => {
loginCoreService.sendEmailVerification.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendEmailVerification('test@example.com');
expect(result.success).toBe(false);
expect(result.data?.verification_code).toBe('123456');
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle verifyEmailCode successfully', async () => {
loginCoreService.verifyEmailCode.mockResolvedValue(true);
const result = await service.verifyEmailCode('test@example.com', '123456');
expect(result.success).toBe(true);
expect(result.message).toBe('邮箱验证成功');
});
it('should handle sendLoginVerificationCode successfully', async () => {
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendLoginVerificationCode('test@example.com');
expect(result.success).toBe(false); // 测试模式下返回false
expect(result.data?.verification_code).toBe('123456');
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle debugVerificationCode successfully', async () => {
const mockDebugInfo = {
email: 'test@example.com',
code: '123456',
expiresAt: new Date(),
attempts: 0
};
loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo);
const result = await service.debugVerificationCode('test@example.com');
expect(result.success).toBe(true);
expect(result.data).toEqual(mockDebugInfo);
expect(result.message).toBe('调试信息获取成功');
});
});
});

View File

@@ -0,0 +1,97 @@
# Shared 共享数据结构模块
Shared 是应用的跨业务模块共享数据结构模块提供标准化的数据传输对象和API响应格式确保整个应用的数据结构一致性和API规范性。
## 应用状态管理
### AppStatusResponseDto
定义应用健康检查和状态查询接口的标准响应格式,包含服务信息、运行状态、环境配置等完整的应用运行时数据。
## 错误响应处理
### ErrorResponseDto
定义全局异常处理的统一错误响应格式提供标准化的错误信息结构支持HTTP状态码、错误消息、时间戳等完整的错误上下文。
## 使用的项目内部依赖
### ApiProperty (来自 @nestjs/swagger)
NestJS Swagger装饰器用于生成API文档和定义响应数据结构的元数据信息。
## 核心特性
### 标准化数据结构
- 统一的DTO类设计模式确保数据传输对象的一致性
- 完整的属性类型定义,提供强类型支持和编译时检查
- 规范的命名约定遵循camelCase属性命名和PascalCase类命名
### Swagger文档集成
- 完整的ApiProperty装饰器配置自动生成API文档
- 详细的属性描述和示例值提升API可读性和可用性
- 枚举值定义和类型约束确保API契约的准确性
### 跨模块复用设计
- 统一的导出接口,简化其他模块的导入路径
- 模块化的文件组织支持按功能分类管理DTO类
- 清晰的职责分离,专注于数据结构定义而非业务逻辑
## 潜在风险
### API契约变更风险
- DTO结构变更可能影响多个业务模块的API兼容性
- 建议在修改现有DTO时进行充分的影响评估和版本管理
- 推荐使用渐进式API演进策略避免破坏性变更
### 数据验证缺失风险
- 当前DTO类只定义数据结构不包含数据验证逻辑
- 建议在使用DTO的Controller层添加适当的数据验证
- 考虑引入class-validator装饰器增强数据验证能力
### 文档同步风险
- Swagger装饰器配置需要与实际数据结构保持同步
- 建议定期检查API文档的准确性和完整性
- 推荐在CI/CD流程中集成API文档生成和验证
## 使用示例
```typescript
// 导入共享DTO
import { AppStatusResponseDto, ErrorResponseDto } from '@/business/shared';
// 在Controller中使用
@ApiResponse({ type: AppStatusResponseDto })
@Get('status')
async getStatus(): Promise<AppStatusResponseDto> {
return {
service: 'Pixel Game Server',
version: '1.0.0',
status: 'running',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development',
storageMode: 'database'
};
}
// 在异常过滤器中使用
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const response: ErrorResponseDto = {
statusCode: 500,
message: 'Internal server error',
timestamp: new Date().toISOString(),
path: request.url,
error: 'INTERNAL_ERROR'
};
return response;
}
}
```
## 版本信息
- **版本**: 1.0.1
- **作者**: moyin
- **创建时间**: 2025-12-17
- **最后修改**: 2026-01-07

View File

@@ -4,16 +4,43 @@
*
* -
* - Swagger
* -
*
* @author angjustinl
* @version 1.0.0
*
* - API响应的数据结构
* - Swagger API文档支持
*
*
* - 2026-01-07: 代码规范优化 - (storage_mode->storageMode)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO
*
*
* -
* -
*
*
* - service -
* - version -
* - status -
* - timestamp -
* - uptime -
* - environment -
* - storageMode -
*
* 使
* -
* -
* -
*/
export class AppStatusResponseDto {
@ApiProperty({
@@ -68,5 +95,5 @@ export class AppStatusResponseDto {
enum: ['database', 'memory'],
type: String
})
storage_mode: 'database' | 'memory';
storageMode: 'database' | 'memory';
}

View File

@@ -4,16 +4,41 @@
*
* -
* - Swagger
* -
*
* @author angjustinl
* @version 1.0.0
*
* -
* - Swagger错误响应文档
*
*
* - 2026-01-07: 代码规范优化 -
*
* @author moyin
* @version 1.0.1
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO
*
*
* -
* -
*
*
* - statusCode - HTTP状态码
* - message -
* - timestamp -
* - path -
* - error -
*
* 使
* -
* - API错误信息标准化
* -
*/
export class ErrorResponseDto {
@ApiProperty({

View File

@@ -4,14 +4,23 @@
* 功能描述:
* - 导出所有共享的 DTO 类
* - 提供统一的导入入口
* - 简化DTO类的导入路径
*
* @author kiro-ai
* @version 1.0.0
* 职责分离:
* - 模块导出统一管理DTO类的导出
* - 路径简化:提供简洁的导入接口
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 更新注释规范和作者信息
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
// 应用状态相关
export * from './app-status.dto';
export * from './app_status.dto';
// 错误响应相关
export * from './error-response.dto';
export * from './error_response.dto';

View File

@@ -4,10 +4,19 @@
* 功能描述:
* - 导出所有共享的组件和类型
* - 提供统一的导入入口
* - 简化其他模块的导入路径
*
* @author kiro-ai
* @version 1.0.0
* 职责分离:
* - 统一导出接口:提供单一的导入入口点
* - 模块封装:隐藏内部文件结构细节
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 更新注释规范和作者信息
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
// DTO

View File

@@ -1,100 +0,0 @@
/**
* 用户状态枚举
*
* 功能描述:
* - 定义用户账户的各种状态
* - 提供状态检查和描述功能
* - 支持用户生命周期管理
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-12-24
*/
/**
* 用户状态枚举
*
* 状态说明:
* - active: 正常状态,可以正常使用所有功能
* - inactive: 未激活状态,通常是新注册用户需要邮箱验证
* - locked: 临时锁定状态,可以解锁恢复
* - banned: 永久禁用状态,需要管理员处理
* - deleted: 软删除状态,数据保留但不可使用
* - pending: 待审核状态,需要管理员审核后激活
*/
export enum UserStatus {
ACTIVE = 'active', // 正常状态
INACTIVE = 'inactive', // 未激活状态
LOCKED = 'locked', // 锁定状态
BANNED = 'banned', // 禁用状态
DELETED = 'deleted', // 删除状态
PENDING = 'pending' // 待审核状态
}
/**
* 获取用户状态的中文描述
*
* @param status 用户状态
* @returns 状态描述
*/
export function getUserStatusDescription(status: UserStatus): string {
const descriptions = {
[UserStatus.ACTIVE]: '正常',
[UserStatus.INACTIVE]: '未激活',
[UserStatus.LOCKED]: '已锁定',
[UserStatus.BANNED]: '已禁用',
[UserStatus.DELETED]: '已删除',
[UserStatus.PENDING]: '待审核'
};
return descriptions[status] || '未知状态';
}
/**
* 检查用户是否可以登录
*
* @param status 用户状态
* @returns 是否可以登录
*/
export function canUserLogin(status: UserStatus): boolean {
// 只有正常状态的用户可以登录
return status === UserStatus.ACTIVE;
}
/**
* 获取用户状态对应的错误消息
*
* @param status 用户状态
* @returns 错误消息
*/
export function getUserStatusErrorMessage(status: UserStatus): string {
const errorMessages = {
[UserStatus.ACTIVE]: '', // 正常状态无错误
[UserStatus.INACTIVE]: '账户未激活,请先验证邮箱',
[UserStatus.LOCKED]: '账户已被锁定,请联系管理员',
[UserStatus.BANNED]: '账户已被禁用,请联系管理员',
[UserStatus.DELETED]: '账户不存在',
[UserStatus.PENDING]: '账户待审核,请等待管理员审核'
};
return errorMessages[status] || '账户状态异常';
}
/**
* 获取所有可用的用户状态
*
* @returns 用户状态数组
*/
export function getAllUserStatuses(): UserStatus[] {
return Object.values(UserStatus);
}
/**
* 检查状态值是否有效
*
* @param status 状态值
* @returns 是否为有效状态
*/
export function isValidUserStatus(status: string): status is UserStatus {
return Object.values(UserStatus).includes(status as UserStatus);
}

View File

@@ -1,22 +0,0 @@
/**
* 用户管理业务模块导出
*
* 功能概述:
* - 用户状态管理(激活、锁定、禁用等)
* - 批量用户操作
* - 用户状态统计和分析
* - 状态变更审计和历史记录
*/
// 模块
export * from './user-mgmt.module';
// 控制器
export * from './controllers/user-status.controller';
// 服务
export * from './services/user-management.service';
// DTO
export * from './dto/user-status.dto';
export * from './dto/user-status-response.dto';

View File

@@ -1,30 +0,0 @@
/**
* 用户管理业务模块
*
* 功能描述:
* - 整合用户状态管理相关的所有组件
* - 提供用户生命周期管理功能
* - 支持批量操作和状态统计
*
* 依赖关系:
* - 依赖 AdminModule 提供底层管理功能
* - 依赖 Core 模块提供基础设施
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-12-24
*/
import { Module } from '@nestjs/common';
import { UserStatusController } from './controllers/user-status.controller';
import { UserManagementService } from './services/user-management.service';
import { AdminModule } from '../admin/admin.module';
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
@Module({
imports: [AdminModule, AdminCoreModule],
controllers: [UserStatusController],
providers: [UserManagementService],
exports: [UserManagementService],
})
export class UserMgmtModule {}

View File

@@ -0,0 +1,186 @@
# UserMgmt 用户管理业务模块
UserMgmt 是应用的用户状态管理业务模块,提供完整的用户状态变更、批量操作、状态统计和审计功能,支持管理员对用户生命周期的全面管理,具备完善的权限控制、频率限制和操作审计能力。
## 用户状态管理
### updateUserStatus()
修改单个用户的账户状态,支持激活、锁定、禁用等操作,记录状态变更原因和审计日志。
### getUserStatusStats()
获取各种用户状态的数量统计信息,提供用户状态分布分析和业务指标计算。
### getUserStatusHistory()
查询指定用户的状态变更历史记录,提供完整的状态变更审计追踪。
## 批量操作管理
### batchUpdateUserStatus()
批量修改多个用户的账户状态,支持数量限制控制和操作结果统计反馈。
## 使用的项目内部依赖
### AdminService (来自 business/admin/admin.service)
底层管理员服务,提供用户状态修改的技术实现和数据持久化能力。
### AdminGuard (来自 business/admin/guards/admin.guard)
管理员权限守卫,确保只有具备管理员权限的用户才能执行状态管理操作。
### UserStatus (本模块)
用户状态枚举,定义用户的激活、锁定、禁用、删除、待审核等状态值。
### UserStatusDto (本模块)
用户状态修改请求数据传输对象,提供状态值和修改原因的数据验证规则。
### BatchUserStatusDto (本模块)
批量用户状态修改请求数据传输对象支持用户ID列表和批量操作数量限制验证。
### UserStatusResponseDto (本模块)
用户状态修改响应数据传输对象提供统一的API响应格式和错误信息封装。
### BatchUserStatusResponseDto (本模块)
批量用户状态修改响应数据传输对象,包含操作结果统计和成功失败详情。
### UserStatusStatsResponseDto (本模块)
用户状态统计响应数据传输对象,提供各状态用户数量和统计时间信息。
### ThrottlePresets (来自 core/security_core/throttle.decorator)
频率限制预设配置,控制管理员操作的频率以防止滥用。
### TimeoutPresets (来自 core/security_core/timeout.decorator)
超时控制预设配置,为不同类型的操作设置合理的超时时间。
### BATCH_OPERATION (本模块)
批量操作相关常量,定义批量操作的最大最小用户数量限制。
### VALIDATION (本模块)
验证规则常量,定义状态修改原因的最大长度等验证参数。
### ERROR_CODES (本模块)
错误代码常量,提供标准化的错误代码定义和错误处理支持。
### MESSAGES (本模块)
业务消息常量,定义用户友好的错误消息和提示信息。
### UTILS (本模块)
工具函数集合,提供时间戳生成等通用功能。
## 核心特性
### RESTful API设计
- 标准化的HTTP方法和状态码使用
- 统一的请求响应数据格式
- 完整的Swagger API文档自动生成
- 符合REST设计原则的资源路径规划
### 权限和安全控制
- AdminGuard管理员权限验证
- JWT Bearer Token身份认证
- 操作频率限制防止API滥用
- 请求超时控制避免资源占用
### 批量操作支持
- 支持1-100个用户的批量状态修改
- 批量操作结果详细统计和反馈
- 部分成功场景的优雅处理
- 批量操作数量限制和业务规则验证
### 数据验证和类型安全
- class-validator装饰器数据验证
- TypeScript类型系统完整支持
- 枚举值验证和错误提示
- 请求参数自动转换和验证
### 审计和日志记录
- 完整的操作审计日志记录
- 状态变更原因和时间戳记录
- 操作者身份和操作类型追踪
- 业务指标统计和分析支持
### 错误处理和用户体验
- 标准化的错误代码和消息
- 用户友好的错误提示信息
- 详细的操作结果反馈
- 优雅的异常处理和降级机制
## 潜在风险
### 批量操作性能风险
- 批量修改100个用户可能造成数据库性能压力
- 大量并发批量操作可能导致系统响应缓慢
- 建议监控批量操作的执行时间和数据库负载
### 权限控制风险
- AdminGuard依赖外部权限验证逻辑
- 权限验证失败可能导致未授权访问
- 建议定期审计管理员权限分配和使用情况
### 数据一致性风险
- 批量操作中部分成功可能导致数据不一致
- 并发状态修改可能产生竞态条件
- 建议在关键业务场景中使用事务控制
### 审计日志存储风险
- 大量的状态变更操作会产生海量审计日志
- 日志存储空间可能快速增长
- 建议制定日志轮转和归档策略
### API滥用风险
- 频率限制可能无法完全防止恶意调用
- 批量操作接口可能被用于攻击
- 建议结合IP限制和行为分析进行防护
### 业务逻辑风险
- 状态变更历史功能当前返回空数据
- 某些边界情况的业务规则可能不完善
- 建议完善状态变更历史功能和业务规则验证
## 使用示例
### 修改单个用户状态
```typescript
// 锁定违规用户
const result = await userManagementService.updateUserStatus(BigInt(123), {
status: UserStatus.LOCKED,
reason: '用户发布违规内容'
});
```
### 批量修改用户状态
```typescript
// 批量激活新用户
const result = await userManagementService.batchUpdateUserStatus({
userIds: ['456', '789', '101'],
status: UserStatus.ACTIVE,
reason: '批量激活通过审核的新用户'
});
```
### 获取用户状态统计
```typescript
// 获取用户状态分布统计
const stats = await userManagementService.getUserStatusStats();
console.log(`活跃用户: ${stats.data.stats.active}`);
```
## 模块配置
### 依赖模块
- AdminModule: 提供底层管理员服务支持
- AdminCoreModule: 提供核心管理功能和权限控制
### 导出服务
- UserManagementService: 用户管理业务逻辑服务
### API路由
- PUT /admin/users/:id/status - 修改用户状态
- POST /admin/users/batch-status - 批量修改用户状态
- GET /admin/users/status-stats - 获取用户状态统计
## 版本信息
- **版本**: 1.0.1
- **作者**: moyin
- **创建时间**: 2025-12-24
- **最后修改**: 2026-01-07
- **修改内容**: 代码规范优化,完善测试覆盖,增强功能文档

View File

@@ -0,0 +1,38 @@
/**
* 用户管理业务模块导出
*
* 功能描述:
* - 用户状态管理(激活、锁定、禁用等)
* - 批量用户操作
* - 用户状态统计和分析
* - 状态变更审计和历史记录
*
* 职责分离:
* - 统一导出用户管理模块的所有公共组件
* - 提供模块化的访问接口
* - 简化外部模块的依赖管理
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,完善注释规范,更新作者信息 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
// 模块
export * from './user_mgmt.module';
// 控制器
export * from './user_status.controller';
// 服务
export * from './user_management.service';
// DTO
export * from './user_status.dto';
export * from './user_status_response.dto';
// 常量
export * from './user_mgmt.constants';

View File

@@ -0,0 +1,453 @@
/**
* 用户管理业务服务测试
*
* 功能描述:
* - 测试用户状态管理业务逻辑
* - 测试批量用户操作功能
* - 测试用户状态统计功能
* - 测试状态变更审计功能
*
* 职责分离:
* - 单元测试覆盖所有公共方法
* - 异常情况和边界情况测试
* - Mock依赖服务的行为验证
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 创建完整的测试覆盖 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2026-01-07
* @lastModified 2026-01-07
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import { UserManagementService } from './user_management.service';
import { AdminService } from '../admin/admin.service';
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
import { UserStatus } from './user_status.enum';
import { BATCH_OPERATION, ERROR_CODES, MESSAGES } from './user_mgmt.constants';
describe('UserManagementService', () => {
let service: UserManagementService;
let mockAdminService: jest.Mocked<AdminService>;
beforeEach(async () => {
const mockAdminServiceProvider = {
updateUserStatus: jest.fn(),
batchUpdateUserStatus: jest.fn(),
getUserStatusStats: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UserManagementService,
{
provide: AdminService,
useValue: mockAdminServiceProvider,
},
],
}).compile();
service = module.get<UserManagementService>(UserManagementService);
mockAdminService = module.get(AdminService);
// Mock Logger to avoid console output during tests
jest.spyOn(Logger.prototype, 'log').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('updateUserStatus', () => {
it('should update user status successfully', async () => {
// Arrange
const userId = BigInt(123);
const userStatusDto: UserStatusDto = {
status: UserStatus.ACTIVE,
reason: '用户申诉通过'
};
const expectedResult = {
success: true,
data: {
user: {
id: '123',
username: 'testuser',
nickname: '测试用户',
status: UserStatus.ACTIVE,
status_description: '正常',
updated_at: new Date()
},
reason: '用户申诉通过'
},
message: '用户状态修改成功'
};
mockAdminService.updateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await service.updateUserStatus(userId, userStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledWith(userId, userStatusDto);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledTimes(1);
});
it('should handle update failure', async () => {
// Arrange
const userId = BigInt(999);
const userStatusDto: UserStatusDto = {
status: UserStatus.LOCKED,
reason: '违规操作'
};
const expectedResult = {
success: false,
message: '用户不存在',
error_code: 'USER_NOT_FOUND'
};
mockAdminService.updateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await service.updateUserStatus(userId, userStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledWith(userId, userStatusDto);
});
it('should log success when update succeeds', async () => {
// Arrange
const userId = BigInt(123);
const userStatusDto: UserStatusDto = {
status: UserStatus.ACTIVE,
reason: '测试'
};
const successResult = {
success: true,
data: {
user: {
id: '123',
username: 'testuser',
nickname: '测试用户',
status: UserStatus.ACTIVE,
status_description: '正常',
updated_at: new Date()
},
reason: '测试'
},
message: '成功'
};
mockAdminService.updateUserStatus.mockResolvedValue(successResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await service.updateUserStatus(userId, userStatusDto);
// Assert
expect(logSpy).toHaveBeenCalledWith(
'用户管理:用户状态修改成功',
expect.objectContaining({
operation: 'user_mgmt_update_status_success',
userId: '123',
newStatus: UserStatus.ACTIVE
})
);
});
});
describe('batchUpdateUserStatus', () => {
it('should batch update user status successfully', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '3'],
status: UserStatus.LOCKED,
reason: '批量锁定违规用户'
};
const expectedResult = {
success: true,
data: {
result: {
success_users: [],
failed_users: [],
success_count: 3,
failed_count: 0,
total_count: 3
},
reason: '批量锁定违规用户'
},
message: '批量用户状态修改完成'
};
mockAdminService.batchUpdateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await service.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockAdminService.batchUpdateUserStatus).toHaveBeenCalledWith(batchUserStatusDto);
});
it('should reject batch operation when user count exceeds limit', async () => {
// Arrange
const userIds = Array.from({ length: BATCH_OPERATION.MAX_USER_COUNT + 1 }, (_, i) => i.toString());
const batchUserStatusDto: BatchUserStatusDto = {
userIds,
status: UserStatus.LOCKED,
reason: '超限测试'
};
// Act
const result = await service.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual({
success: false,
message: MESSAGES.BATCH_OPERATION_LIMIT_ERROR,
error_code: ERROR_CODES.BATCH_OPERATION_LIMIT_EXCEEDED
});
expect(mockAdminService.batchUpdateUserStatus).not.toHaveBeenCalled();
});
it('should handle empty user list', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: [],
status: UserStatus.ACTIVE,
reason: '空列表测试'
};
const expectedResult = {
success: true,
data: {
result: {
success_users: [],
failed_users: [],
success_count: 0,
failed_count: 0,
total_count: 0
}
},
message: '批量操作完成'
};
mockAdminService.batchUpdateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await service.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual(expectedResult);
});
it('should log warning when batch operation exceeds limit', async () => {
// Arrange
const userIds = Array.from({ length: BATCH_OPERATION.MAX_USER_COUNT + 1 }, (_, i) => i.toString());
const batchUserStatusDto: BatchUserStatusDto = {
userIds,
status: UserStatus.LOCKED,
reason: '超限测试'
};
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
// Act
await service.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(warnSpy).toHaveBeenCalledWith(
'用户管理:批量操作数量超限',
expect.objectContaining({
operation: 'user_mgmt_batch_update_limit_exceeded',
requestCount: BATCH_OPERATION.MAX_USER_COUNT + 1,
maxAllowed: BATCH_OPERATION.MAX_USER_COUNT
})
);
});
});
describe('getUserStatusStats', () => {
it('should get user status statistics successfully', async () => {
// Arrange
const expectedResult = {
success: true,
data: {
stats: {
active: 1250,
inactive: 45,
locked: 12,
banned: 8,
deleted: 3,
pending: 15,
total: 1333
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '用户状态统计获取成功'
};
mockAdminService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await service.getUserStatusStats();
// Assert
expect(result).toEqual(expectedResult);
expect(mockAdminService.getUserStatusStats).toHaveBeenCalledTimes(1);
});
it('should handle statistics retrieval failure', async () => {
// Arrange
const expectedResult = {
success: false,
message: '统计数据获取失败',
error_code: 'STATS_RETRIEVAL_FAILED'
};
mockAdminService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await service.getUserStatusStats();
// Assert
expect(result).toEqual(expectedResult);
});
it('should calculate business metrics when stats are available', async () => {
// Arrange
const statsResult = {
success: true,
data: {
stats: {
active: 80,
inactive: 10,
locked: 5,
banned: 3,
deleted: 2,
pending: 0,
total: 100
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '成功'
};
mockAdminService.getUserStatusStats.mockResolvedValue(statsResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await service.getUserStatusStats();
// Assert
expect(logSpy).toHaveBeenCalledWith(
'用户管理:用户状态统计分析',
expect.objectContaining({
operation: 'user_mgmt_status_analysis',
totalUsers: 100,
activeUsers: 80,
activeRate: '80.00%',
problemUsers: 10 // locked + banned + deleted
})
);
});
it('should handle zero total users in statistics', async () => {
// Arrange
const statsResult = {
success: true,
data: {
stats: {
active: 0,
inactive: 0,
locked: 0,
banned: 0,
deleted: 0,
pending: 0,
total: 0
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '成功'
};
mockAdminService.getUserStatusStats.mockResolvedValue(statsResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await service.getUserStatusStats();
// Assert
expect(logSpy).toHaveBeenCalledWith(
'用户管理:用户状态统计分析',
expect.objectContaining({
activeRate: '0%'
})
);
});
});
describe('getUserStatusHistory', () => {
it('should return mock history data with default limit', async () => {
// Arrange
const userId = BigInt(123);
// Act
const result = await service.getUserStatusHistory(userId);
// Assert
expect(result).toEqual({
success: true,
data: {
user_id: '123',
history: [],
total_count: 0
},
message: '状态变更历史获取成功(当前返回空数据,待实现完整功能)'
});
});
it('should return mock history data with custom limit', async () => {
// Arrange
const userId = BigInt(456);
const customLimit = 20;
// Act
const result = await service.getUserStatusHistory(userId, customLimit);
// Assert
expect(result).toEqual({
success: true,
data: {
user_id: '456',
history: [],
total_count: 0
},
message: '状态变更历史获取成功(当前返回空数据,待实现完整功能)'
});
});
it('should log history query operation', async () => {
// Arrange
const userId = BigInt(789);
const limit = 15;
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await service.getUserStatusHistory(userId, limit);
// Assert
expect(logSpy).toHaveBeenCalledWith(
'用户管理:获取用户状态变更历史',
expect.objectContaining({
operation: 'user_mgmt_get_status_history',
userId: '789',
limit: 15
})
);
});
});
});

View File

@@ -7,25 +7,49 @@
* -
* -
*
*
* -
* - AdminService
* -
*
* -
* - AdminService提供的技术能力
* -
*
* @author kiro-ai
* @version 1.0.0
*
* - 2026-01-07: 代码规范优化 - (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { Injectable, Logger } from '@nestjs/common';
import { AdminService } from '../../admin/admin.service';
import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto';
import { AdminService } from '../admin/admin.service';
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
import {
UserStatusResponseDto,
BatchUserStatusResponseDto,
UserStatusStatsResponseDto
} from '../dto/user-status-response.dto';
} from './user_status_response.dto';
import { BATCH_OPERATION, DEFAULTS, ERROR_CODES, MESSAGES, UTILS } from './user_mgmt.constants';
/**
*
*
*
* -
* -
* -
*
*
* - updateUserStatus() -
* - batchUpdateUserStatus() -
* - getUserStatusStats() -
* - getUserStatusHistory() -
*
* 使
* -
* -
* -
*/
@Injectable()
export class UserManagementService {
private readonly logger = new Logger(UserManagementService.name);
@@ -44,6 +68,16 @@ export class UserManagementService {
* @param userId ID
* @param userStatusDto
* @returns
* @throws NotFoundException
* @throws BadRequestException
*
* @example
* ```typescript
* const result = await service.updateUserStatus(BigInt(123), {
* status: UserStatus.ACTIVE,
* reason: '用户申诉通过,恢复正常状态'
* });
* ```
*/
async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise<UserStatusResponseDto> {
this.logger.log('用户管理:开始修改用户状态', {
@@ -51,7 +85,7 @@ export class UserManagementService {
userId: userId.toString(),
newStatus: userStatusDto.status,
reason: userStatusDto.reason,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
// 调用底层管理员服务
@@ -63,7 +97,7 @@ export class UserManagementService {
operation: 'user_mgmt_update_status_success',
userId: userId.toString(),
newStatus: userStatusDto.status,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
}
@@ -81,28 +115,39 @@ export class UserManagementService {
*
* @param batchUserStatusDto
* @returns
* @throws BadRequestException
* @throws InternalServerErrorException
*
* @example
* ```typescript
* const result = await service.batchUpdateUserStatus({
* userIds: ['123', '456'],
* status: UserStatus.LOCKED,
* reason: '批量锁定违规用户'
* });
* ```
*/
async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise<BatchUserStatusResponseDto> {
this.logger.log('用户管理:开始批量修改用户状态', {
operation: 'user_mgmt_batch_update_status',
userCount: batchUserStatusDto.user_ids.length,
userCount: batchUserStatusDto.userIds.length,
newStatus: batchUserStatusDto.status,
reason: batchUserStatusDto.reason,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
// 业务规则:限制批量操作的数量
if (batchUserStatusDto.user_ids.length > 100) {
if (batchUserStatusDto.userIds.length > BATCH_OPERATION.MAX_USER_COUNT) {
this.logger.warn('用户管理:批量操作数量超限', {
operation: 'user_mgmt_batch_update_limit_exceeded',
requestCount: batchUserStatusDto.user_ids.length,
maxAllowed: 100
requestCount: batchUserStatusDto.userIds.length,
maxAllowed: BATCH_OPERATION.MAX_USER_COUNT
});
return {
success: false,
message: '批量操作数量不能超过100个用户',
error_code: 'BATCH_OPERATION_LIMIT_EXCEEDED'
message: MESSAGES.BATCH_OPERATION_LIMIT_ERROR,
error_code: ERROR_CODES.BATCH_OPERATION_LIMIT_EXCEEDED
};
}
@@ -115,7 +160,7 @@ export class UserManagementService {
operation: 'user_mgmt_batch_update_status_success',
successCount: result.data?.result.success_count || 0,
failedCount: result.data?.result.failed_count || 0,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
}
@@ -132,11 +177,18 @@ export class UserManagementService {
* 4.
*
* @returns
* @throws InternalServerErrorException
*
* @example
* ```typescript
* const stats = await service.getUserStatusStats();
* // 返回包含各状态用户数量和分析指标的统计数据
* ```
*/
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
this.logger.log('用户管理:获取用户状态统计', {
operation: 'user_mgmt_get_status_stats',
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
// 调用底层管理员服务
@@ -156,7 +208,7 @@ export class UserManagementService {
activeUsers: stats.active,
activeRate: `${activeRate}%`,
problemUsers: problemUserCount,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
}
@@ -166,25 +218,34 @@ export class UserManagementService {
/**
*
*
*
* -
* -
* -
*
* 1.
* 2.
* 3.
* 4.
*
* @param userId ID
* @param limit
* @returns
* @throws NotFoundException
* @throws BadRequestException
*
* @example
* ```typescript
* const history = await service.getUserStatusHistory(BigInt(123), 20);
* // 返回用户最近20条状态变更记录
* ```
*/
async getUserStatusHistory(userId: bigint, limit: number = 10) {
async getUserStatusHistory(userId: bigint, limit: number = DEFAULTS.STATUS_HISTORY_LIMIT) {
this.logger.log('用户管理:获取用户状态变更历史', {
operation: 'user_mgmt_get_status_history',
userId: userId.toString(),
limit,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
// TODO: 实现状态变更历史查询
// 这里可以调用专门的审计日志服务
// 注意:此功能当前返回模拟数据,实际实现需要集成审计日志服务
// 建议在后续版本中实现完整的状态变更历史查询功能
return {
success: true,
@@ -193,7 +254,7 @@ export class UserManagementService {
history: [] as any[],
total_count: 0
},
message: '状态变更历史获取成功(功能待实现'
message: '状态变更历史获取成功(当前返回空数据,待实现完整功能)'
};
}
}

View File

@@ -0,0 +1,71 @@
/**
* 用户管理业务常量
*
* 功能描述:
* - 定义用户管理模块的业务常量
* - 统一管理魔法数字和配置参数
* - 提供类型安全的常量访问
*
* 职责分离:
* - 业务规则常量定义和管理
* - 验证规则参数统一配置
* - 系统限制和默认值设置
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 创建常量定义文件,消除魔法数字 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2026-01-07
* @lastModified 2026-01-07
*/
/**
* 批量操作相关常量
*/
export const BATCH_OPERATION = {
/** 批量操作最大用户数量限制 */
MAX_USER_COUNT: 100,
/** 批量操作最小用户数量限制 */
MIN_USER_COUNT: 1,
} as const;
/**
* 验证规则相关常量
*/
export const VALIDATION = {
/** 状态修改原因最大长度 */
REASON_MAX_LENGTH: 200,
} as const;
/**
* 默认参数常量
*/
export const DEFAULTS = {
/** 状态变更历史查询默认数量限制 */
STATUS_HISTORY_LIMIT: 10,
} as const;
/**
* 错误代码常量
*/
export const ERROR_CODES = {
/** 批量操作数量超限错误代码 */
BATCH_OPERATION_LIMIT_EXCEEDED: 'BATCH_OPERATION_LIMIT_EXCEEDED',
} as const;
/**
* 业务消息常量
*/
export const MESSAGES = {
/** 批量操作数量超限错误消息 */
BATCH_OPERATION_LIMIT_ERROR: `批量操作数量不能超过${BATCH_OPERATION.MAX_USER_COUNT}个用户`,
} as const;
/**
* 工具函数
*/
export const UTILS = {
/** 获取当前时间戳 */
getCurrentTimestamp: (): string => new Date().toISOString(),
} as const;

View File

@@ -0,0 +1,436 @@
/**
* 用户管理模块集成测试
*
* 功能描述:
* - 测试用户管理模块的完整业务流程
* - 测试控制器与服务的集成
* - 测试真实的HTTP请求处理
* - 测试端到端的业务场景
*
* 职责分离:
* - 集成测试覆盖完整的业务流程
* - 测试模块间的协作和数据流
* - 验证真实环境下的功能表现
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 创建完整的集成测试覆盖 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2026-01-07
* @lastModified 2026-01-07
*/
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { UserStatusController } from './user_status.controller';
import { UserManagementService } from './user_management.service';
import { AdminService } from '../admin/admin.service';
import { AdminGuard } from '../admin/guards/admin.guard';
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
import { UserStatus } from './user_status.enum';
import { BATCH_OPERATION, ERROR_CODES, MESSAGES } from './user_mgmt.constants';
describe('UserManagement Integration', () => {
let app: INestApplication;
let controller: UserStatusController;
let userManagementService: UserManagementService;
let mockAdminService: jest.Mocked<AdminService>;
beforeAll(async () => {
// Create mock AdminService
const mockAdminServiceProvider = {
updateUserStatus: jest.fn(),
batchUpdateUserStatus: jest.fn(),
getUserStatusStats: jest.fn(),
};
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [UserStatusController],
providers: [
UserManagementService,
{
provide: AdminService,
useValue: mockAdminServiceProvider,
},
],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
app = moduleFixture.createNestApplication();
await app.init();
controller = moduleFixture.get<UserStatusController>(UserStatusController);
userManagementService = moduleFixture.get<UserManagementService>(UserManagementService);
mockAdminService = moduleFixture.get(AdminService);
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Complete User Status Management Flow', () => {
it('should handle complete user status update workflow', async () => {
// Arrange
const userId = '123';
const userStatusDto: UserStatusDto = {
status: UserStatus.LOCKED,
reason: '用户违反社区规定'
};
const mockUpdateResult = {
success: true,
data: {
user: {
id: '123',
username: 'testuser',
nickname: '测试用户',
status: UserStatus.LOCKED,
status_description: '已锁定',
updated_at: new Date('2026-01-07T10:00:00.000Z')
},
reason: '用户违反社区规定'
},
message: '用户状态修改成功'
};
mockAdminService.updateUserStatus.mockResolvedValue(mockUpdateResult);
// Act - Controller calls Service, Service calls AdminService
const result = await controller.updateUserStatus(userId, userStatusDto);
// Assert - Verify complete integration
expect(result).toEqual(mockUpdateResult);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledWith(BigInt(123), userStatusDto);
expect(result.data.user.status).toBe(UserStatus.LOCKED);
expect(result.data.reason).toBe('用户违反社区规定');
});
it('should handle complete batch update workflow', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '3'],
status: UserStatus.BANNED,
reason: '批量处理违规用户'
};
const mockBatchResult = {
success: true,
data: {
result: {
success_users: [
{ id: '1', username: 'user1', nickname: '用户1', status: UserStatus.BANNED, status_description: '已封禁', updated_at: new Date() },
{ id: '2', username: 'user2', nickname: '用户2', status: UserStatus.BANNED, status_description: '已封禁', updated_at: new Date() }
],
failed_users: [
{ user_id: '3', error: '用户不存在' }
],
success_count: 2,
failed_count: 1,
total_count: 3
},
reason: '批量处理违规用户'
},
message: '批量用户状态修改完成'
};
mockAdminService.batchUpdateUserStatus.mockResolvedValue(mockBatchResult);
// Act - Complete batch workflow
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert - Verify batch integration
expect(result).toEqual(mockBatchResult);
expect(result.data.result.success_count).toBe(2);
expect(result.data.result.failed_count).toBe(1);
expect(mockAdminService.batchUpdateUserStatus).toHaveBeenCalledWith(batchUserStatusDto);
});
it('should handle complete statistics workflow', async () => {
// Arrange
const mockStatsResult = {
success: true,
data: {
stats: {
active: 1000,
inactive: 200,
locked: 50,
banned: 25,
deleted: 10,
pending: 30,
total: 1315
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '用户状态统计获取成功'
};
mockAdminService.getUserStatusStats.mockResolvedValue(mockStatsResult);
// Act - Complete statistics workflow
const result = await controller.getUserStatusStats();
// Assert - Verify statistics integration
expect(result).toEqual(mockStatsResult);
expect(result.data.stats.total).toBe(1315);
expect(mockAdminService.getUserStatusStats).toHaveBeenCalledTimes(1);
});
});
describe('Business Logic Integration', () => {
it('should enforce batch operation limits through service layer', async () => {
// Arrange - Create request exceeding limits
const userIds = Array.from({ length: BATCH_OPERATION.MAX_USER_COUNT + 1 }, (_, i) => i.toString());
const batchUserStatusDto: BatchUserStatusDto = {
userIds,
status: UserStatus.LOCKED,
reason: '超限测试'
};
// Act - Service should reject before calling AdminService
const result = await userManagementService.batchUpdateUserStatus(batchUserStatusDto);
// Assert - Verify business rule enforcement
expect(result.success).toBe(false);
expect(result.message).toBe(MESSAGES.BATCH_OPERATION_LIMIT_ERROR);
expect(result.error_code).toBe(ERROR_CODES.BATCH_OPERATION_LIMIT_EXCEEDED);
expect(mockAdminService.batchUpdateUserStatus).not.toHaveBeenCalled();
});
it('should handle user status history integration', async () => {
// Arrange
const userId = BigInt(456);
const limit = 10;
// Act - Test history functionality (currently mock implementation)
const result = await userManagementService.getUserStatusHistory(userId, limit);
// Assert - Verify history integration
expect(result.success).toBe(true);
expect(result.data.user_id).toBe('456');
expect(result.data.history).toEqual([]);
expect(result.data.total_count).toBe(0);
expect(result.message).toContain('状态变更历史获取成功');
});
});
describe('Error Handling Integration', () => {
it('should handle service errors through complete stack', async () => {
// Arrange
const userId = '999';
const userStatusDto: UserStatusDto = {
status: UserStatus.ACTIVE,
reason: '测试错误处理'
};
const mockErrorResult = {
success: false,
message: '用户不存在',
error_code: 'USER_NOT_FOUND'
};
mockAdminService.updateUserStatus.mockResolvedValue(mockErrorResult);
// Act - Error propagation through layers
const result = await controller.updateUserStatus(userId, userStatusDto);
// Assert - Verify error handling integration
expect(result.success).toBe(false);
expect(result.message).toBe('用户不存在');
expect(result.error_code).toBe('USER_NOT_FOUND');
});
it('should handle batch operation partial failures', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '999', '888'],
status: UserStatus.ACTIVE,
reason: '批量激活测试'
};
const mockPartialFailureResult = {
success: true,
data: {
result: {
success_users: [
{ id: '1', username: 'user1', nickname: '用户1', status: UserStatus.ACTIVE, status_description: '正常', updated_at: new Date() },
{ id: '2', username: 'user2', nickname: '用户2', status: UserStatus.ACTIVE, status_description: '正常', updated_at: new Date() }
],
failed_users: [
{ user_id: '999', error: '用户不存在' },
{ user_id: '888', error: '用户状态无法修改' }
],
success_count: 2,
failed_count: 2,
total_count: 4
},
reason: '批量激活测试'
},
message: '批量用户状态修改完成'
};
mockAdminService.batchUpdateUserStatus.mockResolvedValue(mockPartialFailureResult);
// Act - Handle partial failures
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert - Verify partial failure handling
expect(result.success).toBe(true);
expect(result.data.result.success_count).toBe(2);
expect(result.data.result.failed_count).toBe(2);
expect(result.data.result.failed_users).toHaveLength(2);
});
it('should handle statistics service failures', async () => {
// Arrange
const mockStatsError = {
success: false,
message: '数据库连接失败',
error_code: 'DATABASE_CONNECTION_ERROR'
};
mockAdminService.getUserStatusStats.mockResolvedValue(mockStatsError);
// Act - Handle statistics errors
const result = await controller.getUserStatusStats();
// Assert - Verify error propagation
expect(result.success).toBe(false);
expect(result.message).toBe('数据库连接失败');
expect(result.error_code).toBe('DATABASE_CONNECTION_ERROR');
});
});
describe('Data Flow Integration', () => {
it('should maintain data consistency through all layers', async () => {
// Arrange
const userId = '789';
const userStatusDto: UserStatusDto = {
status: UserStatus.INACTIVE,
reason: '长期未活跃'
};
const mockResult = {
success: true,
data: {
user: {
id: '789',
username: 'inactive_user',
nickname: '非活跃用户',
status: UserStatus.INACTIVE,
status_description: '非活跃',
updated_at: new Date('2026-01-07T10:00:00.000Z')
},
reason: '长期未活跃'
},
message: '用户状态修改成功'
};
mockAdminService.updateUserStatus.mockResolvedValue(mockResult);
// Act - Data flows through Controller -> Service -> AdminService
const result = await controller.updateUserStatus(userId, userStatusDto);
// Assert - Verify data consistency
expect(result.data.user.id).toBe(userId);
expect(result.data.user.status).toBe(userStatusDto.status);
expect(result.data.reason).toBe(userStatusDto.reason);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledWith(
BigInt(789),
expect.objectContaining({
status: UserStatus.INACTIVE,
reason: '长期未活跃'
})
);
});
it('should handle BigInt conversion correctly in data flow', async () => {
// Arrange - Test large number handling
const largeUserId = '9007199254740991';
const userStatusDto: UserStatusDto = {
status: UserStatus.PENDING,
reason: '大数字ID测试'
};
const mockResult = {
success: true,
data: {
user: {
id: largeUserId,
username: 'large_id_user',
nickname: '大ID用户',
status: UserStatus.PENDING,
status_description: '待处理',
updated_at: new Date()
},
reason: '大数字ID测试'
},
message: '用户状态修改成功'
};
mockAdminService.updateUserStatus.mockResolvedValue(mockResult);
// Act - Test BigInt conversion in data flow
const result = await controller.updateUserStatus(largeUserId, userStatusDto);
// Assert - Verify BigInt handling
expect(result.data.user.id).toBe(largeUserId);
expect(mockAdminService.updateUserStatus).toHaveBeenCalledWith(
BigInt('9007199254740991'),
userStatusDto
);
});
});
describe('Performance Integration', () => {
it('should handle maximum allowed batch size efficiently', async () => {
// Arrange - Test with maximum allowed batch size
const userIds = Array.from({ length: BATCH_OPERATION.MAX_USER_COUNT }, (_, i) => `user_${i}`);
const batchUserStatusDto: BatchUserStatusDto = {
userIds,
status: UserStatus.ACTIVE,
reason: '性能测试'
};
const mockResult = {
success: true,
data: {
result: {
success_users: userIds.map(id => ({
id,
username: `user_${id}`,
nickname: `用户_${id}`,
status: UserStatus.ACTIVE,
status_description: '正常',
updated_at: new Date()
})),
failed_users: [],
success_count: userIds.length,
failed_count: 0,
total_count: userIds.length
},
reason: '性能测试'
},
message: '批量用户状态修改完成'
};
mockAdminService.batchUpdateUserStatus.mockResolvedValue(mockResult);
// Act - Process maximum batch size
const startTime = Date.now();
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
const endTime = Date.now();
// Assert - Verify performance and correctness
expect(result.success).toBe(true);
expect(result.data.result.total_count).toBe(BATCH_OPERATION.MAX_USER_COUNT);
expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
});
});
});

View File

@@ -0,0 +1,52 @@
/**
* 用户管理业务模块
*
* 功能描述:
* - 整合用户状态管理相关的所有组件
* - 提供用户生命周期管理功能
* - 支持批量操作和状态统计
*
* 职责分离:
* - 模块配置和依赖管理
* - 组件注册和导出控制
* - 业务模块边界定义
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,完善注释规范,更新作者信息 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { Module } from '@nestjs/common';
import { UserStatusController } from './user_status.controller';
import { UserManagementService } from './user_management.service';
import { AdminModule } from '../admin/admin.module';
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
/**
* 用户管理业务模块
*
* 职责:
* - 整合用户状态管理的所有业务组件
* - 管理模块间的依赖关系和配置
* - 提供统一的用户管理业务入口
*
* 主要组件:
* - UserStatusController - 用户状态管理API控制器
* - UserManagementService - 用户管理业务逻辑服务
*
* 使用场景:
* - 管理员进行用户状态管理操作
* - 批量用户操作和状态统计
* - 用户生命周期管理流程
*/
@Module({
imports: [AdminModule, AdminCoreModule],
controllers: [UserStatusController],
providers: [UserManagementService],
exports: [UserManagementService],
})
export class UserMgmtModule {}

View File

@@ -0,0 +1,586 @@
/**
* 用户状态管理控制器测试
*
* 功能描述:
* - 测试用户状态管理API接口
* - 测试HTTP请求处理和参数验证
* - 测试权限控制和频率限制
* - 测试响应格式和错误处理
*
* 职责分离:
* - 单元测试覆盖所有API端点
* - Mock业务服务依赖
* - 验证请求参数和响应格式
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 创建完整的控制器测试覆盖 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2026-01-07
* @lastModified 2026-01-07
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import { UserStatusController } from './user_status.controller';
import { UserManagementService } from './user_management.service';
import { AdminGuard } from '../admin/guards/admin.guard';
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
import { UserStatus } from './user_status.enum';
import { BATCH_OPERATION } from './user_mgmt.constants';
describe('UserStatusController', () => {
let controller: UserStatusController;
let mockUserManagementService: jest.Mocked<UserManagementService>;
beforeEach(async () => {
const mockUserManagementServiceProvider = {
updateUserStatus: jest.fn(),
batchUpdateUserStatus: jest.fn(),
getUserStatusStats: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [UserStatusController],
providers: [
{
provide: UserManagementService,
useValue: mockUserManagementServiceProvider,
},
],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
controller = module.get<UserStatusController>(UserStatusController);
mockUserManagementService = module.get(UserManagementService);
// Mock Logger to avoid console output during tests
jest.spyOn(Logger.prototype, 'log').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('updateUserStatus', () => {
it('should update user status successfully', async () => {
// Arrange
const userId = '123';
const userStatusDto: UserStatusDto = {
status: UserStatus.ACTIVE,
reason: '用户申诉通过'
};
const expectedResult = {
success: true,
data: {
user: {
id: '123',
username: 'testuser',
nickname: '测试用户',
status: UserStatus.ACTIVE,
status_description: '正常',
updated_at: new Date()
},
reason: '用户申诉通过'
},
message: '用户状态修改成功'
};
mockUserManagementService.updateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await controller.updateUserStatus(userId, userStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockUserManagementService.updateUserStatus).toHaveBeenCalledWith(BigInt(123), userStatusDto);
expect(mockUserManagementService.updateUserStatus).toHaveBeenCalledTimes(1);
});
it('should handle user not found error', async () => {
// Arrange
const userId = '999';
const userStatusDto: UserStatusDto = {
status: UserStatus.LOCKED,
reason: '违规操作'
};
const expectedResult = {
success: false,
message: '用户不存在',
error_code: 'USER_NOT_FOUND'
};
mockUserManagementService.updateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await controller.updateUserStatus(userId, userStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockUserManagementService.updateUserStatus).toHaveBeenCalledWith(BigInt(999), userStatusDto);
});
it('should log operation details', async () => {
// Arrange
const userId = '456';
const userStatusDto: UserStatusDto = {
status: UserStatus.BANNED,
reason: '严重违规'
};
const mockResult = {
success: true,
data: {
user: {
id: '456',
username: 'testuser',
nickname: '测试用户',
status: UserStatus.BANNED,
status_description: '已封禁',
updated_at: new Date()
},
reason: '严重违规'
},
message: '成功'
};
mockUserManagementService.updateUserStatus.mockResolvedValue(mockResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await controller.updateUserStatus(userId, userStatusDto);
// Assert
expect(logSpy).toHaveBeenCalledWith(
'管理员修改用户状态',
expect.objectContaining({
operation: 'update_user_status',
userId: '456',
newStatus: UserStatus.BANNED,
reason: '严重违规'
})
);
});
it('should convert string id to BigInt correctly', async () => {
// Arrange
const userId = '9007199254740991'; // Large number as string
const userStatusDto: UserStatusDto = {
status: UserStatus.INACTIVE,
reason: '长期未活跃'
};
const mockResult = {
success: true,
data: {
user: {
id: '9007199254740991',
username: 'large_id_user',
nickname: '大ID用户',
status: UserStatus.INACTIVE,
status_description: '非活跃',
updated_at: new Date()
},
reason: '长期未活跃'
},
message: '成功'
};
mockUserManagementService.updateUserStatus.mockResolvedValue(mockResult);
// Act
await controller.updateUserStatus(userId, userStatusDto);
// Assert
expect(mockUserManagementService.updateUserStatus).toHaveBeenCalledWith(
BigInt('9007199254740991'),
userStatusDto
);
});
});
describe('batchUpdateUserStatus', () => {
it('should batch update user status successfully', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '3'],
status: UserStatus.LOCKED,
reason: '批量锁定违规用户'
};
const expectedResult = {
success: true,
data: {
result: {
success_users: [
{ id: '1', username: 'user1', nickname: '用户1', status: UserStatus.LOCKED, status_description: '已锁定', updated_at: new Date() },
{ id: '2', username: 'user2', nickname: '用户2', status: UserStatus.LOCKED, status_description: '已锁定', updated_at: new Date() },
{ id: '3', username: 'user3', nickname: '用户3', status: UserStatus.LOCKED, status_description: '已锁定', updated_at: new Date() }
],
failed_users: [],
success_count: 3,
failed_count: 0,
total_count: 3
},
reason: '批量锁定违规用户'
},
message: '批量用户状态修改完成'
};
mockUserManagementService.batchUpdateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(mockUserManagementService.batchUpdateUserStatus).toHaveBeenCalledWith(batchUserStatusDto);
expect(mockUserManagementService.batchUpdateUserStatus).toHaveBeenCalledTimes(1);
});
it('should handle partial success in batch operation', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '999'],
status: UserStatus.ACTIVE,
reason: '批量激活用户'
};
const expectedResult = {
success: true,
data: {
result: {
success_users: [
{ id: '1', username: 'user1', nickname: '用户1', status: UserStatus.ACTIVE, status_description: '正常', updated_at: new Date() },
{ id: '2', username: 'user2', nickname: '用户2', status: UserStatus.ACTIVE, status_description: '正常', updated_at: new Date() }
],
failed_users: [
{ user_id: '999', error: '用户不存在' }
],
success_count: 2,
failed_count: 1,
total_count: 3
},
reason: '批量激活用户'
},
message: '批量用户状态修改完成'
};
mockUserManagementService.batchUpdateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(result.data.result.success_count).toBe(2);
expect(result.data.result.failed_count).toBe(1);
});
it('should handle empty user list', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: [],
status: UserStatus.ACTIVE,
reason: '空列表测试'
};
const expectedResult = {
success: true,
data: {
result: {
success_users: [],
failed_users: [],
success_count: 0,
failed_count: 0,
total_count: 0
},
reason: '空列表测试'
},
message: '批量操作完成'
};
mockUserManagementService.batchUpdateUserStatus.mockResolvedValue(expectedResult);
// Act
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result).toEqual(expectedResult);
expect(result.data.result.total_count).toBe(0);
});
it('should log batch operation details', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2', '3', '4', '5'],
status: UserStatus.BANNED,
reason: '批量封禁违规用户'
};
const mockResult = {
success: true,
data: {
result: {
success_users: [],
failed_users: [],
success_count: 0,
failed_count: 0,
total_count: 0
}
},
message: '成功'
};
mockUserManagementService.batchUpdateUserStatus.mockResolvedValue(mockResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(logSpy).toHaveBeenCalledWith(
'管理员批量修改用户状态',
expect.objectContaining({
operation: 'batch_update_user_status',
userCount: 5,
newStatus: UserStatus.BANNED,
reason: '批量封禁违规用户'
})
);
});
it('should handle large user list within limits', async () => {
// Arrange
const userIds = Array.from({ length: BATCH_OPERATION.MAX_USER_COUNT }, (_, i) => i.toString());
const batchUserStatusDto: BatchUserStatusDto = {
userIds,
status: UserStatus.INACTIVE,
reason: '批量设置非活跃'
};
const mockResult = {
success: true,
data: {
result: {
success_users: userIds.map(id => ({
id,
username: `user_${id}`,
nickname: `用户_${id}`,
status: UserStatus.INACTIVE,
status_description: '非活跃',
updated_at: new Date()
})),
failed_users: [],
success_count: userIds.length,
failed_count: 0,
total_count: userIds.length
}
},
message: '批量操作完成'
};
mockUserManagementService.batchUpdateUserStatus.mockResolvedValue(mockResult);
// Act
const result = await controller.batchUpdateUserStatus(batchUserStatusDto);
// Assert
expect(result.success).toBe(true);
expect(result.data.result.total_count).toBe(BATCH_OPERATION.MAX_USER_COUNT);
expect(mockUserManagementService.batchUpdateUserStatus).toHaveBeenCalledWith(batchUserStatusDto);
});
});
describe('getUserStatusStats', () => {
it('should get user status statistics successfully', async () => {
// Arrange
const expectedResult = {
success: true,
data: {
stats: {
active: 1250,
inactive: 45,
locked: 12,
banned: 8,
deleted: 3,
pending: 15,
total: 1333
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '用户状态统计获取成功'
};
mockUserManagementService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await controller.getUserStatusStats();
// Assert
expect(result).toEqual(expectedResult);
expect(mockUserManagementService.getUserStatusStats).toHaveBeenCalledTimes(1);
});
it('should handle statistics retrieval failure', async () => {
// Arrange
const expectedResult = {
success: false,
message: '统计数据获取失败',
error_code: 'STATS_RETRIEVAL_FAILED'
};
mockUserManagementService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await controller.getUserStatusStats();
// Assert
expect(result).toEqual(expectedResult);
expect(result.success).toBe(false);
});
it('should log statistics query operation', async () => {
// Arrange
const mockResult = {
success: true,
data: {
stats: {
active: 800,
inactive: 150,
locked: 30,
banned: 15,
deleted: 5,
pending: 20,
total: 1020
},
timestamp: '2026-01-07T15:30:00.000Z'
},
message: '成功'
};
mockUserManagementService.getUserStatusStats.mockResolvedValue(mockResult);
const logSpy = jest.spyOn(Logger.prototype, 'log');
// Act
await controller.getUserStatusStats();
// Assert
expect(logSpy).toHaveBeenCalledWith(
'管理员获取用户状态统计',
expect.objectContaining({
operation: 'get_user_status_stats'
})
);
});
it('should return detailed statistics breakdown', async () => {
// Arrange
const expectedResult = {
success: true,
data: {
stats: {
active: 800,
inactive: 150,
locked: 30,
banned: 15,
deleted: 5,
pending: 20,
total: 1020
},
timestamp: '2026-01-07T15:30:00.000Z',
metadata: {
last_updated: '2026-01-07T15:30:00.000Z',
cache_duration: 300
}
},
message: '用户状态统计获取成功'
};
mockUserManagementService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await controller.getUserStatusStats();
// Assert
expect(result.data.stats.total).toBe(1020);
expect(result.data.stats.active).toBe(800);
expect(result.data.stats.locked).toBe(30);
expect(result.data.stats.banned).toBe(15);
});
it('should handle zero statistics gracefully', async () => {
// Arrange
const expectedResult = {
success: true,
data: {
stats: {
active: 0,
inactive: 0,
locked: 0,
banned: 0,
deleted: 0,
pending: 0,
total: 0
},
timestamp: '2026-01-07T10:00:00.000Z'
},
message: '用户状态统计获取成功'
};
mockUserManagementService.getUserStatusStats.mockResolvedValue(expectedResult);
// Act
const result = await controller.getUserStatusStats();
// Assert
expect(result.data.stats.total).toBe(0);
expect(result.success).toBe(true);
});
});
describe('AdminGuard Integration', () => {
it('should be protected by AdminGuard', () => {
// Verify that AdminGuard is applied to the controller methods
const updateUserStatusMethod = Reflect.getMetadata('__guards__', UserStatusController.prototype.updateUserStatus);
const batchUpdateMethod = Reflect.getMetadata('__guards__', UserStatusController.prototype.batchUpdateUserStatus);
const getStatsMethod = Reflect.getMetadata('__guards__', UserStatusController.prototype.getUserStatusStats);
// At least one method should have guards (they are applied via @UseGuards decorator)
expect(updateUserStatusMethod || batchUpdateMethod || getStatsMethod).toBeTruthy();
});
});
describe('Error Handling', () => {
it('should handle service errors gracefully in updateUserStatus', async () => {
// Arrange
const userId = '123';
const userStatusDto: UserStatusDto = {
status: UserStatus.ACTIVE,
reason: '测试错误处理'
};
mockUserManagementService.updateUserStatus.mockRejectedValue(new Error('Service error'));
// Act & Assert
await expect(controller.updateUserStatus(userId, userStatusDto)).rejects.toThrow('Service error');
});
it('should handle service errors gracefully in batchUpdateUserStatus', async () => {
// Arrange
const batchUserStatusDto: BatchUserStatusDto = {
userIds: ['1', '2'],
status: UserStatus.ACTIVE,
reason: '测试错误处理'
};
mockUserManagementService.batchUpdateUserStatus.mockRejectedValue(new Error('Batch service error'));
// Act & Assert
await expect(controller.batchUpdateUserStatus(batchUserStatusDto)).rejects.toThrow('Batch service error');
});
it('should handle service errors gracefully in getUserStatusStats', async () => {
// Arrange
mockUserManagementService.getUserStatusStats.mockRejectedValue(new Error('Stats service error'));
// Act & Assert
await expect(controller.getUserStatusStats()).rejects.toThrow('Stats service error');
});
});
});

View File

@@ -6,26 +6,54 @@
* -
* -
*
*
* - HTTP请求处理和参数验证
* - API文档生成和接口规范定义
* -
*
* API端点
* - PUT /admin/users/:id/status -
* - POST /admin/users/batch-status -
* - GET /admin/users/status-stats -
*
* @author kiro-ai
* @version 1.0.0
*
* - 2026-01-07: 代码规范优化 - (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Put, Post, UseGuards, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from '../../admin/guards/admin.guard';
import { UserManagementService } from '../services/user-management.service';
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
import { UserStatusDto, BatchUserStatusDto } from '../dto/user-status.dto';
import { UserStatusResponseDto, BatchUserStatusResponseDto, UserStatusStatsResponseDto } from '../dto/user-status-response.dto';
import { AdminGuard } from '../admin/guards/admin.guard';
import { UserManagementService } from './user_management.service';
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decorator';
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
import { UserStatusResponseDto, BatchUserStatusResponseDto, UserStatusStatsResponseDto } from './user_status_response.dto';
import { BATCH_OPERATION, UTILS } from './user_mgmt.constants';
@ApiTags('user-management')
/**
*
*
*
* - HTTP请求
* - RESTful API接口和Swagger文档
* -
*
*
* - updateUserStatus() -
* - batchUpdateUserStatus() -
* - getUserStatusStats() -
*
* 使
* - API管理用户状态
* -
* -
*/
@ApiTags('user_management')
@Controller('admin/users')
export class UserStatusController {
private readonly logger = new Logger(UserStatusController.name);
@@ -35,9 +63,27 @@ export class UserStatusController {
/**
*
*
*
* 1.
* 2. ID格式和状态参数有效性
* 3.
* 4.
* 5.
*
* @param id ID
* @param userStatusDto
* @returns
* @throws ForbiddenException
* @throws NotFoundException
* @throws TooManyRequestsException
*
* @example
* ```typescript
* const result = await controller.updateUserStatus('123', {
* status: UserStatus.LOCKED,
* reason: '用户违反社区规定'
* });
* ```
*/
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@@ -78,7 +124,7 @@ export class UserStatusController {
userId: id,
newStatus: userStatusDto.status,
reason: userStatusDto.reason,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
return await this.userManagementService.updateUserStatus(BigInt(id), userStatusDto);
@@ -87,8 +133,28 @@ export class UserStatusController {
/**
*
*
*
* 1.
* 2. ID列表和状态参数有效性
* 3. ${BATCH_OPERATION.MAX_USER_COUNT}
* 4.
* 5.
* 6.
*
* @param batchUserStatusDto
* @returns
* @throws ForbiddenException
* @throws BadRequestException
* @throws TooManyRequestsException
*
* @example
* ```typescript
* const result = await controller.batchUpdateUserStatus({
* userIds: ['123', '456', '789'],
* status: UserStatus.LOCKED,
* reason: '批量处理违规用户'
* });
* ```
*/
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@@ -120,10 +186,10 @@ export class UserStatusController {
): Promise<BatchUserStatusResponseDto> {
this.logger.log('管理员批量修改用户状态', {
operation: 'batch_update_user_status',
userCount: batchUserStatusDto.user_ids.length,
userCount: batchUserStatusDto.userIds.length,
newStatus: batchUserStatusDto.status,
reason: batchUserStatusDto.reason,
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
return await this.userManagementService.batchUpdateUserStatus(batchUserStatusDto);
@@ -132,7 +198,22 @@ export class UserStatusController {
/**
*
*
*
* 1.
* 2.
* 3.
* 4.
* 5.
*
* @returns
* @throws ForbiddenException
* @throws InternalServerErrorException
*
* @example
* ```typescript
* const stats = await controller.getUserStatusStats();
* // 返回: { active: 1250, inactive: 45, locked: 12, ... }
* ```
*/
@ApiBearerAuth('JWT-auth')
@ApiOperation({
@@ -154,7 +235,7 @@ export class UserStatusController {
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
this.logger.log('管理员获取用户状态统计', {
operation: 'get_user_status_stats',
timestamp: new Date().toISOString()
timestamp: UTILS.getCurrentTimestamp()
});
return await this.userManagementService.getUserStatusStats();

View File

@@ -6,17 +6,40 @@
* -
* -
*
* @author kiro-ai
* @version 1.0.0
*
* -
* -
* - Swagger API文档生成支持
*
*
* - 2026-01-07: 代码规范优化 - (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { IsString, IsNotEmpty, IsEnum, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { UserStatus } from '../enums/user-status.enum';
import { UserStatus } from './user_status.enum';
import { BATCH_OPERATION, VALIDATION } from './user_mgmt.constants';
/**
* DTO
*
*
* -
* -
* - Swagger文档自动生成
*
*
* - status -
* - reason -
*
* 使
* - API请求
* -
*/
export class UserStatusDto {
/**
@@ -39,7 +62,7 @@ export class UserStatusDto {
description: '状态修改原因(可选)',
example: '用户违反社区规定',
required: false,
maxLength: 200
maxLength: VALIDATION.REASON_MAX_LENGTH
})
@IsOptional()
@IsString({ message: '修改原因必须是字符串' })
@@ -48,6 +71,20 @@ export class UserStatusDto {
/**
* DTO
*
*
* -
* - ID列表和状态值的验证规则
* - ${BATCH_OPERATION.MIN_USER_COUNT}-${BATCH_OPERATION.MAX_USER_COUNT}
*
*
* - userIds - ID列表${BATCH_OPERATION.MIN_USER_COUNT}-${BATCH_OPERATION.MAX_USER_COUNT}
* - status -
* - reason -
*
* 使
* - API请求
* -
*/
export class BatchUserStatusDto {
/**
@@ -57,15 +94,15 @@ export class BatchUserStatusDto {
description: '用户ID列表',
example: ['1', '2', '3'],
type: [String],
minItems: 1,
maxItems: 100
minItems: BATCH_OPERATION.MIN_USER_COUNT,
maxItems: BATCH_OPERATION.MAX_USER_COUNT
})
@IsArray({ message: '用户ID列表必须是数组' })
@ArrayMinSize(1, { message: '至少需要选择一个用户' })
@ArrayMaxSize(100, { message: '一次最多只能操作100个用户' })
@ArrayMinSize(BATCH_OPERATION.MIN_USER_COUNT, { message: '至少需要选择一个用户' })
@ArrayMaxSize(BATCH_OPERATION.MAX_USER_COUNT, { message: `一次最多只能操作${BATCH_OPERATION.MAX_USER_COUNT}个用户` })
@IsString({ each: true, message: '用户ID必须是字符串' })
@IsNotEmpty({ each: true, message: '用户ID不能为空' })
user_ids: string[];
userIds: string[];
/**
*
@@ -87,7 +124,7 @@ export class BatchUserStatusDto {
description: '批量修改原因(可选)',
example: '批量处理违规用户',
required: false,
maxLength: 200
maxLength: VALIDATION.REASON_MAX_LENGTH
})
@IsOptional()
@IsString({ message: '修改原因必须是字符串' })

View File

@@ -0,0 +1,31 @@
/**
* 用户状态枚举Business层兼容性导出
*
* 功能描述:
* - 重新导出Core层的用户状态枚举
* - 保持向后兼容性
* - 符合架构分层原则
*
* 职责分离:
* - 提供Business层对Core层用户状态的访问接口
* - 维护现有代码的兼容性
* - 遵循依赖倒置原则
*
* 最近修改:
* - 2026-01-07: 架构优化 - 改为重新导出Core层枚举符合架构分层原则 (修改者: moyin)
*
* @author moyin
* @version 1.0.2
* @since 2025-12-24
* @lastModified 2026-01-07
*/
// 重新导出Core层的用户状态枚举和相关函数
export {
UserStatus,
getUserStatusDescription,
canUserLogin,
getUserStatusErrorMessage,
getAllUserStatuses,
isValidUserStatus
} from '../../core/db/users/user_status.enum';

View File

@@ -6,13 +6,22 @@
* - Swagger文档生成支持
* - API响应的数据格式一致性
*
* @author kiro-ai
* @version 1.0.0
*
* -
* - API响应格式标准化和文档生成
* -
*
*
* - 2026-01-07: 代码规范优化 - (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @since 2025-12-24
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';
import { UserStatus } from '../enums/user-status.enum';
import { UserStatus } from './user_status.enum';
/**
* DTO

View File

@@ -1,172 +1,276 @@
# Zulip集成业务模块
# Zulip 游戏集成业务模块
## 架构重构说明
Zulip 是游戏与Zulip社群平台的集成业务模块提供完整的实时聊天、会话管理、消息过滤和WebSocket通信功能实现游戏内聊天与Zulip社群的双向同步支持基于位置的聊天上下文管理和业务规则驱动的消息过滤控制。
本模块已按照项目的分层架构要求进行重构,将技术实现细节移动到核心服务层,业务逻辑保留在业务层。
## 玩家登录和会话管理
### 重构前后对比
### handlePlayerLogin()
验证游戏Token创建Zulip客户端建立会话映射关系支持JWT认证和API Key获取。
#### 重构前(❌ 违反架构原则)
```
src/business/zulip/services/
├── zulip_client.service.ts # 技术实现API调用
├── zulip_client_pool.service.ts # 技术实现:连接池管理
├── config_manager.service.ts # 技术实现:配置管理
├── zulip_event_processor.service.ts # 技术实现:事件处理
├── session_manager.service.ts # ✅ 业务逻辑:会话管理
└── message_filter.service.ts # ✅ 业务逻辑:消息过滤
```
### handlePlayerLogout()
清理玩家会话注销Zulip事件队列释放相关资源确保连接正常断开。
#### 重构后(✅ 符合架构原则)
```
# 业务逻辑层
src/business/zulip/
├── zulip.service.ts # 业务协调服务
├── zulip_websocket.gateway.ts # WebSocket业务网关
└── services/
├── session_manager.service.ts # 会话业务逻辑
└── message_filter.service.ts # 消息过滤业务规则
### getSession()
根据socketId获取会话信息并更新最后活动时间支持会话状态查询。
# 核心服务层
src/core/zulip/
├── interfaces/
│ └── zulip-core.interfaces.ts # 核心服务接口定义
├── services/
│ ├── zulip_client.service.ts # Zulip API封装
│ ├── zulip_client_pool.service.ts # 客户端池管理
│ ├── config_manager.service.ts # 配置管理
│ ├── zulip_event_processor.service.ts # 事件处理
│ └── ... # 其他技术服务
└── zulip-core.module.ts # 核心服务模块
```
### getSocketsInMap()
获取指定地图中所有在线玩家的Socket ID列表用于消息分发和空间过滤。
### 架构优势
## 消息发送和处理
#### 1. 单一职责原则
- **业务层**:只关注游戏相关的业务逻辑和规则
- **核心层**只处理技术实现和第三方API调用
### sendChatMessage()
处理游戏客户端发送的聊天消息转发到对应的Zulip Stream/Topic包含内容过滤和权限验证。
#### 2. 依赖注入和接口抽象
### processZulipMessage()
处理Zulip事件队列推送的消息转换格式后发送给相关的游戏客户端实现双向通信。
### updatePlayerPosition()
更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入,支持地图切换。
## WebSocket网关功能
### handleConnection()
处理游戏客户端WebSocket连接建立记录连接信息并初始化连接状态。
### handleDisconnect()
处理游戏客户端连接断开,清理相关资源并执行登出逻辑。
### handleLogin()
处理登录消息验证Token并建立会话返回登录结果和用户信息。
### handleChat()
处理聊天消息,验证用户认证状态和消息格式,调用业务服务发送消息。
### sendChatRender()
向指定客户端发送聊天渲染消息,用于显示气泡或聊天框。
### broadcastToMap()
向指定地图的所有客户端广播消息,支持区域性消息分发。
## 会话管理功能
### createSession()
创建会话并绑定Socket_ID与Zulip_Queue_ID建立WebSocket连接与Zulip队列的映射关系。
### injectContext()
上下文注入根据玩家位置确定消息应该发送到的Zulip Stream和Topic。
### destroySession()
清理玩家会话数据,从地图玩家列表中移除,释放相关资源。
### cleanupExpiredSessions()
定时清理超时的会话数据和相关资源返回需要注销的Zulip队列ID列表。
## 消息过滤和安全
### validateMessage()
对消息进行综合验证,包括内容过滤、频率限制和权限验证。
### filterContent()
检查消息内容是否包含敏感词,进行内容过滤和替换。
### checkRateLimit()
检查用户是否超过消息发送频率限制,防止刷屏。
### validatePermission()
验证用户是否有权限向目标Stream发送消息防止位置欺诈。
### logViolation()
记录用户的违规行为,用于监控和分析。
## REST API接口
### sendMessage()
通过REST API发送聊天消息到Zulip推荐使用WebSocket接口
### getChatHistory()
获取指定地图或全局的聊天历史记录,支持分页查询。
### getSystemStatus()
获取WebSocket连接状态、Zulip集成状态等系统信息。
### getWebSocketInfo()
获取WebSocket连接的详细信息包括连接地址、协议等。
## 使用的项目内部依赖
### ZulipCoreModule (来自 core/zulip_core)
提供Zulip核心技术服务包括客户端池管理、配置管理和事件处理等底层技术实现。
### LoginCoreModule (来自 core/login_core)
提供用户认证和Token验证服务支持JWT令牌验证和用户信息获取。
### RedisModule (来自 core/redis)
提供会话状态缓存和数据存储服务,支持会话持久化和快速查询。
### LoggerModule (来自 core/utils/logger)
提供统一的日志记录服务,支持结构化日志和性能监控。
### ZulipAccountsModule (来自 core/db/zulip_accounts)
提供Zulip账号关联管理功能支持用户与Zulip账号的绑定关系。
### AuthModule (来自 business/auth)
提供JWT验证和用户认证服务支持用户身份验证和权限控制。
### IZulipClientPoolService (来自 core/zulip_core/interfaces)
Zulip客户端池服务接口用于管理用户专用的Zulip客户端实例。
### IZulipConfigService (来自 core/zulip_core/interfaces)
Zulip配置服务接口用于获取地图到Stream的映射关系和配置信息。
### ApiKeySecurityService (来自 core/zulip_core/services)
API密钥安全服务用于获取和管理用户的Zulip API Key。
### IRedisService (来自 core/redis)
Redis服务接口用于会话数据存储、频率限制和违规记录管理。
### SendChatMessageDto (本模块)
发送聊天消息的数据传输对象定义消息内容、范围和地图ID等字段。
### ChatMessageResponseDto (本模块)
聊天消息响应的数据传输对象包含成功状态、消息ID和错误信息。
### SystemStatusResponseDto (本模块)
系统状态响应的数据传输对象包含WebSocket状态、Zulip集成状态和系统信息。
## 核心特性
### 双向通信支持
- WebSocket实时通信支持游戏客户端与服务器的实时双向通信
- Zulip集成同步实现游戏内聊天与Zulip社群的双向消息同步
- 事件驱动架构基于事件队列处理Zulip消息推送和游戏事件
### 会话状态管理
- Redis持久化存储会话数据存储在Redis中支持服务重启后状态恢复
- 自动过期清理:定时清理超时会话,释放系统资源
- 多地图支持:支持玩家在不同地图间切换,自动更新地图玩家列表
### 消息过滤和安全
- 敏感词过滤支持block和replace两种级别的敏感词处理
- 频率限制控制:防止用户发送消息过于频繁导致刷屏
- 位置权限验证防止用户向不匹配位置的Stream发送消息
- 违规行为记录:记录和统计用户违规行为,支持监控和分析
### 业务规则引擎
- 上下文注入机制根据玩家位置自动确定消息的目标Stream和Topic
- 动态配置管理支持地图到Stream映射关系的动态配置和热重载
- 权限分级控制:支持不同用户角色的权限控制和消息发送限制
## 潜在风险
### 会话数据丢失
- Redis服务故障可能导致会话数据丢失影响用户体验
- 建议配置Redis主从复制和持久化策略
- 实现会话数据的定期备份和恢复机制
### 消息同步延迟
- Zulip服务器网络延迟可能影响消息同步实时性
- 大量并发消息可能导致事件队列处理延迟
- 建议监控消息处理延迟并设置合理的超时机制
### 频率限制绕过
- 恶意用户可能通过多个账号绕过频率限制
- IP级别的频率限制可能影响正常用户
- 建议结合用户行为分析和动态调整限制策略
### 敏感词过滤失效
- 新型敏感词和变体可能绕过现有过滤规则
- 过度严格的过滤可能影响正常交流
- 建议定期更新敏感词库并优化过滤算法
### WebSocket连接稳定性
- 网络不稳定可能导致WebSocket连接频繁断开重连
- 大量连接可能消耗过多服务器资源
- 建议实现连接池管理和自动重连机制
### 位置验证绕过
- 客户端修改可能绕过位置验证机制
- 服务端位置验证逻辑需要持续完善
- 建议结合多种验证手段和异常行为检测
## 使用示例
### WebSocket 客户端连接
```typescript
// 业务层通过接口依赖核心服务
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
) {}
// 建立WebSocket连接
const socket = io('ws://localhost:3000/zulip');
// 监听连接事件
socket.on('connect', () => {
console.log('Connected to Zulip WebSocket');
});
// 发送登录消息
socket.emit('login', {
token: 'your-jwt-token'
});
// 发送聊天消息
socket.emit('chat', {
content: '大家好!',
scope: 'local',
mapId: 'whale_port'
});
// 监听聊天消息
socket.on('chat_render', (data) => {
console.log('收到消息:', data);
});
```
#### 3. 易于测试和维护
- 业务逻辑可以独立测试,不依赖具体的技术实现
- 核心服务可以独立替换,不影响业务逻辑
- 接口定义清晰,便于理解和维护
### REST API 调用
```typescript
// 发送聊天消息
const response = await fetch('/api/zulip/send-message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer your-jwt-token'
},
body: JSON.stringify({
content: '测试消息',
scope: 'global',
mapId: 'whale_port'
})
});
### 服务职责划分
// 获取聊天历史
const history = await fetch('/api/zulip/chat-history?mapId=whale_port&limit=50');
const messages = await history.json();
#### 业务逻辑层服务
// 获取系统状态
const status = await fetch('/api/zulip/system-status');
const systemInfo = await status.json();
```
| 服务 | 职责 | 业务价值 |
|------|------|----------|
| `ZulipService` | 游戏登录/登出业务流程协调 | 处理玩家生命周期管理 |
| `SessionManagerService` | 游戏会话状态和上下文管理 | 维护玩家位置和聊天上下文 |
| `MessageFilterService` | 消息过滤和业务规则控制 | 实现内容审核和权限验证 |
| `ZulipWebSocketGateway` | WebSocket业务协议处理 | 游戏协议转换和路由 |
#### 核心服务层服务
| 服务 | 职责 | 技术价值 |
|------|------|----------|
| `ZulipClientService` | Zulip REST API封装 | 第三方API调用抽象 |
| `ZulipClientPoolService` | 客户端连接池管理 | 资源管理和性能优化 |
| `ConfigManagerService` | 配置文件管理和热重载 | 系统配置和运维支持 |
| `ZulipEventProcessorService` | 事件队列处理和消息转换 | 异步消息处理机制 |
### 使用示例
#### 业务层调用核心服务
### 服务集成示例
```typescript
@Injectable()
export class ZulipService {
export class GameChatService {
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
private readonly zulipService: ZulipService,
private readonly sessionManager: SessionManagerService
) {}
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
// 业务逻辑:验证和处理
const session = await this.sessionManager.getSession(request.socketId);
const context = await this.sessionManager.injectContext(request.socketId);
async handlePlayerMessage(playerId: string, message: string) {
// 获取玩家会话
const session = await this.sessionManager.getSession(playerId);
// 调用核心服务:技术实现
const result = await this.zulipClientPool.sendMessage(
session.userId,
context.stream,
context.topic,
request.content,
);
// 发送消息到Zulip
const result = await this.zulipService.sendChatMessage({
gameUserId: playerId,
content: message,
scope: 'local',
mapId: session.mapId
});
return { success: result.success, messageId: result.messageId };
return result;
}
}
```
### 迁移指南
如果你的代码中直接导入了已移动的服务,请按以下方式更新:
#### 更新导入路径
```typescript
// ❌ 旧的导入方式
import { ZulipClientPoolService } from './services/zulip_client_pool.service';
// ✅ 新的导入方式(通过依赖注入)
import { IZulipClientPoolService } from '../../core/zulip/interfaces/zulip-core.interfaces';
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
) {}
```
#### 更新模块导入
```typescript
// ✅ 业务模块自动导入核心模块
@Module({
imports: [
ZulipCoreModule, // 自动提供所有核心服务
// ...
],
})
export class ZulipModule {}
```
### 测试策略
#### 业务逻辑测试
```typescript
// 使用Mock核心服务测试业务逻辑
const mockZulipClientPool: IZulipClientPoolService = {
sendMessage: jest.fn().mockResolvedValue({ success: true }),
// ...
};
const module = await Test.createTestingModule({
providers: [
ZulipService,
{ provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockZulipClientPool },
],
}).compile();
```
#### 核心服务测试
```typescript
// 独立测试技术实现
describe('ZulipClientService', () => {
it('should call Zulip API correctly', async () => {
// 测试API调用逻辑
});
});
```
这种架构设计确保了业务逻辑与技术实现的清晰分离,提高了代码的可维护性和可测试性。
## 版本信息
- **版本**: 1.2.1
- **作者**: angjustinl
- **创建时间**: 2025-12-20
- **最后修改**: 2026-01-07

View File

@@ -7,9 +7,19 @@
* - 查看系统状态和统计信息
* - 管理 WebSocket 连接状态
*
* 职责分离:
* - REST接口提供HTTP方式的聊天功能访问
* - 状态查询:提供系统运行状态和统计信息
* - 文档支持提供WebSocket API的使用文档
* - 监控支持:提供连接数和性能监控接口
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @version 1.0.1
* @since 2025-01-07
* @lastModified 2026-01-07
*/
import {
@@ -30,7 +40,7 @@ import {
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { JwtAuthGuard } from '../../auth/jwt_auth.guard';
import { ZulipService } from '../zulip.service';
import { ZulipWebSocketGateway } from '../zulip_websocket.gateway';
import {
@@ -39,7 +49,7 @@ import {
GetChatHistoryDto,
ChatHistoryResponseDto,
SystemStatusResponseDto,
} from '../dto/chat.dto';
} from '../chat.dto';
@ApiTags('chat')
@Controller('chat')

View File

@@ -6,9 +6,19 @@
* -
* -
*
*
* - API文档WebSocket API使用说明
* -
* -
* -
*
*
* - 2026-01-07: 代码规范优化 - (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @version 1.0.1
* @since 2025-01-07
* @lastModified 2026-01-07
*/
import { Controller, Get } from '@nestjs/common';

View File

@@ -0,0 +1,581 @@
/**
* Zulip账号关联管理控制器
*
* 功能描述:
* - 提供Zulip账号关联管理的REST API接口
* - 支持CRUD操作和批量管理
* - 提供账号验证和统计功能
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-07
*/
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpStatus,
HttpCode,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/jwt_auth.guard';
import { ZulipAccountsService } from '../../../core/db/zulip_accounts/zulip_accounts.service';
import { ZulipAccountsMemoryService } from '../../../core/db/zulip_accounts/zulip_accounts_memory.service';
import {
CreateZulipAccountDto,
UpdateZulipAccountDto,
QueryZulipAccountDto,
ZulipAccountResponseDto,
ZulipAccountListResponseDto,
ZulipAccountStatsResponseDto,
BatchUpdateStatusDto,
BatchUpdateResponseDto,
VerifyAccountDto,
VerifyAccountResponseDto,
} from '../../../core/db/zulip_accounts/zulip_accounts.dto';
@ApiTags('zulip-accounts')
@Controller('zulip-accounts')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
export class ZulipAccountsController {
constructor(
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
) {}
/**
* 创建Zulip账号关联
*/
@Post()
@ApiOperation({
summary: '创建Zulip账号关联',
description: '为游戏用户创建与Zulip账号的关联关系'
})
@ApiResponse({
status: 201,
description: '创建成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 400,
description: '请求参数错误',
})
@ApiResponse({
status: 409,
description: '关联已存在',
})
@HttpCode(HttpStatus.CREATED)
async create(@Body() createDto: CreateZulipAccountDto): Promise<ZulipAccountResponseDto> {
return this.zulipAccountsService.create(createDto);
}
/**
* 获取所有Zulip账号关联
*/
@Get()
@ApiOperation({
summary: '查询Zulip账号关联列表',
description: '根据条件查询Zulip账号关联列表'
})
@ApiQuery({
name: 'gameUserId',
required: false,
description: '游戏用户ID',
example: '12345'
})
@ApiQuery({
name: 'zulipUserId',
required: false,
description: 'Zulip用户ID',
example: 67890
})
@ApiQuery({
name: 'zulipEmail',
required: false,
description: 'Zulip邮箱地址',
example: 'user@example.com'
})
@ApiQuery({
name: 'status',
required: false,
description: '账号状态',
enum: ['active', 'inactive', 'suspended', 'error']
})
@ApiQuery({
name: 'includeGameUser',
required: false,
description: '是否包含游戏用户信息',
type: Boolean,
example: false
})
@ApiResponse({
status: 200,
description: '查询成功',
type: ZulipAccountListResponseDto,
})
async findMany(@Query() queryDto: QueryZulipAccountDto): Promise<ZulipAccountListResponseDto> {
return this.zulipAccountsService.findMany(queryDto);
}
/**
* 根据ID获取Zulip账号关联
*/
@Get(':id')
@ApiOperation({
summary: '根据ID获取Zulip账号关联',
description: '根据关联记录ID获取详细信息'
})
@ApiParam({
name: 'id',
description: '关联记录ID',
example: '1'
})
@ApiQuery({
name: 'includeGameUser',
required: false,
description: '是否包含游戏用户信息',
type: Boolean,
example: false
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '记录不存在',
})
async findById(
@Param('id') id: string,
@Query('includeGameUser') includeGameUser?: boolean,
): Promise<ZulipAccountResponseDto> {
return this.zulipAccountsService.findById(id, includeGameUser);
}
/**
* 根据游戏用户ID获取Zulip账号关联
*/
@Get('game-user/:gameUserId')
@ApiOperation({
summary: '根据游戏用户ID获取Zulip账号关联',
description: '根据游戏用户ID获取关联的Zulip账号信息'
})
@ApiParam({
name: 'gameUserId',
description: '游戏用户ID',
example: '12345'
})
@ApiQuery({
name: 'includeGameUser',
required: false,
description: '是否包含游戏用户信息',
type: Boolean,
example: false
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '关联不存在',
})
async findByGameUserId(
@Param('gameUserId') gameUserId: string,
@Query('includeGameUser') includeGameUser?: boolean,
): Promise<ZulipAccountResponseDto | null> {
return this.zulipAccountsService.findByGameUserId(gameUserId, includeGameUser);
}
/**
* 根据Zulip用户ID获取账号关联
*/
@Get('zulip-user/:zulipUserId')
@ApiOperation({
summary: '根据Zulip用户ID获取账号关联',
description: '根据Zulip用户ID获取关联的游戏账号信息'
})
@ApiParam({
name: 'zulipUserId',
description: 'Zulip用户ID',
example: '67890'
})
@ApiQuery({
name: 'includeGameUser',
required: false,
description: '是否包含游戏用户信息',
type: Boolean,
example: false
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '关联不存在',
})
async findByZulipUserId(
@Param('zulipUserId') zulipUserId: string,
@Query('includeGameUser') includeGameUser?: boolean,
): Promise<ZulipAccountResponseDto | null> {
return this.zulipAccountsService.findByZulipUserId(parseInt(zulipUserId), includeGameUser);
}
/**
* 根据Zulip邮箱获取账号关联
*/
@Get('zulip-email/:zulipEmail')
@ApiOperation({
summary: '根据Zulip邮箱获取账号关联',
description: '根据Zulip邮箱地址获取关联的游戏账号信息'
})
@ApiParam({
name: 'zulipEmail',
description: 'Zulip邮箱地址',
example: 'user@example.com'
})
@ApiQuery({
name: 'includeGameUser',
required: false,
description: '是否包含游戏用户信息',
type: Boolean,
example: false
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '关联不存在',
})
async findByZulipEmail(
@Param('zulipEmail') zulipEmail: string,
@Query('includeGameUser') includeGameUser?: boolean,
): Promise<ZulipAccountResponseDto | null> {
return this.zulipAccountsService.findByZulipEmail(zulipEmail, includeGameUser);
}
/**
* 更新Zulip账号关联
*/
@Put(':id')
@ApiOperation({
summary: '更新Zulip账号关联',
description: '根据ID更新Zulip账号关联信息'
})
@ApiParam({
name: 'id',
description: '关联记录ID',
example: '1'
})
@ApiResponse({
status: 200,
description: '更新成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '记录不存在',
})
async update(
@Param('id') id: string,
@Body() updateDto: UpdateZulipAccountDto,
): Promise<ZulipAccountResponseDto> {
return this.zulipAccountsService.update(id, updateDto);
}
/**
* 根据游戏用户ID更新关联
*/
@Put('game-user/:gameUserId')
@ApiOperation({
summary: '根据游戏用户ID更新关联',
description: '根据游戏用户ID更新Zulip账号关联信息'
})
@ApiParam({
name: 'gameUserId',
description: '游戏用户ID',
example: '12345'
})
@ApiResponse({
status: 200,
description: '更新成功',
type: ZulipAccountResponseDto,
})
@ApiResponse({
status: 404,
description: '关联不存在',
})
async updateByGameUserId(
@Param('gameUserId') gameUserId: string,
@Body() updateDto: UpdateZulipAccountDto,
): Promise<ZulipAccountResponseDto> {
return this.zulipAccountsService.updateByGameUserId(gameUserId, updateDto);
}
/**
* 删除Zulip账号关联
*/
@Delete(':id')
@ApiOperation({
summary: '删除Zulip账号关联',
description: '根据ID删除Zulip账号关联记录'
})
@ApiParam({
name: 'id',
description: '关联记录ID',
example: '1'
})
@ApiResponse({
status: 200,
description: '删除成功',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: '删除成功' }
}
}
})
@ApiResponse({
status: 404,
description: '记录不存在',
})
async delete(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
await this.zulipAccountsService.delete(id);
return { success: true, message: '删除成功' };
}
/**
* 根据游戏用户ID删除关联
*/
@Delete('game-user/:gameUserId')
@ApiOperation({
summary: '根据游戏用户ID删除关联',
description: '根据游戏用户ID删除Zulip账号关联记录'
})
@ApiParam({
name: 'gameUserId',
description: '游戏用户ID',
example: '12345'
})
@ApiResponse({
status: 200,
description: '删除成功',
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: '删除成功' }
}
}
})
@ApiResponse({
status: 404,
description: '关联不存在',
})
async deleteByGameUserId(@Param('gameUserId') gameUserId: string): Promise<{ success: boolean; message: string }> {
await this.zulipAccountsService.deleteByGameUserId(gameUserId);
return { success: true, message: '删除成功' };
}
/**
* 获取需要验证的账号列表
*/
@Get('management/verification-needed')
@ApiOperation({
summary: '获取需要验证的账号列表',
description: '获取超过指定时间未验证的账号列表'
})
@ApiQuery({
name: 'maxAge',
required: false,
description: '最大验证间隔毫秒默认24小时',
example: 86400000
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountListResponseDto,
})
async findAccountsNeedingVerification(
@Query('maxAge') maxAge?: number,
): Promise<ZulipAccountListResponseDto> {
return this.zulipAccountsService.findAccountsNeedingVerification(maxAge);
}
/**
* 获取错误状态的账号列表
*/
@Get('management/error-accounts')
@ApiOperation({
summary: '获取错误状态的账号列表',
description: '获取处于错误状态的账号列表'
})
@ApiQuery({
name: 'maxRetryCount',
required: false,
description: '最大重试次数默认3次',
example: 3
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountListResponseDto,
})
async findErrorAccounts(
@Query('maxRetryCount') maxRetryCount?: number,
): Promise<ZulipAccountListResponseDto> {
return this.zulipAccountsService.findErrorAccounts(maxRetryCount);
}
/**
* 批量更新账号状态
*/
@Put('management/batch-status')
@ApiOperation({
summary: '批量更新账号状态',
description: '批量更新多个账号的状态'
})
@ApiResponse({
status: 200,
description: '更新成功',
type: BatchUpdateResponseDto,
})
async batchUpdateStatus(@Body() batchDto: BatchUpdateStatusDto): Promise<BatchUpdateResponseDto> {
return this.zulipAccountsService.batchUpdateStatus(batchDto.ids, batchDto.status);
}
/**
* 获取账号状态统计
*/
@Get('management/statistics')
@ApiOperation({
summary: '获取账号状态统计',
description: '获取各种状态的账号数量统计'
})
@ApiResponse({
status: 200,
description: '获取成功',
type: ZulipAccountStatsResponseDto,
})
async getStatusStatistics(): Promise<ZulipAccountStatsResponseDto> {
return this.zulipAccountsService.getStatusStatistics();
}
/**
* 验证账号有效性
*/
@Post('management/verify')
@ApiOperation({
summary: '验证账号有效性',
description: '验证指定游戏用户的Zulip账号关联是否有效'
})
@ApiResponse({
status: 200,
description: '验证完成',
type: VerifyAccountResponseDto,
})
async verifyAccount(@Body() verifyDto: VerifyAccountDto): Promise<VerifyAccountResponseDto> {
return this.zulipAccountsService.verifyAccount(verifyDto.gameUserId);
}
/**
* 检查邮箱是否已存在
*/
@Get('validation/email-exists/:email')
@ApiOperation({
summary: '检查邮箱是否已存在',
description: '检查指定的Zulip邮箱是否已被其他账号使用'
})
@ApiParam({
name: 'email',
description: 'Zulip邮箱地址',
example: 'user@example.com'
})
@ApiQuery({
name: 'excludeId',
required: false,
description: '排除的记录ID用于更新时检查',
example: '1'
})
@ApiResponse({
status: 200,
description: '检查完成',
schema: {
type: 'object',
properties: {
exists: { type: 'boolean', example: false },
email: { type: 'string', example: 'user@example.com' }
}
}
})
async checkEmailExists(
@Param('email') email: string,
@Query('excludeId') excludeId?: string,
): Promise<{ exists: boolean; email: string }> {
const exists = await this.zulipAccountsService.existsByEmail(email, excludeId);
return { exists, email };
}
/**
* 检查Zulip用户ID是否已存在
*/
@Get('validation/zulip-user-exists/:zulipUserId')
@ApiOperation({
summary: '检查Zulip用户ID是否已存在',
description: '检查指定的Zulip用户ID是否已被其他账号使用'
})
@ApiParam({
name: 'zulipUserId',
description: 'Zulip用户ID',
example: '67890'
})
@ApiQuery({
name: 'excludeId',
required: false,
description: '排除的记录ID用于更新时检查',
example: '1'
})
@ApiResponse({
status: 200,
description: '检查完成',
schema: {
type: 'object',
properties: {
exists: { type: 'boolean', example: false },
zulipUserId: { type: 'number', example: 67890 }
}
}
})
async checkZulipUserIdExists(
@Param('zulipUserId') zulipUserId: string,
@Query('excludeId') excludeId?: string,
): Promise<{ exists: boolean; zulipUserId: number }> {
const zulipUserIdNum = parseInt(zulipUserId);
const exists = await this.zulipAccountsService.existsByZulipUserId(zulipUserIdNum, excludeId);
return { exists, zulipUserId: zulipUserIdNum };
}
}

View File

@@ -13,7 +13,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { MessageFilterService, ViolationType } from './message_filter.service';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipConfigService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
import { IRedisService } from '../../../core/redis/redis.interface';

View File

@@ -7,6 +7,13 @@
* - 防止恶意操作和滥用
* - 与ConfigManager集成实现位置权限验证
*
* 职责分离:
* - 内容审核:检查消息内容是否包含敏感词和恶意链接
* - 频率控制:防止用户发送消息过于频繁导致刷屏
* - 权限验证验证用户是否有权限向目标Stream发送消息
* - 违规记录:记录和统计用户的违规行为
* - 规则管理:动态管理敏感词列表和过滤规则
*
* 主要方法:
* - filterContent(): 内容过滤,敏感词检查
* - checkRateLimit(): 频率限制检查
@@ -23,14 +30,19 @@
* - IRedisService: Redis缓存服务
* - ConfigManagerService: 配置管理服务
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 清理未使用的导入(forwardRef) (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
*
* @author angjustinl
* @version 1.1.0
* @version 1.1.2
* @since 2025-12-25
* @lastModified 2026-01-07
*/
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { Injectable, Logger, Inject } from '@nestjs/common';
import { IRedisService } from '../../../core/redis/redis.interface';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipConfigService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
/**
* 内容过滤结果接口

View File

@@ -25,7 +25,7 @@ import {
CleanupResult
} from './session_cleanup.service';
import { SessionManagerService } from './session_manager.service';
import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipClientPoolService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
describe('SessionCleanupService', () => {
let service: SessionCleanupService;

View File

@@ -23,7 +23,7 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
import { SessionManagerService } from './session_manager.service';
import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipClientPoolService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
/**
* 清理任务配置接口

View File

@@ -13,7 +13,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { SessionManagerService, GameSession, Position } from './session_manager.service';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipConfigService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
import { IRedisService } from '../../../core/redis/redis.interface';

View File

@@ -8,6 +8,13 @@
* - 支持会话状态的序列化和反序列化
* - 支持服务重启后的状态恢复
*
* 职责分离:
* - 会话存储管理会话数据在Redis中的存储和检索
* - 位置跟踪:维护玩家在游戏世界中的位置信息
* - 上下文注入根据玩家位置确定消息的目标Stream和Topic
* - 空间过滤根据地图ID筛选相关的玩家会话
* - 资源清理:定期清理过期会话和释放相关资源
*
* 主要方法:
* - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID
* - getSession(): 获取会话信息
@@ -28,15 +35,19 @@
* - 消息分发时进行空间过滤
* - 玩家登出时清理会话数据
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @version 1.0.1
* @since 2025-12-25
* @lastModified 2026-01-07
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { IRedisService } from '../../../core/redis/redis.interface';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { Internal, Constants } from '../../../core/zulip/interfaces/zulip.interfaces';
import { IZulipConfigService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
import { Internal, Constants } from '../../../core/zulip_core/interfaces/zulip.interfaces';
/**
* 游戏会话接口 - 重新导出以保持向后兼容

View File

@@ -26,7 +26,7 @@ import {
MessageDistributor,
} from './zulip_event_processor.service';
import { SessionManagerService, GameSession } from './session_manager.service';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ZulipEventProcessorService', () => {

View File

@@ -32,7 +32,7 @@
import { Injectable, OnModuleDestroy, Inject, forwardRef, Logger } from '@nestjs/common';
import { SessionManagerService } from './session_manager.service';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
/**
* Zulip消息接口

View File

@@ -50,8 +50,10 @@ import { MessageFilterService } from './services/message_filter.service';
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
import { SessionCleanupService } from './services/session_cleanup.service';
import { ChatController } from './controllers/chat.controller';
import { WebSocketDocsController } from './controllers/websocket-docs.controller';
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
import { WebSocketDocsController } from './controllers/websocket_docs.controller';
import { ZulipAccountsController } from './controllers/zulip_accounts.controller';
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { RedisModule } from '../../core/redis/redis.module';
import { LoggerModule } from '../../core/utils/logger/logger.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
@@ -61,6 +63,8 @@ import { AuthModule } from '../auth/auth.module';
imports: [
// Zulip核心服务模块 - 提供技术实现相关的核心服务
ZulipCoreModule,
// Zulip账号关联模块 - 提供账号关联管理功能
ZulipAccountsModule.forRoot(),
// Redis模块 - 提供会话状态缓存和数据存储
RedisModule,
// 日志模块 - 提供统一的日志记录服务
@@ -89,6 +93,8 @@ import { AuthModule } from '../auth/auth.module';
ChatController,
// WebSocket API文档控制器
WebSocketDocsController,
// Zulip账号关联管理控制器
ZulipAccountsController,
],
exports: [
// 导出主服务供其他模块使用

View File

@@ -39,8 +39,9 @@ import {
IZulipConfigService,
ZulipClientInstance,
SendMessageResult,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
} from '../../core/zulip_core/interfaces/zulip_core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
import { LoginCoreService } from '../../core/login_core/login_core.service';
describe('ZulipService', () => {
let service: ZulipService;
@@ -49,6 +50,7 @@ describe('ZulipService', () => {
let mockMessageFilter: jest.Mocked<MessageFilterService>;
let mockEventProcessor: jest.Mocked<ZulipEventProcessorService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
let mockLoginCoreService: jest.Mocked<LoginCoreService>;
// 创建模拟的Zulip客户端实例
const createMockClientInstance = (overrides: Partial<ZulipClientInstance> = {}): ZulipClientInstance => ({
@@ -136,6 +138,14 @@ describe('ZulipService', () => {
validateConfig: jest.fn(),
} as any;
mockLoginCoreService = {
verifyToken: jest.fn(),
generateTokens: jest.fn(),
refreshTokens: jest.fn(),
revokeToken: jest.fn(),
validateTokenPayload: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
ZulipService,
@@ -160,7 +170,7 @@ describe('ZulipService', () => {
useValue: mockConfigManager,
},
{
provide: ApiKeySecurityService,
provide: 'API_KEY_SECURITY_SERVICE',
useValue: {
extractApiKey: jest.fn(),
validateApiKey: jest.fn(),
@@ -172,10 +182,39 @@ describe('ZulipService', () => {
}),
},
},
{
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
],
}).compile();
service = module.get<ZulipService>(ZulipService);
// 配置LoginCoreService的默认mock行为
mockLoginCoreService.verifyToken.mockImplementation(async (token: string) => {
// 模拟token验证逻辑
if (token.startsWith('invalid')) {
throw new Error('Invalid token');
}
// 从token中提取用户信息模拟JWT解析
const userId = `user_${token.substring(0, 8)}`;
const username = `Player_${userId.substring(5, 10)}`;
const email = `${userId}@example.com`;
return {
sub: userId,
username,
email,
role: 1, // 数字类型的角色
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
iss: 'whale-town',
aud: 'whale-town-users',
};
});
});
it('should be defined', () => {

View File

@@ -6,6 +6,12 @@
* - 整合各个子服务,提供统一的业务接口
* - 处理游戏客户端与Zulip之间的核心业务逻辑
*
* 职责分离:
* - 业务协调:整合会话管理、消息过滤、事件处理等子服务
* - 流程控制:管理玩家登录登出的完整业务流程
* - 接口适配在游戏协议和Zulip协议之间进行转换
* - 错误处理:统一处理业务异常和降级策略
*
* 主要方法:
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
* - handlePlayerLogout(): 处理玩家登出和资源清理
@@ -17,9 +23,15 @@
* - 会话管理和状态维护
* - 消息格式转换和过滤
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 注释规范检查和修正 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 拆分过长方法提取validateLoginParams和createUserSession私有方法 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
*
* @author angjustinl
* @version 1.1.0
* @version 1.2.0
* @since 2026-01-06
* @lastModified 2026-01-07
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
@@ -30,9 +42,9 @@ import { ZulipEventProcessorService } from './services/zulip_event_processor.ser
import {
IZulipClientPoolService,
IZulipConfigService,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
import { LoginService } from '../auth/services/login.service';
IApiKeySecurityService,
} from '../../core/zulip_core/interfaces/zulip_core.interfaces';
import { LoginCoreService } from '../../core/login_core/login_core.service';
/**
* 玩家登录请求接口
@@ -116,8 +128,9 @@ export class ZulipService {
private readonly eventProcessor: ZulipEventProcessorService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly loginService: LoginService,
@Inject('API_KEY_SECURITY_SERVICE')
private readonly apiKeySecurityService: IApiKeySecurityService,
private readonly loginCoreService: LoginCoreService,
) {
this.logger.log('ZulipService初始化完成');
@@ -144,6 +157,18 @@ export class ZulipService {
*
* @throws UnauthorizedException 当Token验证失败时
* @throws InternalServerErrorException 当系统操作失败时
*
* @example
* ```typescript
* const loginRequest: PlayerLoginRequest = {
* token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
* socketId: 'socket_12345'
* };
* const result = await zulipService.handlePlayerLogin(loginRequest);
* if (result.success) {
* console.log(`用户 ${result.username} 登录成功`);
* }
* ```
*/
async handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse> {
const startTime = Date.now();
@@ -156,28 +181,15 @@ export class ZulipService {
try {
// 1. 验证请求参数
if (!request.token || !request.token.trim()) {
this.logger.warn('登录失败Token为空', {
operation: 'handlePlayerLogin',
socketId: request.socketId,
});
const paramValidation = this.validateLoginParams(request);
if (!paramValidation.isValid) {
return {
success: false,
error: 'Token不能为空',
error: paramValidation.error,
};
}
if (!request.socketId || !request.socketId.trim()) {
this.logger.warn('登录失败socketId为空', {
operation: 'handlePlayerLogin',
});
return {
success: false,
error: 'socketId不能为空',
};
}
// 2. 验证游戏Token并获取用户信息 调用认证服务验证Token
// 2. 验证游戏Token并获取用户信息
const userInfo = await this.validateGameToken(request.token);
if (!userInfo) {
this.logger.warn('登录失败Token验证失败', {
@@ -190,80 +202,28 @@ export class ZulipService {
};
}
// 3. 生成会话ID
const sessionId = randomUUID();
// 调试日志:检查用户信息
this.logger.log('用户信息检查', {
operation: 'handlePlayerLogin',
userId: userInfo.userId,
hasZulipApiKey: !!userInfo.zulipApiKey,
zulipApiKeyLength: userInfo.zulipApiKey?.length || 0,
zulipEmail: userInfo.zulipEmail,
email: userInfo.email,
});
// 4. 创建Zulip客户端如果有API Key
let zulipQueueId = `queue_${sessionId}`;
// 3. 创建Zulip客户端和会话
const sessionResult = await this.createUserSession(request.socketId, userInfo);
if (userInfo.zulipApiKey) {
try {
const zulipConfig = this.configManager.getZulipConfig();
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
username: userInfo.zulipEmail || userInfo.email,
apiKey: userInfo.zulipApiKey,
realm: zulipConfig.zulipServerUrl,
});
if (clientInstance.queueId) {
zulipQueueId = clientInstance.queueId;
}
this.logger.log('Zulip客户端创建成功', {
operation: 'handlePlayerLogin',
userId: userInfo.userId,
queueId: zulipQueueId,
});
} catch (zulipError) {
const err = zulipError as Error;
this.logger.warn('Zulip客户端创建失败使用本地模式', {
operation: 'handlePlayerLogin',
userId: userInfo.userId,
error: err.message,
});
// Zulip客户端创建失败不影响登录使用本地模式
}
}
// 5. 创建游戏会话
const session = await this.sessionManager.createSession(
request.socketId,
userInfo.userId,
zulipQueueId,
userInfo.username,
this.DEFAULT_MAP,
{ x: 400, y: 300 },
);
const duration = Date.now() - startTime;
this.logger.log('玩家登录处理完成', {
operation: 'handlePlayerLogin',
socketId: request.socketId,
sessionId,
sessionId: sessionResult.sessionId,
userId: userInfo.userId,
username: userInfo.username,
currentMap: session.currentMap,
currentMap: sessionResult.currentMap,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
sessionId,
sessionId: sessionResult.sessionId,
userId: userInfo.userId,
username: userInfo.username,
currentMap: session.currentMap,
currentMap: sessionResult.currentMap,
};
} catch (error) {
@@ -285,6 +245,108 @@ export class ZulipService {
}
}
/**
* 验证登录请求参数
*
* @param request 登录请求
* @returns 验证结果
* @private
*/
private validateLoginParams(request: PlayerLoginRequest): { isValid: boolean; error?: string } {
if (!request.token || !request.token.trim()) {
this.logger.warn('登录失败Token为空', {
operation: 'validateLoginParams',
socketId: request.socketId,
});
return {
isValid: false,
error: 'Token不能为空',
};
}
if (!request.socketId || !request.socketId.trim()) {
this.logger.warn('登录失败socketId为空', {
operation: 'validateLoginParams',
});
return {
isValid: false,
error: 'socketId不能为空',
};
}
return { isValid: true };
}
/**
* 创建用户会话和Zulip客户端
*
* @param socketId Socket连接ID
* @param userInfo 用户信息
* @returns 会话创建结果
* @private
*/
private async createUserSession(socketId: string, userInfo: any): Promise<{ sessionId: string; currentMap: string }> {
// 生成会话ID
const sessionId = randomUUID();
// 调试日志:检查用户信息
this.logger.log('用户信息检查', {
operation: 'createUserSession',
userId: userInfo.userId,
hasZulipApiKey: !!userInfo.zulipApiKey,
zulipApiKeyLength: userInfo.zulipApiKey?.length || 0,
zulipEmail: userInfo.zulipEmail,
email: userInfo.email,
});
// 创建Zulip客户端如果有API Key
let zulipQueueId = `queue_${sessionId}`;
if (userInfo.zulipApiKey) {
try {
const zulipConfig = this.configManager.getZulipConfig();
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
username: userInfo.zulipEmail || userInfo.email,
apiKey: userInfo.zulipApiKey,
realm: zulipConfig.zulipServerUrl,
});
if (clientInstance.queueId) {
zulipQueueId = clientInstance.queueId;
}
this.logger.log('Zulip客户端创建成功', {
operation: 'createUserSession',
userId: userInfo.userId,
queueId: zulipQueueId,
});
} catch (zulipError) {
const err = zulipError as Error;
this.logger.warn('Zulip客户端创建失败使用本地模式', {
operation: 'createUserSession',
userId: userInfo.userId,
error: err.message,
});
// Zulip客户端创建失败不影响登录使用本地模式
}
}
// 创建游戏会话
const session = await this.sessionManager.createSession(
socketId,
userInfo.userId,
zulipQueueId,
userInfo.username,
this.DEFAULT_MAP,
{ x: 400, y: 300 },
);
return {
sessionId,
currentMap: session.currentMap,
};
}
/**
* 验证游戏Token
*
@@ -308,8 +370,8 @@ export class ZulipService {
});
try {
// 1. 使用LoginService验证JWT token
const payload = await this.loginService.verifyToken(token, 'access');
// 1. 使用LoginCoreService验证JWT token
const payload = await this.loginCoreService.verifyToken(token, 'access');
if (!payload || !payload.sub) {
this.logger.warn('Token载荷无效', {

View File

@@ -6,6 +6,12 @@
* - 实现游戏协议到Zulip协议的转换
* - 提供统一的消息路由和权限控制
*
* 职责分离:
* - 连接管理处理WebSocket连接的建立、维护和断开
* - 协议转换:在游戏客户端协议和内部业务协议之间转换
* - 权限控制:验证用户身份和消息发送权限
* - 消息路由:将消息分发到正确的业务处理服务
*
* 主要方法:
* - handleConnection(): 处理客户端连接建立
* - handleDisconnect(): 处理客户端连接断开
@@ -18,9 +24,13 @@
* - 消息协议转换和路由分发
* - 连接状态管理和权限验证
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @version 1.0.1
* @since 2025-12-25
* @lastModified 2026-01-07
*/
import {