forked from datawhale/whale-town-end
- Add ZulipAccountsEntity, repository, and module for persistent Zulip account storage - Create ZulipAccountService in core layer for managing Zulip account lifecycle - Integrate Zulip account creation into login flow via LoginService - Add comprehensive test suite for Zulip account creation during user registration - Create quick test script for validating registered user Zulip integration - Update UsersEntity to support Zulip account associations - Update auth module to include Zulip and ZulipAccounts dependencies - Fix WebSocket connection protocol from ws:// to wss:// in API documentation - Enhance LoginCoreService to coordinate Zulip account provisioning during authentication
708 lines
18 KiB
TypeScript
708 lines
18 KiB
TypeScript
/**
|
||
* Zulip账号管理核心服务
|
||
*
|
||
* 功能描述:
|
||
* - 自动创建Zulip用户账号
|
||
* - 生成API Key并安全存储
|
||
* - 处理账号创建失败场景
|
||
* - 管理用户账号与游戏账号的关联
|
||
*
|
||
* 主要方法:
|
||
* - createZulipAccount(): 创建新的Zulip用户账号
|
||
* - generateApiKey(): 为用户生成API Key
|
||
* - validateZulipAccount(): 验证Zulip账号有效性
|
||
* - linkGameAccount(): 关联游戏账号与Zulip账号
|
||
*
|
||
* 使用场景:
|
||
* - 用户注册时自动创建Zulip账号
|
||
* - API Key管理和更新
|
||
* - 账号关联和映射存储
|
||
*
|
||
* @author angjustinl
|
||
* @version 1.0.0
|
||
* @since 2025-01-05
|
||
*/
|
||
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ZulipClientConfig } from '../interfaces/zulip-core.interfaces';
|
||
|
||
/**
|
||
* Zulip账号创建请求接口
|
||
*/
|
||
export interface CreateZulipAccountRequest {
|
||
email: string;
|
||
fullName: string;
|
||
password?: string;
|
||
shortName?: string;
|
||
}
|
||
|
||
/**
|
||
* Zulip账号创建结果接口
|
||
*/
|
||
export interface CreateZulipAccountResult {
|
||
success: boolean;
|
||
userId?: number;
|
||
email?: string;
|
||
apiKey?: string;
|
||
error?: string;
|
||
errorCode?: string;
|
||
}
|
||
|
||
/**
|
||
* API Key生成结果接口
|
||
*/
|
||
export interface GenerateApiKeyResult {
|
||
success: boolean;
|
||
apiKey?: string;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 账号验证结果接口
|
||
*/
|
||
export interface ValidateAccountResult {
|
||
success: boolean;
|
||
isValid?: boolean;
|
||
userInfo?: any;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 账号关联信息接口
|
||
*/
|
||
export interface AccountLinkInfo {
|
||
gameUserId: string;
|
||
zulipUserId: number;
|
||
zulipEmail: string;
|
||
zulipApiKey: string;
|
||
createdAt: Date;
|
||
lastVerified?: Date;
|
||
isActive: boolean;
|
||
}
|
||
|
||
/**
|
||
* Zulip账号管理服务类
|
||
*
|
||
* 职责:
|
||
* - 处理Zulip用户账号的创建和管理
|
||
* - 管理API Key的生成和存储
|
||
* - 维护游戏账号与Zulip账号的关联关系
|
||
* - 提供账号验证和状态检查功能
|
||
*
|
||
* 主要方法:
|
||
* - createZulipAccount(): 创建新的Zulip用户账号
|
||
* - generateApiKey(): 为现有用户生成API Key
|
||
* - validateZulipAccount(): 验证Zulip账号有效性
|
||
* - linkGameAccount(): 建立游戏账号与Zulip账号的关联
|
||
* - unlinkGameAccount(): 解除账号关联
|
||
*
|
||
* 使用场景:
|
||
* - 用户注册流程中自动创建Zulip账号
|
||
* - API Key管理和更新
|
||
* - 账号状态监控和维护
|
||
* - 跨平台账号同步
|
||
*/
|
||
@Injectable()
|
||
export class ZulipAccountService {
|
||
private readonly logger = new Logger(ZulipAccountService.name);
|
||
private adminClient: any = null;
|
||
private readonly accountLinks = new Map<string, AccountLinkInfo>();
|
||
|
||
constructor() {
|
||
this.logger.log('ZulipAccountService初始化完成');
|
||
}
|
||
|
||
/**
|
||
* 初始化管理员客户端
|
||
*
|
||
* 功能描述:
|
||
* 使用管理员凭证初始化Zulip客户端,用于创建用户账号
|
||
*
|
||
* @param adminConfig 管理员配置
|
||
* @returns Promise<boolean> 是否初始化成功
|
||
*/
|
||
async initializeAdminClient(adminConfig: ZulipClientConfig): Promise<boolean> {
|
||
this.logger.log('初始化Zulip管理员客户端', {
|
||
operation: 'initializeAdminClient',
|
||
realm: adminConfig.realm,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 动态导入zulip-js
|
||
const zulipInit = await this.loadZulipModule();
|
||
|
||
// 创建管理员客户端
|
||
this.adminClient = await zulipInit({
|
||
username: adminConfig.username,
|
||
apiKey: adminConfig.apiKey,
|
||
realm: adminConfig.realm,
|
||
});
|
||
|
||
// 验证管理员权限
|
||
const profile = await this.adminClient.users.me.getProfile();
|
||
|
||
if (profile.result !== 'success') {
|
||
throw new Error(`管理员客户端验证失败: ${profile.msg || '未知错误'}`);
|
||
}
|
||
|
||
this.logger.log('管理员客户端初始化成功', {
|
||
operation: 'initializeAdminClient',
|
||
adminEmail: profile.email,
|
||
isAdmin: profile.is_admin,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return true;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.error('管理员客户端初始化失败', {
|
||
operation: 'initializeAdminClient',
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建Zulip用户账号
|
||
*
|
||
* 功能描述:
|
||
* 使用管理员权限在Zulip服务器上创建新的用户账号
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证管理员客户端是否已初始化
|
||
* 2. 检查邮箱是否已存在
|
||
* 3. 生成用户密码(如果未提供)
|
||
* 4. 调用Zulip API创建用户
|
||
* 5. 为新用户生成API Key
|
||
* 6. 返回创建结果
|
||
*
|
||
* @param request 账号创建请求
|
||
* @returns Promise<CreateZulipAccountResult> 创建结果
|
||
*/
|
||
async createZulipAccount(request: CreateZulipAccountRequest): Promise<CreateZulipAccountResult> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('开始创建Zulip账号', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
fullName: request.fullName,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 验证管理员客户端
|
||
if (!this.adminClient) {
|
||
throw new Error('管理员客户端未初始化');
|
||
}
|
||
|
||
// 2. 验证请求参数
|
||
if (!request.email || !request.email.trim()) {
|
||
throw new Error('邮箱地址不能为空');
|
||
}
|
||
|
||
if (!request.fullName || !request.fullName.trim()) {
|
||
throw new Error('用户全名不能为空');
|
||
}
|
||
|
||
// 3. 检查邮箱格式
|
||
if (!this.isValidEmail(request.email)) {
|
||
throw new Error('邮箱格式无效');
|
||
}
|
||
|
||
// 4. 检查用户是否已存在
|
||
const existingUser = await this.checkUserExists(request.email);
|
||
if (existingUser) {
|
||
this.logger.warn('用户已存在', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
});
|
||
return {
|
||
success: false,
|
||
error: '用户已存在',
|
||
errorCode: 'USER_ALREADY_EXISTS',
|
||
};
|
||
}
|
||
|
||
// 5. 生成密码(如果未提供)
|
||
const password = request.password || this.generateRandomPassword();
|
||
const shortName = request.shortName || this.generateShortName(request.email);
|
||
|
||
// 6. 创建用户参数
|
||
const createParams = {
|
||
email: request.email,
|
||
password: password,
|
||
full_name: request.fullName,
|
||
short_name: shortName,
|
||
};
|
||
|
||
// 7. 调用Zulip API创建用户
|
||
const createResponse = await this.adminClient.users.create(createParams);
|
||
|
||
if (createResponse.result !== 'success') {
|
||
this.logger.warn('Zulip用户创建失败', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
error: createResponse.msg,
|
||
});
|
||
return {
|
||
success: false,
|
||
error: createResponse.msg || '用户创建失败',
|
||
errorCode: 'ZULIP_CREATE_FAILED',
|
||
};
|
||
}
|
||
|
||
// 8. 为新用户生成API Key
|
||
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
|
||
|
||
if (!apiKeyResult.success) {
|
||
this.logger.warn('API Key生成失败,但用户已创建', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
error: apiKeyResult.error,
|
||
});
|
||
// 用户已创建,但API Key生成失败
|
||
return {
|
||
success: true,
|
||
userId: createResponse.user_id,
|
||
email: request.email,
|
||
error: `用户创建成功,但API Key生成失败: ${apiKeyResult.error}`,
|
||
errorCode: 'API_KEY_GENERATION_FAILED',
|
||
};
|
||
}
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.log('Zulip账号创建成功', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
userId: createResponse.user_id,
|
||
hasApiKey: !!apiKeyResult.apiKey,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
userId: createResponse.user_id,
|
||
email: request.email,
|
||
apiKey: apiKeyResult.apiKey,
|
||
};
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('创建Zulip账号失败', {
|
||
operation: 'createZulipAccount',
|
||
email: request.email,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
errorCode: 'ACCOUNT_CREATION_FAILED',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为用户生成API Key
|
||
*
|
||
* 功能描述:
|
||
* 使用用户凭证获取API Key
|
||
*
|
||
* @param email 用户邮箱
|
||
* @param password 用户密码
|
||
* @returns Promise<GenerateApiKeyResult> 生成结果
|
||
*/
|
||
async generateApiKeyForUser(email: string, password: string): Promise<GenerateApiKeyResult> {
|
||
this.logger.log('为用户生成API Key', {
|
||
operation: 'generateApiKeyForUser',
|
||
email,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 动态导入zulip-js
|
||
const zulipInit = await this.loadZulipModule();
|
||
|
||
// 使用用户凭证获取API Key
|
||
const userClient = await zulipInit({
|
||
username: email,
|
||
password: password,
|
||
realm: this.getRealmFromAdminClient(),
|
||
});
|
||
|
||
// 验证客户端并获取API Key
|
||
const profile = await userClient.users.me.getProfile();
|
||
|
||
if (profile.result !== 'success') {
|
||
throw new Error(`API Key获取失败: ${profile.msg || '未知错误'}`);
|
||
}
|
||
|
||
// 从客户端配置中提取API Key
|
||
const apiKey = userClient.config?.apiKey;
|
||
|
||
if (!apiKey) {
|
||
throw new Error('无法从客户端配置中获取API Key');
|
||
}
|
||
|
||
this.logger.log('API Key生成成功', {
|
||
operation: 'generateApiKeyForUser',
|
||
email,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
apiKey: apiKey,
|
||
};
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('API Key生成失败', {
|
||
operation: 'generateApiKeyForUser',
|
||
email,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证Zulip账号有效性
|
||
*
|
||
* 功能描述:
|
||
* 验证指定的Zulip账号是否存在且有效
|
||
*
|
||
* @param email 用户邮箱
|
||
* @param apiKey 用户API Key(可选)
|
||
* @returns Promise<ValidateAccountResult> 验证结果
|
||
*/
|
||
async validateZulipAccount(email: string, apiKey?: string): Promise<ValidateAccountResult> {
|
||
this.logger.log('验证Zulip账号', {
|
||
operation: 'validateZulipAccount',
|
||
email,
|
||
hasApiKey: !!apiKey,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
if (apiKey) {
|
||
// 使用API Key验证
|
||
const zulipInit = await this.loadZulipModule();
|
||
const userClient = await zulipInit({
|
||
username: email,
|
||
apiKey: apiKey,
|
||
realm: this.getRealmFromAdminClient(),
|
||
});
|
||
|
||
const profile = await userClient.users.me.getProfile();
|
||
|
||
if (profile.result === 'success') {
|
||
this.logger.log('账号验证成功(API Key)', {
|
||
operation: 'validateZulipAccount',
|
||
email,
|
||
userId: profile.user_id,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
isValid: true,
|
||
userInfo: profile,
|
||
};
|
||
} else {
|
||
return {
|
||
success: true,
|
||
isValid: false,
|
||
error: profile.msg || 'API Key验证失败',
|
||
};
|
||
}
|
||
} else {
|
||
// 仅检查用户是否存在
|
||
const userExists = await this.checkUserExists(email);
|
||
|
||
this.logger.log('账号存在性检查完成', {
|
||
operation: 'validateZulipAccount',
|
||
email,
|
||
exists: userExists,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
isValid: userExists,
|
||
};
|
||
}
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('账号验证失败', {
|
||
operation: 'validateZulipAccount',
|
||
email,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 关联游戏账号与Zulip账号
|
||
*
|
||
* 功能描述:
|
||
* 建立游戏用户ID与Zulip账号的映射关系
|
||
*
|
||
* @param gameUserId 游戏用户ID
|
||
* @param zulipUserId Zulip用户ID
|
||
* @param zulipEmail Zulip邮箱
|
||
* @param zulipApiKey Zulip API Key
|
||
* @returns Promise<boolean> 是否关联成功
|
||
*/
|
||
async linkGameAccount(
|
||
gameUserId: string,
|
||
zulipUserId: number,
|
||
zulipEmail: string,
|
||
zulipApiKey: string,
|
||
): Promise<boolean> {
|
||
this.logger.log('关联游戏账号与Zulip账号', {
|
||
operation: 'linkGameAccount',
|
||
gameUserId,
|
||
zulipUserId,
|
||
zulipEmail,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 验证参数
|
||
if (!gameUserId || !zulipUserId || !zulipEmail || !zulipApiKey) {
|
||
throw new Error('关联参数不完整');
|
||
}
|
||
|
||
// 创建关联信息
|
||
const linkInfo: AccountLinkInfo = {
|
||
gameUserId,
|
||
zulipUserId,
|
||
zulipEmail,
|
||
zulipApiKey,
|
||
createdAt: new Date(),
|
||
isActive: true,
|
||
};
|
||
|
||
// 存储关联信息(实际项目中应存储到数据库)
|
||
this.accountLinks.set(gameUserId, linkInfo);
|
||
|
||
this.logger.log('账号关联成功', {
|
||
operation: 'linkGameAccount',
|
||
gameUserId,
|
||
zulipUserId,
|
||
zulipEmail,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return true;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('账号关联失败', {
|
||
operation: 'linkGameAccount',
|
||
gameUserId,
|
||
zulipUserId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解除游戏账号与Zulip账号的关联
|
||
*
|
||
* @param gameUserId 游戏用户ID
|
||
* @returns Promise<boolean> 是否解除成功
|
||
*/
|
||
async unlinkGameAccount(gameUserId: string): Promise<boolean> {
|
||
this.logger.log('解除账号关联', {
|
||
operation: 'unlinkGameAccount',
|
||
gameUserId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
const linkInfo = this.accountLinks.get(gameUserId);
|
||
|
||
if (linkInfo) {
|
||
linkInfo.isActive = false;
|
||
this.accountLinks.delete(gameUserId);
|
||
|
||
this.logger.log('账号关联解除成功', {
|
||
operation: 'unlinkGameAccount',
|
||
gameUserId,
|
||
zulipEmail: linkInfo.zulipEmail,
|
||
});
|
||
}
|
||
|
||
return true;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
|
||
this.logger.error('解除账号关联失败', {
|
||
operation: 'unlinkGameAccount',
|
||
gameUserId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取游戏账号的Zulip关联信息
|
||
*
|
||
* @param gameUserId 游戏用户ID
|
||
* @returns AccountLinkInfo | null 关联信息
|
||
*/
|
||
getAccountLink(gameUserId: string): AccountLinkInfo | null {
|
||
return this.accountLinks.get(gameUserId) || null;
|
||
}
|
||
|
||
/**
|
||
* 获取所有账号关联信息
|
||
*
|
||
* @returns AccountLinkInfo[] 所有关联信息
|
||
*/
|
||
getAllAccountLinks(): AccountLinkInfo[] {
|
||
return Array.from(this.accountLinks.values()).filter(link => link.isActive);
|
||
}
|
||
|
||
/**
|
||
* 检查用户是否已存在
|
||
*
|
||
* @param email 用户邮箱
|
||
* @returns Promise<boolean> 用户是否存在
|
||
* @private
|
||
*/
|
||
private async checkUserExists(email: string): Promise<boolean> {
|
||
try {
|
||
if (!this.adminClient) {
|
||
return false;
|
||
}
|
||
|
||
// 获取所有用户列表
|
||
const usersResponse = await this.adminClient.users.retrieve();
|
||
|
||
if (usersResponse.result === 'success') {
|
||
const users = usersResponse.members || [];
|
||
return users.some((user: any) => user.email === email);
|
||
}
|
||
|
||
return false;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.warn('检查用户存在性失败', {
|
||
operation: 'checkUserExists',
|
||
email,
|
||
error: err.message,
|
||
});
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证邮箱格式
|
||
*
|
||
* @param email 邮箱地址
|
||
* @returns boolean 是否为有效邮箱
|
||
* @private
|
||
*/
|
||
private isValidEmail(email: string): boolean {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return emailRegex.test(email);
|
||
}
|
||
|
||
/**
|
||
* 生成随机密码
|
||
*
|
||
* @returns string 随机密码
|
||
* @private
|
||
*/
|
||
private generateRandomPassword(): string {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||
let password = '';
|
||
for (let i = 0; i < 12; i++) {
|
||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
}
|
||
return password;
|
||
}
|
||
|
||
/**
|
||
* 从邮箱生成短名称
|
||
*
|
||
* @param email 邮箱地址
|
||
* @returns string 短名称
|
||
* @private
|
||
*/
|
||
private generateShortName(email: string): string {
|
||
const localPart = email.split('@')[0];
|
||
// 移除特殊字符,只保留字母数字和下划线
|
||
return localPart.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* 从管理员客户端获取Realm
|
||
*
|
||
* @returns string Realm URL
|
||
* @private
|
||
*/
|
||
private getRealmFromAdminClient(): string {
|
||
if (!this.adminClient || !this.adminClient.config) {
|
||
throw new Error('管理员客户端未初始化或配置缺失');
|
||
}
|
||
return this.adminClient.config.realm;
|
||
}
|
||
|
||
/**
|
||
* 动态加载zulip-js模块
|
||
*
|
||
* @returns Promise<any> zulip-js初始化函数
|
||
* @private
|
||
*/
|
||
private async loadZulipModule(): Promise<any> {
|
||
try {
|
||
// 使用动态导入加载zulip-js
|
||
const zulipModule = await import('zulip-js');
|
||
return zulipModule.default || zulipModule;
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.error('加载zulip-js模块失败', {
|
||
operation: 'loadZulipModule',
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
throw new Error(`加载zulip-js模块失败: ${err.message}`);
|
||
}
|
||
}
|
||
} |