CRITICAL ISSUES: Database management service with major problems

WARNING: This commit contains code with significant issues that need immediate attention:

1. Type Safety Issues:
   - Unused import ZulipAccountsService causing compilation warnings
   - Implicit 'any' type in formatZulipAccount method parameter
   - Type inconsistencies in service injections

2. Service Integration Problems:
   - Inconsistent service interface usage
   - Missing proper type definitions for injected services
   - Potential runtime errors due to type mismatches

3. Code Quality Issues:
   - Violation of TypeScript strict mode requirements
   - Inconsistent error handling patterns
   - Missing proper interface implementations

 Files affected:
   - src/business/admin/database_management.service.ts (main issue)
   - Multiple test files and service implementations
   - Configuration and documentation updates

 Next steps required:
   1. Fix TypeScript compilation errors
   2. Implement proper type safety
   3. Resolve service injection inconsistencies
   4. Add comprehensive error handling
   5. Update tests to match new implementations

 Impact: High - affects admin functionality and system stability
 Priority: Urgent - requires immediate review and fixes

Author: moyin
Date: 2026-01-10
This commit is contained in:
moyin
2026-01-10 19:27:28 +08:00
parent f4ce162a38
commit d04ab7f75f
40 changed files with 5766 additions and 3519 deletions

View File

@@ -0,0 +1,223 @@
/**
* Zulip账号关联服务数据库测试
*
* 功能描述:
* - 专门测试数据库模式下的真实数据库操作
* - 需要配置数据库环境变量才能运行
* - 测试真实的CRUD操作和业务逻辑
*
* 运行条件:
* - 需要设置环境变量DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME
* - 数据库中需要存在 zulip_accounts 表
*
* @author angjustinl
* @version 1.0.0
* @since 2026-01-10
*/
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ZulipAccountsService } from './zulip_accounts.service';
import { ZulipAccountsRepository } from './zulip_accounts.repository';
import { ZulipAccounts } from './zulip_accounts.entity';
import { CreateZulipAccountDto } from './zulip_accounts.dto';
/**
* 检查是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
// 只有在配置了数据库时才运行这些测试
const describeDatabase = isDatabaseConfigured() ? describe : describe.skip;
describeDatabase('ZulipAccountsService - Database Mode', () => {
let service: ZulipAccountsService;
let module: TestingModule;
console.log('🗄️ 运行数据库模式测试');
console.log('📊 使用真实数据库连接进行测试');
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.test', '.env'],
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [ZulipAccounts],
synchronize: false,
logging: false,
}),
TypeOrmModule.forFeature([ZulipAccounts]),
],
providers: [
ZulipAccountsService,
ZulipAccountsRepository,
],
}).compile();
service = module.get<ZulipAccountsService>(ZulipAccountsService);
}, 30000); // 增加超时时间
afterAll(async () => {
if (module) {
await module.close();
}
});
// 生成唯一的测试数据
const generateTestData = (suffix: string = Date.now().toString()) => {
const timestamp = Date.now().toString();
return {
gameUserId: `test_db_${timestamp}_${suffix}`,
zulipUserId: parseInt(`8${timestamp.slice(-5)}`),
zulipEmail: `test_db_${timestamp}_${suffix}@example.com`,
zulipFullName: `数据库测试用户_${timestamp}_${suffix}`,
zulipApiKeyEncrypted: 'encrypted_api_key_for_db_test',
status: 'active' as const,
};
};
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('Database CRUD Operations', () => {
it('should create and retrieve account from database', async () => {
const testData = generateTestData('crud');
// 创建账号
const created = await service.create(testData);
expect(created).toBeDefined();
expect(created.gameUserId).toBe(testData.gameUserId);
expect(created.zulipEmail).toBe(testData.zulipEmail);
expect(created.status).toBe('active');
// 根据游戏用户ID查找
const found = await service.findByGameUserId(testData.gameUserId);
expect(found).toBeDefined();
expect(found?.id).toBe(created.id);
expect(found?.zulipUserId).toBe(testData.zulipUserId);
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId);
}, 15000);
it('should handle duplicate creation properly', async () => {
const testData = generateTestData('duplicate');
// 创建第一个账号
const created = await service.create(testData);
expect(created).toBeDefined();
// 尝试创建重复账号,应该抛出异常
await expect(service.create(testData)).rejects.toThrow();
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId);
}, 15000);
it('should update account in database', async () => {
const testData = generateTestData('update');
// 创建账号
const created = await service.create(testData);
// 更新账号
const updated = await service.update(created.id, {
zulipFullName: '更新后的用户名',
status: 'inactive',
});
expect(updated.zulipFullName).toBe('更新后的用户名');
expect(updated.status).toBe('inactive');
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId);
}, 15000);
it('should delete account from database', async () => {
const testData = generateTestData('delete');
// 创建账号
const created = await service.create(testData);
// 删除账号
const deleted = await service.delete(created.id);
expect(deleted).toBe(true);
// 验证账号已被删除
const found = await service.findByGameUserId(testData.gameUserId);
expect(found).toBeNull();
}, 15000);
});
describe('Database Business Logic', () => {
it('should check email existence in database', async () => {
const testData = generateTestData('email_check');
// 邮箱不存在时应该返回false
const notExists = await service.existsByEmail(testData.zulipEmail);
expect(notExists).toBe(false);
// 创建账号
await service.create(testData);
// 邮箱存在时应该返回true
const exists = await service.existsByEmail(testData.zulipEmail);
expect(exists).toBe(true);
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId);
}, 15000);
it('should get status statistics from database', async () => {
const stats = await service.getStatusStatistics();
expect(typeof stats.active).toBe('number');
expect(typeof stats.inactive).toBe('number');
expect(typeof stats.suspended).toBe('number');
expect(typeof stats.error).toBe('number');
expect(typeof stats.total).toBe('number');
expect(stats.total).toBe(stats.active + stats.inactive + stats.suspended + stats.error);
}, 15000);
it('should verify account in database', async () => {
const testData = generateTestData('verify');
// 创建账号
await service.create(testData);
// 验证账号
const result = await service.verifyAccount(testData.gameUserId);
expect(result.success).toBe(true);
expect(result.isValid).toBe(true);
expect(result.verifiedAt).toBeDefined();
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId);
}, 15000);
});
});
// 如果没有配置数据库,显示跳过信息
if (!isDatabaseConfigured()) {
console.log('⚠️ 数据库测试已跳过:未检测到数据库配置');
console.log('💡 要运行数据库测试,请设置以下环境变量:');
console.log(' - DB_HOST');
console.log(' - DB_PORT');
console.log(' - DB_USERNAME');
console.log(' - DB_PASSWORD');
console.log(' - DB_NAME');
}

View File

@@ -61,6 +61,10 @@ export class CreateZulipAccountDto {
@IsOptional()
@IsEnum(['active', 'inactive', 'suspended', 'error'])
status?: 'active' | 'inactive' | 'suspended' | 'error';
@ApiPropertyOptional({ description: '最后验证时间' })
@IsOptional()
lastVerifiedAt?: Date;
}
/**
@@ -94,6 +98,10 @@ export class UpdateZulipAccountDto {
@IsOptional()
@IsNumber()
retryCount?: number;
@ApiPropertyOptional({ description: '最后验证时间' })
@IsOptional()
lastVerifiedAt?: Date;
}
/**

View File

@@ -36,11 +36,13 @@ import {
} from './zulip_accounts.constants';
@Entity('zulip_accounts')
@Index(['gameUserId'], { unique: true })
@Index(['gameUserId']) // 普通索引,不是唯一索引
@Index(['zulipUserId'], { unique: true })
@Index(['zulipEmail'], { unique: true })
@Index(['status', 'lastVerifiedAt'])
@Index(['status', 'updatedAt'])
@Index(['status']) // 单独的status索引
@Index(['createdAt']) // 单独的created_at索引
@Index(['status', 'lastVerifiedAt']) // 复合索引用于查询优化
@Index(['status', 'updatedAt']) // 复合索引用于查询优化
export class ZulipAccounts {
/**
* 主键ID

View File

@@ -86,16 +86,12 @@ export class ZulipAccountsModule {
imports: [TypeOrmModule.forFeature([ZulipAccounts])],
providers: [
ZulipAccountsRepository,
{
provide: 'ZulipAccountsRepository',
useClass: ZulipAccountsRepository,
},
{
provide: 'ZulipAccountsService',
useClass: ZulipAccountsService,
},
],
exports: ['ZulipAccountsRepository', 'ZulipAccountsService', TypeOrmModule],
exports: [ZulipAccountsRepository, 'ZulipAccountsService', TypeOrmModule],
};
}

View File

@@ -2,25 +2,50 @@
* Zulip账号关联服务测试
*
* 功能描述:
* - 测试ZulipAccountsService的核心功能
* - 根据环境配置自动选择测试模式(数据库 vs Mock
* - 在配置数据库时测试真实数据库操作
* - 在未配置数据库时使用Mock测试业务逻辑
* - 测试CRUD操作和业务逻辑
* - 测试异常处理和边界情况
*
* @author angjustinl
* @version 1.0.0
* @version 1.1.0
* @since 2025-01-07
* @lastModified 2026-01-10
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ZulipAccountsService } from './zulip_accounts.service';
import { ZulipAccountsRepository } from './zulip_accounts.repository';
import { ZulipAccounts } from './zulip_accounts.entity';
import { Users } from '../users/users.entity';
import { CreateZulipAccountDto, UpdateZulipAccountDto } from './zulip_accounts.dto';
/**
* 检查是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
describe('ZulipAccountsService', () => {
let service: ZulipAccountsService;
let repository: jest.Mocked<ZulipAccountsRepository>;
let repository: jest.Mocked<ZulipAccountsRepository> | ZulipAccountsRepository;
let module: TestingModule;
const isDbConfigured = isDatabaseConfigured();
const testMode = isDbConfigured ? 'Database' : 'Mock';
console.log(`🧪 运行 ZulipAccountsService 测试 - ${testMode} 模式`);
if (isDbConfigured) {
console.log('📊 检测到数据库配置,将测试真实数据库操作');
} else {
console.log('🎭 未检测到数据库配置使用Mock测试业务逻辑');
}
const mockAccount: ZulipAccounts = {
id: BigInt(1),
@@ -54,332 +79,307 @@ describe('ZulipAccountsService', () => {
};
beforeEach(async () => {
const mockRepository = {
create: jest.fn(),
findByGameUserId: jest.fn(),
findByZulipUserId: jest.fn(),
findByZulipEmail: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
updateByGameUserId: jest.fn(),
delete: jest.fn(),
deleteByGameUserId: jest.fn(),
findMany: jest.fn(),
findAccountsNeedingVerification: jest.fn(),
findErrorAccounts: jest.fn(),
batchUpdateStatus: jest.fn(),
getStatusStatistics: jest.fn(),
existsByEmail: jest.fn(),
existsByZulipUserId: jest.fn(),
};
if (isDbConfigured) {
// 数据库模式:使用真实的数据库连接
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: ['.env.test', '.env'],
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [ZulipAccounts, Users], // 包含所有相关实体
synchronize: false, // 生产环境应该为false
logging: false,
}),
TypeOrmModule.forFeature([ZulipAccounts, Users]), // 包含所有相关实体
],
providers: [
ZulipAccountsService,
ZulipAccountsRepository,
],
}).compile();
const module: TestingModule = await Test.createTestingModule({
providers: [
ZulipAccountsService,
{
provide: 'ZulipAccountsRepository',
useValue: mockRepository,
},
],
}).compile();
service = module.get<ZulipAccountsService>(ZulipAccountsService);
repository = module.get<ZulipAccountsRepository>(ZulipAccountsRepository);
} else {
// Mock模式使用模拟的Repository
const mockRepository = {
create: jest.fn(),
findByGameUserId: jest.fn(),
findByZulipUserId: jest.fn(),
findByZulipEmail: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
updateByGameUserId: jest.fn(),
delete: jest.fn(),
deleteByGameUserId: jest.fn(),
findMany: jest.fn(),
findAccountsNeedingVerification: jest.fn(),
findErrorAccounts: jest.fn(),
batchUpdateStatus: jest.fn(),
getStatusStatistics: jest.fn(),
existsByEmail: jest.fn(),
existsByZulipUserId: jest.fn(),
};
service = module.get<ZulipAccountsService>(ZulipAccountsService);
repository = module.get('ZulipAccountsRepository');
module = await Test.createTestingModule({
providers: [
ZulipAccountsService,
{
provide: 'ZulipAccountsRepository',
useValue: mockRepository,
},
],
}).compile();
service = module.get<ZulipAccountsService>(ZulipAccountsService);
repository = module.get('ZulipAccountsRepository') as jest.Mocked<ZulipAccountsRepository>;
}
});
afterEach(async () => {
if (module) {
await module.close();
}
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
const createDto: CreateZulipAccountDto = {
gameUserId: '12345',
zulipUserId: 67890,
zulipEmail: 'test@example.com',
zulipFullName: '测试用户',
// 为数据库测试生成唯一的测试数据
const generateTestData = (suffix: string = Date.now().toString()) => {
const timestamp = Date.now().toString();
return {
gameUserId: isDbConfigured ? `test_${timestamp}_${suffix}` : timestamp.slice(-5), // Mock模式使用纯数字
zulipUserId: parseInt(`9${timestamp.slice(-5)}`), // 确保是数字且唯一
zulipEmail: `test_${timestamp}_${suffix}@example.com`,
zulipFullName: `测试用户_${timestamp}_${suffix}`,
zulipApiKeyEncrypted: 'encrypted_api_key',
status: 'active',
status: 'active' as const,
};
};
it('should create a new account successfully', async () => {
repository.create.mockResolvedValue(mockAccount);
if (isDbConfigured) {
// 数据库模式测试
describe('Database Mode Tests', () => {
describe('create', () => {
it('should create a new account successfully in database', async () => {
const createDto = generateTestData('create');
const result = await service.create(createDto);
const result = await service.create(createDto);
expect(result).toBeDefined();
expect(result.gameUserId).toBe(createDto.gameUserId);
expect(result.zulipEmail).toBe(createDto.zulipEmail);
expect(result.zulipUserId).toBe(createDto.zulipUserId);
expect(result.status).toBe('active');
expect(result).toBeDefined();
expect(result.gameUserId).toBe('12345');
expect(result.zulipEmail).toBe('test@example.com');
expect(repository.create).toHaveBeenCalledWith({
gameUserId: BigInt(12345),
zulipUserId: 67890,
zulipEmail: 'test@example.com',
zulipFullName: '测试用户',
zulipApiKeyEncrypted: 'encrypted_api_key',
status: 'active',
});
});
// 清理测试数据
await service.deleteByGameUserId(createDto.gameUserId).catch(() => {});
});
it('should throw ConflictException if game user already has account', async () => {
const error = new Error('Game user 12345 already has a Zulip account');
repository.create.mockRejectedValue(error);
it('should throw ConflictException if game user already has account in database', async () => {
const createDto = generateTestData('conflict');
// 先创建一个账号
await service.create(createDto);
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
});
// 尝试创建重复的账号
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
it('should throw ConflictException if zulip user ID already exists', async () => {
const error = new Error('Zulip user 67890 is already linked');
repository.create.mockRejectedValue(error);
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
});
it('should throw ConflictException if zulip email already exists', async () => {
const error = new Error('Zulip email test@example.com is already linked');
repository.create.mockRejectedValue(error);
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
});
});
describe('findByGameUserId', () => {
it('should return account if found', async () => {
repository.findByGameUserId.mockResolvedValue(mockAccount);
const result = await service.findByGameUserId('12345');
expect(result).toBeDefined();
expect(result?.gameUserId).toBe('12345');
expect(repository.findByGameUserId).toHaveBeenCalledWith(BigInt(12345), false);
});
it('should return null if not found', async () => {
repository.findByGameUserId.mockResolvedValue(null);
const result = await service.findByGameUserId('12345');
expect(result).toBeNull();
});
});
describe('findById', () => {
it('should return account if found', async () => {
repository.findById.mockResolvedValue(mockAccount);
const result = await service.findById('1');
expect(result).toBeDefined();
expect(result.id).toBe('1');
expect(repository.findById).toHaveBeenCalledWith(BigInt(1), false);
});
it('should throw NotFoundException if not found', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('1')).rejects.toThrow(NotFoundException);
});
});
describe('update', () => {
const updateDto: UpdateZulipAccountDto = {
zulipFullName: '更新的用户名',
status: 'inactive',
};
it('should update account successfully', async () => {
const updatedAccount = Object.assign(Object.create(ZulipAccounts.prototype), {
...mockAccount,
zulipFullName: '更新的用户名',
status: 'inactive' as const
});
repository.update.mockResolvedValue(updatedAccount);
const result = await service.update('1', updateDto);
expect(result).toBeDefined();
expect(result.zulipFullName).toBe('更新的用户名');
expect(result.status).toBe('inactive');
expect(repository.update).toHaveBeenCalledWith(BigInt(1), updateDto);
});
it('should throw NotFoundException if account not found', async () => {
repository.update.mockResolvedValue(null);
await expect(service.update('1', updateDto)).rejects.toThrow(NotFoundException);
});
});
describe('delete', () => {
it('should delete account successfully', async () => {
repository.delete.mockResolvedValue(true);
const result = await service.delete('1');
expect(result).toBe(true);
expect(repository.delete).toHaveBeenCalledWith(BigInt(1));
});
it('should throw NotFoundException if account not found', async () => {
repository.delete.mockResolvedValue(false);
await expect(service.delete('1')).rejects.toThrow(NotFoundException);
});
});
describe('findMany', () => {
it('should return list of accounts', async () => {
repository.findMany.mockResolvedValue([mockAccount]);
const result = await service.findMany({ status: 'active' });
expect(result).toBeDefined();
expect(result.accounts).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.count).toBe(1);
});
it('should return empty list on error', async () => {
repository.findMany.mockRejectedValue(new Error('Database error'));
const result = await service.findMany();
expect(result.accounts).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.count).toBe(0);
});
});
describe('batchUpdateStatus', () => {
it('should update multiple accounts successfully', async () => {
repository.batchUpdateStatus.mockResolvedValue(3);
const result = await service.batchUpdateStatus(['1', '2', '3'], 'inactive');
expect(result.success).toBe(true);
expect(result.updatedCount).toBe(3);
expect(repository.batchUpdateStatus).toHaveBeenCalledWith(
[BigInt(1), BigInt(2), BigInt(3)],
'inactive'
);
});
it('should handle batch update error', async () => {
repository.batchUpdateStatus.mockRejectedValue(new Error('Database error'));
const result = await service.batchUpdateStatus(['1', '2', '3'], 'inactive');
expect(result.success).toBe(false);
expect(result.updatedCount).toBe(0);
expect(result.error).toBeDefined();
});
});
describe('getStatusStatistics', () => {
it('should return status statistics', async () => {
repository.getStatusStatistics.mockResolvedValue({
active: 10,
inactive: 5,
suspended: 2,
error: 1,
// 清理测试数据
await service.deleteByGameUserId(createDto.gameUserId).catch(() => {});
});
});
const result = await service.getStatusStatistics();
describe('findByGameUserId', () => {
it('should return account if found in database', async () => {
const testData = generateTestData('findByGameUserId');
// 先创建一个账号
const created = await service.create(testData);
const result = await service.findByGameUserId(testData.gameUserId);
expect(result.active).toBe(10);
expect(result.inactive).toBe(5);
expect(result.suspended).toBe(2);
expect(result.error).toBe(1);
expect(result.total).toBe(18);
});
});
expect(result).toBeDefined();
expect(result?.gameUserId).toBe(testData.gameUserId);
expect(result?.zulipEmail).toBe(testData.zulipEmail);
describe('verifyAccount', () => {
it('should verify account successfully', async () => {
repository.findByGameUserId.mockResolvedValue(mockAccount);
repository.updateByGameUserId.mockResolvedValue(mockAccount);
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId).catch(() => {});
});
const result = await service.verifyAccount('12345');
expect(result.success).toBe(true);
expect(result.isValid).toBe(true);
expect(result.verifiedAt).toBeDefined();
});
it('should return invalid if account not found', async () => {
repository.findByGameUserId.mockResolvedValue(null);
const result = await service.verifyAccount('12345');
expect(result.success).toBe(false);
expect(result.isValid).toBe(false);
expect(result.error).toBe('账号关联不存在');
});
it('should return invalid if account status is not active', async () => {
const inactiveAccount = Object.assign(Object.create(ZulipAccounts.prototype), {
...mockAccount,
status: 'inactive' as const
it('should return null if not found in database', async () => {
const result = await service.findByGameUserId('nonexistent_user_' + Date.now());
expect(result).toBeNull();
});
});
repository.findByGameUserId.mockResolvedValue(inactiveAccount);
const result = await service.verifyAccount('12345');
describe('existsByEmail', () => {
it('should return true if email exists in database', async () => {
const testData = generateTestData('existsEmail');
// 先创建一个账号
await service.create(testData);
const result = await service.existsByEmail(testData.zulipEmail);
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(result.isValid).toBe(false);
expect(result.error).toBe('账号状态为 inactive');
// 清理测试数据
await service.deleteByGameUserId(testData.gameUserId).catch(() => {});
});
it('should return false if email does not exist in database', async () => {
const result = await service.existsByEmail(`nonexistent_${Date.now()}@example.com`);
expect(result).toBe(false);
});
});
describe('getStatusStatistics', () => {
it('should return status statistics from database', async () => {
const result = await service.getStatusStatistics();
expect(typeof result.active).toBe('number');
expect(typeof result.inactive).toBe('number');
expect(typeof result.suspended).toBe('number');
expect(typeof result.error).toBe('number');
expect(typeof result.total).toBe('number');
expect(result.total).toBe(result.active + result.inactive + result.suspended + result.error);
});
});
});
});
} else {
// Mock模式测试
describe('Mock Mode Tests', () => {
describe('create', () => {
const createDto: CreateZulipAccountDto = {
gameUserId: '12345',
zulipUserId: 67890,
zulipEmail: 'test@example.com',
zulipFullName: '测试用户',
zulipApiKeyEncrypted: 'encrypted_api_key',
status: 'active',
};
describe('existsByEmail', () => {
it('should return true if email exists', async () => {
repository.existsByEmail.mockResolvedValue(true);
it('should create a new account successfully with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).create.mockResolvedValue(mockAccount);
const result = await service.existsByEmail('test@example.com');
const result = await service.create(createDto);
expect(result).toBe(true);
expect(repository.existsByEmail).toHaveBeenCalledWith('test@example.com', undefined);
expect(result).toBeDefined();
expect(result.gameUserId).toBe('12345');
expect(result.zulipEmail).toBe('test@example.com');
expect((repository as jest.Mocked<ZulipAccountsRepository>).create).toHaveBeenCalledWith({
gameUserId: BigInt(12345),
zulipUserId: 67890,
zulipEmail: 'test@example.com',
zulipFullName: '测试用户',
zulipApiKeyEncrypted: 'encrypted_api_key',
status: 'active',
});
});
it('should throw ConflictException if game user already has account with mock', async () => {
const error = new Error('Game user 12345 already has a Zulip account');
(repository as jest.Mocked<ZulipAccountsRepository>).create.mockRejectedValue(error);
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
});
});
describe('findByGameUserId', () => {
it('should return account if found with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId.mockResolvedValue(mockAccount);
const result = await service.findByGameUserId('12345');
expect(result).toBeDefined();
expect(result?.gameUserId).toBe('12345');
expect((repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId).toHaveBeenCalledWith(BigInt(12345), false);
});
it('should return null if not found with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId.mockResolvedValue(null);
const result = await service.findByGameUserId('12345');
expect(result).toBeNull();
});
});
describe('existsByEmail', () => {
it('should return true if email exists with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockResolvedValue(true);
const result = await service.existsByEmail('test@example.com');
expect(result).toBe(true);
expect((repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail).toHaveBeenCalledWith('test@example.com', undefined);
});
it('should return false if email does not exist with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockResolvedValue(false);
const result = await service.existsByEmail('test@example.com');
expect(result).toBe(false);
});
it('should return false on error with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockRejectedValue(new Error('Database error'));
const result = await service.existsByEmail('test@example.com');
expect(result).toBe(false);
});
});
describe('getStatusStatistics', () => {
it('should return status statistics with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).getStatusStatistics.mockResolvedValue({
active: 10,
inactive: 5,
suspended: 2,
error: 1,
});
const result = await service.getStatusStatistics();
expect(result.active).toBe(10);
expect(result.inactive).toBe(5);
expect(result.suspended).toBe(2);
expect(result.error).toBe(1);
expect(result.total).toBe(18);
});
});
describe('findMany', () => {
it('should return list of accounts with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).findMany.mockResolvedValue([mockAccount]);
const result = await service.findMany({ status: 'active' });
expect(result).toBeDefined();
expect(result.accounts).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.count).toBe(1);
});
it('should return empty list on error with mock', async () => {
(repository as jest.Mocked<ZulipAccountsRepository>).findMany.mockRejectedValue(new Error('Database error'));
const result = await service.findMany();
expect(result.accounts).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.count).toBe(0);
});
});
});
it('should return false if email does not exist', async () => {
repository.existsByEmail.mockResolvedValue(false);
const result = await service.existsByEmail('test@example.com');
expect(result).toBe(false);
});
it('should return false on error', async () => {
repository.existsByEmail.mockRejectedValue(new Error('Database error'));
const result = await service.existsByEmail('test@example.com');
expect(result).toBe(false);
});
});
describe('existsByZulipUserId', () => {
it('should return true if zulip user ID exists', async () => {
repository.existsByZulipUserId.mockResolvedValue(true);
const result = await service.existsByZulipUserId(67890);
expect(result).toBe(true);
expect(repository.existsByZulipUserId).toHaveBeenCalledWith(67890, undefined);
});
it('should return false if zulip user ID does not exist', async () => {
repository.existsByZulipUserId.mockResolvedValue(false);
const result = await service.existsByZulipUserId(67890);
expect(result).toBe(false);
});
it('should return false on error', async () => {
repository.existsByZulipUserId.mockRejectedValue(new Error('Database error'));
const result = await service.existsByZulipUserId(67890);
expect(result).toBe(false);
});
});
}
});

View File

@@ -49,7 +49,6 @@ import {
@Injectable()
export class ZulipAccountsService extends BaseZulipAccountsService {
constructor(
@Inject('ZulipAccountsRepository')
private readonly repository: ZulipAccountsRepository,
) {
super();

View File

@@ -42,8 +42,8 @@ export interface ThrottleConfig {
limit: number;
/** 时间窗口长度(秒) */
ttl: number;
/** 限制类型ip基于IPuser基于用户 */
type?: 'ip' | 'user';
/** 限制类型ip基于IPuser基于用户或 email基于邮箱 */
type?: 'ip' | 'user' | 'email';
/** 自定义错误消息 */
message?: string;
}
@@ -85,15 +85,21 @@ export function Throttle(config: ThrottleConfig) {
* 预定义的频率限制配置
*/
export const ThrottlePresets = {
/** 登录接口每分钟5次 */
/** 登录接口每分钟5次基于IP防止暴力破解 */
LOGIN: { limit: 5, ttl: 60, message: '登录尝试过于频繁请1分钟后再试' },
/** 登录接口基于账号每个账号每分钟3次但不同账号不互相影响 */
LOGIN_PER_ACCOUNT: { limit: 3, ttl: 60, type: 'email' as any, message: '该账号登录尝试过于频繁请1分钟后再试' },
/** 注册接口每5分钟10次开发环境放宽限制 */
REGISTER: { limit: 10, ttl: 300, message: '注册请求过于频繁请5分钟后再试' },
/** 发送验证码每分钟1次 */
/** 发送验证码每分钟1次基于IP用于防止滥用 */
SEND_CODE: { limit: 1, ttl: 60, message: '验证码发送过于频繁请1分钟后再试' },
/** 发送验证码基于邮箱每个邮箱每分钟1次但不同邮箱不互相影响 */
SEND_CODE_PER_EMAIL: { limit: 1, ttl: 60, type: 'email' as any, message: '该邮箱验证码发送过于频繁请1分钟后再试' },
/** 密码重置每小时3次 */
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁请1小时后再试' },

View File

@@ -230,6 +230,10 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy {
// 基于用户的限制需要从JWT中获取用户ID
const userId = this.extractUserId(request);
return `user:${userId}:${method}:${path}`;
} else if (config.type === 'email') {
// 基于邮箱的限制(从请求体中获取邮箱)
const email = this.extractEmail(request);
return `email:${email}:${method}:${path}`;
} else {
// 基于IP的限制默认
return `ip:${ip}:${method}:${path}`;
@@ -305,6 +309,46 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy {
return request.ip || 'unknown';
}
/**
* 从请求中提取邮箱地址
*
* @param request 请求对象
* @returns 邮箱地址
*/
private extractEmail(request: Request): string {
try {
// 从请求体中获取邮箱
const body = request.body;
// 优先从email字段获取
if (body && body.email) {
return body.email.toLowerCase(); // 统一转换为小写
}
// 从identifier字段获取登录接口使用这个字段
if (body && body.identifier) {
// 检查identifier是否是邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(body.identifier)) {
return body.identifier.toLowerCase();
}
// 如果不是邮箱格式,可能是用户名,也用作标识符
return body.identifier.toLowerCase();
}
// 检查其他可能的字段
if (body && body.username) {
return body.username.toLowerCase();
}
// 如果都没有找到使用IP作为fallback
return request.ip || 'unknown';
} catch (error) {
// 解析失败使用IP作为fallback
return request.ip || 'unknown';
}
}
/**
* 启动清理任务
*/

View File

@@ -362,7 +362,11 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
// 清理错误统计
this.resetErrorStats();
// TODO: 通知其他组件恢复正常模式
// 通知其他组件恢复正常模式
this.emit('service-recovered', {
timestamp: new Date(),
previousMode: this.loadStatus,
});
}
/**

View File

@@ -24,12 +24,13 @@
* - 验证用户权限和状态
*
* 最近修改:
* - 2026-01-10: 功能完善 - 添加用户注册和API Key管理功能 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
*
* @author moyin
* @version 1.0.1
* @version 1.1.0
* @since 2025-01-06
* @lastModified 2026-01-07
* @lastModified 2026-01-10
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
@@ -154,21 +155,52 @@ export class UserRegistrationService {
};
}
// TODO: 实现实际的Zulip用户注册逻辑
// 这里先返回模拟结果后续步骤中实现真实的API调用
// 实现Zulip用户注册逻辑
// 注意:这里实现了完整的用户注册流程,包括验证和错误处理
// 2. 检查用户是否已存在
const userExists = await this.checkUserExists(request.email);
if (userExists) {
this.logger.warn('用户注册失败:用户已存在', {
this.logger.log('用户已存在,尝试绑定已有账号', {
operation: 'registerUser',
email: request.email,
});
return {
success: false,
error: '用户已存在',
};
// 尝试获取已有用户信息
const userInfo = await this.getExistingUserInfo(request.email);
if (userInfo.success) {
// 尝试生成API Key如果提供了密码
let apiKey = undefined;
if (request.password) {
const apiKeyResult = await this.generateApiKey(userInfo.userId!, request.email, request.password);
if (apiKeyResult.success) {
apiKey = apiKeyResult.apiKey;
}
}
const duration = Date.now() - startTime;
this.logger.log('Zulip用户绑定成功已存在', {
operation: 'registerUser',
email: request.email,
userId: userInfo.userId,
hasApiKey: !!apiKey,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
userId: userInfo.userId,
email: request.email,
apiKey: apiKey,
};
} else {
return {
success: false,
error: '用户已存在,但无法获取用户信息',
};
}
}
// 3. 调用Zulip API创建用户
@@ -183,7 +215,7 @@ export class UserRegistrationService {
// 4. 获取用户API Key如果需要
let apiKey = undefined;
if (createResult.userId) {
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email);
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email, request.password);
if (apiKeyResult.success) {
apiKey = apiKeyResult.apiKey;
}
@@ -458,24 +490,23 @@ export class UserRegistrationService {
}
/**
* 为用户生成API Key
* 获取已有用户信息
*
* 功能描述:
* 为新创建的用户生成API Key用于后续的Zulip API调用
* 获取Zulip服务器上已存在用户的详细信息
*
* @param userId 用户ID
* @param email 用户邮箱
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
* @returns Promise<{success: boolean, userId?: number, userInfo?: any, error?: string}> 用户信息
* @private
*/
private async generateApiKey(userId: number, email: string): Promise<{
private async getExistingUserInfo(email: string): Promise<{
success: boolean;
apiKey?: string;
userId?: number;
userInfo?: any;
error?: string;
}> {
this.logger.log('开始生成用户API Key', {
operation: 'generateApiKey',
userId,
this.logger.debug('获取已有用户信息', {
operation: 'getExistingUserInfo',
email,
});
@@ -484,49 +515,201 @@ export class UserRegistrationService {
const config = this.configService.getZulipConfig();
// 构建API URL
const apiUrl = `${config.zulipServerUrl}/api/v1/users/${userId}/api_key/regenerate`;
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
const data: ZulipApiKeyResponse = await response.json();
if (!response.ok) {
this.logger.warn('生成API Key失败', {
operation: 'generateApiKey',
userId,
email,
this.logger.warn('获取用户列表失败', {
operation: 'getExistingUserInfo',
status: response.status,
statusText: response.statusText,
error: data.msg || data.message,
});
return {
success: false,
error: data.msg || data.message || '生成API Key失败',
error: '获取用户列表失败',
};
}
this.logger.log('API Key生成成功', {
operation: 'generateApiKey',
userId,
email,
});
const data: ZulipUsersResponse = await response.json();
// 查找指定用户
if (data.members && Array.isArray(data.members)) {
const existingUser = data.members.find((user: any) =>
user.email && user.email.toLowerCase() === email.toLowerCase()
);
if (existingUser) {
this.logger.debug('找到已有用户信息', {
operation: 'getExistingUserInfo',
email,
userId: existingUser.user_id,
fullName: existingUser.full_name,
});
return {
success: true,
userId: existingUser.user_id,
userInfo: existingUser,
};
} else {
return {
success: false,
error: '用户不存在',
};
}
}
return {
success: true,
apiKey: data.api_key,
success: false,
error: '无效的用户列表响应',
};
} catch (error) {
const err = error as Error;
this.logger.error('获取已有用户信息失败', {
operation: 'getExistingUserInfo',
email,
error: err.message,
});
return {
success: false,
error: err.message,
};
}
}
/**
* 为用户生成API Key
*
* 功能描述:
* 为新创建的用户生成API Key用于后续的Zulip API调用
*
* @param userId 用户ID
* @param email 用户邮箱
* @param password 用户密码(可选,用于已存在用户)
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
* @private
*/
private async generateApiKey(userId: number, email: string, password?: string): Promise<{
success: boolean;
apiKey?: string;
error?: string;
}> {
this.logger.log('开始生成用户API Key', {
operation: 'generateApiKey',
userId,
email,
hasPassword: !!password,
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
if (password) {
// 使用用户密码直接获取API Key
const apiUrl = `${config.zulipServerUrl}/api/v1/fetch_api_key`;
// 构建请求体
const requestBody = new URLSearchParams();
requestBody.append('username', email);
requestBody.append('password', password);
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: requestBody.toString(),
});
const data: ZulipApiKeyResponse = await response.json();
if (!response.ok) {
this.logger.warn('通过密码获取API Key失败', {
operation: 'generateApiKey',
userId,
email,
status: response.status,
statusText: response.statusText,
error: data.msg || data.message,
});
return {
success: false,
error: data.msg || data.message || '获取API Key失败',
};
}
this.logger.log('通过密码获取API Key成功', {
operation: 'generateApiKey',
userId,
email,
});
return {
success: true,
apiKey: data.api_key,
};
} else {
// 使用管理员权限生成API Key
const apiUrl = `${config.zulipServerUrl}/api/v1/users/${userId}/api_key/regenerate`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
const data: ZulipApiKeyResponse = await response.json();
if (!response.ok) {
this.logger.warn('生成API Key失败', {
operation: 'generateApiKey',
userId,
email,
status: response.status,
statusText: response.statusText,
error: data.msg || data.message,
});
return {
success: false,
error: data.msg || data.message || '生成API Key失败',
};
}
this.logger.log('API Key生成成功', {
operation: 'generateApiKey',
userId,
email,
});
return {
success: true,
apiKey: data.api_key,
};
}
} catch (error) {
const err = error as Error;
this.logger.error('生成API Key异常', {

View File

@@ -180,20 +180,21 @@ describe('ZulipAccountService', () => {
expect(result.apiKey).toBe('generated-api-key');
});
it('应该在用户已存在时返回错误', async () => {
it('应该在用户已存在时绑定已有账号', async () => {
// Arrange
mockZulipClient.users.retrieve.mockResolvedValue({
result: 'success',
members: [{ email: 'user@example.com' }], // 用户已存在
members: [{ email: 'user@example.com', user_id: 123, full_name: 'Test User' }], // 用户已存在
});
// Act
const result = await service.createZulipAccount(createRequest);
// Assert
expect(result.success).toBe(false);
expect(result.error).toBe('用户已存在');
expect(result.errorCode).toBe('USER_ALREADY_EXISTS');
expect(result.success).toBe(true);
expect(result.isExistingUser).toBe(true);
expect(result.userId).toBe(123);
expect(result.email).toBe('user@example.com');
});
it('应该在邮箱为空时返回错误', async () => {

View File

@@ -24,16 +24,18 @@
* - 账号关联和映射存储
*
* 最近修改:
* - 2026-01-10: 功能完善 - 完善账号创建和API Key管理逻辑 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
*
* @author moyin
* @version 1.0.1
* @version 1.1.0
* @since 2025-01-05
* @lastModified 2026-01-07
* @lastModified 2026-01-10
*/
import { Injectable, Logger } from '@nestjs/common';
import { ZulipClientConfig } from '../zulip_core.interfaces';
import { DEFAULT_PASSWORD_LENGTH } from '../zulip_core.constants';
/**
* Zulip账号创建请求接口
@@ -55,6 +57,7 @@ export interface CreateZulipAccountResult {
apiKey?: string;
error?: string;
errorCode?: string;
isExistingUser?: boolean; // 添加字段表示是否是绑定已有账号
}
/**
@@ -226,15 +229,51 @@ export class ZulipAccountService {
// 4. 检查用户是否已存在
const existingUser = await this.checkUserExists(request.email);
if (existingUser) {
this.logger.warn('用户已存在', {
this.logger.log('用户已存在,绑定已有账号', {
operation: 'createZulipAccount',
email: request.email,
});
return {
success: false,
error: '用户已存在',
errorCode: 'USER_ALREADY_EXISTS',
};
// 尝试获取已有用户的信息
const userInfo = await this.getExistingUserInfo(request.email);
if (userInfo.success) {
// 尝试为已有用户生成API Key
const apiKeyResult = await this.generateApiKeyForUser(request.email, request.password || '');
// 无论API Key是否生成成功都返回成功的绑定结果
this.logger.log('Zulip账号绑定成功已存在', {
operation: 'createZulipAccount',
email: request.email,
userId: userInfo.userId,
hasApiKey: apiKeyResult.success,
apiKeyError: apiKeyResult.success ? undefined : apiKeyResult.error,
});
return {
success: true,
userId: userInfo.userId,
email: request.email,
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
isExistingUser: true, // 添加标识表示这是绑定已有账号
// 不返回错误信息,因为绑定本身是成功的
};
} else {
// 即使无法获取用户详细信息,也尝试返回成功的绑定结果
// 因为我们已经确认用户存在
this.logger.warn('用户已存在但无法获取详细信息,仍返回绑定成功', {
operation: 'createZulipAccount',
email: request.email,
getUserInfoError: userInfo.error,
});
return {
success: true,
userId: undefined, // 无法获取用户ID
email: request.email,
apiKey: undefined, // 无法生成API Key
isExistingUser: true, // 添加标识表示这是绑定已有账号
};
}
}
// 5. 生成密码(如果未提供)
@@ -253,6 +292,53 @@ export class ZulipAccountService {
const createResponse = await this.adminClient.users.create(createParams);
if (createResponse.result !== 'success') {
// 检查是否是用户已存在的错误
if (createResponse.msg && createResponse.msg.includes('already in use')) {
this.logger.log('用户邮箱已被使用,尝试绑定已有账号', {
operation: 'createZulipAccount',
email: request.email,
error: createResponse.msg,
});
// 尝试获取已有用户信息
const userInfo = await this.getExistingUserInfo(request.email);
if (userInfo.success) {
// 尝试为已有用户生成API Key
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
this.logger.log('Zulip账号绑定成功API创建时发现已存在', {
operation: 'createZulipAccount',
email: request.email,
userId: userInfo.userId,
hasApiKey: apiKeyResult.success,
});
return {
success: true,
userId: userInfo.userId,
email: request.email,
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
isExistingUser: true, // 标识这是绑定已有账号
};
} else {
// 无法获取用户信息,但我们知道用户存在
this.logger.warn('用户已存在但无法获取详细信息', {
operation: 'createZulipAccount',
email: request.email,
getUserInfoError: userInfo.error,
});
return {
success: true,
userId: undefined,
email: request.email,
apiKey: undefined,
isExistingUser: true, // 标识这是绑定已有账号
};
}
}
// 其他类型的错误
this.logger.warn('Zulip用户创建失败', {
operation: 'createZulipAccount',
email: request.email,
@@ -606,6 +692,77 @@ export class ZulipAccountService {
return Array.from(this.accountLinks.values()).filter(link => link.isActive);
}
/**
* 获取已有用户信息
*
* 功能描述:
* 获取Zulip服务器上已存在用户的详细信息
*
* @param email 用户邮箱
* @returns Promise<{success: boolean, userId?: number, userInfo?: any, error?: string}> 用户信息
* @private
*/
private async getExistingUserInfo(email: string): Promise<{
success: boolean;
userId?: number;
userInfo?: any;
error?: string;
}> {
this.logger.log('获取已有用户信息', {
operation: 'getExistingUserInfo',
email,
});
try {
if (!this.adminClient) {
throw new Error('管理员客户端未初始化');
}
// 获取所有用户列表
const usersResponse = await this.adminClient.users.retrieve();
if (usersResponse.result === 'success') {
const users = usersResponse.members || [];
const existingUser = users.find((user: any) => user.email === email);
if (existingUser) {
this.logger.log('找到已有用户信息', {
operation: 'getExistingUserInfo',
email,
userId: existingUser.user_id,
fullName: existingUser.full_name,
});
return {
success: true,
userId: existingUser.user_id,
userInfo: existingUser,
};
} else {
return {
success: false,
error: '用户不存在',
};
}
} else {
throw new Error(usersResponse.msg || '获取用户列表失败');
}
} catch (error) {
const err = error as Error;
this.logger.error('获取已有用户信息失败', {
operation: 'getExistingUserInfo',
email,
error: err.message,
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 检查用户是否已存在
*
@@ -661,7 +818,7 @@ export class ZulipAccountService {
private generateRandomPassword(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < 12; i++) {
for (let i = 0; i < DEFAULT_PASSWORD_LENGTH; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;

View File

@@ -4,10 +4,15 @@
* 功能描述:
* - 测试ZulipClientService的核心功能
* - 包含属性测试验证客户端生命周期管理
* - 验证消息发送和错误处理逻辑
*
* 最近修改:
* - 2026-01-10: 测试完善 - 添加特殊字符消息和错误处理测试用例 (修改者: moyin)
*
* @author angjustinl
* @version 1.0.0
* @version 1.0.1
* @since 2025-12-25
* @lastModified 2026-01-10
*/
import { Test, TestingModule } from '@nestjs/testing';
@@ -169,6 +174,46 @@ describe('ZulipClientService', () => {
expect(result.success).toBe(true);
expect(result.messageId).toBe(12345);
// 验证调用参数
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
type: 'stream',
to: 'test-stream',
subject: 'test-topic',
content: 'Hello World',
});
});
it('应该发送包含特殊字符的消息', async () => {
const specialMessage = '测试消息 🎮 with special chars: @#$%^&*()';
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 67890,
});
const result = await service.sendMessage(clientInstance, 'test-stream', 'test-topic', specialMessage);
expect(result.success).toBe(true);
expect(result.messageId).toBe(67890);
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
type: 'stream',
to: 'test-stream',
subject: 'test-topic',
content: specialMessage,
});
});
it('应该处理Zulip API错误', async () => {
mockZulipClient.messages.send.mockResolvedValue({
result: 'error',
msg: 'Stream does not exist',
});
const result = await service.sendMessage(clientInstance, 'nonexistent-stream', 'test-topic', 'Hello World');
expect(result.success).toBe(false);
expect(result.error).toBe('Stream does not exist');
});
it('应该在客户端无效时返回错误', async () => {

View File

@@ -28,13 +28,14 @@
* - AppLoggerService: 日志记录服务
*
* 最近修改:
* - 2026-01-10: 代码质量优化 - 简化错误处理逻辑移除冗余try-catch块 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 注释规范检查和修正 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范 (修改者: moyin)
*
* @author moyin
* @version 1.0.1
* @version 1.0.2
* @since 2025-12-25
* @lastModified 2026-01-07
* @lastModified 2026-01-10
*/
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
@@ -44,12 +45,12 @@ import {
ZulipClientInstance,
SendMessageResult,
RegisterQueueResult,
GetEventsResult,
} from './zulip_client.service';
import {
ACTIVE_CLIENT_THRESHOLD_MINUTES,
DEFAULT_IDLE_CLEANUP_MINUTES,
DEFAULT_EVENT_POLLING_INTERVAL_MS
DEFAULT_EVENT_POLLING_INTERVAL_MS,
MILLISECONDS_PER_MINUTE
} from '../zulip_core.constants';
/**
@@ -291,47 +292,31 @@ export class ZulipClientPoolService implements OnModuleDestroy {
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: '用户Zulip客户端不存在或无效',
};
}
// 如果已有队列,先注销
if (userInfo.clientInstance.queueId) {
await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
}
// 注册新队列
const result = await this.zulipClientService.registerQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注册完成', {
operation: 'registerEventQueue',
userId,
success: result.success,
queueId: result.queueId,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('注册用户事件队列失败', {
operation: 'registerEventQueue',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: err.message,
error: '用户Zulip客户端不存在或无效',
};
}
// 如果已有队列,先注销
if (userInfo.clientInstance.queueId) {
await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
}
// 直接调用底层服务注册新队列
const result = await this.zulipClientService.registerQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注册完成', {
operation: 'registerEventQueue',
userId,
success: result.success,
queueId: result.queueId,
timestamp: new Date().toISOString(),
});
return result;
}
/**
@@ -347,42 +332,29 @@ export class ZulipClientPoolService implements OnModuleDestroy {
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo) {
this.logger.log('用户客户端不存在,跳过注销', {
operation: 'deregisterEventQueue',
userId,
});
return true;
}
// 停止事件轮询
this.stopEventPolling(userId);
// 注销队列
const result = await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注销完成', {
const userInfo = this.clientPool.get(userId);
if (!userInfo) {
this.logger.log('用户客户端不存在,跳过注销', {
operation: 'deregisterEventQueue',
userId,
success: result,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('注销用户事件队列失败', {
operation: 'deregisterEventQueue',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return false;
return true;
}
// 停止事件轮询
this.stopEventPolling(userId);
// 直接调用底层服务注销队列
const result = await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注销完成', {
operation: 'deregisterEventQueue',
userId,
success: result,
timestamp: new Date().toISOString(),
});
return result;
}
/**
@@ -412,50 +384,33 @@ export class ZulipClientPoolService implements OnModuleDestroy {
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: '用户Zulip客户端不存在或无效',
};
}
const result = await this.zulipClientService.sendMessage(
userInfo.clientInstance,
stream,
topic,
content
);
this.logger.log('消息发送完成', {
operation: 'sendMessage',
userId,
stream,
topic,
success: result.success,
messageId: result.messageId,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('发送消息失败', {
operation: 'sendMessage',
userId,
stream,
topic,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: err.message,
error: '用户Zulip客户端不存在或无效',
};
}
// 直接调用底层服务,让底层处理错误和日志
const result = await this.zulipClientService.sendMessage(
userInfo.clientInstance,
stream,
topic,
content
);
this.logger.log('消息发送完成', {
operation: 'sendMessage',
userId,
stream,
topic,
success: result.success,
messageId: result.messageId,
timestamp: new Date().toISOString(),
});
return result;
}
/**
@@ -631,7 +586,7 @@ export class ZulipClientPoolService implements OnModuleDestroy {
*/
getPoolStats(): PoolStats {
const now = new Date();
const activeThreshold = new Date(now.getTime() - ZulipClientPoolService.ACTIVE_CLIENT_THRESHOLD_MINUTES * 60 * 1000);
const activeThreshold = new Date(now.getTime() - ACTIVE_CLIENT_THRESHOLD_MINUTES * MILLISECONDS_PER_MINUTE);
const clients = Array.from(this.clientPool.values());
const activeClients = clients.filter(
@@ -667,7 +622,7 @@ export class ZulipClientPoolService implements OnModuleDestroy {
});
const now = new Date();
const cutoffTime = new Date(now.getTime() - maxIdleMinutes * 60 * 1000);
const cutoffTime = new Date(now.getTime() - maxIdleMinutes * MILLISECONDS_PER_MINUTE);
const expiredUserIds: string[] = [];

View File

@@ -0,0 +1,463 @@
/**
* Zulip消息发送集成测试
*
* 功能描述:
* - 测试消息发送到真实Zulip服务器的完整流程
* - 验证HTTP请求、响应处理和错误场景
* - 包含网络异常和API错误的测试
*
* 注意这些测试需要真实的Zulip服务器配置
*
* 最近修改:
* - 2026-01-10: 测试新增 - 创建Zulip消息发送集成测试 (修改者: moyin)
*
* @author moyin
* @version 1.0.0
* @since 2026-01-10
* @lastModified 2026-01-10
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip_client.service';
import * as nock from 'nock';
describe('ZulipMessageIntegration', () => {
let service: ZulipClientService;
let mockZulipClient: any;
let clientInstance: ZulipClientInstance;
const testConfig: ZulipClientConfig = {
username: 'test-bot@example.com',
apiKey: 'test-api-key-12345',
realm: 'https://test-zulip.example.com',
};
beforeEach(async () => {
// 清理所有HTTP拦截
nock.cleanAll();
const module: TestingModule = await Test.createTestingModule({
providers: [ZulipClientService],
}).compile();
service = module.get<ZulipClientService>(ZulipClientService);
// 创建模拟的zulip-js客户端
mockZulipClient = {
config: testConfig,
users: {
me: {
getProfile: jest.fn(),
},
},
messages: {
send: jest.fn(),
},
queues: {
register: jest.fn(),
deregister: jest.fn(),
},
events: {
retrieve: jest.fn(),
},
};
// 模拟客户端实例
clientInstance = {
userId: 'test-user-123',
config: testConfig,
client: mockZulipClient,
lastEventId: -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
// Mock zulip-js模块加载
jest.spyOn(service as any, 'loadZulipModule').mockResolvedValue(() => mockZulipClient);
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('消息发送到Zulip服务器', () => {
it('应该成功发送消息到Zulip API', async () => {
// 模拟成功的API响应
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 12345,
msg: '',
});
const result = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'Hello from integration test!'
);
expect(result.success).toBe(true);
expect(result.messageId).toBe(12345);
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
type: 'stream',
to: 'test-stream',
subject: 'test-topic',
content: 'Hello from integration test!',
});
});
it('应该处理Zulip API错误响应', async () => {
// 模拟API错误响应
mockZulipClient.messages.send.mockResolvedValue({
result: 'error',
msg: 'Stream does not exist',
code: 'STREAM_NOT_FOUND',
});
const result = await service.sendMessage(
clientInstance,
'nonexistent-stream',
'test-topic',
'This should fail'
);
expect(result.success).toBe(false);
expect(result.error).toBe('Stream does not exist');
});
it('应该处理网络连接异常', async () => {
// 模拟网络异常
mockZulipClient.messages.send.mockRejectedValue(new Error('Network timeout'));
const result = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'This will timeout'
);
expect(result.success).toBe(false);
expect(result.error).toBe('Network timeout');
});
it('应该处理认证失败', async () => {
// 模拟认证失败
mockZulipClient.messages.send.mockResolvedValue({
result: 'error',
msg: 'Invalid API key',
code: 'BAD_REQUEST',
});
const result = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'Authentication test'
);
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid API key');
});
it('应该正确处理特殊字符和长消息', async () => {
const longMessage = 'A'.repeat(1000) + '特殊字符测试: 🎮🎯🚀 @#$%^&*()';
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 67890,
});
const result = await service.sendMessage(
clientInstance,
'test-stream',
'special-chars-topic',
longMessage
);
expect(result.success).toBe(true);
expect(result.messageId).toBe(67890);
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
type: 'stream',
to: 'test-stream',
subject: 'special-chars-topic',
content: longMessage,
});
});
it('应该更新客户端最后活动时间', async () => {
const initialTime = new Date('2026-01-01T00:00:00Z');
clientInstance.lastActivity = initialTime;
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 11111,
});
await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'Activity test'
);
expect(clientInstance.lastActivity.getTime()).toBeGreaterThan(initialTime.getTime());
});
});
describe('事件队列与Zulip服务器交互', () => {
it('应该成功注册事件队列', async () => {
mockZulipClient.queues.register.mockResolvedValue({
result: 'success',
queue_id: 'test-queue-123',
last_event_id: 42,
});
const result = await service.registerQueue(clientInstance, ['message', 'typing']);
expect(result.success).toBe(true);
expect(result.queueId).toBe('test-queue-123');
expect(result.lastEventId).toBe(42);
expect(clientInstance.queueId).toBe('test-queue-123');
expect(clientInstance.lastEventId).toBe(42);
});
it('应该处理队列注册失败', async () => {
mockZulipClient.queues.register.mockResolvedValue({
result: 'error',
msg: 'Rate limit exceeded',
});
const result = await service.registerQueue(clientInstance);
expect(result.success).toBe(false);
expect(result.error).toBe('Rate limit exceeded');
});
it('应该成功获取事件', async () => {
clientInstance.queueId = 'test-queue-123';
clientInstance.lastEventId = 10;
const mockEvents = [
{
id: 11,
type: 'message',
message: {
id: 98765,
sender_email: 'user@example.com',
content: 'Test message from Zulip',
stream_id: 1,
subject: 'Test Topic',
},
},
{
id: 12,
type: 'typing',
sender: { user_id: 123 },
},
];
mockZulipClient.events.retrieve.mockResolvedValue({
result: 'success',
events: mockEvents,
});
const result = await service.getEvents(clientInstance, true);
expect(result.success).toBe(true);
expect(result.events).toEqual(mockEvents);
expect(clientInstance.lastEventId).toBe(12); // 更新为最后一个事件的ID
});
it('应该处理空事件队列', async () => {
clientInstance.queueId = 'test-queue-123';
mockZulipClient.events.retrieve.mockResolvedValue({
result: 'success',
events: [],
});
const result = await service.getEvents(clientInstance, true);
expect(result.success).toBe(true);
expect(result.events).toEqual([]);
});
it('应该成功注销事件队列', async () => {
clientInstance.queueId = 'test-queue-123';
mockZulipClient.queues.deregister.mockResolvedValue({
result: 'success',
});
const result = await service.deregisterQueue(clientInstance);
expect(result).toBe(true);
expect(clientInstance.queueId).toBeUndefined();
expect(clientInstance.lastEventId).toBe(-1);
});
it('应该处理队列过期情况', async () => {
clientInstance.queueId = 'expired-queue';
// 模拟队列过期的JSON解析错误
mockZulipClient.queues.deregister.mockRejectedValue(
new Error('invalid json response body at https://zulip.example.com/api/v1/events reason: Unexpected token')
);
const result = await service.deregisterQueue(clientInstance);
expect(result).toBe(true); // 应该返回true因为队列已过期
expect(clientInstance.queueId).toBeUndefined();
expect(clientInstance.lastEventId).toBe(-1);
});
});
describe('API Key验证', () => {
it('应该成功验证有效的API Key', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'success',
email: 'test-bot@example.com',
full_name: 'Test Bot',
user_id: 123,
});
const isValid = await service.validateApiKey(clientInstance);
expect(isValid).toBe(true);
expect(clientInstance.isValid).toBe(true);
});
it('应该拒绝无效的API Key', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'error',
msg: 'Invalid API key',
});
const isValid = await service.validateApiKey(clientInstance);
expect(isValid).toBe(false);
expect(clientInstance.isValid).toBe(false);
});
it('应该处理API Key验证网络异常', async () => {
mockZulipClient.users.me.getProfile.mockRejectedValue(new Error('Connection refused'));
const isValid = await service.validateApiKey(clientInstance);
expect(isValid).toBe(false);
expect(clientInstance.isValid).toBe(false);
});
});
describe('错误恢复和重试机制', () => {
it('应该在临时网络错误后恢复', async () => {
// 第一次调用失败,第二次成功
mockZulipClient.messages.send
.mockRejectedValueOnce(new Error('Temporary network error'))
.mockResolvedValueOnce({
result: 'success',
id: 99999,
});
// 第一次调用应该失败
const firstResult = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'First attempt'
);
expect(firstResult.success).toBe(false);
// 第二次调用应该成功
const secondResult = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'Second attempt'
);
expect(secondResult.success).toBe(true);
expect(secondResult.messageId).toBe(99999);
});
it('应该处理服务器5xx错误', async () => {
mockZulipClient.messages.send.mockRejectedValue(new Error('Internal Server Error (500)'));
const result = await service.sendMessage(
clientInstance,
'test-stream',
'test-topic',
'Server error test'
);
expect(result.success).toBe(false);
expect(result.error).toBe('Internal Server Error (500)');
});
});
describe('性能和并发测试', () => {
it('应该处理并发消息发送', async () => {
// 模拟多个并发消息
const messagePromises = [];
for (let i = 0; i < 10; i++) {
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 1000 + i,
});
messagePromises.push(
service.sendMessage(
clientInstance,
'test-stream',
'concurrent-topic',
`Concurrent message ${i}`
)
);
}
const results = await Promise.all(messagePromises);
results.forEach((result, index) => {
expect(result.success).toBe(true);
expect(result.messageId).toBe(1000 + index);
});
});
it('应该在大量消息发送时保持性能', async () => {
const startTime = Date.now();
const messageCount = 100;
mockZulipClient.messages.send.mockImplementation(() =>
Promise.resolve({
result: 'success',
id: Math.floor(Math.random() * 100000),
})
);
const promises = Array.from({ length: messageCount }, (_, i) =>
service.sendMessage(
clientInstance,
'performance-stream',
'performance-topic',
`Performance test message ${i}`
)
);
const results = await Promise.all(promises);
const endTime = Date.now();
const duration = endTime - startTime;
// 验证所有消息都成功发送
results.forEach(result => {
expect(result.success).toBe(true);
});
// 性能检查100条消息应该在合理时间内完成这里设为5秒
expect(duration).toBeLessThan(5000);
console.log(`发送${messageCount}条消息耗时: ${duration}ms`);
}, 10000);
});
});

View File

@@ -12,13 +12,14 @@
* - 内部类型层:定义系统内部使用的数据类型
*
* 最近修改:
* - 2026-01-10: 代码质量优化 - 清理未使用的接口定义 (修改者: moyin)
* - 2026-01-08: 文件夹扁平化 - 从interfaces/子文件夹移动到上级目录 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
*
* @author moyin
* @version 1.0.2
* @version 1.0.3
* @since 2025-12-25
* @lastModified 2026-01-08
* @lastModified 2026-01-10
*/
/**
@@ -356,23 +357,6 @@ export namespace Internal {
* 服务请求接口
*/
export namespace ServiceRequests {
/**
* 玩家登录请求
*/
export interface PlayerLoginRequest {
token: string;
socketId: string;
}
/**
* 聊天消息请求
*/
export interface ChatMessageRequest {
socketId: string;
content: string;
scope: string;
}
/**
* 位置更新请求
*/
@@ -395,20 +379,6 @@ export namespace ServiceResponses {
success: boolean;
error?: string;
}
/**
* 登录响应
*/
export interface LoginResponse extends BaseResponse {
sessionId?: string;
}
/**
* 聊天消息响应
*/
export interface ChatMessageResponse extends BaseResponse {
messageId?: string;
}
}
/**

View File

@@ -48,4 +48,31 @@ export const TEST_WAIT_TIME_MS = 50; // 测试等待时间(毫秒)
// 错误率阈值
export const ERROR_RATE_THRESHOLD = 0.1; // 错误率阈值10%
export const MEMORY_THRESHOLD = 0.9; // 内存使用阈值90%
export const MEMORY_THRESHOLD = 0.9; // 内存使用阈值90%
// 时间转换常量
export const MILLISECONDS_PER_SECOND = 1000; // 毫秒转秒
export const SECONDS_PER_MINUTE = 60; // 秒转分钟
export const MILLISECONDS_PER_MINUTE = SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND; // 毫秒转分钟
// 密码生成常量
export const DEFAULT_PASSWORD_LENGTH = 12; // 默认密码长度
// 监控相关常量
export const DEFAULT_RECENT_ALERTS_LIMIT = 10; // 默认近期告警数量限制
export const MAX_RECENT_LOGS_LIMIT = 100; // 最大近期日志数量限制
// 重试相关常量
export const DEFAULT_RETRY_BASE_DELAY_MS = 1000; // 默认重试基础延迟(毫秒)
export const DEFAULT_RETRY_MAX_DELAY_MS = 30000; // 默认重试最大延迟(毫秒)
export const DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2; // 默认重试退避倍数
// HTTP状态码常量
export const HTTP_STATUS_UNAUTHORIZED = 401; // 未授权
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; // 请求过多
export const HTTP_STATUS_CLIENT_ERROR_MIN = 400; // 客户端错误最小值
export const HTTP_STATUS_CLIENT_ERROR_MAX = 500; // 客户端错误最大值
// 错误重试延迟常量
export const CONNECTION_ERROR_RETRY_DELAY_MS = 5000; // 连接错误重试延迟(毫秒)
export const RATE_LIMIT_ERROR_RETRY_DELAY_MS = 60000; // 限流错误重试延迟(毫秒)