forked from datawhale/whale-town-end
Compare commits
23 Commits
faf93a30e1
...
zulip_dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b380e4bb9 | |||
|
|
8f9a6e7f9d | ||
| 07d9c736fa | |||
| 5e1afc2875 | |||
|
|
3733717d1f | ||
|
|
470b0b8dbf | ||
| c2ecb3c1a7 | |||
|
|
6ad8d80449 | ||
| fcb81f80d9 | |||
| 065d3f2fc6 | |||
|
|
f335b72f6d | ||
|
|
3bf1b6f474 | ||
|
|
38f9f81b6c | ||
|
|
4818279fac | ||
|
|
270e7e5bd2 | ||
|
|
e282c9dd16 | ||
|
|
d8b7143f60 | ||
|
|
6002f53cbc | ||
| 9cb172d645 | |||
|
|
70c020a97c | ||
| 67ade48ad7 | |||
|
|
29b8b05a2a | ||
| bbf3476d75 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ coverage/
|
||||
|
||||
# Redis数据文件(本地开发用)
|
||||
redis-data/
|
||||
|
||||
.kiro/
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,31 +0,0 @@
|
||||
# 使用官方 Node.js 镜像
|
||||
FROM node:lts-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置构建参数
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
|
||||
# 设置 npm 和 pnpm 镜像源
|
||||
RUN npm config set registry ${NPM_REGISTRY} && \
|
||||
npm install -g pnpm && \
|
||||
pnpm config set registry ${NPM_REGISTRY}
|
||||
|
||||
# 复制 package.json
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN pnpm run build
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动应用
|
||||
CMD ["pnpm", "run", "start:prod"]
|
||||
@@ -1,163 +0,0 @@
|
||||
# Zulip模块重构总结
|
||||
|
||||
## 重构完成情况
|
||||
|
||||
✅ **重构已完成** - 项目编译成功,架构符合分层设计原则
|
||||
|
||||
## 重构内容
|
||||
|
||||
### 1. 架构分层重构
|
||||
|
||||
#### 移动到核心服务层 (`src/core/zulip/`)
|
||||
以下技术实现相关的服务已移动到核心服务层:
|
||||
|
||||
- `zulip_client.service.ts` - Zulip REST API封装
|
||||
- `zulip_client_pool.service.ts` - 客户端连接池管理
|
||||
- `config_manager.service.ts` - 配置文件管理和热重载
|
||||
- `api_key_security.service.ts` - API Key安全存储
|
||||
- `error_handler.service.ts` - 错误处理和重试机制
|
||||
- `monitoring.service.ts` - 系统监控和健康检查
|
||||
- `stream_initializer.service.ts` - Stream初始化服务
|
||||
|
||||
#### 保留在业务逻辑层 (`src/business/zulip/`)
|
||||
以下业务逻辑相关的服务保留在业务层:
|
||||
|
||||
- `zulip.service.ts` - 主要业务协调服务
|
||||
- `zulip_websocket.gateway.ts` - WebSocket业务网关
|
||||
- `session_manager.service.ts` - 游戏会话业务逻辑
|
||||
- `message_filter.service.ts` - 消息过滤业务规则
|
||||
- `zulip_event_processor.service.ts` - 事件处理业务逻辑
|
||||
- `session_cleanup.service.ts` - 会话清理业务逻辑
|
||||
|
||||
### 2. 依赖注入重构
|
||||
|
||||
#### 创建接口抽象
|
||||
- 新增 `src/core/zulip/interfaces/zulip-core.interfaces.ts`
|
||||
- 定义核心服务接口:`IZulipClientService`、`IZulipClientPoolService`、`IZulipConfigService`
|
||||
|
||||
#### 更新依赖注入
|
||||
业务层服务现在通过接口依赖核心服务:
|
||||
|
||||
```typescript
|
||||
// 旧方式 - 直接依赖具体实现
|
||||
constructor(
|
||||
private readonly zulipClientPool: ZulipClientPoolService,
|
||||
) {}
|
||||
|
||||
// 新方式 - 通过接口依赖
|
||||
constructor(
|
||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||||
private readonly zulipClientPool: IZulipClientPoolService,
|
||||
) {}
|
||||
```
|
||||
|
||||
### 3. 模块结构重构
|
||||
|
||||
#### 核心服务模块
|
||||
- 新增 `ZulipCoreModule` - 提供所有技术实现服务
|
||||
- 通过依赖注入标识符导出服务
|
||||
|
||||
#### 业务逻辑模块
|
||||
- 更新 `ZulipModule` - 导入核心模块,专注业务逻辑
|
||||
- 移除技术实现相关的服务提供者
|
||||
|
||||
### 4. 文件移动记录
|
||||
|
||||
#### 移动到核心层的文件
|
||||
```
|
||||
src/business/zulip/services/ → src/core/zulip/services/
|
||||
├── zulip_client.service.ts
|
||||
├── zulip_client_pool.service.ts
|
||||
├── config_manager.service.ts
|
||||
├── api_key_security.service.ts
|
||||
├── error_handler.service.ts
|
||||
├── monitoring.service.ts
|
||||
├── stream_initializer.service.ts
|
||||
└── 对应的 .spec.ts 测试文件
|
||||
|
||||
src/business/zulip/ → src/core/zulip/
|
||||
├── interfaces/
|
||||
├── config/
|
||||
└── types/
|
||||
```
|
||||
|
||||
## 架构优势
|
||||
|
||||
### 1. 符合分层架构原则
|
||||
- **业务层**:只关注游戏相关的业务逻辑和规则
|
||||
- **核心层**:只处理技术实现和第三方API调用
|
||||
|
||||
### 2. 依赖倒置
|
||||
- 业务层依赖接口,不依赖具体实现
|
||||
- 核心层提供接口实现
|
||||
- 便于测试和替换实现
|
||||
|
||||
### 3. 单一职责
|
||||
- 每个服务职责明确
|
||||
- 业务逻辑与技术实现分离
|
||||
- 代码更易维护和理解
|
||||
|
||||
### 4. 可测试性
|
||||
- 业务逻辑可以独立测试
|
||||
- 通过Mock接口进行单元测试
|
||||
- 技术实现可以独立验证
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 已完成
|
||||
- [x] 文件移动和重新组织
|
||||
- [x] 接口定义和抽象
|
||||
- [x] 依赖注入重构
|
||||
- [x] 模块结构调整
|
||||
- [x] 编译通过验证
|
||||
- [x] 测试文件的依赖注入配置更新
|
||||
- [x] 所有测试用例通过验证
|
||||
|
||||
### ✅ 测试修复完成
|
||||
- [x] `zulip_event_processor.service.spec.ts` - 更新依赖注入配置
|
||||
- [x] `message_filter.service.spec.ts` - 已通过测试
|
||||
- [x] `session_manager.service.spec.ts` - 已通过测试
|
||||
- [x] 核心服务测试文件导入路径修复
|
||||
- [x] 所有Zulip相关测试通过
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 业务层开发
|
||||
```typescript
|
||||
// 在业务服务中使用核心服务
|
||||
@Injectable()
|
||||
export class MyBusinessService {
|
||||
constructor(
|
||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||||
private readonly zulipClientPool: IZulipClientPoolService,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 测试配置
|
||||
```typescript
|
||||
// 测试中Mock核心服务
|
||||
const mockZulipClientPool: IZulipClientPoolService = {
|
||||
sendMessage: jest.fn().mockResolvedValue({ success: true }),
|
||||
// ...
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
MyBusinessService,
|
||||
{ provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockZulipClientPool },
|
||||
],
|
||||
}).compile();
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
重构成功实现了以下目标:
|
||||
|
||||
1. **架构合规**:符合项目的分层架构设计原则
|
||||
2. **职责分离**:业务逻辑与技术实现清晰分离
|
||||
3. **依赖解耦**:通过接口实现依赖倒置
|
||||
4. **可维护性**:代码结构更清晰,易于维护和扩展
|
||||
5. **可测试性**:业务逻辑可以独立测试
|
||||
|
||||
项目现在具有更好的架构设计,为后续开发和维护奠定了良好基础。
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 部署脚本模板 - 用于 Gitea Webhook 自动部署
|
||||
# 复制此文件为 deploy.sh 并根据服务器环境修改配置
|
||||
set -e
|
||||
|
||||
echo "开始部署 Pixel Game Server..."
|
||||
|
||||
# 项目路径(根据你的服务器实际路径修改)
|
||||
PROJECT_PATH="/var/www/pixel-game-server"
|
||||
BACKUP_PATH="/var/backups/pixel-game-server"
|
||||
|
||||
# 创建备份
|
||||
echo "创建备份..."
|
||||
mkdir -p $BACKUP_PATH
|
||||
cp -r $PROJECT_PATH $BACKUP_PATH/backup-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# 进入项目目录
|
||||
cd $PROJECT_PATH
|
||||
|
||||
# 拉取最新代码
|
||||
echo "拉取最新代码..."
|
||||
git pull origin main
|
||||
|
||||
# 安装/更新依赖
|
||||
echo "安装依赖..."
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 构建项目
|
||||
echo "构建项目..."
|
||||
pnpm run build
|
||||
|
||||
# 重启服务
|
||||
echo "重启服务..."
|
||||
if command -v pm2 &> /dev/null; then
|
||||
# 使用 PM2
|
||||
pm2 restart pixel-game-server || pm2 start dist/main.js --name pixel-game-server
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
# 使用 Docker Compose
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
else
|
||||
# 使用 systemd
|
||||
sudo systemctl restart pixel-game-server
|
||||
fi
|
||||
|
||||
echo "部署完成!"
|
||||
|
||||
# 清理旧备份(保留最近5个)
|
||||
find $BACKUP_PATH -maxdepth 1 -type d -name "backup-*" | sort -r | tail -n +6 | xargs rm -rf
|
||||
|
||||
echo "服务状态检查..."
|
||||
sleep 5
|
||||
curl -f http://localhost:3000/health || echo "警告:服务健康检查失败"
|
||||
@@ -1,36 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=mysql
|
||||
- DB_PORT=3306
|
||||
- DB_USERNAME=pixel_game
|
||||
- DB_PASSWORD=your_password
|
||||
- DB_NAME=pixel_game_db
|
||||
depends_on:
|
||||
- mysql
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=root_password
|
||||
- MYSQL_DATABASE=pixel_game_db
|
||||
- MYSQL_USER=pixel_game
|
||||
- MYSQL_PASSWORD=your_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
@@ -1,257 +0,0 @@
|
||||
# API 状态码说明
|
||||
|
||||
## 📊 概述
|
||||
|
||||
本文档说明了项目中使用的 HTTP 状态码,特别是针对邮件发送功能的特殊状态码处理。
|
||||
|
||||
## 🔢 标准状态码
|
||||
|
||||
| 状态码 | 含义 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 200 | OK | 请求成功 |
|
||||
| 201 | Created | 资源创建成功(如用户注册) |
|
||||
| 400 | Bad Request | 请求参数错误 |
|
||||
| 401 | Unauthorized | 未授权(如密码错误) |
|
||||
| 403 | Forbidden | 权限不足 |
|
||||
| 404 | Not Found | 资源不存在 |
|
||||
| 409 | Conflict | 资源冲突(如用户名已存在) |
|
||||
| 429 | Too Many Requests | 请求频率过高 |
|
||||
| 500 | Internal Server Error | 服务器内部错误 |
|
||||
|
||||
## 🎯 特殊状态码
|
||||
|
||||
### 206 Partial Content - 测试模式
|
||||
|
||||
**使用场景:** 邮件发送功能在测试模式下使用
|
||||
|
||||
**含义:** 请求部分成功,但未完全达到预期效果
|
||||
|
||||
**具体应用:**
|
||||
- 验证码已生成,但邮件未真实发送
|
||||
- 功能正常工作,但处于测试/开发模式
|
||||
- 用户可以获得验证码进行测试,但需要知道这不是真实发送
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": {
|
||||
"verification_code": "123456",
|
||||
"is_test_mode": true
|
||||
},
|
||||
"message": "⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。",
|
||||
"error_code": "TEST_MODE_ONLY"
|
||||
}
|
||||
```
|
||||
|
||||
## 📧 邮件发送接口状态码
|
||||
|
||||
### 发送邮箱验证码 - POST /auth/send-email-verification
|
||||
|
||||
| 状态码 | 场景 | 响应 |
|
||||
|--------|------|------|
|
||||
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收邮件" }` |
|
||||
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
|
||||
| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` |
|
||||
| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` |
|
||||
|
||||
### 发送密码重置验证码 - POST /auth/forgot-password
|
||||
|
||||
| 状态码 | 场景 | 响应 |
|
||||
|--------|------|------|
|
||||
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收" }` |
|
||||
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
|
||||
| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` |
|
||||
| 404 | 用户不存在 | `{ "success": false, "message": "用户不存在" }` |
|
||||
|
||||
### 重新发送邮箱验证码 - POST /auth/resend-email-verification
|
||||
|
||||
| 状态码 | 场景 | 响应 |
|
||||
|--------|------|------|
|
||||
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已重新发送,请查收邮件" }` |
|
||||
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
|
||||
| 400 | 邮箱已验证或用户不存在 | `{ "success": false, "message": "邮箱已验证,无需重复验证" }` |
|
||||
| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` |
|
||||
|
||||
## 🔄 模式切换
|
||||
|
||||
### 测试模式 → 真实发送模式
|
||||
|
||||
**配置前(测试模式):**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/send-email-verification \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com"}'
|
||||
|
||||
# 响应:206 Partial Content
|
||||
{
|
||||
"success": false,
|
||||
"data": {
|
||||
"verification_code": "123456",
|
||||
"is_test_mode": true
|
||||
},
|
||||
"message": "⚠️ 测试模式:验证码已生成但未真实发送...",
|
||||
"error_code": "TEST_MODE_ONLY"
|
||||
}
|
||||
```
|
||||
|
||||
**配置后(真实发送模式):**
|
||||
```bash
|
||||
# 同样的请求
|
||||
curl -X POST http://localhost:3000/auth/send-email-verification \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com"}'
|
||||
|
||||
# 响应:200 OK
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"is_test_mode": false
|
||||
},
|
||||
"message": "验证码已发送,请查收邮件"
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 前端处理建议
|
||||
|
||||
### JavaScript 示例
|
||||
|
||||
```javascript
|
||||
async function sendEmailVerification(email) {
|
||||
try {
|
||||
const response = await fetch('/auth/send-email-verification', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
// 真实发送成功
|
||||
showSuccess('验证码已发送,请查收邮件');
|
||||
} else if (response.status === 206) {
|
||||
// 测试模式
|
||||
showWarning(`测试模式:验证码是 ${data.data.verification_code}`);
|
||||
showInfo('请配置邮件服务以启用真实发送');
|
||||
} else {
|
||||
// 其他错误
|
||||
showError(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('网络错误,请稍后重试');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React 示例
|
||||
|
||||
```jsx
|
||||
const handleSendVerification = async (email) => {
|
||||
try {
|
||||
const response = await fetch('/auth/send-email-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
setMessage({ type: 'success', text: '验证码已发送,请查收邮件' });
|
||||
break;
|
||||
case 206:
|
||||
setMessage({
|
||||
type: 'warning',
|
||||
text: `测试模式:验证码是 ${data.data.verification_code}`
|
||||
});
|
||||
setShowConfigTip(true);
|
||||
break;
|
||||
case 400:
|
||||
setMessage({ type: 'error', text: data.message });
|
||||
break;
|
||||
case 429:
|
||||
setMessage({ type: 'error', text: '发送频率过高,请稍后重试' });
|
||||
break;
|
||||
default:
|
||||
setMessage({ type: 'error', text: '发送失败,请稍后重试' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '网络错误,请稍后重试' });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 🎨 UI 展示建议
|
||||
|
||||
### 测试模式提示
|
||||
|
||||
```html
|
||||
<!-- 成功状态 (200) -->
|
||||
<div class="alert alert-success">
|
||||
✅ 验证码已发送,请查收邮件
|
||||
</div>
|
||||
|
||||
<!-- 测试模式 (206) -->
|
||||
<div class="alert alert-warning">
|
||||
⚠️ 测试模式:验证码是 123456
|
||||
<br>
|
||||
<small>请配置邮件服务以启用真实发送</small>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 (400+) -->
|
||||
<div class="alert alert-danger">
|
||||
❌ 发送失败:邮箱格式错误
|
||||
</div>
|
||||
```
|
||||
|
||||
## 📝 开发建议
|
||||
|
||||
### 1. 状态码检查
|
||||
|
||||
```javascript
|
||||
// 推荐:明确检查状态码
|
||||
if (response.status === 206) {
|
||||
// 处理测试模式
|
||||
} else if (response.status === 200) {
|
||||
// 处理真实发送
|
||||
}
|
||||
|
||||
// 不推荐:只检查 success 字段
|
||||
if (data.success) {
|
||||
// 可能遗漏测试模式的情况
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
```javascript
|
||||
// 推荐:根据 error_code 进行精确处理
|
||||
switch (data.error_code) {
|
||||
case 'TEST_MODE_ONLY':
|
||||
handleTestMode(data);
|
||||
break;
|
||||
case 'SEND_CODE_FAILED':
|
||||
handleSendFailure(data);
|
||||
break;
|
||||
default:
|
||||
handleGenericError(data);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 用户体验
|
||||
|
||||
- **测试模式**:清晰提示用户当前处于测试模式
|
||||
- **配置引导**:提供配置邮件服务的链接或说明
|
||||
- **验证码显示**:在测试模式下直接显示验证码
|
||||
- **状态区分**:用不同的颜色和图标区分不同状态
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [邮件服务配置指南](./EMAIL_CONFIGURATION.md)
|
||||
- [快速启动指南](./QUICK_START.md)
|
||||
- [API 文档](./api/README.md)
|
||||
@@ -25,6 +25,29 @@ Whale Town 采用**业务功能模块化架构**,将代码按业务功能而
|
||||
- **模块化设计** - 每个模块独立完整,可单独测试和部署
|
||||
- **配置驱动** - 通过环境变量控制运行模式和行为
|
||||
|
||||
### 🛠️ 技术栈
|
||||
|
||||
#### 后端技术栈
|
||||
- **框架**: NestJS 11.x (基于Express)
|
||||
- **语言**: TypeScript 5.x
|
||||
- **数据库**: MySQL + TypeORM (生产) / 内存数据库 (开发)
|
||||
- **缓存**: Redis + IORedis (生产) / 文件存储 (开发)
|
||||
- **认证**: JWT + bcrypt
|
||||
- **验证**: class-validator + class-transformer
|
||||
- **文档**: Swagger/OpenAPI
|
||||
- **测试**: Jest + Supertest
|
||||
- **日志**: Pino + nestjs-pino
|
||||
- **WebSocket**: Socket.IO
|
||||
- **邮件**: Nodemailer
|
||||
- **集成**: Zulip API
|
||||
|
||||
#### 前端技术栈
|
||||
- **框架**: React 18.x
|
||||
- **构建工具**: Vite 7.x
|
||||
- **UI库**: Ant Design 5.x
|
||||
- **路由**: React Router DOM 6.x
|
||||
- **语言**: TypeScript 5.x
|
||||
|
||||
### 📊 整体架构图
|
||||
|
||||
```
|
||||
@@ -40,11 +63,11 @@ Whale Town 采用**业务功能模块化架构**,将代码按业务功能而
|
||||
│ 🎯 业务功能模块层 │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🔐 用户认证 │ │ 👥 用户管理 │ │ 🛡️ 管理员 │ │
|
||||
│ │ (auth) │ │ (user-mgmt) │ │ (admin) │ │
|
||||
│ │ (auth) │ │ (user_mgmt) │ │ (admin) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🔒 安全防护 │ │ 💬 Zulip集成 │ │ 🔗 共享组件 │ │
|
||||
│ │ (security) │ │ (zulip) │ │ (shared) │ │
|
||||
│ │ 💬 Zulip集成 │ │ 🔗 共享组件 │ │ │ │
|
||||
│ │ (zulip) │ │ (shared) │ │ │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
⬇️
|
||||
@@ -52,11 +75,11 @@ Whale Town 采用**业务功能模块化架构**,将代码按业务功能而
|
||||
│ ⚙️ 核心技术服务层 │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🔑 登录核心 │ │ 👑 管理员核心 │ │ 💬 Zulip核心 │ │
|
||||
│ │ (login_core) │ │ (admin_core) │ │ (zulip) │ │
|
||||
│ │ (auth_core) │ │ (admin_core) │ │ (zulip) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🛠️ 工具服务 │ │ 📧 邮件服务 │ │ 📝 日志服务 │ │
|
||||
│ │ (utils) │ │ (email) │ │ (logger) │ │
|
||||
│ │ 🛡️ 安全核心 │ │ 🛠️ 工具服务 │ │ 📧 邮件服务 │ │
|
||||
│ │ (security_core)│ │ (utils) │ │ (email) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
⬇️
|
||||
@@ -80,56 +103,50 @@ Whale Town 采用**业务功能模块化架构**,将代码按业务功能而
|
||||
```
|
||||
src/business/
|
||||
├── 📂 auth/ # 🔐 用户认证模块
|
||||
│ ├── 📄 auth.controller.ts # HTTP接口控制器
|
||||
│ ├── 📄 auth.service.ts # 业务逻辑服务
|
||||
│ ├── 📄 auth.module.ts # 模块定义
|
||||
│ ├── 📂 controllers/ # 控制器
|
||||
│ │ └── 📄 login.controller.ts # 登录接口控制器
|
||||
│ ├── 📂 services/ # 业务服务
|
||||
│ │ ├── 📄 login.service.ts # 登录业务逻辑
|
||||
│ │ └── 📄 login.service.spec.ts # 登录服务测试
|
||||
│ ├── 📂 dto/ # 数据传输对象
|
||||
│ │ ├── 📄 login.dto.ts # 登录请求DTO
|
||||
│ │ ├── 📄 register.dto.ts # 注册请求DTO
|
||||
│ │ └── 📄 reset-password.dto.ts # 重置密码DTO
|
||||
│ └── 📂 __tests__/ # 单元测试
|
||||
│ └── 📄 auth.service.spec.ts
|
||||
│ │ └── 📄 login_response.dto.ts # 登录响应DTO
|
||||
│ └── 📂 guards/ # 权限守卫(预留)
|
||||
│
|
||||
├── 📂 user-mgmt/ # 👥 用户管理模块
|
||||
│ ├── 📄 user-mgmt.controller.ts # 用户管理接口
|
||||
│ ├── 📄 user-mgmt.service.ts # 用户状态管理逻辑
|
||||
│ ├── 📄 user-mgmt.module.ts # 模块定义
|
||||
│ ├── 📂 controllers/ # 控制器
|
||||
│ │ └── 📄 user-status.controller.ts # 用户状态管理接口
|
||||
│ ├── 📂 services/ # 业务服务
|
||||
│ │ └── 📄 user-management.service.ts # 用户管理逻辑
|
||||
│ ├── 📂 dto/ # 数据传输对象
|
||||
│ │ ├── 📄 update-status.dto.ts # 状态更新DTO
|
||||
│ │ └── 📄 batch-status.dto.ts # 批量操作DTO
|
||||
│ └── 📂 enums/ # 枚举定义
|
||||
│ └── 📄 user-status.enum.ts # 用户状态枚举
|
||||
│ │ ├── 📄 user-status.dto.ts # 用户状态DTO
|
||||
│ │ └── 📄 user-status-response.dto.ts # 状态响应DTO
|
||||
│ ├── 📂 enums/ # 枚举定义
|
||||
│ │ └── 📄 user-status.enum.ts # 用户状态枚举
|
||||
│ └── 📂 tests/ # 测试文件(预留)
|
||||
│
|
||||
├── 📂 admin/ # 🛡️ 管理员模块
|
||||
│ ├── 📄 admin.controller.ts # 管理员接口
|
||||
│ ├── 📄 admin.service.ts # 管理员业务逻辑
|
||||
│ ├── 📄 admin.module.ts # 模块定义
|
||||
│ ├── 📄 admin.service.spec.ts # 管理员服务测试
|
||||
│ ├── 📂 dto/ # 数据传输对象
|
||||
│ └── 📂 guards/ # 权限守卫
|
||||
│ └── 📄 admin.guard.ts # 管理员权限验证
|
||||
│
|
||||
├── 📂 security/ # 🔒 安全防护模块
|
||||
│ ├── 📄 security.module.ts # 安全模块定义
|
||||
│ ├── 📂 guards/ # 安全守卫
|
||||
│ │ ├── 📄 throttle.guard.ts # 频率限制守卫
|
||||
│ │ ├── 📄 maintenance.guard.ts # 维护模式守卫
|
||||
│ │ └── 📄 content-type.guard.ts # 内容类型守卫
|
||||
│ └── 📂 interceptors/ # 拦截器
|
||||
│ └── 📄 timeout.interceptor.ts # 超时拦截器
|
||||
│
|
||||
├── 📂 zulip/ # 💬 Zulip集成模块
|
||||
│ ├── 📄 zulip.service.ts # Zulip业务服务
|
||||
│ ├── 📄 zulip_websocket.gateway.ts # WebSocket网关
|
||||
│ ├── 📄 zulip.module.ts # 模块定义
|
||||
│ ├── 📂 interfaces/ # 接口定义
|
||||
│ └── 📂 services/ # 子服务
|
||||
│ ├── 📄 message_filter.service.ts # 消息过滤
|
||||
│ └── 📄 session_cleanup.service.ts # 会话清理
|
||||
│
|
||||
└── 📂 shared/ # 🔗 共享业务组件
|
||||
├── 📂 decorators/ # 装饰器
|
||||
├── 📂 pipes/ # 管道
|
||||
├── 📂 filters/ # 异常过滤器
|
||||
└── 📂 interfaces/ # 接口定义
|
||||
├── 📂 dto/ # 共享数据传输对象
|
||||
└── 📄 index.ts # 导出文件
|
||||
```
|
||||
|
||||
### ⚙️ 核心技术服务 (`src/core/`)
|
||||
@@ -139,72 +156,84 @@ src/business/
|
||||
```
|
||||
src/core/
|
||||
├── 📂 db/ # 🗄️ 数据库层
|
||||
│ ├── 📄 db.module.ts # 数据库模块
|
||||
│ ├── 📂 users/ # 用户数据服务
|
||||
│ │ ├── 📄 users.service.ts # MySQL数据库实现
|
||||
│ │ ├── 📄 users-memory.service.ts # 内存数据库实现
|
||||
│ │ ├── 📄 users.interface.ts # 用户服务接口
|
||||
│ │ └── 📄 user.entity.ts # 用户实体定义
|
||||
│ └── 📂 entities/ # 数据库实体
|
||||
│ └── 📄 *.entity.ts # 各种实体定义
|
||||
│ └── 📂 users/ # 用户数据服务
|
||||
│ ├── 📄 users.service.ts # MySQL数据库实现
|
||||
│ ├── 📄 users_memory.service.ts # 内存数据库实现
|
||||
│ ├── 📄 users.dto.ts # 用户数据传输对象
|
||||
│ ├── 📄 users.entity.ts # 用户实体定义
|
||||
│ ├── 📄 users.module.ts # 用户数据模块
|
||||
│ └── 📄 users.service.spec.ts # 用户服务测试
|
||||
│
|
||||
├── 📂 redis/ # 🔴 Redis缓存层
|
||||
│ ├── 📄 redis.module.ts # Redis模块
|
||||
│ ├── 📄 redis.service.ts # Redis真实实现
|
||||
│ ├── 📄 real-redis.service.ts # Redis真实实现
|
||||
│ ├── 📄 file-redis.service.ts # 文件存储实现
|
||||
│ └── 📄 redis.interface.ts # Redis服务接口
|
||||
│
|
||||
├── 📂 login_core/ # 🔑 登录核心服务
|
||||
│ ├── 📄 login-core.service.ts # 登录核心逻辑
|
||||
│ ├── 📄 login-core.module.ts # 模块定义
|
||||
│ └── 📄 login-core.interface.ts # 接口定义
|
||||
│ ├── 📄 login_core.service.ts # 登录核心逻辑
|
||||
│ ├── 📄 login_core.module.ts # 模块定义
|
||||
│ └── 📄 login_core.service.spec.ts # 登录核心测试
|
||||
│
|
||||
├── 📂 admin_core/ # 👑 管理员核心服务
|
||||
│ ├── 📄 admin-core.service.ts # 管理员核心逻辑
|
||||
│ ├── 📄 admin-core.module.ts # 模块定义
|
||||
│ └── 📄 admin-core.interface.ts # 接口定义
|
||||
│ ├── 📄 admin_core.service.ts # 管理员核心逻辑
|
||||
│ ├── 📄 admin_core.module.ts # 模块定义
|
||||
│ └── 📄 admin_core.service.spec.ts # 管理员核心测试
|
||||
│
|
||||
├── 📂 zulip/ # 💬 Zulip核心服务
|
||||
│ ├── 📄 zulip-core.module.ts # Zulip核心模块
|
||||
│ ├── 📄 zulip-api.service.ts # Zulip API服务
|
||||
│ └── 📄 zulip-websocket.service.ts # WebSocket服务
|
||||
│ ├── 📂 config/ # 配置文件
|
||||
│ ├── 📂 interfaces/ # 接口定义
|
||||
│ ├── 📂 services/ # 核心服务
|
||||
│ ├── 📂 types/ # 类型定义
|
||||
│ └── 📄 index.ts # 导出文件
|
||||
│
|
||||
├── 📂 security_core/ # 🛡️ 安全核心模块
|
||||
│ ├── 📄 security_core.module.ts # 安全模块定义
|
||||
│ ├── 📂 guards/ # 安全守卫
|
||||
│ │ └── 📄 throttle.guard.ts # 频率限制守卫
|
||||
│ ├── 📂 interceptors/ # 拦截器
|
||||
│ │ └── 📄 timeout.interceptor.ts # 超时拦截器
|
||||
│ ├── 📂 middleware/ # 中间件
|
||||
│ │ ├── 📄 maintenance.middleware.ts # 维护模式中间件
|
||||
│ │ └── 📄 content_type.middleware.ts # 内容类型中间件
|
||||
│ └── 📂 decorators/ # 装饰器
|
||||
│ ├── 📄 throttle.decorator.ts # 频率限制装饰器
|
||||
│ └── 📄 timeout.decorator.ts # 超时装饰器
|
||||
│
|
||||
└── 📂 utils/ # 🛠️ 工具服务
|
||||
├── 📂 email/ # 📧 邮件服务
|
||||
│ ├── 📄 email.service.ts # 邮件发送服务
|
||||
│ ├── 📄 email.module.ts # 邮件模块
|
||||
│ └── 📄 email.interface.ts # 邮件接口
|
||||
│ └── 📄 email.service.spec.ts # 邮件服务测试
|
||||
├── 📂 verification/ # 🔢 验证码服务
|
||||
│ ├── 📄 verification.service.ts # 验证码生成验证
|
||||
│ └── 📄 verification.module.ts # 验证码模块
|
||||
│ ├── 📄 verification.module.ts # 验证码模块
|
||||
│ └── 📄 verification.service.spec.ts # 验证码服务测试
|
||||
└── 📂 logger/ # 📝 日志服务
|
||||
├── 📄 logger.service.ts # 日志记录服务
|
||||
└── 📄 logger.module.ts # 日志模块
|
||||
├── 📄 logger.module.ts # 日志模块
|
||||
├── 📄 logger.config.ts # 日志配置
|
||||
└── 📄 log_management.service.ts # 日志管理服务
|
||||
```
|
||||
|
||||
### 🎨 前端管理界面 (`client/`)
|
||||
|
||||
> **设计原则**: 独立的前端项目,提供管理员后台功能
|
||||
> **设计原则**: 独立的前端项目,提供管理员后台功能,基于React + Vite + Ant Design
|
||||
|
||||
```
|
||||
client/
|
||||
├── 📂 src/ # 前端源码
|
||||
│ ├── 📂 components/ # 通用组件
|
||||
│ │ ├── 📄 Layout.tsx # 布局组件
|
||||
│ │ ├── 📄 UserTable.tsx # 用户表格组件
|
||||
│ │ └── 📄 LogViewer.tsx # 日志查看组件
|
||||
│ ├── 📂 app/ # 应用组件
|
||||
│ │ ├── 📄 App.tsx # 应用主组件
|
||||
│ │ └── 📄 AdminLayout.tsx # 管理员布局组件
|
||||
│ ├── 📂 pages/ # 页面组件
|
||||
│ │ ├── 📄 Login.tsx # 登录页面
|
||||
│ │ ├── 📄 Dashboard.tsx # 仪表板
|
||||
│ │ ├── 📄 UserManagement.tsx # 用户管理
|
||||
│ │ └── 📄 LogManagement.tsx # 日志管理
|
||||
│ ├── 📂 services/ # API服务
|
||||
│ │ ├── 📄 LoginPage.tsx # 登录页面
|
||||
│ │ ├── 📄 UsersPage.tsx # 用户管理页面
|
||||
│ │ └── 📄 LogsPage.tsx # 日志管理页面
|
||||
│ ├── 📂 lib/ # 工具库
|
||||
│ │ ├── 📄 api.ts # API客户端
|
||||
│ │ ├── 📄 auth.ts # 认证服务
|
||||
│ │ └── 📄 users.ts # 用户服务
|
||||
│ ├── 📂 utils/ # 工具函数
|
||||
│ ├── 📂 types/ # TypeScript类型
|
||||
│ ├── 📄 App.tsx # 应用主组件
|
||||
│ │ └── 📄 adminAuth.ts # 管理员认证服务
|
||||
│ └── 📄 main.tsx # 应用入口
|
||||
├── 📂 dist/ # 构建产物
|
||||
├── 📄 package.json # 前端依赖
|
||||
@@ -220,21 +249,17 @@ client/
|
||||
docs/
|
||||
├── 📄 README.md # 📖 文档导航中心
|
||||
├── 📄 ARCHITECTURE.md # 🏗️ 架构设计文档
|
||||
├── 📄 API_STATUS_CODES.md # 📋 API状态码说明
|
||||
├── 📄 CONTRIBUTORS.md # 🤝 贡献者指南
|
||||
│
|
||||
├── 📂 api/ # 🔌 API接口文档
|
||||
│ ├── 📄 README.md # API文档使用指南
|
||||
│ ├── 📄 api-documentation.md # 完整API接口文档
|
||||
│ ├── 📄 openapi.yaml # OpenAPI规范文件
|
||||
│ └── 📄 postman-collection.json # Postman测试集合
|
||||
│ └── 📄 api-documentation.md # 完整API接口文档
|
||||
│
|
||||
├── 📂 development/ # 💻 开发指南
|
||||
│ ├── 📄 backend_development_guide.md # 后端开发规范
|
||||
│ ├── 📄 git_commit_guide.md # Git提交规范
|
||||
│ ├── 📄 AI辅助开发规范指南.md # AI辅助开发指南
|
||||
│ ├── 📄 TESTING.md # 测试指南
|
||||
│ └── 📄 naming_convention.md # 命名规范
|
||||
│ └── 📄 TESTING.md # 测试指南
|
||||
│
|
||||
└── 📂 deployment/ # 🚀 部署文档
|
||||
└── 📄 DEPLOYMENT.md # 生产环境部署指南
|
||||
@@ -261,14 +286,17 @@ test/
|
||||
├── 📄 .env # 🔧 环境变量配置
|
||||
├── 📄 .env.example # 🔧 环境变量示例
|
||||
├── 📄 .env.production.example # 🔧 生产环境示例
|
||||
├── 📄 package.json # 📋 项目依赖配置
|
||||
├── 📄 package.json # 📋 后端项目依赖配置
|
||||
├── 📄 pnpm-workspace.yaml # 📦 pnpm工作空间配置
|
||||
├── 📄 tsconfig.json # 📘 TypeScript配置
|
||||
├── 📄 jest.config.js # 🧪 Jest测试配置
|
||||
├── 📄 nest-cli.json # 🏠 NestJS CLI配置
|
||||
├── 📄 docker-compose.yml # 🐳 Docker编排配置
|
||||
├── 📄 Dockerfile # 🐳 Docker镜像配置
|
||||
└── 📄 ecosystem.config.js # 🚀 PM2进程管理配置
|
||||
|
||||
client/
|
||||
├── 📄 package.json # 📋 前端项目依赖配置
|
||||
├── 📄 vite.config.ts # ⚡ Vite构建配置
|
||||
└── 📄 tsconfig.json # 📘 前端TypeScript配置
|
||||
```
|
||||
|
||||
---
|
||||
@@ -327,18 +355,17 @@ test/
|
||||
|
||||
### 🔄 数据流向
|
||||
|
||||
#### 用户注册流程示例
|
||||
#### 用户登录流程示例
|
||||
|
||||
```
|
||||
1. 📱 用户请求 → AuthController.register()
|
||||
1. 📱 用户请求 → LoginController.login()
|
||||
2. 🔍 参数验证 → class-validator装饰器
|
||||
3. 🎯 业务逻辑 → AuthService.register()
|
||||
4. ⚙️ 核心服务 → LoginCoreService.createUser()
|
||||
5. 📧 发送邮件 → EmailService.sendVerificationCode()
|
||||
6. 🔢 生成验证码 → VerificationService.generate()
|
||||
7. 💾 存储数据 → UsersService.create() + RedisService.set()
|
||||
8. 📝 记录日志 → LoggerService.log()
|
||||
9. ✅ 返回响应 → 用户收到成功响应
|
||||
3. 🎯 业务逻辑 → LoginService.login()
|
||||
4. ⚙️ 核心服务 → LoginCoreService.validateUser()
|
||||
5. 📧 发送验证码 → VerificationService.generate()
|
||||
6. 💾 存储数据 → UsersService.findByEmail() + RedisService.set()
|
||||
7. 📝 记录日志 → LoggerService.log()
|
||||
8. ✅ 返回响应 → 用户收到登录结果
|
||||
```
|
||||
|
||||
#### 管理员操作流程示例
|
||||
@@ -369,7 +396,7 @@ test/
|
||||
| 功能模块 | 🧪 开发测试模式 | 🚀 生产部署模式 |
|
||||
|----------|----------------|----------------|
|
||||
| **数据库** | 内存存储 (UsersMemoryService) | MySQL (UsersService + TypeORM) |
|
||||
| **缓存** | 文件存储 (FileRedisService) | Redis (RedisService + IORedis) |
|
||||
| **缓存** | 文件存储 (FileRedisService) | Redis (RealRedisService + IORedis) |
|
||||
| **邮件** | 控制台输出 (测试模式) | SMTP服务器 (生产模式) |
|
||||
| **日志** | 控制台 + 文件 | 结构化日志 + 日志轮转 |
|
||||
| **配置** | `.env` 默认配置 | 环境变量 + 配置中心 |
|
||||
@@ -431,7 +458,7 @@ EMAIL_PASS=your_app_password
|
||||
const useFileRedis = configService.get<boolean>('USE_FILE_REDIS');
|
||||
return useFileRedis
|
||||
? new FileRedisService()
|
||||
: new RedisService(configService);
|
||||
: new RealRedisService(configService);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
@@ -471,7 +498,7 @@ AppModule (应用主模块)
|
||||
├── 📊 ConfigModule (全局配置)
|
||||
├── 📝 LoggerModule (日志系统)
|
||||
├── 🔴 RedisModule (缓存服务)
|
||||
│ ├── RedisService (真实Redis)
|
||||
│ ├── RealRedisService (真实Redis)
|
||||
│ └── FileRedisService (文件存储)
|
||||
├── 🗄️ UsersModule (用户数据)
|
||||
│ ├── UsersService (MySQL数据库)
|
||||
@@ -481,16 +508,15 @@ AppModule (应用主模块)
|
||||
├── 🔑 LoginCoreModule (登录核心)
|
||||
├── 👑 AdminCoreModule (管理员核心)
|
||||
├── 💬 ZulipCoreModule (Zulip核心)
|
||||
├── 🔒 SecurityCoreModule (安全核心)
|
||||
│
|
||||
├── 🎯 业务功能模块
|
||||
│ ├── 🔐 AuthModule (用户认证)
|
||||
│ │ └── 依赖: LoginCoreModule, EmailModule, VerificationModule
|
||||
│ │ └── 依赖: LoginCoreModule, EmailModule, VerificationModule, SecurityCoreModule
|
||||
│ ├── 👥 UserMgmtModule (用户管理)
|
||||
│ │ └── 依赖: UsersModule, LoggerModule
|
||||
│ │ └── 依赖: UsersModule, LoggerModule, SecurityCoreModule
|
||||
│ ├── 🛡️ AdminModule (管理员)
|
||||
│ │ └── 依赖: AdminCoreModule, UsersModule
|
||||
│ ├── 🔒 SecurityModule (安全防护)
|
||||
│ │ └── 依赖: RedisModule, LoggerModule
|
||||
│ │ └── 依赖: AdminCoreModule, UsersModule, SecurityCoreModule
|
||||
│ ├── 💬 ZulipModule (Zulip集成)
|
||||
│ │ └── 依赖: ZulipCoreModule, RedisModule
|
||||
│ └── 🔗 SharedModule (共享组件)
|
||||
@@ -500,7 +526,7 @@ AppModule (应用主模块)
|
||||
|
||||
#### 用户认证流程
|
||||
```
|
||||
AuthController → AuthService → LoginCoreService
|
||||
AuthController → LoginService → LoginCoreService
|
||||
↓
|
||||
EmailService ← VerificationService ← RedisService
|
||||
↓
|
||||
|
||||
@@ -9,18 +9,22 @@
|
||||
**moyin** - 主要维护者
|
||||
- Gitea: [@moyin](https://gitea.xinghangee.icu/moyin)
|
||||
- Email: xinghang_a@proton.me
|
||||
- 提交数: **66 commits**
|
||||
- 提交数: **112 commits**
|
||||
- 主要贡献:
|
||||
- 🚀 项目架构设计与初始化
|
||||
- 🔐 完整用户认证系统实现
|
||||
- 📧 邮箱验证系统设计与开发
|
||||
- 🗄️ Redis缓存服务(文件存储+真实Redis双模式)
|
||||
- 📝 完整的API文档系统(Swagger UI + OpenAPI)
|
||||
- 🧪 测试框架搭建与114个测试用例编写
|
||||
- 🧪 测试框架搭建与507个测试用例编写
|
||||
- 📊 高性能日志系统集成(Pino)
|
||||
- 🔧 项目配置优化与部署方案
|
||||
- 🐛 验证码TTL重置关键问题修复
|
||||
- 📚 完整的项目文档体系建设
|
||||
- 🏗️ **Zulip模块架构重构** - 业务功能模块化架构设计与实现
|
||||
- 📖 **架构文档重写** - 详细的架构设计文档和开发者指南
|
||||
- 🔄 **验证码冷却时间优化** - 自动清除机制设计与实现
|
||||
- 📋 **文档清理优化** - 项目文档结构化整理和维护体系建立
|
||||
|
||||
### 🌟 核心开发者
|
||||
|
||||
@@ -28,18 +32,21 @@
|
||||
- Gitea: [@ANGJustinl](https://gitea.xinghangee.icu/ANGJustinl)
|
||||
- GitHub: [@ANGJustinl](https://github.com/ANGJustinl)
|
||||
- Email: 96008766+ANGJustinl@users.noreply.github.com
|
||||
- 提交数: **2 commits**
|
||||
- 提交数: **7 commits**
|
||||
- 主要贡献:
|
||||
- 🔄 邮箱验证流程重构与优化
|
||||
- 💾 基于内存的用户服务实现
|
||||
- 🛠️ API响应处理改进
|
||||
- 🧪 测试用例完善与错误修复
|
||||
- 📚 系统架构优化
|
||||
- 💬 **Zulip集成系统** - 完整的Zulip实时通信系统开发
|
||||
- 🔧 **E2E测试修复** - Zulip集成的端到端测试优化
|
||||
- 🎯 **验证码登录测试** - 验证码登录功能测试用例编写
|
||||
|
||||
**jianuo** - 核心开发者
|
||||
- Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo)
|
||||
- Email: 32106500027@e.gzhu.edu.cn
|
||||
- 提交数: **6 commits**
|
||||
- 提交数: **11 commits**
|
||||
- 主要贡献:
|
||||
- 🎛️ **管理员后台系统** - 完整的前后端管理界面开发
|
||||
- 📊 **日志管理功能** - 运行时日志查看与下载系统
|
||||
@@ -48,14 +55,42 @@
|
||||
- ⚙️ **TypeScript配置优化** - Node16模块解析配置
|
||||
- 🐳 **Docker部署优化** - 容器化部署问题修复
|
||||
- 📖 **技术栈文档更新** - 项目技术栈说明完善
|
||||
- 🔧 **项目配置优化** - 构建和开发环境配置改进
|
||||
|
||||
## 贡献统计
|
||||
|
||||
| 贡献者 | 提交数 | 主要领域 | 贡献占比 |
|
||||
|--------|--------|----------|----------|
|
||||
| moyin | 66 | 架构设计、核心功能、文档、测试 | 88% |
|
||||
| jianuo | 6 | 管理员后台、日志系统、部署优化 | 8% |
|
||||
| angjustinl | 2 | 功能优化、测试、重构 | 3% |
|
||||
| moyin | 112 | 架构设计、核心功能、文档、测试、Zulip重构 | 86% |
|
||||
| jianuo | 11 | 管理员后台、日志系统、部署优化、配置管理 | 8% |
|
||||
| angjustinl | 7 | Zulip集成、功能优化、测试、重构 | 5% |
|
||||
|
||||
## 🌟 最新重要贡献
|
||||
|
||||
### 🏗️ Zulip模块架构重构 (2025年12月31日)
|
||||
**主要贡献者**: moyin, angjustinl
|
||||
|
||||
这是项目历史上最重要的架构重构之一:
|
||||
|
||||
- **架构重构**: 实现业务功能模块化架构,将Zulip模块按照业务层和核心层进行清晰分离
|
||||
- **代码迁移**: 36个文件的重构和迁移,涉及2773行代码的新增和125行的删除
|
||||
- **依赖注入**: 通过接口抽象实现业务层与核心层的完全解耦
|
||||
- **测试完善**: 所有507个测试用例通过,确保重构的安全性
|
||||
|
||||
### 📚 项目文档体系优化 (2025年12月31日)
|
||||
**主要贡献者**: moyin
|
||||
|
||||
- **架构文档重写**: `docs/ARCHITECTURE.md` 从简单架构图扩展为800+行的完整架构设计文档
|
||||
- **README优化**: 采用总分结构设计,详细的文件结构总览
|
||||
- **文档清理**: 新增 `docs/DOCUMENT_CLEANUP.md` 记录文档维护过程
|
||||
- **开发者体验**: 建立完整的文档导航体系,提升开发者上手体验
|
||||
|
||||
### 💬 Zulip集成系统 (2025年12月25日)
|
||||
**主要贡献者**: angjustinl
|
||||
|
||||
- **完整集成**: 实现与Zulip的完整集成,支持实时通信功能
|
||||
- **WebSocket支持**: 建立稳定的WebSocket连接和消息处理机制
|
||||
- **测试覆盖**: 完善的E2E测试确保集成功能的稳定性
|
||||
|
||||
## 项目里程碑
|
||||
|
||||
@@ -72,6 +107,13 @@
|
||||
- **12月20日**: jianuo完善日志管理功能
|
||||
- **12月21日**: jianuo添加管理员后台单元测试
|
||||
- **12月22日**: 管理员后台功能合并到主分支
|
||||
- **12月25日**: angjustinl开发完整的Zulip集成系统
|
||||
- **12月25日**: 实现验证码冷却时间自动清除机制
|
||||
- **12月25日**: 完成邮箱冲突检测优化v1.1.1
|
||||
- **12月25日**: 升级项目版本到v1.1.0
|
||||
- **12月31日**: **重大架构重构** - 完成Zulip模块业务功能模块化架构重构
|
||||
- **12月31日**: **文档体系优化** - 项目文档结构化整理和架构文档重写
|
||||
- **12月31日**: **测试覆盖完善** - 所有507个测试用例通过,测试覆盖率达到新高
|
||||
|
||||
## 如何成为贡献者
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# 📝 文档清理说明
|
||||
|
||||
> 记录项目文档整理和优化的过程,确保文档结构清晰、内容准确。
|
||||
|
||||
## 🎯 清理目标
|
||||
|
||||
- **删除多余README** - 移除重复和过时的README文件
|
||||
- **优化主文档** - 改进项目主README的文件格式和结构说明
|
||||
- **完善架构文档** - 详细说明项目架构和文件夹组织结构
|
||||
- **统一文档风格** - 采用总分结构,方便开发者理解
|
||||
|
||||
## 📊 文档清理结果
|
||||
|
||||
### ✅ 保留的README文件
|
||||
|
||||
| 文件路径 | 保留原因 | 主要内容 |
|
||||
|----------|----------|----------|
|
||||
| `README.md` | 项目主文档 | 项目介绍、快速开始、技术栈、功能特性 |
|
||||
| `docs/README.md` | 文档导航中心 | 文档结构说明、导航链接 |
|
||||
| `client/README.md` | 前端项目文档 | 前端管理界面的独立说明 |
|
||||
| `docs/api/README.md` | API文档指南 | API文档使用说明和快速测试 |
|
||||
| `src/business/zulip/README.md` | 模块架构说明 | Zulip模块重构的详细说明 |
|
||||
|
||||
### ❌ 删除的README文件
|
||||
|
||||
**无** - 经过分析,所有现有README文件都有其存在价值,未删除任何文件。
|
||||
|
||||
### 🔄 优化的文档
|
||||
|
||||
#### 1. 主README.md优化
|
||||
- **文件结构总览** - 添加了详细的项目文件结构说明
|
||||
- **图标化展示** - 使用emoji图标让结构更直观
|
||||
- **层次化组织** - 按照总分结构组织内容
|
||||
|
||||
#### 2. 架构文档大幅改进 (docs/ARCHITECTURE.md)
|
||||
- **完整重写** - 从简单的架构图扩展为完整的架构设计文档
|
||||
- **目录结构详解** - 详细说明每个文件夹的作用和内容
|
||||
- **分层架构设计** - 清晰的架构分层和模块依赖关系
|
||||
- **双模式架构** - 详细说明开发测试模式和生产部署模式
|
||||
- **扩展指南** - 提供添加新模块和功能的详细指导
|
||||
|
||||
## 📁 文档结构优化
|
||||
|
||||
### 🎯 总分结构设计
|
||||
|
||||
采用**总分结构**组织文档,便于开发者快速理解:
|
||||
|
||||
```
|
||||
📚 文档层次结构
|
||||
├── 🏠 项目总览 (README.md)
|
||||
│ ├── 🎯 项目简介和特性
|
||||
│ ├── 🚀 快速开始指南
|
||||
│ ├── 📁 文件结构总览 ⭐ 新增
|
||||
│ ├── 🛠️ 技术栈说明
|
||||
│ └── 📚 文档导航链接
|
||||
│
|
||||
├── 🏗️ 架构设计 (docs/ARCHITECTURE.md) ⭐ 大幅改进
|
||||
│ ├── 📊 整体架构图
|
||||
│ ├── 📁 目录结构详解
|
||||
│ ├── 🏗️ 分层架构设计
|
||||
│ ├── 🔄 双模式架构
|
||||
│ └── 🚀 扩展指南
|
||||
│
|
||||
├── 📖 文档中心 (docs/README.md)
|
||||
│ ├── 📋 文档导航
|
||||
│ ├── 🏗️ 文档结构说明
|
||||
│ └── 📝 文档维护原则
|
||||
│
|
||||
├── 🔌 API文档 (docs/api/README.md)
|
||||
│ ├── 📊 API接口概览
|
||||
│ ├── 🚀 快速开始
|
||||
│ └── 🧪 测试指南
|
||||
│
|
||||
└── 🎨 前端文档 (client/README.md)
|
||||
├── 🚀 快速开始
|
||||
├── 🎯 核心功能
|
||||
└── 🔧 开发指南
|
||||
```
|
||||
|
||||
### 📊 文档内容优化
|
||||
|
||||
#### 1. 视觉化改进
|
||||
- **emoji图标** - 使用统一的emoji图标系统
|
||||
- **表格展示** - 用表格清晰展示对比信息
|
||||
- **代码示例** - 提供完整的代码示例和配置
|
||||
- **架构图** - 使用ASCII艺术绘制清晰的架构图
|
||||
|
||||
#### 2. 结构化内容
|
||||
- **目录导航** - 每个长文档都有详细目录
|
||||
- **分层说明** - 按照业务功能模块化的原则组织
|
||||
- **实用指南** - 提供具体的操作步骤和扩展指南
|
||||
|
||||
#### 3. 开发者友好
|
||||
- **快速上手** - 新开发者指南,从规范学习到架构理解
|
||||
- **总分结构** - 先总览后详细,便于快速理解
|
||||
- **实际案例** - 提供真实的代码示例和使用场景
|
||||
|
||||
## 🎯 文档维护原则
|
||||
|
||||
### ✅ 保留标准
|
||||
- **长期价值** - 对整个项目生命周期都有价值
|
||||
- **参考价值** - 开发、部署、维护时需要查阅
|
||||
- **规范指导** - 团队协作和代码质量保证
|
||||
|
||||
### ❌ 清理标准
|
||||
- **阶段性文档** - 只在特定开发阶段有用
|
||||
- **临时记录** - 会议记录、临时决策等
|
||||
- **过时信息** - 已经不适用的旧版本文档
|
||||
|
||||
### 🔄 更新策略
|
||||
- **及时更新** - 功能变更时同步更新相关文档
|
||||
- **版本控制** - 重要变更记录版本历史
|
||||
- **定期审查** - 定期检查文档的准确性和有效性
|
||||
|
||||
## 📈 改进效果
|
||||
|
||||
### 🎯 开发者体验提升
|
||||
- **快速理解** - 通过总分结构快速掌握项目架构
|
||||
- **准确信息** - 文档与实际代码结构完全一致
|
||||
- **实用指导** - 提供具体的开发和扩展指南
|
||||
|
||||
### 📚 文档质量提升
|
||||
- **结构清晰** - 层次分明的文档组织结构
|
||||
- **内容完整** - 覆盖项目的所有重要方面
|
||||
- **易于维护** - 明确的维护原则和更新策略
|
||||
|
||||
### 🚀 项目可维护性提升
|
||||
- **架构清晰** - 详细的架构文档便于理解和扩展
|
||||
- **规范统一** - 统一的文档风格和组织原则
|
||||
- **知识传承** - 完整的文档体系便于团队协作
|
||||
|
||||
---
|
||||
|
||||
**📝 通过系统性的文档清理和优化,项目文档现在更加清晰、准确、实用!**
|
||||
|
||||
## 📅 清理记录
|
||||
|
||||
- **清理时间**: 2025年12月31日
|
||||
- **清理范围**: 项目根目录及所有子目录的README文件
|
||||
- **主要改进**: 架构文档完全重写,主README结构优化
|
||||
- **保留文件**: 5个README文件全部保留
|
||||
- **删除文件**: 0个(所有文件都有价值)
|
||||
@@ -358,7 +358,16 @@ node test-stream-initialization.js
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.0.0 (2025-12-25)
|
||||
### v1.1.0 (2026-01-06)
|
||||
- **修复 JWT Token 验证和 API Key 管理**
|
||||
- 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题
|
||||
- `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证
|
||||
- 修复消息发送时使用错误的硬编码 API Key 问题
|
||||
- 现在正确从 Redis 读取用户注册时存储的真实 API Key
|
||||
- 添加 `AuthModule` 到 `ZulipModule` 的依赖注入
|
||||
- 消息发送功能现已完全正常工作 ✅
|
||||
|
||||
### v1.0.1 (2025-12-25)
|
||||
- 更新地图配置为 9 区域系统
|
||||
- 添加 Stream Initializer Service 自动初始化服务
|
||||
- 更新默认出生点为鲸之港 (Whale Port)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
### 连接地址
|
||||
|
||||
```
|
||||
ws://localhost:3000/game
|
||||
wss://localhost:3000/game
|
||||
```
|
||||
|
||||
### 连接参数
|
||||
|
||||
@@ -69,3 +69,148 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||||
3. 协议统一:
|
||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新)
|
||||
|
||||
### 3.1 用户注册和 API Key 生成流程
|
||||
|
||||
当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key:
|
||||
|
||||
```
|
||||
用户注册 (POST /auth/register)
|
||||
↓
|
||||
1. 创建游戏账号 (LoginService.register)
|
||||
↓
|
||||
2. 初始化 Zulip 管理员客户端
|
||||
↓
|
||||
3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount)
|
||||
- 使用相同的邮箱和密码
|
||||
- 调用 Zulip API: POST /api/v1/users
|
||||
↓
|
||||
4. 获取 API Key (ZulipAccountService.generateApiKeyForUser)
|
||||
- 使用 fetch_api_key 端点(固定的、基于密码的 Key)
|
||||
- 注意:不使用 regenerate_api_key(会生成新 Key)
|
||||
↓
|
||||
5. 加密存储 API Key (ApiKeySecurityService.storeApiKey)
|
||||
- 使用 AES-256-GCM 加密
|
||||
- 存储到 Redis: zulip:api_key:{userId}
|
||||
↓
|
||||
6. 创建账号关联记录 (ZulipAccountsRepository)
|
||||
- 存储 gameUserId ↔ zulipUserId 映射
|
||||
↓
|
||||
7. 生成 JWT Token (LoginService.generateTokenPair)
|
||||
- 包含用户信息:sub, username, email, role
|
||||
- 返回 access_token 和 refresh_token
|
||||
```
|
||||
|
||||
### 3.2 JWT Token 验证流程
|
||||
|
||||
当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key:
|
||||
|
||||
```
|
||||
WebSocket 登录 (login 消息)
|
||||
↓
|
||||
1. ZulipService.validateGameToken(token)
|
||||
↓
|
||||
2. 调用 LoginService.verifyToken(token, 'access')
|
||||
- 验证签名、过期时间、载荷
|
||||
- 提取用户信息:userId, username, email
|
||||
↓
|
||||
3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey)
|
||||
- 解密存储的 API Key
|
||||
- 更新访问计数和时间
|
||||
↓
|
||||
4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient)
|
||||
- 使用真实的用户 API Key
|
||||
- 注册事件队列
|
||||
↓
|
||||
5. 创建游戏会话 (SessionManagerService.createSession)
|
||||
- 绑定 socketId ↔ zulipQueueId
|
||||
- 记录用户位置信息
|
||||
↓
|
||||
6. 返回登录成功
|
||||
```
|
||||
|
||||
### 3.3 消息发送流程(使用正确的 API Key)
|
||||
|
||||
```
|
||||
发送聊天消息 (chat 消息)
|
||||
↓
|
||||
1. ZulipService.sendChatMessage()
|
||||
↓
|
||||
2. 获取会话信息 (SessionManagerService.getSession)
|
||||
- 获取 userId 和当前位置
|
||||
↓
|
||||
3. 上下文注入 (SessionManagerService.injectContext)
|
||||
- 根据位置确定目标 Stream/Topic
|
||||
↓
|
||||
4. 消息验证 (MessageFilterService.validateMessage)
|
||||
- 内容过滤、频率限制
|
||||
↓
|
||||
5. 发送到 Zulip (ZulipClientPoolService.sendMessage)
|
||||
- 使用用户的真实 API Key
|
||||
- 调用 Zulip API: POST /api/v1/messages
|
||||
↓
|
||||
6. 返回发送结果
|
||||
```
|
||||
|
||||
### 3.4 关键修复说明
|
||||
|
||||
**问题 1: JWT Token 签名冲突**
|
||||
- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值
|
||||
- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递
|
||||
- **文件**: `src/business/auth/services/login.service.ts`
|
||||
|
||||
**问题 2: 使用硬编码的旧 API Key**
|
||||
- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key
|
||||
- **修复**: 从 Redis 读取用户注册时存储的真实 API Key
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`
|
||||
|
||||
**问题 3: 重复实现 JWT 验证逻辑**
|
||||
- **原因**: `ZulipService` 自己实现了 JWT 解析
|
||||
- **修复**: 复用 `LoginService.verifyToken()` 方法
|
||||
- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts`
|
||||
|
||||
### 3.5 API Key 安全机制
|
||||
|
||||
**加密存储**:
|
||||
- 使用 AES-256-GCM 算法加密
|
||||
- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取
|
||||
- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}`
|
||||
|
||||
**访问控制**:
|
||||
- 频率限制:每分钟最多 60 次访问
|
||||
- 访问日志:记录每次访问的时间和次数
|
||||
- 安全事件:记录所有关键操作(存储、访问、更新、删除)
|
||||
|
||||
**环境变量配置**:
|
||||
```bash
|
||||
# 生成 64 字符的十六进制密钥(32 字节 = 256 位)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# 在 .env 文件中配置
|
||||
ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
```
|
||||
|
||||
### 3.6 测试验证
|
||||
|
||||
使用测试脚本验证功能:
|
||||
|
||||
```bash
|
||||
# 测试注册用户的 Zulip 集成
|
||||
node docs/systems/zulip/quick_tests/test-registered-user.js
|
||||
|
||||
# 验证 API Key 一致性
|
||||
node docs/systems/zulip/quick_tests/verify-api-key.js
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ WebSocket 连接成功
|
||||
- ✅ JWT Token 验证通过
|
||||
- ✅ 从 Redis 获取正确的 API Key
|
||||
- ✅ 消息成功发送到 Zulip
|
||||
|
||||
---
|
||||
|
||||
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 测试通过 WebSocket 接收 Zulip 消息
|
||||
*
|
||||
* 设计理念:
|
||||
* - Zulip API Key 永不下发到客户端
|
||||
* - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行
|
||||
* - 客户端只接收 chat_render 消息,不直接调用 Zulip API
|
||||
*
|
||||
* 功能:
|
||||
* 1. 登录游戏服务器获取 JWT Token
|
||||
* 2. 通过 WebSocket 连接游戏服务器
|
||||
* 3. 在当前地图 (Whale Port) 接收消息
|
||||
* 4. 切换到 Pumpkin Valley 接收消息
|
||||
* 5. 统计接收到的消息数量
|
||||
*
|
||||
* 使用方法:
|
||||
* node docs/systems/zulip/quick_tests/test-get-messages.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
// 测试配置
|
||||
const TEST_CONFIG = {
|
||||
whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒
|
||||
pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒
|
||||
totalTimeout: 30000 // 总超时时间 30 秒
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 通过 WebSocket 接收消息
|
||||
*/
|
||||
async function receiveMessagesViaWebSocket(userInfo) {
|
||||
console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
const receivedMessages = {
|
||||
whalePort: [],
|
||||
pumpkinValley: []
|
||||
};
|
||||
|
||||
let currentMap = 'whale_port';
|
||||
let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
|
||||
// 发送登录消息
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 发送登录消息...');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
// 登录成功
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 会话ID: ${data.sessionId}`);
|
||||
console.log(` 用户ID: ${data.userId}`);
|
||||
console.log(` 当前地图: ${data.currentMap}`);
|
||||
|
||||
testPhase = 1;
|
||||
currentMap = data.currentMap || 'whale_port';
|
||||
|
||||
console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息');
|
||||
|
||||
// 在 Whale Port 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`);
|
||||
|
||||
// 切换到 Pumpkin Valley
|
||||
console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`);
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
testPhase = 2;
|
||||
currentMap = 'pumpkin_valley';
|
||||
|
||||
console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`);
|
||||
console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息');
|
||||
|
||||
// 在 Pumpkin Valley 等待一段时间
|
||||
setTimeout(() => {
|
||||
console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
testPhase = 3;
|
||||
console.log('\n📊 测试完成,断开连接...');
|
||||
socket.disconnect();
|
||||
}, TEST_CONFIG.pumpkinValleyWaitTime);
|
||||
}, TEST_CONFIG.whalePortWaitTime);
|
||||
});
|
||||
|
||||
// 接收到消息 (chat_render)
|
||||
socket.on('chat_render', (data) => {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
||||
|
||||
console.log(`\n📨 [${timestamp}] 收到消息:`);
|
||||
console.log(` ├─ 发送者: ${data.from}`);
|
||||
console.log(` ├─ 内容: ${data.txt}`);
|
||||
console.log(` ├─ Stream: ${data.stream || '未知'}`);
|
||||
console.log(` ├─ Topic: ${data.topic || '未知'}`);
|
||||
console.log(` └─ 当前地图: ${currentMap}`);
|
||||
|
||||
// 记录消息
|
||||
const message = {
|
||||
from: data.from,
|
||||
content: data.txt,
|
||||
stream: data.stream,
|
||||
topic: data.topic,
|
||||
timestamp: new Date(),
|
||||
map: currentMap
|
||||
};
|
||||
|
||||
if (testPhase === 1) {
|
||||
receivedMessages.whalePort.push(message);
|
||||
} else if (testPhase === 2) {
|
||||
receivedMessages.pumpkinValley.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
resolve(receivedMessages);
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// 总超时保护
|
||||
setTimeout(() => {
|
||||
if (socket.connected) {
|
||||
console.log('\n⏰ 测试超时,关闭连接');
|
||||
socket.disconnect();
|
||||
}
|
||||
}, TEST_CONFIG.totalTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试流程
|
||||
*/
|
||||
async function runTest() {
|
||||
console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息');
|
||||
console.log('='.repeat(60));
|
||||
console.log('📋 设计理念: Zulip API Key 永不下发到客户端');
|
||||
console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2-5: 通过 WebSocket 接收消息
|
||||
const receivedMessages = await receiveMessagesViaWebSocket(userInfo);
|
||||
|
||||
// 步骤6: 统计信息
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`);
|
||||
console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||
console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`);
|
||||
|
||||
// 显示详细消息列表
|
||||
if (receivedMessages.whalePort.length > 0) {
|
||||
console.log('\n📬 Whale Port 消息列表:');
|
||||
receivedMessages.whalePort.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (receivedMessages.pumpkinValley.length > 0) {
|
||||
console.log('\n📬 Pumpkin Valley 消息列表:');
|
||||
receivedMessages.pumpkinValley.forEach((msg, index) => {
|
||||
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n🎉 测试完成!');
|
||||
console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API');
|
||||
console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTest();
|
||||
@@ -1,15 +1,102 @@
|
||||
const zulip = require('zulip-js');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取用户信息
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
email: response.data.data.user.email,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用密码获取 Zulip API Key
|
||||
*/
|
||||
async function getZulipApiKey(email, password) {
|
||||
console.log('\n📝 步骤 2: 获取 Zulip API Key');
|
||||
console.log(` 邮箱: ${email}`);
|
||||
|
||||
try {
|
||||
// Zulip API 使用 Basic Auth 和 form data
|
||||
const response = await axios.post(
|
||||
'https://zulip.xinghangee.icu/api/v1/fetch_api_key',
|
||||
`username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.result === 'success') {
|
||||
console.log('✅ 成功获取 API Key');
|
||||
console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`);
|
||||
console.log(` 用户ID: ${response.data.user_id}`);
|
||||
return {
|
||||
apiKey: response.data.api_key,
|
||||
email: response.data.email,
|
||||
userId: response.data.user_id
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.msg || '获取 API Key 失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listSubscriptions() {
|
||||
console.log('🔧 检查用户订阅的 Streams...');
|
||||
console.log('🚀 开始测试用户订阅的 Streams');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录游戏服务器
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2: 获取 Zulip API Key
|
||||
const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password);
|
||||
|
||||
console.log('\n📝 步骤 3: 检查用户订阅的 Streams');
|
||||
|
||||
const config = {
|
||||
username: 'angjustinl@mail.angforever.top',
|
||||
apiKey: 'lCPWC...pqNfGF8',
|
||||
username: zulipAuth.email,
|
||||
apiKey: zulipAuth.apiKey,
|
||||
realm: 'https://zulip.xinghangee.icu/'
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await zulip(config);
|
||||
|
||||
// 获取用户信息
|
||||
@@ -29,15 +116,15 @@ async function listSubscriptions() {
|
||||
});
|
||||
|
||||
// 检查是否有 "Novice Village"
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village');
|
||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley');
|
||||
if (noviceVillage) {
|
||||
console.log('\n✅ "Novice Village" Stream 已存在!');
|
||||
console.log('\n✅ "Pumpkin Valley" Stream 已存在!');
|
||||
|
||||
// 测试发送消息
|
||||
console.log('\n📤 测试发送消息...');
|
||||
const result = await client.messages.send({
|
||||
type: 'stream',
|
||||
to: 'Novice Village',
|
||||
to: 'Pumpkin Valley',
|
||||
subject: 'General',
|
||||
content: '测试消息:系统集成测试成功 🎮'
|
||||
});
|
||||
@@ -48,7 +135,7 @@ async function listSubscriptions() {
|
||||
console.log('❌ 消息发送失败:', result.msg);
|
||||
}
|
||||
} else {
|
||||
console.log('\n⚠️ "Novice Village" Stream 不存在');
|
||||
console.log('\n⚠️ "Pumpkin Valley" Stream 不存在');
|
||||
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
||||
|
||||
// 尝试发送到第一个可用的 Stream
|
||||
@@ -79,7 +166,9 @@ async function listSubscriptions() {
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
listSubscriptions();
|
||||
|
||||
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 测试新注册用户的Zulip账号功能
|
||||
*
|
||||
* 功能:
|
||||
* 1. 验证新注册用户可以通过游戏服务器登录
|
||||
* 2. 验证Zulip账号已正确创建和关联
|
||||
* 3. 验证用户可以通过WebSocket发送消息到Zulip
|
||||
* 4. 验证用户可以接收来自Zulip的消息
|
||||
*
|
||||
* 使用方法:
|
||||
* node docs/systems/zulip/quick_tests/test-registered-user.js
|
||||
*/
|
||||
|
||||
const io = require('socket.io-client');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 步骤1: 登录游戏服务器获取token
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 昵称: ${response.data.data.user.nickname}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤2: 通过WebSocket连接并测试Zulip集成
|
||||
*/
|
||||
async function testZulipIntegration(userInfo) {
|
||||
console.log('\n📡 步骤 2: 测试 Zulip 集成');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
let testResults = {
|
||||
connected: false,
|
||||
loggedIn: false,
|
||||
messageSent: false,
|
||||
messageReceived: false
|
||||
};
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testResults.connected = true;
|
||||
testStep = 1;
|
||||
|
||||
// 发送登录消息
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 发送登录消息...');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
// 登录成功
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 会话ID: ${data.sessionId}`);
|
||||
console.log(` 用户ID: ${data.userId}`);
|
||||
console.log(` 用户名: ${data.username}`);
|
||||
console.log(` 当前地图: ${data.currentMap}`);
|
||||
testResults.loggedIn = true;
|
||||
testStep = 2;
|
||||
|
||||
// 等待Zulip客户端初始化
|
||||
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: `🎮 【注册用户测试】来自 ${userInfo.username} 的消息!\n` +
|
||||
`时间: ${new Date().toLocaleString()}\n` +
|
||||
`这是通过新注册账号发送的测试消息。`,
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 发送测试消息到 Zulip...');
|
||||
console.log(` 内容: ${chatMessage.content.split('\n')[0]}`);
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// 消息发送成功
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 消息发送成功');
|
||||
console.log(` 消息ID: ${data.id || '未知'}`);
|
||||
testResults.messageSent = true;
|
||||
testStep = 3;
|
||||
|
||||
// 等待一段时间接收消息
|
||||
setTimeout(() => {
|
||||
console.log('\n📊 测试完成,断开连接...');
|
||||
socket.disconnect();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// 接收到消息
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('📨 收到来自 Zulip 的消息:');
|
||||
console.log(` 发送者: ${data.from}`);
|
||||
console.log(` 内容: ${data.txt}`);
|
||||
console.log(` Stream: ${data.stream || '未知'}`);
|
||||
console.log(` Topic: ${data.topic || '未知'}`);
|
||||
testResults.messageReceived = true;
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
resolve(testResults);
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {
|
||||
if (socket.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印测试结果
|
||||
*/
|
||||
function printTestResults(results) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const checks = [
|
||||
{ name: 'WebSocket 连接', passed: results.connected },
|
||||
{ name: '游戏服务器登录', passed: results.loggedIn },
|
||||
{ name: '发送消息到 Zulip', passed: results.messageSent },
|
||||
{ name: '接收 Zulip 消息', passed: results.messageReceived }
|
||||
];
|
||||
|
||||
checks.forEach(check => {
|
||||
const icon = check.passed ? '✅' : '❌';
|
||||
console.log(`${icon} ${check.name}: ${check.passed ? '通过' : '失败'}`);
|
||||
});
|
||||
|
||||
const passedCount = checks.filter(c => c.passed).length;
|
||||
const totalCount = checks.length;
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log(`总计: ${passedCount}/${totalCount} 项测试通过`);
|
||||
|
||||
if (passedCount === totalCount) {
|
||||
console.log('\n🎉 所有测试通过!Zulip账号创建和集成功能正常!');
|
||||
console.log('💡 提示: 请访问 https://zulip.xinghangee.icu/ 查看发送的消息');
|
||||
} else {
|
||||
console.log('\n⚠️ 部分测试失败,请检查日志');
|
||||
}
|
||||
console.log('='.repeat(60));
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试流程
|
||||
*/
|
||||
async function runTest() {
|
||||
console.log('🚀 开始测试新注册用户的 Zulip 集成功能');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
try {
|
||||
// 步骤1: 登录
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
// 步骤2: 测试Zulip集成
|
||||
const results = await testZulipIntegration(userInfo);
|
||||
|
||||
// 打印结果
|
||||
printTestResults(results);
|
||||
|
||||
process.exit(results.connected && results.loggedIn && results.messageSent ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTest();
|
||||
@@ -1,13 +1,60 @@
|
||||
const io = require('socket.io-client');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置
|
||||
const GAME_SERVER = 'http://localhost:3000';
|
||||
const TEST_USER = {
|
||||
username: 'angtest123',
|
||||
password: 'angtest123',
|
||||
email: 'angjustinl@163.com'
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录游戏服务器获取token
|
||||
*/
|
||||
async function loginToGameServer() {
|
||||
console.log('📝 步骤 1: 登录游戏服务器');
|
||||
console.log(` 用户名: ${TEST_USER.username}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||
identifier: TEST_USER.username,
|
||||
password: TEST_USER.password
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
console.log('✅ 登录成功');
|
||||
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||
console.log(` 昵称: ${response.data.data.user.nickname}`);
|
||||
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||
console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`);
|
||||
return {
|
||||
userId: response.data.data.user.id,
|
||||
username: response.data.data.user.username,
|
||||
token: response.data.data.access_token
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用用户 API Key 测试 Zulip 集成
|
||||
async function testWithUserApiKey() {
|
||||
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
||||
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
||||
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
||||
console.log('📡 游戏服务器: http://localhost:3000/game');
|
||||
console.log('🚀 开始测试用户 API Key 的 Zulip 集成');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const socket = io('http://localhost:3000/game', {
|
||||
try {
|
||||
// 登录获取 token
|
||||
const userInfo = await loginToGameServer();
|
||||
|
||||
console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成');
|
||||
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||
|
||||
const socket = io(`${GAME_SERVER}/game`, {
|
||||
transports: ['websocket'],
|
||||
timeout: 20000
|
||||
});
|
||||
@@ -18,18 +65,18 @@ async function testWithUserApiKey() {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用包含用户 API Key 的 token
|
||||
// 使用真实的 JWT token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||
token: userInfo.token
|
||||
};
|
||||
|
||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||
console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 1 完成: 登录成功');
|
||||
console.log('✅ 步骤 3 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
@@ -37,24 +84,24 @@ async function testWithUserApiKey() {
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||
'使用用户 API Key 发送此消息。',
|
||||
content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` +
|
||||
`时间: ${new Date().toLocaleString()}\n` +
|
||||
`使用真实 API Key 发送此消息。`,
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||
console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||
console.log('✅ 步骤 4 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
@@ -63,7 +110,7 @@ async function testWithUserApiKey() {
|
||||
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||
console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
@@ -80,7 +127,7 @@ async function testWithUserApiKey() {
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||
console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
@@ -88,7 +135,7 @@ async function testWithUserApiKey() {
|
||||
});
|
||||
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('📨 收到来自 Zulip 的消息:');
|
||||
console.log('\n📨 收到来自 Zulip 的消息:');
|
||||
console.log(' 发送者:', data.from);
|
||||
console.log(' 内容:', data.txt);
|
||||
console.log(' Stream:', data.stream || '未知');
|
||||
@@ -100,15 +147,19 @@ async function testWithUserApiKey() {
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 WebSocket 连接已关闭');
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
console.log(' 完成步骤:', testStep, '/ 4');
|
||||
console.log('\n🔌 WebSocket 连接已关闭');
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log(' 完成步骤:', testStep, '/ 3');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
} else {
|
||||
console.log(' ⚠️ 部分测试未完成');
|
||||
}
|
||||
process.exit(0);
|
||||
console.log('='.repeat(60));
|
||||
process.exit(testStep >= 3 ? 0 : 1);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
@@ -118,10 +169,15 @@ async function testWithUserApiKey() {
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 测试时间到,关闭连接');
|
||||
console.log('\n⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 测试失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔧 准备测试环境...');
|
||||
testWithUserApiKey().catch(console.error);
|
||||
// 运行测试
|
||||
testWithUserApiKey();
|
||||
311
full_diagnosis.js
Normal file
311
full_diagnosis.js
Normal file
@@ -0,0 +1,311 @@
|
||||
const io = require('socket.io-client');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
console.log('🔍 全面WebSocket连接诊断');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 1. 测试基础网络连接
|
||||
async function testBasicConnection() {
|
||||
console.log('\n1️⃣ 测试基础HTTPS连接...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(`✅ HTTPS连接成功 - 状态码: ${res.statusCode}`);
|
||||
console.log(`📋 服务器: ${res.headers.server || '未知'}`);
|
||||
resolve({ success: true, statusCode: res.statusCode });
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(`❌ HTTPS连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('❌ HTTPS连接超时');
|
||||
req.destroy();
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 测试本地服务器
|
||||
async function testLocalServer() {
|
||||
console.log('\n2️⃣ 测试本地服务器...');
|
||||
|
||||
const testPaths = [
|
||||
'http://localhost:3000/',
|
||||
'http://localhost:3000/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
for (const url of testPaths) {
|
||||
console.log(`🧪 测试: ${url}`);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const urlObj = new URL(url);
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 本地服务器正常');
|
||||
} else {
|
||||
console.log(` ⚠️ 本地服务器响应: ${res.statusCode}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 本地服务器连接失败: ${error.message}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 本地服务器超时');
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 测试远程Socket.IO路径
|
||||
async function testRemoteSocketIO() {
|
||||
console.log('\n3️⃣ 测试远程Socket.IO路径...');
|
||||
|
||||
const testPaths = [
|
||||
'/socket.io/?EIO=4&transport=polling',
|
||||
'/game/socket.io/?EIO=4&transport=polling',
|
||||
'/socket.io/?transport=polling',
|
||||
'/api/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const path of testPaths) {
|
||||
console.log(`🧪 测试路径: ${path}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: path,
|
||||
method: 'GET',
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'User-Agent': 'socket.io-diagnosis'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 路径可用');
|
||||
console.log(` 📄 响应: ${data.substring(0, 50)}...`);
|
||||
} else {
|
||||
console.log(` ❌ 路径不可用: ${res.statusCode}`);
|
||||
}
|
||||
resolve({ path, statusCode: res.statusCode, success: res.statusCode === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 请求失败: ${error.message}`);
|
||||
resolve({ path, error: error.message, success: false });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 请求超时');
|
||||
req.destroy();
|
||||
resolve({ path, error: 'timeout', success: false });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 4. 测试Socket.IO客户端连接
|
||||
async function testSocketIOClient() {
|
||||
console.log('\n4️⃣ 测试Socket.IO客户端连接...');
|
||||
|
||||
const configs = [
|
||||
{
|
||||
name: 'HTTPS + 所有传输方式',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['websocket', 'polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + 仅Polling',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + /game namespace',
|
||||
url: 'https://whaletownend.xinghangee.icu/game',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
console.log(`🧪 测试: ${config.name}`);
|
||||
console.log(` URL: ${config.url}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const socket = io(config.url, config.options);
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
socket.disconnect();
|
||||
console.log(' ❌ 连接超时');
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
}
|
||||
}, config.options.timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(' ✅ 连接成功');
|
||||
console.log(` 📡 Socket ID: ${socket.id}`);
|
||||
console.log(` 🚀 传输方式: ${socket.io.engine.transport.name}`);
|
||||
socket.disconnect();
|
||||
resolve({ success: true, transport: socket.io.engine.transport.name });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(` ❌ 连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
results.push({ config: config.name, ...result });
|
||||
|
||||
// 等待1秒再测试下一个
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 5. 检查DNS解析
|
||||
async function testDNS() {
|
||||
console.log('\n5️⃣ 检查DNS解析...');
|
||||
|
||||
const dns = require('dns');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup('whaletownend.xinghangee.icu', (err, address, family) => {
|
||||
if (err) {
|
||||
console.log(`❌ DNS解析失败: ${err.message}`);
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
console.log(`✅ DNS解析成功: ${address} (IPv${family})`);
|
||||
resolve({ success: true, address, family });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主诊断函数
|
||||
async function runFullDiagnosis() {
|
||||
console.log('开始全面诊断...\n');
|
||||
|
||||
try {
|
||||
const dnsResult = await testDNS();
|
||||
const basicResult = await testBasicConnection();
|
||||
await testLocalServer();
|
||||
const socketIOPaths = await testRemoteSocketIO();
|
||||
const clientResults = await testSocketIOClient();
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 诊断结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log(`1. DNS解析: ${dnsResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (dnsResult.address) {
|
||||
console.log(` IP地址: ${dnsResult.address}`);
|
||||
}
|
||||
|
||||
console.log(`2. HTTPS连接: ${basicResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (basicResult.error) {
|
||||
console.log(` 错误: ${basicResult.error}`);
|
||||
}
|
||||
|
||||
const workingPaths = socketIOPaths.filter(r => r.success);
|
||||
console.log(`3. Socket.IO路径: ${workingPaths.length}/${socketIOPaths.length} 个可用`);
|
||||
workingPaths.forEach(p => {
|
||||
console.log(` ✅ ${p.path}`);
|
||||
});
|
||||
|
||||
const workingClients = clientResults.filter(r => r.success);
|
||||
console.log(`4. Socket.IO客户端: ${workingClients.length}/${clientResults.length} 个成功`);
|
||||
workingClients.forEach(c => {
|
||||
console.log(` ✅ ${c.config} (${c.transport})`);
|
||||
});
|
||||
|
||||
console.log('\n💡 建议:');
|
||||
|
||||
if (!dnsResult.success) {
|
||||
console.log('❌ DNS解析失败 - 检查域名配置');
|
||||
} else if (!basicResult.success) {
|
||||
console.log('❌ 基础HTTPS连接失败 - 检查服务器状态和防火墙');
|
||||
} else if (workingPaths.length === 0) {
|
||||
console.log('❌ 所有Socket.IO路径都不可用 - 检查nginx配置和后端服务');
|
||||
} else if (workingClients.length === 0) {
|
||||
console.log('❌ Socket.IO客户端无法连接 - 可能是CORS或协议问题');
|
||||
} else {
|
||||
console.log('✅ 部分功能正常 - 使用可用的配置继续开发');
|
||||
|
||||
if (workingClients.length > 0) {
|
||||
const bestConfig = workingClients.find(c => c.transport === 'websocket') || workingClients[0];
|
||||
console.log(`💡 推荐使用: ${bestConfig.config}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('诊断过程中发生错误:', error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runFullDiagnosis();
|
||||
@@ -3,6 +3,12 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "../config/**/*",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts",
|
||||
"test:unit": "jest --testPathPattern=spec.ts --testPathIgnorePatterns=e2e.spec.ts",
|
||||
"test:all": "cross-env RUN_E2E_TESTS=true jest"
|
||||
},
|
||||
"keywords": [
|
||||
"game",
|
||||
@@ -25,6 +28,7 @@
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^10.4.20",
|
||||
"@nestjs/platform-socket.io": "^10.4.20",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
@@ -40,6 +44,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
@@ -59,9 +64,11 @@
|
||||
"@nestjs/testing": "^10.4.20",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.27",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"fast-check": "^4.5.2",
|
||||
"jest": "^29.7.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
|
||||
@@ -12,9 +12,9 @@ import { ZulipModule } from './business/zulip/zulip.module';
|
||||
import { RedisModule } from './core/redis/redis.module';
|
||||
import { AdminModule } from './business/admin/admin.module';
|
||||
import { UserMgmtModule } from './business/user-mgmt/user-mgmt.module';
|
||||
import { SecurityModule } from './business/security/security.module';
|
||||
import { MaintenanceMiddleware } from './business/security/middleware/maintenance.middleware';
|
||||
import { ContentTypeMiddleware } from './business/security/middleware/content-type.middleware';
|
||||
import { SecurityCoreModule } from './core/security_core/security_core.module';
|
||||
import { MaintenanceMiddleware } from './core/security_core/middleware/maintenance.middleware';
|
||||
import { ContentTypeMiddleware } from './core/security_core/middleware/content_type.middleware';
|
||||
|
||||
/**
|
||||
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
||||
@@ -71,7 +71,7 @@ function isDatabaseConfigured(): boolean {
|
||||
ZulipModule,
|
||||
UserMgmtModule,
|
||||
AdminModule,
|
||||
SecurityModule,
|
||||
SecurityCoreModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
AdminUserResponseDto,
|
||||
AdminRuntimeLogsResponseDto
|
||||
} from './dto/admin-response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../security/decorators/throttle.decorator';
|
||||
import { Throttle, ThrottlePresets } from '../../core/security_core/decorators/throttle.decorator';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - 用户登录、注册、密码管理
|
||||
* - GitHub OAuth集成
|
||||
* - 邮箱验证功能
|
||||
* - JWT令牌管理和验证
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
@@ -13,14 +14,41 @@
|
||||
*/
|
||||
|
||||
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 { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [LoginCoreModule],
|
||||
imports: [
|
||||
LoginCoreModule,
|
||||
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],
|
||||
providers: [
|
||||
LoginService,
|
||||
],
|
||||
exports: [LoginService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -13,6 +13,7 @@
|
||||
* - POST /auth/forgot-password - 发送密码重置验证码
|
||||
* - POST /auth/reset-password - 重置密码
|
||||
* - PUT /auth/change-password - 修改密码
|
||||
* - POST /auth/refresh-token - 刷新访问令牌
|
||||
*
|
||||
* @author moyin angjustinl
|
||||
* @version 1.0.0
|
||||
@@ -23,7 +24,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP
|
||||
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 } from '../dto/login.dto';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
RegisterResponseDto,
|
||||
@@ -31,10 +32,11 @@ import {
|
||||
ForgotPasswordResponseDto,
|
||||
CommonResponseDto,
|
||||
TestModeEmailVerificationResponseDto,
|
||||
SuccessEmailVerificationResponseDto
|
||||
SuccessEmailVerificationResponseDto,
|
||||
RefreshTokenResponseDto
|
||||
} from '../dto/login_response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../../security/decorators/throttle.decorator';
|
||||
import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator';
|
||||
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
|
||||
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -609,4 +611,107 @@ export class LoginController {
|
||||
message: '限流记录已清除'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证刷新令牌的有效性和格式
|
||||
* 2. 检查用户状态是否正常
|
||||
* 3. 生成新的JWT令牌对
|
||||
* 4. 返回新的访问令牌和刷新令牌
|
||||
*
|
||||
* @param refreshTokenDto 刷新令牌数据
|
||||
* @param res Express响应对象
|
||||
* @returns 新的令牌对
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '刷新访问令牌',
|
||||
description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。'
|
||||
})
|
||||
@ApiBody({ type: RefreshTokenDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '令牌刷新成功',
|
||||
type: RefreshTokenResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 401,
|
||||
description: '刷新令牌无效或已过期'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 404,
|
||||
description: '用户不存在或已被禁用'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '刷新请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.REFRESH_TOKEN)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('refresh-token')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log('令牌刷新请求', {
|
||||
operation: 'refreshToken',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
39
src/business/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 当前用户装饰器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 从请求上下文中提取当前认证用户信息
|
||||
* - 简化控制器中获取用户信息的操作
|
||||
*
|
||||
* 使用示例:
|
||||
* ```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;
|
||||
},
|
||||
);
|
||||
@@ -425,3 +425,20 @@ export class SendLoginVerificationCodeDto {
|
||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新令牌请求DTO
|
||||
*/
|
||||
export class RefreshTokenDto {
|
||||
/**
|
||||
* 刷新令牌
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
minLength: 1
|
||||
})
|
||||
@IsString({ message: '刷新令牌必须是字符串' })
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refresh_token: string;
|
||||
}
|
||||
@@ -80,17 +80,28 @@ export class LoginResponseDataDto {
|
||||
user: UserInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌',
|
||||
description: 'JWT访问令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
access_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
required: false
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
refresh_token?: string;
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌过期时间(秒)',
|
||||
example: 604800
|
||||
})
|
||||
expires_in: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '令牌类型',
|
||||
example: 'Bearer'
|
||||
})
|
||||
token_type: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否为新用户',
|
||||
@@ -393,3 +404,63 @@ export class SuccessEmailVerificationResponseDto {
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌刷新响应数据DTO
|
||||
*/
|
||||
export class RefreshTokenResponseDataDto {
|
||||
@ApiProperty({
|
||||
description: 'JWT访问令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
access_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'JWT刷新令牌',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '访问令牌过期时间(秒)',
|
||||
example: 604800
|
||||
})
|
||||
expires_in: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '令牌类型',
|
||||
example: 'Bearer'
|
||||
})
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌刷新响应DTO
|
||||
*/
|
||||
export class RefreshTokenResponseDto {
|
||||
@ApiProperty({
|
||||
description: '请求是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应数据',
|
||||
type: RefreshTokenResponseDataDto,
|
||||
required: false
|
||||
})
|
||||
data?: RefreshTokenResponseDataDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '令牌刷新成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'TOKEN_REFRESH_FAILED',
|
||||
required: false
|
||||
})
|
||||
error_code?: string;
|
||||
}
|
||||
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
128
src/business/auth/examples/jwt-usage-example.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* JWT 使用示例
|
||||
*
|
||||
* 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
|
||||
import { JwtAuthGuard, JwtPayload } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* 示例控制器 - 展示 JWT 认证的使用方法
|
||||
*/
|
||||
@Controller('example')
|
||||
export class ExampleController {
|
||||
|
||||
/**
|
||||
* 公开接口 - 无需认证
|
||||
*/
|
||||
@Get('public')
|
||||
getPublicData() {
|
||||
return {
|
||||
message: '这是一个公开接口,无需认证',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 受保护的接口 - 需要 JWT 认证
|
||||
*
|
||||
* 请求头示例:
|
||||
* Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
*/
|
||||
@Get('protected')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getProtectedData(@CurrentUser() user: JwtPayload) {
|
||||
return {
|
||||
message: '这是一个受保护的接口,需要有效的 JWT 令牌',
|
||||
user: {
|
||||
id: user.sub,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getUserProfile(@CurrentUser() user: JwtPayload) {
|
||||
return {
|
||||
profile: {
|
||||
userId: user.sub,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tokenIssuedAt: new Date(user.iat * 1000).toISOString(),
|
||||
tokenExpiresAt: new Date(user.exp * 1000).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的特定属性
|
||||
*/
|
||||
@Get('username')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getUsername(@CurrentUser('username') username: string) {
|
||||
return {
|
||||
username,
|
||||
message: `你好,${username}!`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要特定角色的接口
|
||||
*/
|
||||
@Post('admin-only')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
adminOnlyAction(@CurrentUser() user: JwtPayload, @Body() data: any) {
|
||||
// 检查用户角色
|
||||
if (user.role !== 1) { // 假设 1 是管理员角色
|
||||
return {
|
||||
success: false,
|
||||
message: '权限不足,仅管理员可访问',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '管理员操作执行成功',
|
||||
data,
|
||||
operator: user.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用说明:
|
||||
*
|
||||
* 1. 首先调用登录接口获取 JWT 令牌:
|
||||
* POST /auth/login
|
||||
* {
|
||||
* "identifier": "username",
|
||||
* "password": "password"
|
||||
* }
|
||||
*
|
||||
* 2. 从响应中获取 access_token
|
||||
*
|
||||
* 3. 在后续请求中添加 Authorization 头:
|
||||
* Authorization: Bearer <access_token>
|
||||
*
|
||||
* 4. 访问受保护的接口:
|
||||
* GET /example/protected
|
||||
* GET /example/profile
|
||||
* GET /example/username
|
||||
* POST /example/admin-only
|
||||
*
|
||||
* 错误处理:
|
||||
* - 401 Unauthorized: 令牌缺失或无效
|
||||
* - 403 Forbidden: 令牌有效但权限不足
|
||||
*/
|
||||
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
83
src/business/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
/**
|
||||
* 登录业务服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试登录相关的业务逻辑
|
||||
* - 测试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),
|
||||
@@ -26,7 +55,20 @@ describe('LoginService', () => {
|
||||
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(),
|
||||
@@ -40,6 +82,36 @@ describe('LoginService', () => {
|
||||
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({
|
||||
@@ -49,11 +121,72 @@ describe('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', () => {
|
||||
@@ -61,7 +194,7 @@ describe('LoginService', () => {
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should login successfully', async () => {
|
||||
it('should login successfully and return JWT tokens', async () => {
|
||||
loginCoreService.login.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: false
|
||||
@@ -74,7 +207,40 @@ describe('LoginService', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBeDefined();
|
||||
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 () => {
|
||||
@@ -87,16 +253,80 @@ describe('LoginService', () => {
|
||||
|
||||
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', async () => {
|
||||
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',
|
||||
@@ -104,13 +334,323 @@ describe('LoginService', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.is_new_user).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('verificationCodeLogin', () => {
|
||||
it('should login with verification code successfully', async () => {
|
||||
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
|
||||
@@ -123,23 +663,74 @@ describe('LoginService', () => {
|
||||
|
||||
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 verification code login failure', async () => {
|
||||
loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误'));
|
||||
it('should handle sendPasswordResetCode in test mode', async () => {
|
||||
loginCoreService.sendPasswordResetCode.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.verificationCodeLogin({
|
||||
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: '999999'
|
||||
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.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED');
|
||||
});
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||
});
|
||||
|
||||
describe('sendLoginVerificationCode', () => {
|
||||
it('should send login verification code successfully', async () => {
|
||||
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
|
||||
@@ -151,5 +742,22 @@ describe('LoginService', () => {
|
||||
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('调试信息获取成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,54 @@
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
/**
|
||||
* JWT载荷接口
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
/** 用户ID */
|
||||
sub: string;
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 用户角色 */
|
||||
role: number;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 令牌类型 */
|
||||
type: 'access' | 'refresh';
|
||||
/** 签发时间 */
|
||||
iat?: number;
|
||||
/** 过期时间 */
|
||||
exp?: number;
|
||||
/** 签发者 */
|
||||
iss?: string;
|
||||
/** 受众 */
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌对接口
|
||||
*/
|
||||
export interface TokenPair {
|
||||
/** 访问令牌 */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token: string;
|
||||
/** 访问令牌过期时间(秒) */
|
||||
expires_in: number;
|
||||
/** 令牌类型 */
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应数据接口
|
||||
@@ -35,10 +80,14 @@ export interface LoginResponse {
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
/** 访问令牌(实际应用中应生成JWT) */
|
||||
/** 访问令牌 */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token?: string;
|
||||
refresh_token: string;
|
||||
/** 访问令牌过期时间(秒) */
|
||||
expires_in: number;
|
||||
/** 令牌类型 */
|
||||
token_type: string;
|
||||
/** 是否为新用户 */
|
||||
is_new_user?: boolean;
|
||||
/** 消息 */
|
||||
@@ -65,33 +114,72 @@ export class LoginService {
|
||||
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsRepository')
|
||||
private readonly zulipAccountsRepository: ZulipAccountsRepository,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject('UsersService')
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* @param loginRequest 登录请求
|
||||
* @returns 登录响应
|
||||
* 功能描述:
|
||||
* 处理用户登录请求,验证用户凭据并生成JWT令牌
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用核心服务进行用户认证
|
||||
* 2. 生成JWT访问令牌和刷新令牌
|
||||
* 3. 记录登录日志和安全审计
|
||||
* 4. 返回用户信息和令牌
|
||||
*
|
||||
* @param loginRequest 登录请求数据
|
||||
* @returns Promise<ApiResponse<LoginResponse>> 登录响应
|
||||
*
|
||||
* @throws BadRequestException 当登录参数无效时
|
||||
* @throws UnauthorizedException 当用户凭据错误时
|
||||
* @throws InternalServerErrorException 当系统错误时
|
||||
*/
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// 调用核心服务进行认证
|
||||
try {
|
||||
this.logger.log('用户登录尝试', {
|
||||
operation: 'login',
|
||||
identifier: loginRequest.identifier,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 生成访问令牌(实际应用中应使用JWT)
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
// 2. 生成JWT令牌对
|
||||
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
// 3. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: '登录成功'
|
||||
};
|
||||
|
||||
this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户登录成功', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
isNewUser: authResult.isNewUser,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -99,11 +187,20 @@ export class LoginService {
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error('用户登录失败', {
|
||||
operation: 'login',
|
||||
identifier: loginRequest.identifier,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '登录失败',
|
||||
message: err.message || '登录失败',
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
@@ -116,36 +213,109 @@ export class LoginService {
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||
|
||||
// 调用核心服务进行注册
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
await this.initializeZulipAdminClient();
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
// 3. 创建Zulip账号(使用相同的邮箱和密码)
|
||||
let zulipAccountCreated = false;
|
||||
try {
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||
zulipAccountCreated = true;
|
||||
|
||||
// 格式化响应数据
|
||||
this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, {
|
||||
operation: 'register',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
hasEmail: !!registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
});
|
||||
}
|
||||
} catch (zulipError) {
|
||||
const err = zulipError as Error;
|
||||
this.logger.error(`Zulip账号创建失败,回滚用户注册`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
}, err.stack);
|
||||
|
||||
// 回滚游戏用户注册
|
||||
try {
|
||||
await this.loginCoreService.deleteUser(authResult.user.id);
|
||||
this.logger.log(`用户注册回滚成功: ${registerRequest.username}`);
|
||||
} catch (rollbackError) {
|
||||
const rollbackErr = rollbackError as Error;
|
||||
this.logger.error(`用户注册回滚失败`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
rollbackError: rollbackErr.message,
|
||||
}, rollbackErr.stack);
|
||||
}
|
||||
|
||||
// 抛出原始错误
|
||||
throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`);
|
||||
}
|
||||
|
||||
// 4. 生成JWT令牌对
|
||||
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||
|
||||
// 5. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: true,
|
||||
message: '注册成功'
|
||||
message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功'
|
||||
};
|
||||
|
||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, {
|
||||
operation: 'register',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
zulipAccountCreated,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: '注册成功'
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error));
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '注册失败',
|
||||
message: err.message || '注册失败',
|
||||
error_code: 'REGISTER_FAILED'
|
||||
};
|
||||
}
|
||||
@@ -164,13 +334,16 @@ export class LoginService {
|
||||
// 调用核心服务进行OAuth认证
|
||||
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
// 生成JWT令牌对
|
||||
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
|
||||
};
|
||||
@@ -457,23 +630,272 @@ export class LoginService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成访问令牌
|
||||
* 生成JWT令牌对
|
||||
*
|
||||
* 功能描述:
|
||||
* 为用户生成访问令牌和刷新令牌,符合JWT标准和安全最佳实践
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 创建访问令牌载荷(短期有效)
|
||||
* 2. 创建刷新令牌载荷(长期有效)
|
||||
* 3. 使用配置的密钥签名令牌
|
||||
* 4. 返回完整的令牌对信息
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @returns 访问令牌
|
||||
* @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 generateAccessToken(user: Users): string {
|
||||
// 实际应用中应使用JWT库生成真正的JWT令牌
|
||||
// 这里仅用于演示,生成一个简单的令牌
|
||||
const payload = {
|
||||
userId: user.id.toString(),
|
||||
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,
|
||||
timestamp: Date.now()
|
||||
email: user.email,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
// 简单的Base64编码(实际应用中应使用JWT)
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
// 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 过期时间(秒)
|
||||
* @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天
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 验证码登录
|
||||
@@ -488,13 +910,16 @@ export class LoginService {
|
||||
// 调用核心服务进行验证码认证
|
||||
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = this.generateAccessToken(authResult.user);
|
||||
// 生成JWT令牌对
|
||||
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: accessToken,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: '验证码登录成功'
|
||||
};
|
||||
@@ -592,4 +1017,171 @@ export class LoginService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Zulip管理员客户端
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 从环境变量获取管理员配置
|
||||
* 2. 验证配置完整性
|
||||
* 3. 初始化ZulipAccountService的管理员客户端
|
||||
*
|
||||
* @throws Error 当配置缺失或初始化失败时
|
||||
* @private
|
||||
*/
|
||||
private async initializeZulipAdminClient(): Promise<void> {
|
||||
try {
|
||||
// 从环境变量获取管理员配置
|
||||
const adminConfig = {
|
||||
realm: process.env.ZULIP_SERVER_URL || '',
|
||||
username: process.env.ZULIP_BOT_EMAIL || '',
|
||||
apiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||
};
|
||||
|
||||
// 验证配置完整性
|
||||
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||
throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
|
||||
}
|
||||
|
||||
// 初始化管理员客户端
|
||||
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error('Zulip管理员客户端初始化失败');
|
||||
}
|
||||
|
||||
this.logger.log('Zulip管理员客户端初始化成功', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
realm: adminConfig.realm,
|
||||
adminEmail: adminConfig.username,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户创建Zulip账号
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新注册的游戏用户创建对应的Zulip账号并建立关联
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 使用相同的邮箱和密码创建Zulip账号
|
||||
* 2. 加密存储API Key
|
||||
* 3. 在数据库中建立关联关系
|
||||
* 4. 处理创建失败的情况
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @throws Error 当Zulip账号创建失败时
|
||||
* @private
|
||||
*/
|
||||
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始为用户创建Zulip账号', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
nickname: gameUser.nickname,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 检查是否已存在Zulip账号关联
|
||||
const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id);
|
||||
if (existingAccount) {
|
||||
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
existingZulipUserId: existingAccount.zulipUserId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 创建Zulip账号
|
||||
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||
email: gameUser.email,
|
||||
fullName: gameUser.nickname,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error || 'Zulip账号创建失败');
|
||||
}
|
||||
|
||||
// 3. 存储API Key
|
||||
if (createResult.apiKey) {
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
gameUser.id.toString(),
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 在数据库中创建关联记录
|
||||
await this.zulipAccountsRepository.create({
|
||||
gameUserId: gameUser.id,
|
||||
zulipUserId: createResult.userId!,
|
||||
zulipEmail: createResult.email!,
|
||||
zulipFullName: gameUser.nickname,
|
||||
zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
||||
if (createResult.apiKey) {
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
gameUser.id.toString(),
|
||||
createResult.userId!,
|
||||
createResult.email!,
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip账号创建和关联成功', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId,
|
||||
zulipEmail: createResult.email,
|
||||
hasApiKey: !!createResult.apiKey,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('为用户创建Zulip账号失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
// 清理可能创建的部分数据
|
||||
try {
|
||||
await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id);
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
cleanupError: (cleanupError as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* LoginService Zulip账号创建属性测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试用户注册时Zulip账号创建的一致性
|
||||
* - 验证账号关联和数据完整性
|
||||
* - 测试失败回滚机制
|
||||
*
|
||||
* 属性测试:
|
||||
* - 属性 13: Zulip账号创建一致性
|
||||
* - 验证需求: 账号创建成功率和数据一致性
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
describe('LoginService - Zulip账号创建属性测试', () => {
|
||||
let loginService: LoginService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
// 测试用的模拟数据生成器
|
||||
const validEmailArb = fc.string({ minLength: 5, maxLength: 50 })
|
||||
.filter(s => s.includes('@') && s.includes('.'))
|
||||
.map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`);
|
||||
|
||||
const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 })
|
||||
.filter(s => /^[a-zA-Z0-9_]+$/.test(s));
|
||||
|
||||
const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 })
|
||||
.filter(s => s.trim().length > 0);
|
||||
|
||||
const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 })
|
||||
.filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s));
|
||||
|
||||
const registerRequestArb = fc.record({
|
||||
username: validUsernameArb,
|
||||
email: validEmailArb,
|
||||
nickname: validNicknameArb,
|
||||
password: validPasswordArb,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建模拟服务
|
||||
const mockLoginCoreService = {
|
||||
register: jest.fn(),
|
||||
deleteUser: 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: ZulipAccountService,
|
||||
useValue: mockZulipAccountService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useValue: mockZulipAccountsRepository,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn().mockReturnValue('mock_jwt_token'),
|
||||
signAsync: jest.fn().mockResolvedValue('mock_jwt_token'),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'JWT_SECRET':
|
||||
return 'test_jwt_secret_key_for_testing';
|
||||
case 'JWT_EXPIRES_IN':
|
||||
return '7d';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: {
|
||||
findById: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
loginService = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
zulipAccountsRepository = module.get('ZulipAccountsRepository');
|
||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||
|
||||
// Mock LoginService 的 initializeZulipAdminClient 方法
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
|
||||
// 设置环境变量模拟
|
||||
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.ZULIP_SERVER_URL;
|
||||
delete process.env.ZULIP_BOT_EMAIL;
|
||||
delete process.env.ZULIP_BOT_API_KEY;
|
||||
});
|
||||
|
||||
/**
|
||||
* 属性 13: Zulip账号创建一致性
|
||||
*
|
||||
* 验证需求: 账号创建成功率和数据一致性
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. 成功注册时,游戏账号和Zulip账号都应该被创建
|
||||
* 2. 账号关联信息应该正确存储
|
||||
* 3. Zulip账号创建失败时,游戏账号应该被回滚
|
||||
* 4. 数据一致性:邮箱、昵称等信息应该保持一致
|
||||
*/
|
||||
describe('属性 13: Zulip账号创建一致性', () => {
|
||||
it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const mockZulipResult = {
|
||||
success: true,
|
||||
userId: Math.floor(Math.random() * 1000000),
|
||||
email: registerRequest.email,
|
||||
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||
};
|
||||
|
||||
const mockZulipAccount: ZulipAccounts = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
gameUserId: mockGameUser.id,
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: mockZulipResult.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ZulipAccounts;
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||
zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount);
|
||||
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
expect(result.data?.user.email).toBe(registerRequest.email);
|
||||
expect(result.data?.user.nickname).toBe(registerRequest.nickname);
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
|
||||
// 验证Zulip管理员客户端初始化
|
||||
expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
|
||||
|
||||
// 验证游戏用户注册
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email,
|
||||
fullName: registerRequest.nickname,
|
||||
password: registerRequest.password,
|
||||
});
|
||||
|
||||
// 验证API Key存储
|
||||
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith(
|
||||
mockGameUser.id.toString(),
|
||||
mockZulipResult.apiKey
|
||||
);
|
||||
|
||||
// 验证账号关联创建
|
||||
expect(zulipAccountsRepository.create).toHaveBeenCalledWith({
|
||||
gameUserId: mockGameUser.id,
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: mockZulipResult.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 验证内存关联
|
||||
expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith(
|
||||
mockGameUser.id.toString(),
|
||||
mockZulipResult.userId,
|
||||
mockZulipResult.email,
|
||||
mockZulipResult.apiKey
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在Zulip账号创建失败时回滚游戏账号', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
// 设置模拟行为 - Zulip账号创建失败
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Zulip服务器连接失败',
|
||||
errorCode: 'CONNECTION_FAILED',
|
||||
});
|
||||
loginCoreService.deleteUser.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip账号创建失败');
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建尝试
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email,
|
||||
fullName: registerRequest.nickname,
|
||||
password: registerRequest.password,
|
||||
});
|
||||
|
||||
// 验证游戏用户被回滚删除
|
||||
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id);
|
||||
|
||||
// 验证没有创建账号关联
|
||||
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||
expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理已存在Zulip账号关联的情况', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const existingZulipAccount: ZulipAccounts = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
gameUserId: mockGameUser.id,
|
||||
zulipUserId: 12345,
|
||||
zulipEmail: registerRequest.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'existing_encrypted_key',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as ZulipAccounts;
|
||||
|
||||
// 设置模拟行为 - 已存在Zulip账号关联
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该成功
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证检查了现有关联
|
||||
expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id);
|
||||
|
||||
// 验证没有尝试创建新的Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理缺少邮箱或密码的注册请求', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: validUsernameArb,
|
||||
nickname: validNicknameArb,
|
||||
email: fc.option(validEmailArb, { nil: undefined }),
|
||||
password: fc.option(validPasswordArb, { nil: undefined }),
|
||||
}),
|
||||
async (registerRequest) => {
|
||||
// 只测试缺少邮箱或密码的情况
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
return; // 跳过完整数据的情况
|
||||
}
|
||||
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email || null,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: registerRequest.password ? 'hashed_password' : null,
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest as RegisterRequest);
|
||||
|
||||
// 验证结果 - 注册应该成功,但跳过Zulip账号创建
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证没有尝试创建Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理Zulip管理员客户端初始化失败', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 设置模拟行为 - 管理员客户端初始化失败
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip管理员客户端初始化失败');
|
||||
|
||||
// 验证没有尝试创建游戏用户
|
||||
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||
|
||||
// 验证没有尝试创建Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
|
||||
// 恢复 mock
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理环境变量缺失的情况', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 清除环境变量
|
||||
delete process.env.ZULIP_SERVER_URL;
|
||||
delete process.env.ZULIP_BOT_EMAIL;
|
||||
delete process.env.ZULIP_BOT_API_KEY;
|
||||
|
||||
// 重新设置 mock 以模拟环境变量缺失的错误
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'));
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip管理员配置不完整');
|
||||
|
||||
// 验证没有尝试创建游戏用户
|
||||
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||
|
||||
// 恢复环境变量和 mock
|
||||
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 数据一致性验证测试
|
||||
*
|
||||
* 验证游戏账号和Zulip账号之间的数据一致性
|
||||
*/
|
||||
describe('数据一致性验证', () => {
|
||||
it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const mockZulipResult = {
|
||||
success: true,
|
||||
userId: Math.floor(Math.random() * 1000000),
|
||||
email: registerRequest.email,
|
||||
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||
};
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||
zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts);
|
||||
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
await loginService.register(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建时使用了正确的数据
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email, // 相同的邮箱
|
||||
fullName: registerRequest.nickname, // 相同的昵称
|
||||
password: registerRequest.password, // 相同的密码
|
||||
});
|
||||
|
||||
// 验证账号关联存储了正确的数据
|
||||
expect(zulipAccountsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameUserId: mockGameUser.id,
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: registerRequest.email, // 相同的邮箱
|
||||
zulipFullName: registerRequest.nickname, // 相同的昵称
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,8 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Put, Post, UseGuard
|
||||
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 '../../security/decorators/throttle.decorator';
|
||||
import { Timeout, TimeoutPresets } from '../../security/decorators/timeout.decorator';
|
||||
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';
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
* - 业务规则驱动的消息过滤和权限控制
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 2.0.0
|
||||
* @since 2025-12-31
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-06
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
@@ -53,6 +53,7 @@ import { ZulipCoreModule } from '../../core/zulip/zulip-core.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';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
LoggerModule,
|
||||
// 登录模块 - 提供用户认证和Token验证
|
||||
LoginCoreModule,
|
||||
// 认证模块 - 提供JWT验证和用户认证服务
|
||||
AuthModule,
|
||||
],
|
||||
providers: [
|
||||
// 主协调服务 - 整合各子服务,提供统一业务接口
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ZulipClientInstance,
|
||||
SendMessageResult,
|
||||
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
||||
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
|
||||
|
||||
describe('ZulipService', () => {
|
||||
let service: ZulipService;
|
||||
@@ -158,6 +159,19 @@ describe('ZulipService', () => {
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: {
|
||||
extractApiKey: jest.fn(),
|
||||
validateApiKey: jest.fn(),
|
||||
encryptApiKey: jest.fn(),
|
||||
decryptApiKey: jest.fn(),
|
||||
getApiKey: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* - 消息格式转换和过滤
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-06
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
@@ -31,6 +31,8 @@ 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';
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
@@ -114,8 +116,13 @@ export class ZulipService {
|
||||
private readonly eventProcessor: ZulipEventProcessorService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
private readonly loginService: LoginService,
|
||||
) {
|
||||
this.logger.log('ZulipService初始化完成');
|
||||
|
||||
// 启动事件处理
|
||||
this.initializeEventProcessing();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,9 +177,7 @@ export class ZulipService {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 验证游戏Token并获取用户信息
|
||||
// TODO: 实际项目中应该调用认证服务验证Token
|
||||
// 这里暂时使用模拟数据
|
||||
// 2. 验证游戏Token并获取用户信息 调用认证服务验证Token
|
||||
const userInfo = await this.validateGameToken(request.token);
|
||||
if (!userInfo) {
|
||||
this.logger.warn('登录失败:Token验证失败', {
|
||||
@@ -286,7 +291,7 @@ export class ZulipService {
|
||||
* 功能描述:
|
||||
* 验证游戏Token的有效性,返回用户信息
|
||||
*
|
||||
* @param token 游戏Token
|
||||
* @param token 游戏Token (JWT)
|
||||
* @returns Promise<UserInfo | null> 用户信息,验证失败返回null
|
||||
* @private
|
||||
*/
|
||||
@@ -297,68 +302,84 @@ export class ZulipService {
|
||||
zulipEmail?: string;
|
||||
zulipApiKey?: string;
|
||||
} | null> {
|
||||
// TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token)
|
||||
// 这里暂时使用模拟数据进行开发测试
|
||||
|
||||
this.logger.debug('验证游戏Token', {
|
||||
operation: 'validateGameToken',
|
||||
tokenLength: token.length,
|
||||
});
|
||||
|
||||
// 模拟Token验证
|
||||
// 实际实现应该:
|
||||
// 1. 调用LoginService验证Token
|
||||
// 2. 从数据库获取用户的Zulip API Key
|
||||
// 3. 返回完整的用户信息
|
||||
try {
|
||||
// 1. 使用LoginService验证JWT token
|
||||
const payload = await this.loginService.verifyToken(token, 'access');
|
||||
|
||||
if (token.startsWith('invalid')) {
|
||||
if (!payload || !payload.sub) {
|
||||
this.logger.warn('Token载荷无效', {
|
||||
operation: 'validateGameToken',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从Token中提取用户ID(模拟)
|
||||
const userId = `user_${token.substring(0, 8)}`;
|
||||
const userId = payload.sub;
|
||||
const username = payload.username || `user_${userId}`;
|
||||
const email = payload.email || `${userId}@example.com`;
|
||||
|
||||
// 为测试用户提供真实的 Zulip API Key
|
||||
this.logger.debug('Token解析成功', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
});
|
||||
|
||||
// 2. 从ApiKeySecurityService获取真实的Zulip API Key
|
||||
let zulipApiKey = undefined;
|
||||
let zulipEmail = undefined;
|
||||
|
||||
// 检查是否是配置了真实 Zulip API Key 的测试用户
|
||||
const hasTestApiKey = token.includes('lCPWCPf');
|
||||
const hasUserApiKey = token.includes('W2KhXaQx');
|
||||
const hasOldApiKey = token.includes('MZ1jEMQo');
|
||||
const isRealUserToken = token === 'real_user_token_with_zulip_key_123';
|
||||
try {
|
||||
// 尝试从Redis获取存储的API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
|
||||
this.logger.log('Token检查', {
|
||||
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||
zulipApiKey = apiKeyResult.apiKey;
|
||||
// 使用游戏账号的邮箱
|
||||
zulipEmail = email;
|
||||
|
||||
this.logger.log('从存储获取到Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
tokenPrefix: token.substring(0, 20),
|
||||
hasUserApiKey,
|
||||
hasOldApiKey,
|
||||
isRealUserToken,
|
||||
});
|
||||
|
||||
if (isRealUserToken || hasUserApiKey || hasTestApiKey || hasOldApiKey) {
|
||||
// 使用用户的真实 API Key
|
||||
// 注意:这个API Key对应的Zulip用户邮箱是 user8@zulip.xinghangee.icu
|
||||
zulipApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8';
|
||||
zulipEmail = 'angjustinl@mail.angforever.top';
|
||||
|
||||
this.logger.log('配置真实Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
zulipEmail,
|
||||
hasApiKey: true,
|
||||
apiKeyLength: zulipApiKey.length,
|
||||
});
|
||||
} else {
|
||||
this.logger.debug('用户没有存储的Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
reason: apiKeyResult.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn('获取Zulip API Key失败', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
username: `Player_${userId.substring(5, 10)}`,
|
||||
email: `${userId}@example.com`,
|
||||
// 实际项目中从数据库获取
|
||||
username,
|
||||
email,
|
||||
zulipEmail,
|
||||
zulipApiKey,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn('Token验证失败', {
|
||||
operation: 'validateGameToken',
|
||||
error: err.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -757,5 +778,42 @@ export class ZulipService {
|
||||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||||
return this.sessionManager.getSocketsInMap(mapId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件处理器实例
|
||||
*
|
||||
* 功能描述:
|
||||
* 返回ZulipEventProcessorService实例,用于设置消息分发器
|
||||
*
|
||||
* @returns ZulipEventProcessorService 事件处理器实例
|
||||
*/
|
||||
getEventProcessor(): ZulipEventProcessorService {
|
||||
return this.eventProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理
|
||||
*
|
||||
* 功能描述:
|
||||
* 启动Zulip事件处理循环,用于接收和处理从Zulip服务器返回的消息
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private async initializeEventProcessing(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('开始初始化Zulip事件处理');
|
||||
|
||||
// 启动事件处理循环
|
||||
await this.eventProcessor.startEventProcessing();
|
||||
|
||||
this.logger.log('Zulip事件处理初始化完成');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('初始化Zulip事件处理失败', {
|
||||
operation: 'initializeEventProcessing',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,9 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
||||
namespace: '/game',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 设置消息分发器,使ZulipEventProcessorService能够向客户端发送消息
|
||||
this.setupMessageDistributor();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,6 +376,13 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
||||
): Promise<void> {
|
||||
const clientData = client.data as ClientData | undefined;
|
||||
|
||||
console.log('🔍 DEBUG: handleChat 被调用了!', {
|
||||
socketId: client.id,
|
||||
data: data,
|
||||
clientData: clientData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.logger.log('收到聊天消息', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
@@ -749,5 +759,41 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置消息分发器
|
||||
*
|
||||
* 功能描述:
|
||||
* 将当前WebSocket网关设置为ZulipEventProcessorService的消息分发器,
|
||||
* 使其能够接收从Zulip返回的消息并转发给游戏客户端
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private setupMessageDistributor(): void {
|
||||
try {
|
||||
// 获取ZulipEventProcessorService实例
|
||||
const eventProcessor = this.zulipService.getEventProcessor();
|
||||
|
||||
if (eventProcessor) {
|
||||
// 设置消息分发器
|
||||
eventProcessor.setMessageDistributor(this);
|
||||
|
||||
this.logger.log('消息分发器设置完成', {
|
||||
operation: 'setupMessageDistributor',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('无法获取ZulipEventProcessorService实例', {
|
||||
operation: 'setupMessageDistributor',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('设置消息分发器失败', {
|
||||
operation: 'setupMessageDistributor',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
* @since 2025-12-17
|
||||
*/
|
||||
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
import { ZulipAccounts } from '../zulip_accounts/zulip_accounts.entity';
|
||||
|
||||
/**
|
||||
* 用户实体类
|
||||
@@ -432,4 +433,25 @@ export class Users {
|
||||
comment: '更新时间'
|
||||
})
|
||||
updated_at: Date;
|
||||
|
||||
/**
|
||||
* 关联的Zulip账号
|
||||
*
|
||||
* 关系设计:
|
||||
* - 类型:一对一关系(OneToOne)
|
||||
* - 外键:在ZulipAccounts表中
|
||||
* - 级联:不设置级联删除,保证数据安全
|
||||
*
|
||||
* 业务规则:
|
||||
* - 每个游戏用户最多关联一个Zulip账号
|
||||
* - 支持延迟加载,提高查询性能
|
||||
* - 可选关联,不是所有用户都有Zulip账号
|
||||
*
|
||||
* 使用场景:
|
||||
* - 游戏内聊天功能集成
|
||||
* - 跨平台消息同步
|
||||
* - 用户身份验证和权限管理
|
||||
*/
|
||||
@OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser)
|
||||
zulipAccount?: ZulipAccounts;
|
||||
}
|
||||
185
src/core/db/zulip_accounts/zulip_accounts.entity.ts
Normal file
185
src/core/db/zulip_accounts/zulip_accounts.entity.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Zulip账号关联实体
|
||||
*
|
||||
* 功能描述:
|
||||
* - 存储游戏用户与Zulip账号的关联关系
|
||||
* - 管理Zulip账号的基本信息和状态
|
||||
* - 提供账号验证和同步功能
|
||||
*
|
||||
* 关联关系:
|
||||
* - 与Users表建立一对一关系
|
||||
* - 存储Zulip用户ID、邮箱、API Key等信息
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { Users } from '../users/users.entity';
|
||||
|
||||
@Entity('zulip_accounts')
|
||||
@Index(['zulip_user_id'], { unique: true })
|
||||
@Index(['zulip_email'], { unique: true })
|
||||
export class ZulipAccounts {
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id: bigint;
|
||||
|
||||
/**
|
||||
* 关联的游戏用户ID
|
||||
*/
|
||||
@Column({ type: 'bigint', name: 'game_user_id', comment: '关联的游戏用户ID' })
|
||||
gameUserId: bigint;
|
||||
|
||||
/**
|
||||
* Zulip用户ID
|
||||
*/
|
||||
@Column({ type: 'int', name: 'zulip_user_id', comment: 'Zulip服务器上的用户ID' })
|
||||
zulipUserId: number;
|
||||
|
||||
/**
|
||||
* Zulip用户邮箱
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 255, name: 'zulip_email', comment: 'Zulip账号邮箱地址' })
|
||||
zulipEmail: string;
|
||||
|
||||
/**
|
||||
* Zulip用户全名
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 100, name: 'zulip_full_name', comment: 'Zulip账号全名' })
|
||||
zulipFullName: string;
|
||||
|
||||
/**
|
||||
* Zulip API Key(加密存储)
|
||||
*/
|
||||
@Column({ type: 'text', name: 'zulip_api_key_encrypted', comment: '加密存储的Zulip API Key' })
|
||||
zulipApiKeyEncrypted: string;
|
||||
|
||||
/**
|
||||
* 账号状态
|
||||
* - active: 正常激活状态
|
||||
* - inactive: 未激活状态
|
||||
* - suspended: 暂停状态
|
||||
* - error: 错误状态
|
||||
*/
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['active', 'inactive', 'suspended', 'error'],
|
||||
default: 'active',
|
||||
comment: '账号状态:active-正常,inactive-未激活,suspended-暂停,error-错误'
|
||||
})
|
||||
status: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
|
||||
/**
|
||||
* 最后验证时间
|
||||
*/
|
||||
@Column({ type: 'timestamp', name: 'last_verified_at', nullable: true, comment: '最后一次验证Zulip账号的时间' })
|
||||
lastVerifiedAt: Date | null;
|
||||
|
||||
/**
|
||||
* 最后同步时间
|
||||
*/
|
||||
@Column({ type: 'timestamp', name: 'last_synced_at', nullable: true, comment: '最后一次同步数据的时间' })
|
||||
lastSyncedAt: Date | null;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
@Column({ type: 'text', name: 'error_message', nullable: true, comment: '最后一次操作的错误信息' })
|
||||
errorMessage: string | null;
|
||||
|
||||
/**
|
||||
* 重试次数
|
||||
*/
|
||||
@Column({ type: 'int', name: 'retry_count', default: 0, comment: '创建或同步失败的重试次数' })
|
||||
retryCount: number;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@CreateDateColumn({ name: 'created_at', comment: '记录创建时间' })
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@UpdateDateColumn({ name: 'updated_at', comment: '记录最后更新时间' })
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* 关联的游戏用户
|
||||
*/
|
||||
@OneToOne(() => Users, user => user.zulipAccount)
|
||||
@JoinColumn({ name: 'game_user_id' })
|
||||
gameUser: Users;
|
||||
|
||||
/**
|
||||
* 检查账号是否处于正常状态
|
||||
*
|
||||
* @returns boolean 是否为正常状态
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否需要重新验证
|
||||
*
|
||||
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||
* @returns boolean 是否需要重新验证
|
||||
*/
|
||||
needsVerification(maxAge: number = 24 * 60 * 60 * 1000): boolean {
|
||||
if (!this.lastVerifiedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timeDiff = now.getTime() - this.lastVerifiedAt.getTime();
|
||||
return timeDiff > maxAge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新验证时间
|
||||
*/
|
||||
updateVerificationTime(): void {
|
||||
this.lastVerifiedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新同步时间
|
||||
*/
|
||||
updateSyncTime(): void {
|
||||
this.lastSyncedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误状态
|
||||
*
|
||||
* @param errorMessage 错误信息
|
||||
*/
|
||||
setError(errorMessage: string): void {
|
||||
this.status = 'error';
|
||||
this.errorMessage = errorMessage;
|
||||
this.retryCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误状态
|
||||
*/
|
||||
clearError(): void {
|
||||
if (this.status === 'error') {
|
||||
this.status = 'active';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置重试计数
|
||||
*/
|
||||
resetRetryCount(): void {
|
||||
this.retryCount = 0;
|
||||
}
|
||||
}
|
||||
81
src/core/db/zulip_accounts/zulip_accounts.module.ts
Normal file
81
src/core/db/zulip_accounts/zulip_accounts.module.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Zulip账号关联数据模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供Zulip账号关联数据的访问接口
|
||||
* - 封装TypeORM实体和Repository
|
||||
* - 为业务层提供数据访问服务
|
||||
* - 支持数据库和内存模式的动态切换
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Module, DynamicModule, Global } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
||||
import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository';
|
||||
|
||||
/**
|
||||
* 检查数据库配置是否完整
|
||||
*
|
||||
* @returns 是否配置了数据库
|
||||
*/
|
||||
function isDatabaseConfigured(): boolean {
|
||||
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||
return requiredEnvVars.every(varName => process.env[varName]);
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
export class ZulipAccountsModule {
|
||||
/**
|
||||
* 创建数据库模式的Zulip账号模块
|
||||
*
|
||||
* @returns 配置了TypeORM的动态模块
|
||||
*/
|
||||
static forDatabase(): DynamicModule {
|
||||
return {
|
||||
module: ZulipAccountsModule,
|
||||
imports: [TypeOrmModule.forFeature([ZulipAccounts])],
|
||||
providers: [
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useClass: ZulipAccountsRepository,
|
||||
},
|
||||
],
|
||||
exports: ['ZulipAccountsRepository', TypeOrmModule],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内存模式的Zulip账号模块
|
||||
*
|
||||
* @returns 配置了内存存储的动态模块
|
||||
*/
|
||||
static forMemory(): DynamicModule {
|
||||
return {
|
||||
module: ZulipAccountsModule,
|
||||
providers: [
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useClass: ZulipAccountsMemoryRepository,
|
||||
},
|
||||
],
|
||||
exports: ['ZulipAccountsRepository'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据环境自动选择模式
|
||||
*
|
||||
* @returns 动态模块
|
||||
*/
|
||||
static forRoot(): DynamicModule {
|
||||
return isDatabaseConfigured()
|
||||
? ZulipAccountsModule.forDatabase()
|
||||
: ZulipAccountsModule.forMemory();
|
||||
}
|
||||
}
|
||||
323
src/core/db/zulip_accounts/zulip_accounts.repository.ts
Normal file
323
src/core/db/zulip_accounts/zulip_accounts.repository.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Zulip账号关联数据访问层
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供Zulip账号关联数据的CRUD操作
|
||||
* - 封装复杂查询逻辑和数据库交互
|
||||
* - 实现数据访问层的业务逻辑抽象
|
||||
*
|
||||
* 主要功能:
|
||||
* - 账号关联的创建、查询、更新、删除
|
||||
* - 支持按游戏用户ID、Zulip用户ID、邮箱查询
|
||||
* - 提供账号状态管理和批量操作
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||
|
||||
/**
|
||||
* 创建Zulip账号关联的数据传输对象
|
||||
*/
|
||||
export interface CreateZulipAccountDto {
|
||||
gameUserId: bigint;
|
||||
zulipUserId: number;
|
||||
zulipEmail: string;
|
||||
zulipFullName: string;
|
||||
zulipApiKeyEncrypted: string;
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Zulip账号关联的数据传输对象
|
||||
*/
|
||||
export interface UpdateZulipAccountDto {
|
||||
zulipFullName?: string;
|
||||
zulipApiKeyEncrypted?: string;
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
lastVerifiedAt?: Date;
|
||||
lastSyncedAt?: Date;
|
||||
errorMessage?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip账号查询条件
|
||||
*/
|
||||
export interface ZulipAccountQueryOptions {
|
||||
gameUserId?: bigint;
|
||||
zulipUserId?: number;
|
||||
zulipEmail?: string;
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
includeGameUser?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ZulipAccountsRepository {
|
||||
constructor(
|
||||
@InjectRepository(ZulipAccounts)
|
||||
private readonly repository: Repository<ZulipAccounts>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新的Zulip账号关联
|
||||
*
|
||||
* @param createDto 创建数据
|
||||
* @returns Promise<ZulipAccounts> 创建的关联记录
|
||||
*/
|
||||
async create(createDto: CreateZulipAccountDto): Promise<ZulipAccounts> {
|
||||
const zulipAccount = this.repository.create(createDto);
|
||||
return await this.repository.save(zulipAccount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID查找Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @param includeGameUser 是否包含游戏用户信息
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
const relations = includeGameUser ? ['gameUser'] : [];
|
||||
|
||||
return await this.repository.findOne({
|
||||
where: { gameUserId },
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Zulip用户ID查找账号关联
|
||||
*
|
||||
* @param zulipUserId Zulip用户ID
|
||||
* @param includeGameUser 是否包含游戏用户信息
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
const relations = includeGameUser ? ['gameUser'] : [];
|
||||
|
||||
return await this.repository.findOne({
|
||||
where: { zulipUserId },
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Zulip邮箱查找账号关联
|
||||
*
|
||||
* @param zulipEmail Zulip邮箱
|
||||
* @param includeGameUser 是否包含游戏用户信息
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
const relations = includeGameUser ? ['gameUser'] : [];
|
||||
|
||||
return await this.repository.findOne({
|
||||
where: { zulipEmail },
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @param includeGameUser 是否包含游戏用户信息
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findById(id: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
const relations = includeGameUser ? ['gameUser'] : [];
|
||||
|
||||
return await this.repository.findOne({
|
||||
where: { id },
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @param updateDto 更新数据
|
||||
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||
*/
|
||||
async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||
await this.repository.update({ id }, updateDto);
|
||||
return await this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID更新Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @param updateDto 更新数据
|
||||
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||
*/
|
||||
async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||
await this.repository.update({ gameUserId }, updateDto);
|
||||
return await this.findByGameUserId(gameUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
*/
|
||||
async delete(id: bigint): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return result.affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID删除Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
*/
|
||||
async deleteByGameUserId(gameUserId: bigint): Promise<boolean> {
|
||||
const result = await this.repository.delete({ gameUserId });
|
||||
return result.affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询多个Zulip账号关联
|
||||
*
|
||||
* @param options 查询选项
|
||||
* @returns Promise<ZulipAccounts[]> 关联记录列表
|
||||
*/
|
||||
async findMany(options: ZulipAccountQueryOptions = {}): Promise<ZulipAccounts[]> {
|
||||
const { includeGameUser, ...whereOptions } = options;
|
||||
const relations = includeGameUser ? ['gameUser'] : [];
|
||||
|
||||
// 构建查询条件
|
||||
const where: FindOptionsWhere<ZulipAccounts> = {};
|
||||
if (whereOptions.gameUserId) where.gameUserId = whereOptions.gameUserId;
|
||||
if (whereOptions.zulipUserId) where.zulipUserId = whereOptions.zulipUserId;
|
||||
if (whereOptions.zulipEmail) where.zulipEmail = whereOptions.zulipEmail;
|
||||
if (whereOptions.status) where.status = whereOptions.status;
|
||||
|
||||
return await this.repository.find({
|
||||
where,
|
||||
relations,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要验证的账号列表
|
||||
*
|
||||
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||
* @returns Promise<ZulipAccounts[]> 需要验证的账号列表
|
||||
*/
|
||||
async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise<ZulipAccounts[]> {
|
||||
const cutoffTime = new Date(Date.now() - maxAge);
|
||||
|
||||
return await this.repository
|
||||
.createQueryBuilder('zulip_accounts')
|
||||
.where('zulip_accounts.status = :status', { status: 'active' })
|
||||
.andWhere(
|
||||
'(zulip_accounts.last_verified_at IS NULL OR zulip_accounts.last_verified_at < :cutoffTime)',
|
||||
{ cutoffTime }
|
||||
)
|
||||
.orderBy('zulip_accounts.last_verified_at', 'ASC', 'NULLS FIRST')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误状态的账号列表
|
||||
*
|
||||
* @param maxRetryCount 最大重试次数,默认3次
|
||||
* @returns Promise<ZulipAccounts[]> 错误状态的账号列表
|
||||
*/
|
||||
async findErrorAccounts(maxRetryCount: number = 3): Promise<ZulipAccounts[]> {
|
||||
return await this.repository.find({
|
||||
where: { status: 'error' },
|
||||
order: { updatedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新账号状态
|
||||
*
|
||||
* @param ids 账号ID列表
|
||||
* @param status 新状态
|
||||
* @returns Promise<number> 更新的记录数
|
||||
*/
|
||||
async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(ZulipAccounts)
|
||||
.set({ status })
|
||||
.whereInIds(ids)
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计各状态的账号数量
|
||||
*
|
||||
* @returns Promise<Record<string, number>> 状态统计
|
||||
*/
|
||||
async getStatusStatistics(): Promise<Record<string, number>> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('zulip_accounts')
|
||||
.select('zulip_accounts.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('zulip_accounts.status')
|
||||
.getRawMany();
|
||||
|
||||
const statistics: Record<string, number> = {};
|
||||
result.forEach(row => {
|
||||
statistics[row.status] = parseInt(row.count, 10);
|
||||
});
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查邮箱是否已存在
|
||||
*
|
||||
* @param zulipEmail Zulip邮箱
|
||||
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||
* @returns Promise<boolean> 是否已存在
|
||||
*/
|
||||
async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise<boolean> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('zulip_accounts')
|
||||
.where('zulip_accounts.zulip_email = :zulipEmail', { zulipEmail });
|
||||
|
||||
if (excludeId) {
|
||||
queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId });
|
||||
}
|
||||
|
||||
const count = await queryBuilder.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Zulip用户ID是否已存在
|
||||
*
|
||||
* @param zulipUserId Zulip用户ID
|
||||
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||
* @returns Promise<boolean> 是否已存在
|
||||
*/
|
||||
async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise<boolean> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('zulip_accounts')
|
||||
.where('zulip_accounts.zulip_user_id = :zulipUserId', { zulipUserId });
|
||||
|
||||
if (excludeId) {
|
||||
queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId });
|
||||
}
|
||||
|
||||
const count = await queryBuilder.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
299
src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts
Normal file
299
src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Zulip账号关联内存数据访问层
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供Zulip账号关联数据的内存存储实现
|
||||
* - 用于开发和测试环境
|
||||
* - 实现与数据库版本相同的接口
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-05
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||
import {
|
||||
CreateZulipAccountDto,
|
||||
UpdateZulipAccountDto,
|
||||
ZulipAccountQueryOptions,
|
||||
} from './zulip_accounts.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ZulipAccountsMemoryRepository {
|
||||
private accounts: Map<bigint, ZulipAccounts> = new Map();
|
||||
private currentId: bigint = BigInt(1);
|
||||
|
||||
/**
|
||||
* 创建新的Zulip账号关联
|
||||
*
|
||||
* @param createDto 创建数据
|
||||
* @returns Promise<ZulipAccounts> 创建的关联记录
|
||||
*/
|
||||
async create(createDto: CreateZulipAccountDto): Promise<ZulipAccounts> {
|
||||
const account = new ZulipAccounts();
|
||||
account.id = this.currentId++;
|
||||
account.gameUserId = createDto.gameUserId;
|
||||
account.zulipUserId = createDto.zulipUserId;
|
||||
account.zulipEmail = createDto.zulipEmail;
|
||||
account.zulipFullName = createDto.zulipFullName;
|
||||
account.zulipApiKeyEncrypted = createDto.zulipApiKeyEncrypted;
|
||||
account.status = createDto.status || 'active';
|
||||
account.createdAt = new Date();
|
||||
account.updatedAt = new Date();
|
||||
|
||||
this.accounts.set(account.id, account);
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID查找Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
for (const account of this.accounts.values()) {
|
||||
if (account.gameUserId === gameUserId) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Zulip用户ID查找账号关联
|
||||
*
|
||||
* @param zulipUserId Zulip用户ID
|
||||
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
for (const account of this.accounts.values()) {
|
||||
if (account.zulipUserId === zulipUserId) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Zulip邮箱查找账号关联
|
||||
*
|
||||
* @param zulipEmail Zulip邮箱
|
||||
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
for (const account of this.accounts.values()) {
|
||||
if (account.zulipEmail === zulipEmail) {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||
*/
|
||||
async findById(id: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||
return this.accounts.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @param updateDto 更新数据
|
||||
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||
*/
|
||||
async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||
const account = this.accounts.get(id);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(account, updateDto);
|
||||
account.updatedAt = new Date();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID更新Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @param updateDto 更新数据
|
||||
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||
*/
|
||||
async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||
const account = await this.findByGameUserId(gameUserId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(account, updateDto);
|
||||
account.updatedAt = new Date();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Zulip账号关联
|
||||
*
|
||||
* @param id 关联记录ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
*/
|
||||
async delete(id: bigint): Promise<boolean> {
|
||||
return this.accounts.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据游戏用户ID删除Zulip账号关联
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
*/
|
||||
async deleteByGameUserId(gameUserId: bigint): Promise<boolean> {
|
||||
for (const [id, account] of this.accounts.entries()) {
|
||||
if (account.gameUserId === gameUserId) {
|
||||
return this.accounts.delete(id);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询多个Zulip账号关联
|
||||
*
|
||||
* @param options 查询选项
|
||||
* @returns Promise<ZulipAccounts[]> 关联记录列表
|
||||
*/
|
||||
async findMany(options: ZulipAccountQueryOptions = {}): Promise<ZulipAccounts[]> {
|
||||
let results = Array.from(this.accounts.values());
|
||||
|
||||
if (options.gameUserId) {
|
||||
results = results.filter(a => a.gameUserId === options.gameUserId);
|
||||
}
|
||||
if (options.zulipUserId) {
|
||||
results = results.filter(a => a.zulipUserId === options.zulipUserId);
|
||||
}
|
||||
if (options.zulipEmail) {
|
||||
results = results.filter(a => a.zulipEmail === options.zulipEmail);
|
||||
}
|
||||
if (options.status) {
|
||||
results = results.filter(a => a.status === options.status);
|
||||
}
|
||||
|
||||
// 按创建时间降序排序
|
||||
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要验证的账号列表
|
||||
*
|
||||
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||
* @returns Promise<ZulipAccounts[]> 需要验证的账号列表
|
||||
*/
|
||||
async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise<ZulipAccounts[]> {
|
||||
const cutoffTime = new Date(Date.now() - maxAge);
|
||||
|
||||
return Array.from(this.accounts.values())
|
||||
.filter(account =>
|
||||
account.status === 'active' &&
|
||||
(!account.lastVerifiedAt || account.lastVerifiedAt < cutoffTime)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!a.lastVerifiedAt) return -1;
|
||||
if (!b.lastVerifiedAt) return 1;
|
||||
return a.lastVerifiedAt.getTime() - b.lastVerifiedAt.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误状态的账号列表
|
||||
*
|
||||
* @param maxRetryCount 最大重试次数(内存模式忽略)
|
||||
* @returns Promise<ZulipAccounts[]> 错误状态的账号列表
|
||||
*/
|
||||
async findErrorAccounts(maxRetryCount: number = 3): Promise<ZulipAccounts[]> {
|
||||
return Array.from(this.accounts.values())
|
||||
.filter(account => account.status === 'error')
|
||||
.sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新账号状态
|
||||
*
|
||||
* @param ids 账号ID列表
|
||||
* @param status 新状态
|
||||
* @returns Promise<number> 更新的记录数
|
||||
*/
|
||||
async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise<number> {
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
const account = this.accounts.get(id);
|
||||
if (account) {
|
||||
account.status = status;
|
||||
account.updatedAt = new Date();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计各状态的账号数量
|
||||
*
|
||||
* @returns Promise<Record<string, number>> 状态统计
|
||||
*/
|
||||
async getStatusStatistics(): Promise<Record<string, number>> {
|
||||
const statistics: Record<string, number> = {};
|
||||
|
||||
for (const account of this.accounts.values()) {
|
||||
const status = account.status;
|
||||
statistics[status] = (statistics[status] || 0) + 1;
|
||||
}
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查邮箱是否已存在
|
||||
*
|
||||
* @param zulipEmail Zulip邮箱
|
||||
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||
* @returns Promise<boolean> 是否已存在
|
||||
*/
|
||||
async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise<boolean> {
|
||||
for (const [id, account] of this.accounts.entries()) {
|
||||
if (account.zulipEmail === zulipEmail && (!excludeId || id !== excludeId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Zulip用户ID是否已存在
|
||||
*
|
||||
* @param zulipUserId Zulip用户ID
|
||||
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||
* @returns Promise<boolean> 是否已存在
|
||||
*/
|
||||
async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise<boolean> {
|
||||
for (const [id, account] of this.accounts.entries()) {
|
||||
if (account.zulipUserId === zulipUserId && (!excludeId || id !== excludeId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -826,4 +826,36 @@ export class LoginCoreService {
|
||||
VerificationCodeType.EMAIL_VERIFICATION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*
|
||||
* 功能描述:
|
||||
* 删除指定的用户记录,用于注册失败时的回滚操作
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 执行用户删除操作
|
||||
* 3. 返回删除结果
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @returns Promise<boolean> 是否删除成功
|
||||
* @throws NotFoundException 用户不存在时
|
||||
*/
|
||||
async deleteUser(userId: bigint): Promise<boolean> {
|
||||
// 1. 验证用户是否存在
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 2. 执行删除操作
|
||||
try {
|
||||
await this.usersService.remove(userId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`删除用户失败: ${userId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ export const ThrottlePresets = {
|
||||
/** 密码重置:每小时3次 */
|
||||
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' },
|
||||
|
||||
/** 令牌刷新:每分钟10次 */
|
||||
REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' },
|
||||
|
||||
/** 管理员操作:每分钟10次 */
|
||||
ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 安全功能模块导出
|
||||
* 核心安全模块导出
|
||||
*
|
||||
* 功能概述:
|
||||
* - 频率限制和防护机制
|
||||
@@ -10,14 +10,14 @@
|
||||
*/
|
||||
|
||||
// 模块
|
||||
export * from './security.module';
|
||||
export * from './security_core.module';
|
||||
|
||||
// 守卫
|
||||
export * from './guards/throttle.guard';
|
||||
|
||||
// 中间件
|
||||
export * from './middleware/maintenance.middleware';
|
||||
export * from './middleware/content-type.middleware';
|
||||
export * from './middleware/content_type.middleware';
|
||||
|
||||
// 拦截器
|
||||
export * from './interceptors/timeout.interceptor';
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* 安全功能模块
|
||||
* 核心安全模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合所有安全相关功能
|
||||
* - 提供系统级安全防护功能
|
||||
* - 频率限制和请求超时控制
|
||||
* - 维护模式和内容类型验证
|
||||
* - 系统安全防护机制
|
||||
* - 全局安全中间件和守卫
|
||||
*
|
||||
* @author kiro-ai
|
||||
* @version 1.0.0
|
||||
@@ -34,4 +34,4 @@ import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
|
||||
],
|
||||
exports: [ThrottleGuard, TimeoutInterceptor],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
export class SecurityCoreModule {}
|
||||
@@ -5,7 +5,7 @@
|
||||
* - 测试ApiKeySecurityService的核心功能
|
||||
* - 包含属性测试验证API Key安全存储
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -548,4 +548,424 @@ describe('ApiKeySecurityService', () => {
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
|
||||
// ==================== 补充测试用例 ====================
|
||||
|
||||
describe('访问频率限制测试', () => {
|
||||
it('应该在超过频率限制时拒绝访问', async () => {
|
||||
const userId = 'rate-limit-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
// 存储API Key
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
// 模拟已达到频率限制
|
||||
const rateLimitKey = `zulip:api_key_access:${userId}`;
|
||||
memoryStore.set(rateLimitKey, { value: '60' });
|
||||
|
||||
// 尝试获取API Key应该被拒绝
|
||||
const result = await service.getApiKey(userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('访问频率过高');
|
||||
});
|
||||
|
||||
it('应该正确处理频率限制计数', async () => {
|
||||
const userId = 'counter-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
// 连续访问多次
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = await service.getApiKey(userId);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
|
||||
// 检查计数器
|
||||
const rateLimitKey = `zulip:api_key_access:${userId}`;
|
||||
const count = await mockRedisService.get(rateLimitKey);
|
||||
expect(parseInt(count || '0', 10)).toBe(5);
|
||||
});
|
||||
|
||||
it('应该在Redis错误时默认允许访问', async () => {
|
||||
const userId = 'redis-error-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
// 模拟Redis错误
|
||||
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
mockRedisService.incr.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
// 应该仍然允许访问
|
||||
const result = await service.getApiKey(userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.apiKey).toBe(apiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Redis错误处理测试', () => {
|
||||
it('应该处理存储时的Redis错误', async () => {
|
||||
mockRedisService.set.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
const result = await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('存储失败');
|
||||
});
|
||||
|
||||
it('应该处理获取时的Redis错误', async () => {
|
||||
// 模拟所有Redis get调用都失败
|
||||
mockRedisService.get.mockRejectedValue(new Error('Redis connection failed'));
|
||||
|
||||
const result = await service.getApiKey('user-123');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('获取失败');
|
||||
});
|
||||
|
||||
it('应该处理删除时的Redis错误', async () => {
|
||||
mockRedisService.del.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
const result = await service.deleteApiKey('user-123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理检查存在性时的Redis错误', async () => {
|
||||
mockRedisService.exists.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
const result = await service.hasApiKey('user-123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理获取统计信息时的Redis错误', async () => {
|
||||
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
const stats = await service.getApiKeyStats('user-123');
|
||||
expect(stats.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据损坏处理测试', () => {
|
||||
it('应该处理损坏的JSON数据', async () => {
|
||||
const userId = 'corrupted-data-user';
|
||||
const storageKey = `zulip:api_key:${userId}`;
|
||||
|
||||
// 存储损坏的JSON数据
|
||||
memoryStore.set(storageKey, { value: 'invalid-json-data' });
|
||||
|
||||
const result = await service.getApiKey(userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('获取失败');
|
||||
});
|
||||
|
||||
it('应该处理缺少必要字段的数据', async () => {
|
||||
const userId = 'incomplete-data-user';
|
||||
const storageKey = `zulip:api_key:${userId}`;
|
||||
|
||||
// 存储不完整的数据
|
||||
const incompleteData = {
|
||||
encryptedKey: 'some-encrypted-data',
|
||||
// 缺少 iv 和 authTag
|
||||
};
|
||||
memoryStore.set(storageKey, { value: JSON.stringify(incompleteData) });
|
||||
|
||||
const result = await service.getApiKey(userId);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('可疑访问记录测试', () => {
|
||||
it('应该记录可疑访问事件', async () => {
|
||||
const userId = 'suspicious-user';
|
||||
const reason = 'multiple_failed_attempts';
|
||||
const details = { attemptCount: 5, timeWindow: '1min' };
|
||||
const metadata = { ipAddress: '192.168.1.100', userAgent: 'TestAgent' };
|
||||
|
||||
await service.logSuspiciousAccess(userId, reason, details, metadata);
|
||||
|
||||
// 验证安全日志被记录
|
||||
expect(mockRedisService.setex).toHaveBeenCalled();
|
||||
const setexCalls = mockRedisService.setex.mock.calls;
|
||||
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||
expect(securityLogCall).toBeDefined();
|
||||
|
||||
// 验证日志内容
|
||||
const logData = JSON.parse(securityLogCall![2]);
|
||||
expect(logData.eventType).toBe(SecurityEventType.SUSPICIOUS_ACCESS);
|
||||
expect(logData.severity).toBe(SecuritySeverity.WARNING);
|
||||
expect(logData.details.reason).toBe(reason);
|
||||
expect(logData.ipAddress).toBe(metadata.ipAddress);
|
||||
});
|
||||
});
|
||||
|
||||
describe('元数据处理测试', () => {
|
||||
it('应该在安全日志中记录IP地址和User-Agent', async () => {
|
||||
const userId = 'metadata-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
const metadata = {
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Test Browser)',
|
||||
};
|
||||
|
||||
await service.storeApiKey(userId, apiKey, metadata);
|
||||
|
||||
// 验证元数据被记录在安全日志中
|
||||
const setexCalls = mockRedisService.setex.mock.calls;
|
||||
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||
expect(securityLogCall).toBeDefined();
|
||||
|
||||
const logData = JSON.parse(securityLogCall![2]);
|
||||
expect(logData.ipAddress).toBe(metadata.ipAddress);
|
||||
expect(logData.userAgent).toBe(metadata.userAgent);
|
||||
});
|
||||
|
||||
it('应该处理缺少元数据的情况', async () => {
|
||||
const userId = 'no-metadata-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
// 不提供元数据
|
||||
const result = await service.storeApiKey(userId, apiKey);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// 验证安全日志仍然被记录
|
||||
const setexCalls = mockRedisService.setex.mock.calls;
|
||||
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
|
||||
expect(securityLogCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件测试', () => {
|
||||
it('应该处理极长的用户ID', async () => {
|
||||
const longUserId = 'a'.repeat(1000);
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
const result = await service.storeApiKey(longUserId, apiKey);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const getResult = await service.getApiKey(longUserId);
|
||||
expect(getResult.success).toBe(true);
|
||||
expect(getResult.apiKey).toBe(apiKey);
|
||||
});
|
||||
|
||||
it('应该处理最大长度的API Key', async () => {
|
||||
const userId = 'max-key-user';
|
||||
const maxLengthApiKey = 'a'.repeat(128); // 最大允许长度
|
||||
|
||||
const result = await service.storeApiKey(userId, maxLengthApiKey);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const getResult = await service.getApiKey(userId);
|
||||
expect(getResult.success).toBe(true);
|
||||
expect(getResult.apiKey).toBe(maxLengthApiKey);
|
||||
});
|
||||
|
||||
it('应该拒绝超长的API Key', async () => {
|
||||
const userId = 'overlong-key-user';
|
||||
const overlongApiKey = 'a'.repeat(129); // 超过最大长度
|
||||
|
||||
const result = await service.storeApiKey(userId, overlongApiKey);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('格式无效');
|
||||
});
|
||||
|
||||
it('应该处理最小长度的API Key', async () => {
|
||||
const userId = 'min-key-user';
|
||||
const minLengthApiKey = 'a'.repeat(16); // 最小允许长度
|
||||
|
||||
const result = await service.storeApiKey(userId, minLengthApiKey);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const getResult = await service.getApiKey(userId);
|
||||
expect(getResult.success).toBe(true);
|
||||
expect(getResult.apiKey).toBe(minLengthApiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('时间相关测试', () => {
|
||||
it('应该正确设置创建时间和更新时间', async () => {
|
||||
const userId = 'time-test-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
const beforeStore = new Date();
|
||||
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
const stats = await service.getApiKeyStats(userId);
|
||||
expect(stats.exists).toBe(true);
|
||||
expect(stats.createdAt).toBeDefined();
|
||||
expect(stats.updatedAt).toBeDefined();
|
||||
expect(stats.createdAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
|
||||
expect(stats.updatedAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
|
||||
});
|
||||
|
||||
it('应该在访问时更新最后访问时间', async () => {
|
||||
const userId = 'access-time-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
// 等待一小段时间
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const beforeAccess = new Date();
|
||||
await service.getApiKey(userId);
|
||||
|
||||
const stats = await service.getApiKeyStats(userId);
|
||||
expect(stats.lastAccessedAt).toBeDefined();
|
||||
expect(stats.lastAccessedAt!.getTime()).toBeGreaterThanOrEqual(beforeAccess.getTime());
|
||||
});
|
||||
|
||||
it('应该在更新时保持创建时间不变', async () => {
|
||||
const userId = 'update-time-user';
|
||||
const oldApiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
const newApiKey = 'newkeyabcdefghijklmnopqrstuvwx';
|
||||
|
||||
await service.storeApiKey(userId, oldApiKey);
|
||||
const statsAfterStore = await service.getApiKeyStats(userId);
|
||||
const originalCreatedAt = statsAfterStore.createdAt;
|
||||
|
||||
// 等待一小段时间
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
await service.updateApiKey(userId, newApiKey);
|
||||
const statsAfterUpdate = await service.getApiKeyStats(userId);
|
||||
|
||||
expect(statsAfterUpdate.createdAt).toEqual(originalCreatedAt);
|
||||
expect(statsAfterUpdate.updatedAt!.getTime()).toBeGreaterThan(statsAfterStore.updatedAt!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('并发访问测试', () => {
|
||||
it('应该处理并发的API Key访问', async () => {
|
||||
const userId = 'concurrent-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
await service.storeApiKey(userId, apiKey);
|
||||
|
||||
// 并发访问 - 使用串行方式来确保计数正确
|
||||
const results = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = await service.getApiKey(userId);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// 所有访问都应该成功
|
||||
results.forEach(result => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.apiKey).toBe(apiKey);
|
||||
});
|
||||
|
||||
// 访问计数应该正确
|
||||
const stats = await service.getApiKeyStats(userId);
|
||||
expect(stats.accessCount).toBe(10);
|
||||
});
|
||||
|
||||
it('应该处理并发的存储和获取操作', async () => {
|
||||
const userId = 'concurrent-store-get-user';
|
||||
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
// 并发执行存储和获取操作
|
||||
const storePromise = service.storeApiKey(userId, apiKey);
|
||||
const getPromise = service.getApiKey(userId);
|
||||
|
||||
const [storeResult, getResult] = await Promise.all([storePromise, getPromise]);
|
||||
|
||||
// 存储应该成功
|
||||
expect(storeResult.success).toBe(true);
|
||||
|
||||
// 获取可能成功也可能失败(取决于执行顺序)
|
||||
if (getResult.success) {
|
||||
expect(getResult.apiKey).toBe(apiKey);
|
||||
} else {
|
||||
expect(getResult.message).toContain('不存在');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('安全事件记录错误处理', () => {
|
||||
it('应该处理记录安全事件时的Redis错误', async () => {
|
||||
mockRedisService.setex.mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
// 记录安全事件不应该抛出异常
|
||||
await expect(service.logSecurityEvent({
|
||||
eventType: SecurityEventType.API_KEY_STORED,
|
||||
severity: SecuritySeverity.INFO,
|
||||
userId: 'test-user',
|
||||
details: { action: 'test' },
|
||||
timestamp: new Date(),
|
||||
})).resolves.not.toThrow();
|
||||
|
||||
// 应该记录错误日志
|
||||
expect(Logger.prototype.error).toHaveBeenCalledWith(
|
||||
'记录安全事件失败',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('环境变量处理测试', () => {
|
||||
it('应该在没有环境变量时使用默认密钥并记录警告', () => {
|
||||
// 这个测试需要在服务初始化时进行,当前实现中已经初始化了
|
||||
// 验证警告日志被记录
|
||||
expect(Logger.prototype.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('使用默认加密密钥')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('属性测试 - 错误处理和边界条件', () => {
|
||||
/**
|
||||
* 属性测试: 任何Redis错误都不应该导致服务崩溃
|
||||
*/
|
||||
it('任何Redis错误都不应该导致服务崩溃', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
|
||||
fc.constantFrom('set', 'get', 'del', 'exists', 'setex', 'incr'),
|
||||
async (userId, apiKey, failingMethod) => {
|
||||
// 清理之前的数据
|
||||
memoryStore.clear();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 模拟特定方法失败
|
||||
(mockRedisService as any)[failingMethod].mockRejectedValueOnce(
|
||||
new Error(`${failingMethod} failed`)
|
||||
);
|
||||
|
||||
// 执行操作不应该抛出异常
|
||||
await expect(service.storeApiKey(userId.trim(), apiKey)).resolves.not.toThrow();
|
||||
await expect(service.getApiKey(userId.trim())).resolves.not.toThrow();
|
||||
await expect(service.deleteApiKey(userId.trim())).resolves.not.toThrow();
|
||||
await expect(service.hasApiKey(userId.trim())).resolves.not.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性测试: 任何输入都不应该导致服务崩溃
|
||||
*/
|
||||
it('任何输入都不应该导致服务崩溃', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.string({ maxLength: 1000 }), // 任意字符串作为用户ID
|
||||
fc.string({ maxLength: 1000 }), // 任意字符串作为API Key
|
||||
async (userId, apiKey) => {
|
||||
// 清理之前的数据
|
||||
memoryStore.clear();
|
||||
|
||||
// 任何输入都不应该导致崩溃
|
||||
await expect(service.storeApiKey(userId, apiKey)).resolves.not.toThrow();
|
||||
await expect(service.getApiKey(userId)).resolves.not.toThrow();
|
||||
await expect(service.updateApiKey(userId, apiKey)).resolves.not.toThrow();
|
||||
await expect(service.deleteApiKey(userId)).resolves.not.toThrow();
|
||||
await expect(service.hasApiKey(userId)).resolves.not.toThrow();
|
||||
await expect(service.getApiKeyStats(userId)).resolves.not.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* - AppLoggerService: 日志记录服务
|
||||
* - IRedisService: Redis缓存服务
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - 测试ConfigManagerService的核心功能
|
||||
* - 包含属性测试验证配置验证正确性
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -60,6 +60,13 @@ describe('ConfigManagerService', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 设置测试环境变量
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.ZULIP_SERVER_URL = 'https://test-zulip.com';
|
||||
process.env.ZULIP_BOT_EMAIL = 'test-bot@test.com';
|
||||
process.env.ZULIP_BOT_API_KEY = 'test-api-key';
|
||||
process.env.ZULIP_API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
@@ -88,6 +95,12 @@ describe('ConfigManagerService', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.ZULIP_SERVER_URL;
|
||||
delete process.env.ZULIP_BOT_EMAIL;
|
||||
delete process.env.ZULIP_BOT_API_KEY;
|
||||
delete process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -595,4 +608,504 @@ describe('ConfigManagerService', () => {
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
|
||||
// ==================== 补充测试用例 ====================
|
||||
|
||||
describe('hasMap - 检查地图是否存在', () => {
|
||||
it('应该返回true当地图存在时', () => {
|
||||
const exists = service.hasMap('novice_village');
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('应该返回false当地图不存在时', () => {
|
||||
const exists = service.hasMap('nonexistent');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理空字符串输入', () => {
|
||||
const exists = service.hasMap('');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理null/undefined输入', () => {
|
||||
const exists1 = service.hasMap(null as any);
|
||||
const exists2 = service.hasMap(undefined as any);
|
||||
expect(exists1).toBe(false);
|
||||
expect(exists2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMapIds - 获取所有地图ID', () => {
|
||||
it('应该返回所有地图ID列表', () => {
|
||||
const mapIds = service.getAllMapIds();
|
||||
expect(mapIds).toContain('novice_village');
|
||||
expect(mapIds).toContain('tavern');
|
||||
expect(mapIds.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapConfigByStream - 根据Stream获取地图配置', () => {
|
||||
it('应该返回正确的地图配置', () => {
|
||||
const config = service.getMapConfigByStream('Novice Village');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.mapId).toBe('novice_village');
|
||||
});
|
||||
|
||||
it('应该支持大小写不敏感查询', () => {
|
||||
const config = service.getMapConfigByStream('novice village');
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.mapId).toBe('novice_village');
|
||||
});
|
||||
|
||||
it('应该在Stream不存在时返回null', () => {
|
||||
const config = service.getMapConfigByStream('nonexistent');
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllStreams - 获取所有Stream名称', () => {
|
||||
it('应该返回所有Stream名称列表', () => {
|
||||
const streams = service.getAllStreams();
|
||||
expect(streams).toContain('Novice Village');
|
||||
expect(streams).toContain('Tavern');
|
||||
expect(streams.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasStream - 检查Stream是否存在', () => {
|
||||
it('应该返回true当Stream存在时', () => {
|
||||
const exists = service.hasStream('Novice Village');
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持大小写不敏感查询', () => {
|
||||
const exists = service.hasStream('novice village');
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('应该返回false当Stream不存在时', () => {
|
||||
const exists = service.hasStream('nonexistent');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理空字符串输入', () => {
|
||||
const exists = service.hasStream('');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findObjectByTopic - 根据Topic查找交互对象', () => {
|
||||
it('应该找到正确的交互对象', () => {
|
||||
const obj = service.findObjectByTopic('Notice Board');
|
||||
expect(obj).toBeDefined();
|
||||
expect(obj?.objectId).toBe('notice_board');
|
||||
expect(obj?.mapId).toBe('novice_village');
|
||||
});
|
||||
|
||||
it('应该支持大小写不敏感查询', () => {
|
||||
const obj = service.findObjectByTopic('notice board');
|
||||
expect(obj).toBeDefined();
|
||||
expect(obj?.objectId).toBe('notice_board');
|
||||
});
|
||||
|
||||
it('应该在Topic不存在时返回null', () => {
|
||||
const obj = service.findObjectByTopic('nonexistent');
|
||||
expect(obj).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理空字符串输入', () => {
|
||||
const obj = service.findObjectByTopic('');
|
||||
expect(obj).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObjectsInMap - 获取地图中的所有交互对象', () => {
|
||||
it('应该返回地图中的所有交互对象', () => {
|
||||
const objects = service.getObjectsInMap('novice_village');
|
||||
expect(objects.length).toBe(1);
|
||||
expect(objects[0].objectId).toBe('notice_board');
|
||||
expect(objects[0].mapId).toBe('novice_village');
|
||||
});
|
||||
|
||||
it('应该在地图不存在时返回空数组', () => {
|
||||
const objects = service.getObjectsInMap('nonexistent');
|
||||
expect(objects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigFilePath - 获取配置文件路径', () => {
|
||||
it('应该返回正确的配置文件路径', () => {
|
||||
const filePath = service.getConfigFilePath();
|
||||
expect(filePath).toContain('map-config.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configFileExists - 检查配置文件是否存在', () => {
|
||||
it('应该返回true当配置文件存在时', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
const exists = service.configFileExists();
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('应该返回false当配置文件不存在时', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
const exists = service.configFileExists();
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadConfig - 热重载配置', () => {
|
||||
it('应该成功重载配置', async () => {
|
||||
await expect(service.reloadConfig()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('应该在配置文件读取失败时抛出错误', async () => {
|
||||
mockFs.readFileSync.mockImplementation(() => {
|
||||
throw new Error('File read error');
|
||||
});
|
||||
|
||||
await expect(service.reloadConfig()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipConfig - 获取Zulip配置', () => {
|
||||
it('应该返回Zulip配置对象', () => {
|
||||
const config = service.getZulipConfig();
|
||||
expect(config).toBeDefined();
|
||||
expect(config.zulipServerUrl).toBeDefined();
|
||||
expect(config.websocketPort).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMapConfigs - 获取所有地图配置', () => {
|
||||
it('应该返回所有地图配置列表', () => {
|
||||
const configs = service.getAllMapConfigs();
|
||||
expect(configs.length).toBe(2);
|
||||
expect(configs.some(c => c.mapId === 'novice_village')).toBe(true);
|
||||
expect(configs.some(c => c.mapId === 'tavern')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('配置文件监听功能', () => {
|
||||
let mockWatcher: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWatcher = {
|
||||
close: jest.fn(),
|
||||
};
|
||||
(fs.watch as jest.Mock).mockReturnValue(mockWatcher);
|
||||
});
|
||||
|
||||
describe('enableConfigWatcher - 启用配置文件监听', () => {
|
||||
it('应该成功启用配置文件监听', () => {
|
||||
const result = service.enableConfigWatcher();
|
||||
expect(result).toBe(true);
|
||||
expect(fs.watch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在配置文件不存在时返回false', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
const result = service.enableConfigWatcher();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该在已启用时跳过重复启用', () => {
|
||||
service.enableConfigWatcher();
|
||||
(fs.watch as jest.Mock).mockClear();
|
||||
|
||||
const result = service.enableConfigWatcher();
|
||||
expect(result).toBe(true);
|
||||
expect(fs.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理fs.watch抛出的错误', () => {
|
||||
(fs.watch as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Watch error');
|
||||
});
|
||||
|
||||
const result = service.enableConfigWatcher();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableConfigWatcher - 禁用配置文件监听', () => {
|
||||
it('应该成功禁用配置文件监听', () => {
|
||||
service.enableConfigWatcher();
|
||||
service.disableConfigWatcher();
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理未启用监听的情况', () => {
|
||||
// 不应该抛出错误
|
||||
expect(() => service.disableConfigWatcher()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigWatcherEnabled - 检查监听状态', () => {
|
||||
it('应该返回正确的监听状态', () => {
|
||||
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||
|
||||
service.enableConfigWatcher();
|
||||
expect(service.isConfigWatcherEnabled()).toBe(true);
|
||||
|
||||
service.disableConfigWatcher();
|
||||
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullConfiguration - 获取完整配置', () => {
|
||||
it('应该返回完整的配置对象', () => {
|
||||
const config = service.getFullConfiguration();
|
||||
expect(config).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfigValue - 更新配置值', () => {
|
||||
it('应该成功更新有效的配置值', () => {
|
||||
// 这个测试需要模拟fullConfig存在
|
||||
const result = service.updateConfigValue('message.rateLimit', 20);
|
||||
// 由于测试环境中fullConfig可能未初始化,这里主要测试不抛出异常
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
it('应该在配置键不存在时返回false', () => {
|
||||
const result = service.updateConfigValue('nonexistent.key', 'value');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理无效的键路径', () => {
|
||||
const result = service.updateConfigValue('', 'value');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportMapConfig - 导出地图配置', () => {
|
||||
it('应该成功导出配置到文件', () => {
|
||||
const result = service.exportMapConfig();
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理文件写入错误', () => {
|
||||
mockFs.writeFileSync.mockImplementation(() => {
|
||||
throw new Error('Write error');
|
||||
});
|
||||
|
||||
const result = service.exportMapConfig();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该支持自定义文件路径', () => {
|
||||
const customPath = '/custom/path/config.json';
|
||||
const result = service.exportMapConfig(customPath);
|
||||
expect(result).toBe(true);
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
customPath,
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理测试', () => {
|
||||
it('应该处理JSON解析错误', async () => {
|
||||
mockFs.readFileSync.mockReturnValue('invalid json');
|
||||
|
||||
await expect(service.loadMapConfig()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('应该处理文件系统错误', async () => {
|
||||
mockFs.readFileSync.mockImplementation(() => {
|
||||
throw new Error('File system error');
|
||||
});
|
||||
|
||||
await expect(service.loadMapConfig()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('应该处理配置验证过程中的错误', async () => {
|
||||
// 模拟验证过程中抛出异常
|
||||
const originalValidateMapConfig = (service as any).validateMapConfig;
|
||||
(service as any).validateMapConfig = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Validation error');
|
||||
});
|
||||
|
||||
const result = await service.validateConfig();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('验证过程出错'))).toBe(true);
|
||||
|
||||
// 恢复原方法
|
||||
(service as any).validateMapConfig = originalValidateMapConfig;
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件测试', () => {
|
||||
it('应该处理空的地图配置', async () => {
|
||||
const emptyConfig = { maps: [] };
|
||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(emptyConfig));
|
||||
|
||||
await service.loadMapConfig();
|
||||
|
||||
const mapIds = service.getAllMapIds();
|
||||
expect(mapIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该处理大量地图配置', async () => {
|
||||
const largeConfig = {
|
||||
maps: Array.from({ length: 1000 }, (_, i) => ({
|
||||
mapId: `map_${i}`,
|
||||
mapName: `地图${i}`,
|
||||
zulipStream: `Stream${i}`,
|
||||
interactionObjects: []
|
||||
}))
|
||||
};
|
||||
mockFs.readFileSync.mockReturnValue(JSON.stringify(largeConfig));
|
||||
|
||||
await service.loadMapConfig();
|
||||
|
||||
const mapIds = service.getAllMapIds();
|
||||
expect(mapIds.length).toBe(1000);
|
||||
});
|
||||
|
||||
it('应该处理极长的字符串输入', () => {
|
||||
const longString = 'a'.repeat(10000);
|
||||
const stream = service.getStreamByMap(longString);
|
||||
expect(stream).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理特殊字符输入', () => {
|
||||
const specialChars = '!@#$%^&*()[]{}|;:,.<>?';
|
||||
const stream = service.getStreamByMap(specialChars);
|
||||
expect(stream).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('并发操作测试', () => {
|
||||
it('应该处理并发的配置查询', async () => {
|
||||
const promises = Array.from({ length: 100 }, () =>
|
||||
Promise.resolve(service.getStreamByMap('novice_village'))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach(result => {
|
||||
expect(result).toBe('Novice Village');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理并发的配置重载', async () => {
|
||||
const promises = Array.from({ length: 10 }, () => service.reloadConfig());
|
||||
|
||||
// 不应该抛出异常
|
||||
await expect(Promise.all(promises)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('内存管理测试', () => {
|
||||
it('应该正确清理资源', () => {
|
||||
service.enableConfigWatcher();
|
||||
|
||||
// 模拟模块销毁
|
||||
service.onModuleDestroy();
|
||||
|
||||
expect(service.isConfigWatcherEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('属性测试 - 配置查询一致性', () => {
|
||||
/**
|
||||
* 属性测试: 配置查询的一致性
|
||||
* 验证双向查询的一致性(mapId <-> stream)
|
||||
*/
|
||||
it('mapId和stream之间的双向查询应该保持一致', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 从现有的mapId中选择
|
||||
fc.constantFrom('novice_village', 'tavern'),
|
||||
async (mapId) => {
|
||||
// 通过mapId获取stream
|
||||
const stream = service.getStreamByMap(mapId);
|
||||
expect(stream).not.toBeNull();
|
||||
|
||||
// 通过stream反向获取mapId
|
||||
const retrievedMapId = service.getMapIdByStream(stream!);
|
||||
expect(retrievedMapId).toBe(mapId);
|
||||
|
||||
// 通过stream获取配置
|
||||
const config = service.getMapConfigByStream(stream!);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.mapId).toBe(mapId);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性测试: 交互对象查询的一致性
|
||||
*/
|
||||
it('交互对象的不同查询方式应该返回一致的结果', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.constantFrom('novice_village', 'tavern'),
|
||||
async (mapId) => {
|
||||
// 获取地图中的所有对象
|
||||
const objectsInMap = service.getObjectsInMap(mapId);
|
||||
|
||||
for (const obj of objectsInMap) {
|
||||
// 通过topic查找对象
|
||||
const objByTopic = service.findObjectByTopic(obj.zulipTopic);
|
||||
expect(objByTopic).not.toBeNull();
|
||||
expect(objByTopic!.objectId).toBe(obj.objectId);
|
||||
expect(objByTopic!.mapId).toBe(mapId);
|
||||
|
||||
// 通过mapId和objectId获取topic
|
||||
const topic = service.getTopicByObject(mapId, obj.objectId);
|
||||
expect(topic).toBe(obj.zulipTopic);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性测试: 配置验证的幂等性
|
||||
*/
|
||||
it('配置验证应该是幂等的', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
mapId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
mapName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
zulipStream: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
interactionObjects: fc.array(
|
||||
fc.record({
|
||||
objectId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
objectName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
zulipTopic: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
||||
position: fc.record({
|
||||
x: fc.integer({ min: 0, max: 10000 }),
|
||||
y: fc.integer({ min: 0, max: 10000 }),
|
||||
}),
|
||||
}),
|
||||
{ maxLength: 5 }
|
||||
),
|
||||
}),
|
||||
async (config) => {
|
||||
// 多次验证同一个配置应该返回相同结果
|
||||
const result1 = service.validateMapConfigDetailed(config);
|
||||
const result2 = service.validateMapConfigDetailed(config);
|
||||
const result3 = service.validateMapConfigDetailed(config);
|
||||
|
||||
expect(result1.valid).toBe(result2.valid);
|
||||
expect(result2.valid).toBe(result3.valid);
|
||||
expect(result1.errors).toEqual(result2.errors);
|
||||
expect(result2.errors).toEqual(result3.errors);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* 依赖模块:
|
||||
* - AppLoggerService: 日志记录服务
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -139,10 +139,39 @@ export class ConfigManagerService implements OnModuleDestroy {
|
||||
private configLoadTime: Date;
|
||||
private configWatcher: fs.FSWatcher | null = null;
|
||||
private isWatcherEnabled: boolean = false;
|
||||
private readonly CONFIG_DIR = path.join(process.cwd(), 'config', 'zulip');
|
||||
private readonly CONFIG_DIR = this.getConfigDir();
|
||||
private readonly MAP_CONFIG_FILE = 'map-config.json';
|
||||
private readonly logger = new Logger(ConfigManagerService.name);
|
||||
|
||||
/**
|
||||
* 获取配置目录路径
|
||||
*
|
||||
* 在开发环境中使用 config/zulip
|
||||
* 在生产环境中使用 dist/zulip (编译后的位置)
|
||||
*/
|
||||
private getConfigDir(): string {
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
if (isDevelopment) {
|
||||
// 开发环境:使用源码目录
|
||||
return path.join(process.cwd(), 'config', 'zulip');
|
||||
} else {
|
||||
// 生产环境:使用编译后的目录
|
||||
const distConfigPath = path.join(process.cwd(), 'dist', 'zulip');
|
||||
const rootConfigPath = path.join(process.cwd(), 'config', 'zulip');
|
||||
|
||||
// 优先使用 dist/zulip,如果不存在则回退到 config/zulip
|
||||
if (fs.existsSync(distConfigPath)) {
|
||||
return distConfigPath;
|
||||
} else if (fs.existsSync(rootConfigPath)) {
|
||||
return rootConfigPath;
|
||||
} else {
|
||||
// 都不存在,使用默认路径
|
||||
return distConfigPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.logger.log('ConfigManagerService初始化完成');
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
|
||||
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -570,4 +570,457 @@ describe('ErrorHandlerService', () => {
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
|
||||
// ==================== 补充测试用例 ====================
|
||||
|
||||
describe('错误统计测试', () => {
|
||||
it('应该正确记录和获取错误统计', async () => {
|
||||
// 触发几个不同类型的错误
|
||||
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth');
|
||||
await service.handleZulipError({ code: 429, message: 'Rate limit' }, 'rate');
|
||||
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth2');
|
||||
|
||||
const stats = service.getErrorStats();
|
||||
|
||||
expect(stats.serviceStatus).toBeDefined();
|
||||
expect(stats.errorCounts).toBeDefined();
|
||||
expect(stats.recentErrors).toBeDefined();
|
||||
|
||||
// 应该有认证错误和频率限制错误的记录
|
||||
expect(Object.keys(stats.errorCounts).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('应该能够重置错误统计', async () => {
|
||||
// 先产生一些错误
|
||||
await service.handleZulipError({ code: 500, message: 'Server error' }, 'test');
|
||||
|
||||
service.resetErrorStats();
|
||||
|
||||
const stats = service.getErrorStats();
|
||||
expect(Object.keys(stats.errorCounts)).toHaveLength(0);
|
||||
expect(Object.keys(stats.recentErrors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务健康检查测试', () => {
|
||||
it('应该返回完整的健康状态信息', async () => {
|
||||
const health = await service.checkServiceHealth();
|
||||
|
||||
expect(health.status).toBeDefined();
|
||||
expect(health.details).toBeDefined();
|
||||
expect(health.details.serviceStatus).toBeDefined();
|
||||
expect(health.details.errorCounts).toBeDefined();
|
||||
expect(health.details.lastErrors).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该在降级模式下返回正确状态', async () => {
|
||||
await service.enableDegradedMode();
|
||||
|
||||
const health = await service.checkServiceHealth();
|
||||
|
||||
expect(health.status).toBe(ServiceStatus.DEGRADED);
|
||||
expect(health.details.degradedModeStartTime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('配置获取测试', () => {
|
||||
it('应该返回正确的配置信息', () => {
|
||||
const config = service.getConfig();
|
||||
|
||||
expect(config.degradedModeEnabled).toBe(true);
|
||||
expect(config.autoReconnectEnabled).toBe(true);
|
||||
expect(config.maxReconnectAttempts).toBe(5);
|
||||
expect(config.reconnectBaseDelay).toBe(1000);
|
||||
expect(config.apiTimeout).toBe(30000);
|
||||
expect(config.maxRetries).toBe(3);
|
||||
expect(config.maxConnections).toBe(1000);
|
||||
});
|
||||
|
||||
it('应该返回正确的单个配置项', () => {
|
||||
expect(service.isDegradedModeEnabled()).toBe(true);
|
||||
expect(service.isAutoReconnectEnabled()).toBe(true);
|
||||
expect(service.getApiTimeout()).toBe(30000);
|
||||
expect(service.getMaxRetries()).toBe(3);
|
||||
expect(service.getMaxReconnectAttempts()).toBe(5);
|
||||
expect(service.getReconnectBaseDelay()).toBe(1000);
|
||||
});
|
||||
|
||||
it('应该返回默认重试配置', () => {
|
||||
const retryConfig = service.getDefaultRetryConfig();
|
||||
|
||||
expect(retryConfig.maxRetries).toBe(3);
|
||||
expect(retryConfig.baseDelay).toBe(1000);
|
||||
expect(retryConfig.maxDelay).toBe(30000);
|
||||
expect(retryConfig.backoffMultiplier).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('状态检查方法测试', () => {
|
||||
it('应该正确检查服务可用性', () => {
|
||||
expect(service.isServiceAvailable()).toBe(true);
|
||||
|
||||
// 设置为不可用状态(通过私有属性)
|
||||
(service as any).serviceStatus = ServiceStatus.UNAVAILABLE;
|
||||
expect(service.isServiceAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('应该正确检查降级模式状态', async () => {
|
||||
expect(service.isDegradedMode()).toBe(false);
|
||||
|
||||
await service.enableDegradedMode();
|
||||
expect(service.isDegradedMode()).toBe(true);
|
||||
|
||||
await service.enableNormalMode();
|
||||
expect(service.isDegradedMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('连接数管理测试', () => {
|
||||
it('应该能够设置最大连接数', () => {
|
||||
service.setMaxConnections(500);
|
||||
expect(service.getConfig().maxConnections).toBe(500);
|
||||
});
|
||||
|
||||
it('应该正确处理负连接数变化', () => {
|
||||
service.updateActiveConnections(100);
|
||||
service.updateActiveConnections(-150); // 应该不会变成负数
|
||||
|
||||
// 活跃连接数不应该小于0
|
||||
const loadStatus = service.getLoadStatus();
|
||||
expect(loadStatus).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该在连接数达到上限时限制新连接', () => {
|
||||
service.setMaxConnections(100);
|
||||
service.updateActiveConnections(100);
|
||||
|
||||
expect(service.shouldLimitNewConnections()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('带超时和重试的操作执行测试', () => {
|
||||
it('应该成功执行带超时和重试的操作', async () => {
|
||||
const operation = jest.fn().mockResolvedValue('success');
|
||||
|
||||
const result = await service.executeWithTimeoutAndRetry(
|
||||
operation,
|
||||
{ timeout: 1000, operation: 'test' },
|
||||
{ maxRetries: 2, baseDelay: 10 }
|
||||
);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('应该在超时后重试', async () => {
|
||||
let callCount = 0;
|
||||
const operation = jest.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return new Promise(resolve => setTimeout(() => resolve('late'), 200));
|
||||
}
|
||||
return Promise.resolve('success');
|
||||
});
|
||||
|
||||
const result = await service.executeWithTimeoutAndRetry(
|
||||
operation,
|
||||
{ timeout: 50, operation: 'test' },
|
||||
{ maxRetries: 2, baseDelay: 10 }
|
||||
);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('重连状态管理测试', () => {
|
||||
it('应该正确获取重连状态', async () => {
|
||||
const reconnectCallback = jest.fn().mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve(false), 100))
|
||||
);
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 3,
|
||||
baseDelay: 50,
|
||||
});
|
||||
|
||||
const state = service.getReconnectState('user1');
|
||||
expect(state).toBeDefined();
|
||||
expect(state?.userId).toBe('user1');
|
||||
expect(state?.isReconnecting).toBe(true);
|
||||
|
||||
// 清理
|
||||
service.cancelReconnect('user1');
|
||||
});
|
||||
|
||||
it('应该在重连失败达到最大次数后停止', async () => {
|
||||
const reconnectCallback = jest.fn().mockResolvedValue(false);
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 2,
|
||||
baseDelay: 10,
|
||||
});
|
||||
|
||||
// 等待重连尝试完成
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// 应该尝试了最大次数
|
||||
expect(reconnectCallback).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 重连状态应该被清理
|
||||
expect(service.getReconnectState('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理重连回调异常', async () => {
|
||||
const reconnectCallback = jest.fn().mockRejectedValue(new Error('Reconnect failed'));
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 2,
|
||||
baseDelay: 10,
|
||||
});
|
||||
|
||||
// 等待重连尝试完成
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// 应该尝试了最大次数
|
||||
expect(reconnectCallback).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 重连状态应该被清理
|
||||
expect(service.getReconnectState('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该在已有重连进行时跳过新的重连调度', async () => {
|
||||
const reconnectCallback1 = jest.fn().mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve(false), 200))
|
||||
);
|
||||
const reconnectCallback2 = jest.fn().mockResolvedValue(true);
|
||||
|
||||
// 第一次调度
|
||||
const scheduled1 = await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback: reconnectCallback1,
|
||||
maxAttempts: 3,
|
||||
baseDelay: 50,
|
||||
});
|
||||
|
||||
// 第二次调度(应该被跳过)
|
||||
const scheduled2 = await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback: reconnectCallback2,
|
||||
maxAttempts: 3,
|
||||
baseDelay: 50,
|
||||
});
|
||||
|
||||
expect(scheduled1).toBe(true);
|
||||
expect(scheduled2).toBe(false);
|
||||
expect(reconnectCallback2).not.toHaveBeenCalled();
|
||||
|
||||
// 清理
|
||||
service.cancelReconnect('user1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('事件发射测试', () => {
|
||||
it('应该在启用降级模式时发射事件', async () => {
|
||||
const eventListener = jest.fn();
|
||||
service.on('degraded_mode_enabled', eventListener);
|
||||
|
||||
await service.enableDegradedMode();
|
||||
|
||||
expect(eventListener).toHaveBeenCalledWith({
|
||||
startTime: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在重连成功时发射事件', async () => {
|
||||
const eventListener = jest.fn();
|
||||
service.on('reconnect_success', eventListener);
|
||||
|
||||
const reconnectCallback = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
});
|
||||
|
||||
// 等待重连完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(eventListener).toHaveBeenCalledWith({
|
||||
userId: 'user1',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在重连失败时发射事件', async () => {
|
||||
const eventListener = jest.fn();
|
||||
service.on('reconnect_failed', eventListener);
|
||||
|
||||
const reconnectCallback = jest.fn().mockResolvedValue(false);
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 1,
|
||||
baseDelay: 10,
|
||||
});
|
||||
|
||||
// 等待重连完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(eventListener).toHaveBeenCalledWith({
|
||||
userId: 'user1',
|
||||
attempts: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理边界条件测试', () => {
|
||||
it('应该处理空错误对象', async () => {
|
||||
const result = await service.handleZulipError(null, 'test');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.shouldRetry).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理没有code和message的错误', async () => {
|
||||
const result = await service.handleZulipError({}, 'test');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理错误处理过程中的异常', async () => {
|
||||
// 模拟错误分类过程中的异常
|
||||
const originalClassifyError = (service as any).classifyError;
|
||||
(service as any).classifyError = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Classification error');
|
||||
});
|
||||
|
||||
const result = await service.handleZulipError({ code: 500 }, 'test');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('错误处理失败');
|
||||
|
||||
// 恢复原方法
|
||||
(service as any).classifyError = originalClassifyError;
|
||||
});
|
||||
});
|
||||
|
||||
describe('模块销毁测试', () => {
|
||||
it('应该正确清理所有资源', async () => {
|
||||
// 设置一些状态
|
||||
await service.enableDegradedMode();
|
||||
|
||||
const reconnectCallback = jest.fn().mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve(false), 1000))
|
||||
);
|
||||
|
||||
await service.scheduleReconnect({
|
||||
userId: 'user1',
|
||||
reconnectCallback,
|
||||
maxAttempts: 5,
|
||||
baseDelay: 100,
|
||||
});
|
||||
|
||||
// 销毁模块
|
||||
await service.onModuleDestroy();
|
||||
|
||||
// 验证资源被清理
|
||||
expect(service.getReconnectState('user1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('并发操作测试', () => {
|
||||
it('应该处理并发的错误处理请求', async () => {
|
||||
const errors = [
|
||||
{ code: 401, message: 'Unauthorized' },
|
||||
{ code: 429, message: 'Rate limit' },
|
||||
{ code: 500, message: 'Server error' },
|
||||
{ code: 'ECONNREFUSED', message: 'Connection refused' },
|
||||
];
|
||||
|
||||
const promises = errors.map((error, index) =>
|
||||
service.handleZulipError(error, `operation${index}`)
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// 所有请求都应该得到处理
|
||||
expect(results).toHaveLength(4);
|
||||
results.forEach(result => {
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(typeof result.shouldRetry).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理并发的重连请求', async () => {
|
||||
const users = ['user1', 'user2', 'user3'];
|
||||
const reconnectCallback = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const promises = users.map(userId =>
|
||||
service.scheduleReconnect({
|
||||
userId,
|
||||
reconnectCallback,
|
||||
maxAttempts: 2,
|
||||
baseDelay: 10,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// 所有重连都应该被调度
|
||||
expect(results.every(r => r === true)).toBe(true);
|
||||
|
||||
// 等待重连完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 验证所有用户的重连状态都被清理
|
||||
users.forEach(userId => {
|
||||
expect(service.getReconnectState(userId)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
it('应该能够处理大量错误而不影响性能', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 处理100个错误
|
||||
const promises = Array.from({ length: 100 }, (_, i) =>
|
||||
service.handleZulipError({ code: 500, message: `Error ${i}` }, `op${i}`)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// 应该在合理时间内完成(比如1秒)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('应该能够处理大量连接数更新', () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 更新1000次连接数
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
service.updateActiveConnections(1);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// 应该在合理时间内完成
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* 依赖模块:
|
||||
* - AppLoggerService: 日志记录服务
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl, moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -239,8 +239,8 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
||||
this.logger.warn('处理Zulip API错误', {
|
||||
operation: 'handleZulipError',
|
||||
targetOperation: operation,
|
||||
errorMessage: error.message,
|
||||
errorCode: error.code,
|
||||
errorMessage: error?.message || 'Unknown error',
|
||||
errorCode: error?.code || 'UNKNOWN',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -269,7 +269,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
||||
this.logger.error('错误处理过程中发生异常', {
|
||||
operation: 'handleZulipError',
|
||||
targetOperation: operation,
|
||||
originalError: error.message,
|
||||
originalError: error?.message || 'Unknown error',
|
||||
handlingError: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
@@ -438,7 +438,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
||||
this.logger.warn('处理连接错误', {
|
||||
operation: 'handleConnectionError',
|
||||
connectionType,
|
||||
errorMessage: error.message,
|
||||
errorMessage: error?.message || 'Unknown connection error',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -632,16 +632,16 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
||||
case ErrorType.ZULIP_API_ERROR:
|
||||
return {
|
||||
success: false,
|
||||
shouldRetry: error.code >= 500, // 服务器错误可以重试
|
||||
shouldRetry: error?.code >= 500, // 服务器错误可以重试
|
||||
retryAfter: 2000,
|
||||
message: `Zulip API错误: ${error.message}`,
|
||||
message: `Zulip API错误: ${error?.message || 'Unknown API error'}`,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
shouldRetry: false,
|
||||
message: `未知错误: ${error.message}`,
|
||||
message: `未知错误: ${error?.message || 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* **Feature: zulip-integration, Property 11: 系统监控和告警**
|
||||
* **Validates: Requirements 9.4**
|
||||
*
|
||||
* @author angjustinl
|
||||
* @author angjustinl moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
@@ -730,4 +730,362 @@ describe('MonitoringService', () => {
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ==================== 补充测试用例 ====================
|
||||
|
||||
describe('边界条件和错误处理测试', () => {
|
||||
it('应该正确处理活跃连接数不会变成负数', () => {
|
||||
// 先断开一个不存在的连接
|
||||
service.logConnection({
|
||||
socketId: 'socket1',
|
||||
eventType: ConnectionEventType.DISCONNECTED,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const stats = service.getStats();
|
||||
expect(stats.connections.active).toBe(0); // 不应该是负数
|
||||
});
|
||||
|
||||
it('应该正确处理空的最近告警列表', () => {
|
||||
const alerts = service.getRecentAlerts(5);
|
||||
expect(alerts).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该正确处理超出限制的最近告警请求', () => {
|
||||
// 添加一些告警
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.sendAlert({
|
||||
id: `alert-${i}`,
|
||||
level: AlertLevel.INFO,
|
||||
title: `Alert ${i}`,
|
||||
message: `Message ${i}`,
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 请求超过实际数量的告警
|
||||
const alerts = service.getRecentAlerts(10);
|
||||
expect(alerts.length).toBe(5);
|
||||
});
|
||||
|
||||
it('应该正确处理零除法情况', () => {
|
||||
// 在没有任何API调用时获取统计
|
||||
const stats = service.getStats();
|
||||
expect(stats.apiCalls.avgResponseTime).toBe(0); // 应该是0而不是NaN
|
||||
});
|
||||
|
||||
it('应该正确处理消息延迟统计的零除法', () => {
|
||||
// 在没有任何消息时获取统计
|
||||
const stats = service.getStats();
|
||||
expect(stats.messages.avgLatency).toBe(0); // 应该是0而不是NaN
|
||||
});
|
||||
|
||||
it('应该正确处理不同级别的告警日志', () => {
|
||||
const logSpy = jest.spyOn(Logger.prototype, 'log');
|
||||
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
|
||||
const errorSpy = jest.spyOn(Logger.prototype, 'error');
|
||||
|
||||
// INFO级别
|
||||
service.sendAlert({
|
||||
id: 'info-alert',
|
||||
level: AlertLevel.INFO,
|
||||
title: 'Info Alert',
|
||||
message: 'Info message',
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// WARNING级别
|
||||
service.sendAlert({
|
||||
id: 'warn-alert',
|
||||
level: AlertLevel.WARNING,
|
||||
title: 'Warning Alert',
|
||||
message: 'Warning message',
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// ERROR级别
|
||||
service.sendAlert({
|
||||
id: 'error-alert',
|
||||
level: AlertLevel.ERROR,
|
||||
title: 'Error Alert',
|
||||
message: 'Error message',
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// CRITICAL级别
|
||||
service.sendAlert({
|
||||
id: 'critical-alert',
|
||||
level: AlertLevel.CRITICAL,
|
||||
title: 'Critical Alert',
|
||||
message: 'Critical message',
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalled(); // INFO
|
||||
expect(warnSpy).toHaveBeenCalled(); // WARNING
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2); // ERROR + CRITICAL
|
||||
});
|
||||
|
||||
it('应该正确处理健康检查中的降级状态', async () => {
|
||||
// 先添加一些正常连接
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.logConnection({
|
||||
socketId: `socket-normal-${i}`,
|
||||
eventType: ConnectionEventType.CONNECTED,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 然后添加一些错误来触发降级状态
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.logConnection({
|
||||
socketId: `socket-error-${i}`,
|
||||
eventType: ConnectionEventType.ERROR,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const health = await service.checkSystemHealth();
|
||||
|
||||
// 错误率应该是 5/10 = 50%,超过阈值 10%,应该是不健康状态
|
||||
expect(health.components.websocket.status).toMatch(/^(degraded|unhealthy)$/);
|
||||
});
|
||||
|
||||
it('应该正确处理API调用的降级状态', async () => {
|
||||
// 先添加一些成功的API调用
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.logApiCall({
|
||||
operation: 'test',
|
||||
userId: 'user1',
|
||||
result: ApiCallResult.SUCCESS,
|
||||
responseTime: 100,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 然后模拟大量失败的API调用
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.logApiCall({
|
||||
operation: 'test',
|
||||
userId: 'user1',
|
||||
result: ApiCallResult.FAILURE,
|
||||
responseTime: 100,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const health = await service.checkSystemHealth();
|
||||
|
||||
// 错误率应该是 5/15 = 33%,超过阈值 10%,应该是不健康状态
|
||||
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
|
||||
});
|
||||
|
||||
it('应该正确处理慢API调用的降级状态', async () => {
|
||||
// 模拟慢API调用
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.logApiCall({
|
||||
operation: 'test',
|
||||
userId: 'user1',
|
||||
result: ApiCallResult.SUCCESS,
|
||||
responseTime: 15000, // 超过阈值
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const health = await service.checkSystemHealth();
|
||||
|
||||
// 应该检测到API组件降级
|
||||
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
|
||||
});
|
||||
|
||||
it('应该正确处理模块生命周期', () => {
|
||||
// 测试模块初始化
|
||||
service.onModuleInit();
|
||||
|
||||
// 验证健康检查已启动(通过检查私有属性)
|
||||
expect((service as any).healthCheckInterval).toBeDefined();
|
||||
|
||||
// 测试模块销毁
|
||||
service.onModuleDestroy();
|
||||
|
||||
// 验证健康检查已停止
|
||||
expect((service as any).healthCheckInterval).toBeNull();
|
||||
});
|
||||
|
||||
it('应该正确处理最近日志的大小限制', () => {
|
||||
const maxLogs = (service as any).maxRecentLogs;
|
||||
|
||||
// 添加超过限制的API调用日志
|
||||
for (let i = 0; i < maxLogs + 10; i++) {
|
||||
service.logApiCall({
|
||||
operation: `test-${i}`,
|
||||
userId: 'user1',
|
||||
result: ApiCallResult.SUCCESS,
|
||||
responseTime: 100,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证最近日志数量不超过限制
|
||||
const recentLogs = (service as any).recentApiCalls;
|
||||
expect(recentLogs.length).toBeLessThanOrEqual(maxLogs);
|
||||
});
|
||||
|
||||
it('应该正确处理最近告警的大小限制', () => {
|
||||
const maxLogs = (service as any).maxRecentLogs;
|
||||
|
||||
// 添加超过限制的告警
|
||||
for (let i = 0; i < maxLogs + 10; i++) {
|
||||
service.sendAlert({
|
||||
id: `alert-${i}`,
|
||||
level: AlertLevel.INFO,
|
||||
title: `Alert ${i}`,
|
||||
message: `Message ${i}`,
|
||||
component: 'test',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证最近告警数量不超过限制
|
||||
const recentAlerts = (service as any).recentAlerts;
|
||||
expect(recentAlerts.length).toBeLessThanOrEqual(maxLogs);
|
||||
});
|
||||
|
||||
it('应该正确处理消息转发错误统计', () => {
|
||||
service.logMessageForward({
|
||||
fromUserId: 'user1',
|
||||
toUserIds: ['user2'],
|
||||
stream: 'test-stream',
|
||||
topic: 'test-topic',
|
||||
direction: 'upstream',
|
||||
success: false, // 失败的消息
|
||||
latency: 100,
|
||||
error: 'Transfer failed',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const stats = service.getStats();
|
||||
expect(stats.messages.errors).toBe(1);
|
||||
});
|
||||
|
||||
it('应该正确处理带有元数据的连接日志', () => {
|
||||
const eventHandler = jest.fn();
|
||||
service.on('connection_event', eventHandler);
|
||||
|
||||
service.logConnection({
|
||||
socketId: 'socket1',
|
||||
userId: 'user1',
|
||||
eventType: ConnectionEventType.CONNECTED,
|
||||
duration: 1000,
|
||||
timestamp: new Date(),
|
||||
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
|
||||
});
|
||||
|
||||
expect(eventHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
|
||||
})
|
||||
);
|
||||
|
||||
service.removeListener('connection_event', eventHandler);
|
||||
});
|
||||
|
||||
it('应该正确处理带有元数据的API调用日志', () => {
|
||||
const eventHandler = jest.fn();
|
||||
service.on('api_call', eventHandler);
|
||||
|
||||
service.logApiCall({
|
||||
operation: 'sendMessage',
|
||||
userId: 'user1',
|
||||
result: ApiCallResult.SUCCESS,
|
||||
responseTime: 100,
|
||||
statusCode: 200,
|
||||
timestamp: new Date(),
|
||||
metadata: { endpoint: '/api/messages', method: 'POST' },
|
||||
});
|
||||
|
||||
expect(eventHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { endpoint: '/api/messages', method: 'POST' },
|
||||
})
|
||||
);
|
||||
|
||||
service.removeListener('api_call', eventHandler);
|
||||
});
|
||||
|
||||
it('应该正确处理带有消息ID的消息转发日志', () => {
|
||||
const eventHandler = jest.fn();
|
||||
service.on('message_forward', eventHandler);
|
||||
|
||||
service.logMessageForward({
|
||||
messageId: 12345,
|
||||
fromUserId: 'user1',
|
||||
toUserIds: ['user2', 'user3'],
|
||||
stream: 'test-stream',
|
||||
topic: 'test-topic',
|
||||
direction: 'downstream',
|
||||
success: true,
|
||||
latency: 50,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
expect(eventHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 12345,
|
||||
})
|
||||
);
|
||||
|
||||
service.removeListener('message_forward', eventHandler);
|
||||
});
|
||||
|
||||
it('应该正确处理带有详情的操作确认', () => {
|
||||
const eventHandler = jest.fn();
|
||||
service.on('operation_confirmed', eventHandler);
|
||||
|
||||
service.confirmOperation({
|
||||
operationId: 'op123',
|
||||
operation: 'sendMessage',
|
||||
userId: 'user1',
|
||||
success: true,
|
||||
timestamp: new Date(),
|
||||
details: { messageId: 456, recipients: 3 },
|
||||
});
|
||||
|
||||
expect(eventHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: { messageId: 456, recipients: 3 },
|
||||
})
|
||||
);
|
||||
|
||||
service.removeListener('operation_confirmed', eventHandler);
|
||||
});
|
||||
|
||||
it('应该正确处理带有元数据的告警', () => {
|
||||
const eventHandler = jest.fn();
|
||||
service.on('alert', eventHandler);
|
||||
|
||||
service.sendAlert({
|
||||
id: 'alert123',
|
||||
level: AlertLevel.WARNING,
|
||||
title: 'Test Alert',
|
||||
message: 'Test message',
|
||||
component: 'test-component',
|
||||
timestamp: new Date(),
|
||||
metadata: { threshold: 5000, actual: 7000 },
|
||||
});
|
||||
|
||||
expect(eventHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { threshold: 5000, actual: 7000 },
|
||||
})
|
||||
);
|
||||
|
||||
service.removeListener('alert', eventHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
388
src/core/zulip/services/user_management.service.spec.ts
Normal file
388
src/core/zulip/services/user_management.service.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Zulip用户管理服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试UserManagementService的核心功能
|
||||
* - 测试用户查询和验证逻辑
|
||||
* - 测试错误处理和边界情况
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserManagementService, UserQueryRequest, UserValidationRequest } from './user_management.service';
|
||||
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||
|
||||
// 模拟fetch
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('UserManagementService', () => {
|
||||
let service: UserManagementService;
|
||||
let mockConfigService: jest.Mocked<IZulipConfigService>;
|
||||
let mockFetch: jest.MockedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 重置fetch模拟
|
||||
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||
mockFetch.mockClear();
|
||||
|
||||
// 创建模拟的配置服务
|
||||
mockConfigService = {
|
||||
getZulipConfig: jest.fn().mockReturnValue({
|
||||
zulipServerUrl: 'https://test.zulip.com',
|
||||
zulipBotEmail: 'bot@test.com',
|
||||
zulipBotApiKey: 'test-api-key',
|
||||
}),
|
||||
getMapIdByStream: jest.fn(),
|
||||
getStreamByMap: jest.fn(),
|
||||
getMapConfig: jest.fn(),
|
||||
hasMap: jest.fn(),
|
||||
getAllMapIds: jest.fn(),
|
||||
getMapConfigByStream: jest.fn(),
|
||||
getAllStreams: jest.fn(),
|
||||
hasStream: jest.fn(),
|
||||
findObjectByTopic: jest.fn(),
|
||||
getObjectsInMap: jest.fn(),
|
||||
getTopicByObject: jest.fn(),
|
||||
findNearbyObject: jest.fn(),
|
||||
reloadConfig: jest.fn(),
|
||||
validateConfig: jest.fn(),
|
||||
getAllMapConfigs: jest.fn(),
|
||||
getConfigStats: jest.fn(),
|
||||
getConfigFilePath: jest.fn(),
|
||||
configFileExists: jest.fn(),
|
||||
enableConfigWatcher: jest.fn(),
|
||||
disableConfigWatcher: jest.fn(),
|
||||
isConfigWatcherEnabled: jest.fn(),
|
||||
getFullConfiguration: jest.fn(),
|
||||
updateConfigValue: jest.fn(),
|
||||
exportMapConfig: jest.fn(),
|
||||
} as jest.Mocked<IZulipConfigService>;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserManagementService,
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserManagementService>(UserManagementService);
|
||||
});
|
||||
|
||||
it('应该正确初始化服务', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('checkUserExists - 检查用户是否存在', () => {
|
||||
it('应该正确检查存在的用户', async () => {
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [
|
||||
{
|
||||
user_id: 1,
|
||||
email: 'test@example.com',
|
||||
full_name: 'Test User',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_bot: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkUserExists('test@example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://test.zulip.com/api/v1/users',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': expect.stringContaining('Basic'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确检查不存在的用户', async () => {
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [
|
||||
{
|
||||
user_id: 1,
|
||||
email: 'other@example.com',
|
||||
full_name: 'Other User',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_bot: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkUserExists('test@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理无效邮箱', async () => {
|
||||
const result = await service.checkUserExists('invalid-email');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理API调用失败', async () => {
|
||||
// 模拟API失败
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
const result = await service.checkUserExists('test@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理网络异常', async () => {
|
||||
// 模拟网络异常
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await service.checkUserExists('test@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo - 获取用户信息', () => {
|
||||
it('应该成功获取用户信息', async () => {
|
||||
const request: UserQueryRequest = {
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [
|
||||
{
|
||||
user_id: 1,
|
||||
email: 'test@example.com',
|
||||
full_name: 'Test User',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_bot: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.getUserInfo(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.userId).toBe(1);
|
||||
expect(result.email).toBe('test@example.com');
|
||||
expect(result.fullName).toBe('Test User');
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.isAdmin).toBe(false);
|
||||
expect(result.isBot).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理用户不存在的情况', async () => {
|
||||
const request: UserQueryRequest = {
|
||||
email: 'nonexistent@example.com',
|
||||
};
|
||||
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.getUserInfo(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('用户不存在');
|
||||
});
|
||||
|
||||
it('应该拒绝无效邮箱', async () => {
|
||||
const request: UserQueryRequest = {
|
||||
email: 'invalid-email',
|
||||
};
|
||||
|
||||
const result = await service.getUserInfo(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('邮箱格式无效');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUserCredentials - 验证用户凭据', () => {
|
||||
it('应该成功验证有效的API Key', async () => {
|
||||
const request: UserValidationRequest = {
|
||||
email: 'test@example.com',
|
||||
apiKey: 'valid-api-key',
|
||||
};
|
||||
|
||||
// 模拟API Key验证响应(第一个调用)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
// 模拟用户列表API响应(第二个调用)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [
|
||||
{
|
||||
user_id: 1,
|
||||
email: 'test@example.com',
|
||||
full_name: 'Test User',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_bot: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.validateUserCredentials(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.userId).toBe(1);
|
||||
});
|
||||
|
||||
it('应该拒绝无效的API Key', async () => {
|
||||
const request: UserValidationRequest = {
|
||||
email: 'test@example.com',
|
||||
apiKey: 'invalid-api-key',
|
||||
};
|
||||
|
||||
// 模拟API Key验证失败(第一个调用)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
} as Response);
|
||||
|
||||
const result = await service.validateUserCredentials(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空的API Key', async () => {
|
||||
const request: UserValidationRequest = {
|
||||
email: 'test@example.com',
|
||||
apiKey: '',
|
||||
};
|
||||
|
||||
const result = await service.validateUserCredentials(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('API Key不能为空');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝无效邮箱', async () => {
|
||||
const request: UserValidationRequest = {
|
||||
email: 'invalid-email',
|
||||
apiKey: 'some-api-key',
|
||||
};
|
||||
|
||||
const result = await service.validateUserCredentials(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('邮箱格式无效');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllUsers - 获取所有用户', () => {
|
||||
it('应该成功获取用户列表', async () => {
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [
|
||||
{
|
||||
user_id: 1,
|
||||
email: 'user1@example.com',
|
||||
full_name: 'User One',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_bot: false,
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
email: 'user2@example.com',
|
||||
full_name: 'User Two',
|
||||
is_active: true,
|
||||
is_admin: true,
|
||||
is_bot: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.getAllUsers();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.users?.[0]).toEqual({
|
||||
userId: 1,
|
||||
email: 'user1@example.com',
|
||||
fullName: 'User One',
|
||||
isActive: true,
|
||||
isAdmin: false,
|
||||
isBot: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理空用户列表', async () => {
|
||||
// 模拟API响应
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
members: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await service.getAllUsers();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users).toHaveLength(0);
|
||||
expect(result.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理API调用失败', async () => {
|
||||
// 模拟API失败
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
} as Response);
|
||||
|
||||
const result = await service.getAllUsers();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API调用失败');
|
||||
});
|
||||
});
|
||||
});
|
||||
539
src/core/zulip/services/user_management.service.ts
Normal file
539
src/core/zulip/services/user_management.service.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Zulip用户管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 查询和验证Zulip用户信息
|
||||
* - 检查用户是否存在
|
||||
* - 获取用户详细信息
|
||||
* - 验证用户凭据和权限
|
||||
*
|
||||
* 主要方法:
|
||||
* - checkUserExists(): 检查用户是否存在
|
||||
* - getUserInfo(): 获取用户详细信息
|
||||
* - validateUserCredentials(): 验证用户凭据
|
||||
* - getAllUsers(): 获取所有用户列表
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登录时验证用户存在性
|
||||
* - 获取用户基本信息
|
||||
* - 验证用户权限和状态
|
||||
* - 管理员查看用户列表
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||
|
||||
/**
|
||||
* Zulip API响应接口
|
||||
*/
|
||||
interface ZulipApiResponse {
|
||||
result?: 'success' | 'error';
|
||||
msg?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息接口
|
||||
*/
|
||||
interface ZulipUser {
|
||||
user_id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
is_owner: boolean;
|
||||
is_bot: boolean;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表响应接口
|
||||
*/
|
||||
interface ZulipUsersResponse extends ZulipApiResponse {
|
||||
members?: ZulipUser[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户查询请求接口
|
||||
*/
|
||||
export interface UserQueryRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息响应接口
|
||||
*/
|
||||
export interface UserInfoResponse {
|
||||
success: boolean;
|
||||
userId?: number;
|
||||
email?: string;
|
||||
fullName?: string;
|
||||
isActive?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isBot?: boolean;
|
||||
dateJoined?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户验证请求接口
|
||||
*/
|
||||
export interface UserValidationRequest {
|
||||
email: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户验证响应接口
|
||||
*/
|
||||
export interface UserValidationResponse {
|
||||
success: boolean;
|
||||
isValid?: boolean;
|
||||
userId?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表响应接口
|
||||
*/
|
||||
export interface UsersListResponse {
|
||||
success: boolean;
|
||||
users?: Array<{
|
||||
userId: number;
|
||||
email: string;
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
isAdmin: boolean;
|
||||
isBot: boolean;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip用户管理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 查询和验证Zulip用户信息
|
||||
* - 检查用户是否存在于Zulip服务器
|
||||
* - 获取用户详细信息和权限状态
|
||||
* - 提供用户管理相关的API接口
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserManagementService {
|
||||
private readonly logger = new Logger(UserManagementService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configService: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('UserManagementService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否存在
|
||||
*
|
||||
* 功能描述:
|
||||
* 通过Zulip API检查指定邮箱的用户是否存在
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 获取所有用户列表
|
||||
* 2. 在列表中查找指定邮箱
|
||||
* 3. 返回用户存在性结果
|
||||
*
|
||||
* @param email 用户邮箱
|
||||
* @returns Promise<boolean> 是否存在
|
||||
*/
|
||||
async checkUserExists(email: string): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始检查用户是否存在', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证邮箱格式
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
this.logger.warn('邮箱格式无效', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 获取用户列表
|
||||
const usersResult = await this.getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
this.logger.warn('获取用户列表失败', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
error: usersResult.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查用户是否存在
|
||||
const userExists = usersResult.users?.some(user =>
|
||||
user.email.toLowerCase() === email.toLowerCase()
|
||||
) || false;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户存在性检查完成', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
exists: userExists,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return userExists;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('检查用户存在性失败', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详细信息
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据邮箱获取用户的详细信息
|
||||
*
|
||||
* @param request 用户查询请求
|
||||
* @returns Promise<UserInfoResponse>
|
||||
*/
|
||||
async getUserInfo(request: UserQueryRequest): Promise<UserInfoResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始获取用户信息', {
|
||||
operation: 'getUserInfo',
|
||||
email: request.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证请求参数
|
||||
if (!request.email || !this.isValidEmail(request.email)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '邮箱格式无效',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 获取用户列表
|
||||
const usersResult = await this.getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: usersResult.error || '获取用户列表失败',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 查找指定用户
|
||||
const user = usersResult.users?.find(u =>
|
||||
u.email.toLowerCase() === request.email.toLowerCase()
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
};
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户信息获取完成', {
|
||||
operation: 'getUserInfo',
|
||||
email: request.email,
|
||||
userId: user.userId,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
isActive: user.isActive,
|
||||
isAdmin: user.isAdmin,
|
||||
isBot: user.isBot,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('获取用户信息失败', {
|
||||
operation: 'getUserInfo',
|
||||
email: request.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '系统错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户凭据
|
||||
*
|
||||
* 功能描述:
|
||||
* 验证用户的API Key是否有效
|
||||
*
|
||||
* @param request 用户验证请求
|
||||
* @returns Promise<UserValidationResponse>
|
||||
*/
|
||||
async validateUserCredentials(request: UserValidationRequest): Promise<UserValidationResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始验证用户凭据', {
|
||||
operation: 'validateUserCredentials',
|
||||
email: request.email,
|
||||
hasApiKey: !!request.apiKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证请求参数
|
||||
if (!request.email || !this.isValidEmail(request.email)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '邮箱格式无效',
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'API Key不能为空',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 使用用户的API Key测试连接
|
||||
const isValid = await this.testUserApiKey(request.email, request.apiKey);
|
||||
|
||||
// 3. 如果API Key有效,获取用户ID
|
||||
let userId = undefined;
|
||||
if (isValid) {
|
||||
const userInfo = await this.getUserInfo({ email: request.email });
|
||||
if (userInfo.success) {
|
||||
userId = userInfo.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户凭据验证完成', {
|
||||
operation: 'validateUserCredentials',
|
||||
email: request.email,
|
||||
isValid,
|
||||
userId,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isValid,
|
||||
userId,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('验证用户凭据失败', {
|
||||
operation: 'validateUserCredentials',
|
||||
email: request.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '系统错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户列表
|
||||
*
|
||||
* 功能描述:
|
||||
* 从Zulip服务器获取所有用户的列表
|
||||
*
|
||||
* @returns Promise<UsersListResponse>
|
||||
*/
|
||||
async getAllUsers(): Promise<UsersListResponse> {
|
||||
this.logger.debug('开始获取用户列表', {
|
||||
operation: 'getAllUsers',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||
|
||||
// 构建认证头
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn('获取用户列表失败', {
|
||||
operation: 'getAllUsers',
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `API调用失败: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: ZulipUsersResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const users = data.members?.map(user => ({
|
||||
userId: user.user_id,
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
isActive: user.is_active,
|
||||
isAdmin: user.is_admin,
|
||||
isBot: user.is_bot,
|
||||
})) || [];
|
||||
|
||||
this.logger.debug('用户列表获取完成', {
|
||||
operation: 'getAllUsers',
|
||||
userCount: users.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
users,
|
||||
totalCount: users.length,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取用户列表异常', {
|
||||
operation: 'getAllUsers',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '系统错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户API Key是否有效
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用用户的API Key测试是否能够成功调用Zulip API
|
||||
*
|
||||
* @param email 用户邮箱
|
||||
* @param apiKey 用户API Key
|
||||
* @returns Promise<boolean> 是否有效
|
||||
* @private
|
||||
*/
|
||||
private async testUserApiKey(email: string, apiKey: string): Promise<boolean> {
|
||||
this.logger.debug('测试用户API Key', {
|
||||
operation: 'testUserApiKey',
|
||||
email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL - 使用获取用户自己信息的接口
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users/me`;
|
||||
|
||||
// 使用用户的API Key构建认证头
|
||||
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const isValid = response.ok;
|
||||
|
||||
this.logger.debug('API Key测试完成', {
|
||||
operation: 'testUserApiKey',
|
||||
email,
|
||||
isValid,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
return isValid;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('测试API Key异常', {
|
||||
operation: 'testUserApiKey',
|
||||
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);
|
||||
}
|
||||
}
|
||||
188
src/core/zulip/services/user_registration.service.spec.ts
Normal file
188
src/core/zulip/services/user_registration.service.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Zulip用户注册服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试UserRegistrationService的核心功能
|
||||
* - 测试用户注册流程和验证逻辑
|
||||
* - 测试错误处理和边界情况
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserRegistrationService, UserRegistrationRequest } from './user_registration.service';
|
||||
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||
|
||||
describe('UserRegistrationService', () => {
|
||||
let service: UserRegistrationService;
|
||||
let mockConfigService: jest.Mocked<IZulipConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建模拟的配置服务
|
||||
mockConfigService = {
|
||||
getZulipConfig: jest.fn().mockReturnValue({
|
||||
zulipServerUrl: 'https://test.zulip.com',
|
||||
zulipBotEmail: 'bot@test.com',
|
||||
zulipBotApiKey: 'test-api-key',
|
||||
}),
|
||||
getMapIdByStream: jest.fn(),
|
||||
getStreamByMap: jest.fn(),
|
||||
getMapConfig: jest.fn(),
|
||||
hasMap: jest.fn(),
|
||||
getAllMapIds: jest.fn(),
|
||||
getMapConfigByStream: jest.fn(),
|
||||
getAllStreams: jest.fn(),
|
||||
hasStream: jest.fn(),
|
||||
findObjectByTopic: jest.fn(),
|
||||
getObjectsInMap: jest.fn(),
|
||||
getTopicByObject: jest.fn(),
|
||||
findNearbyObject: jest.fn(),
|
||||
reloadConfig: jest.fn(),
|
||||
validateConfig: jest.fn(),
|
||||
getAllMapConfigs: jest.fn(),
|
||||
getConfigStats: jest.fn(),
|
||||
getConfigFilePath: jest.fn(),
|
||||
configFileExists: jest.fn(),
|
||||
enableConfigWatcher: jest.fn(),
|
||||
disableConfigWatcher: jest.fn(),
|
||||
isConfigWatcherEnabled: jest.fn(),
|
||||
getFullConfiguration: jest.fn(),
|
||||
updateConfigValue: jest.fn(),
|
||||
exportMapConfig: jest.fn(),
|
||||
} as jest.Mocked<IZulipConfigService>;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserRegistrationService,
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserRegistrationService>(UserRegistrationService);
|
||||
});
|
||||
|
||||
it('应该正确初始化服务', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('registerUser - 用户注册', () => {
|
||||
it('应该成功注册有效用户', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'Test User',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.email).toBe(request.email);
|
||||
expect(result.userId).toBeDefined();
|
||||
expect(result.apiKey).toBeDefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该拒绝无效邮箱', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'invalid-email',
|
||||
fullName: 'Test User',
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('邮箱格式无效');
|
||||
});
|
||||
|
||||
it('应该拒绝空邮箱', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: '',
|
||||
fullName: 'Test User',
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('邮箱不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空用户名', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: '',
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('用户全名不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝过短的用户名', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'A',
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('用户全名至少需要2个字符');
|
||||
});
|
||||
|
||||
it('应该拒绝过长的用户名', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'A'.repeat(101), // 101个字符
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('用户全名不能超过100个字符');
|
||||
});
|
||||
|
||||
it('应该拒绝过短的密码', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'Test User',
|
||||
password: '123', // 只有3个字符
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('密码至少需要6个字符');
|
||||
});
|
||||
|
||||
it('应该接受没有密码的注册', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'Test User',
|
||||
// 不提供密码
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝过长的短名称', async () => {
|
||||
const request: UserRegistrationRequest = {
|
||||
email: 'test@example.com',
|
||||
fullName: 'Test User',
|
||||
shortName: 'A'.repeat(51), // 51个字符
|
||||
};
|
||||
|
||||
const result = await service.registerUser(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('短名称不能超过50个字符');
|
||||
});
|
||||
});
|
||||
});
|
||||
531
src/core/zulip/services/user_registration.service.ts
Normal file
531
src/core/zulip/services/user_registration.service.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Zulip用户管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 查询和验证Zulip用户信息
|
||||
* - 检查用户是否存在
|
||||
* - 获取用户详细信息
|
||||
* - 管理用户API Key(如果有权限)
|
||||
*
|
||||
* 主要方法:
|
||||
* - checkUserExists(): 检查用户是否存在
|
||||
* - getUserInfo(): 获取用户详细信息
|
||||
* - validateUserCredentials(): 验证用户凭据
|
||||
* - getUserApiKey(): 获取用户API Key(需要管理员权限)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户登录时验证用户存在性
|
||||
* - 获取用户基本信息
|
||||
* - 验证用户权限和状态
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
|
||||
|
||||
/**
|
||||
* Zulip API响应接口
|
||||
*/
|
||||
interface ZulipApiResponse {
|
||||
result?: 'success' | 'error';
|
||||
msg?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表响应接口
|
||||
*/
|
||||
interface ZulipUsersResponse extends ZulipApiResponse {
|
||||
members?: Array<{
|
||||
email: string;
|
||||
user_id: number;
|
||||
full_name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户响应接口
|
||||
*/
|
||||
interface ZulipCreateUserResponse extends ZulipApiResponse {
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key响应接口
|
||||
*/
|
||||
interface ZulipApiKeyResponse extends ZulipApiResponse {
|
||||
api_key?: string;
|
||||
}
|
||||
export interface UserRegistrationRequest {
|
||||
email: string;
|
||||
fullName: string;
|
||||
password?: string;
|
||||
shortName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册响应接口
|
||||
*/
|
||||
export interface UserRegistrationResponse {
|
||||
success: boolean;
|
||||
userId?: number;
|
||||
email?: string;
|
||||
apiKey?: string;
|
||||
error?: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip用户注册服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 处理新用户在Zulip服务器上的注册
|
||||
* - 验证用户信息的有效性
|
||||
* - 与Zulip API交互创建用户账户
|
||||
* - 管理注册流程和错误处理
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserRegistrationService {
|
||||
private readonly logger = new Logger(UserRegistrationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configService: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('UserRegistrationService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新用户到Zulip服务器
|
||||
*
|
||||
* 功能描述:
|
||||
* 在Zulip服务器上创建新用户账户
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户注册信息
|
||||
* 2. 检查用户是否已存在
|
||||
* 3. 调用Zulip API创建用户
|
||||
* 4. 获取用户API Key
|
||||
* 5. 返回注册结果
|
||||
*
|
||||
* @param request 用户注册请求数据
|
||||
* @returns Promise<UserRegistrationResponse>
|
||||
*/
|
||||
async registerUser(request: UserRegistrationRequest): Promise<UserRegistrationResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始注册Zulip用户', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
fullName: request.fullName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证用户注册信息
|
||||
const validationResult = this.validateUserInfo(request);
|
||||
if (!validationResult.valid) {
|
||||
this.logger.warn('用户注册信息验证失败', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: validationResult.errors.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 实现实际的Zulip用户注册逻辑
|
||||
// 这里先返回模拟结果,后续步骤中实现真实的API调用
|
||||
|
||||
// 2. 检查用户是否已存在
|
||||
const userExists = await this.checkUserExists(request.email);
|
||||
if (userExists) {
|
||||
this.logger.warn('用户注册失败:用户已存在', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '用户已存在',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 调用Zulip API创建用户
|
||||
const createResult = await this.createZulipUser(request);
|
||||
if (!createResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: createResult.error || '创建用户失败',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 获取用户API Key(如果需要)
|
||||
let apiKey = undefined;
|
||||
if (createResult.userId) {
|
||||
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email);
|
||||
if (apiKeyResult.success) {
|
||||
apiKey = apiKeyResult.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip用户注册完成(模拟)', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: createResult.userId,
|
||||
email: request.email,
|
||||
apiKey: apiKey,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('Zulip用户注册失败', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '注册失败,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户注册信息
|
||||
*
|
||||
* 功能描述:
|
||||
* 验证用户提供的注册信息是否有效
|
||||
*
|
||||
* @param request 用户注册请求
|
||||
* @returns {valid: boolean, errors: string[]} 验证结果
|
||||
* @private
|
||||
*/
|
||||
private validateUserInfo(request: UserRegistrationRequest): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证邮箱
|
||||
if (!request.email || !request.email.trim()) {
|
||||
errors.push('邮箱不能为空');
|
||||
} else if (!this.isValidEmail(request.email)) {
|
||||
errors.push('邮箱格式无效');
|
||||
}
|
||||
|
||||
// 验证全名
|
||||
if (!request.fullName || !request.fullName.trim()) {
|
||||
errors.push('用户全名不能为空');
|
||||
} else if (request.fullName.trim().length < 2) {
|
||||
errors.push('用户全名至少需要2个字符');
|
||||
} else if (request.fullName.trim().length > 100) {
|
||||
errors.push('用户全名不能超过100个字符');
|
||||
}
|
||||
|
||||
// 验证密码(如果提供)
|
||||
if (request.password && request.password.length < 6) {
|
||||
errors.push('密码至少需要6个字符');
|
||||
}
|
||||
|
||||
// 验证短名称(如果提供)
|
||||
if (request.shortName && request.shortName.trim().length > 50) {
|
||||
errors.push('短名称不能超过50个字符');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns boolean 是否有效
|
||||
* @private
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已存在
|
||||
*
|
||||
* 功能描述:
|
||||
* 通过Zulip API检查指定邮箱的用户是否已存在
|
||||
*
|
||||
* @param email 用户邮箱
|
||||
* @returns Promise<boolean> 是否存在
|
||||
* @private
|
||||
*/
|
||||
private async checkUserExists(email: string): Promise<boolean> {
|
||||
this.logger.debug('检查用户是否存在', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||
|
||||
// 构建认证头
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn('获取用户列表失败', {
|
||||
operation: 'checkUserExists',
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return false; // 如果API调用失败,假设用户不存在
|
||||
}
|
||||
|
||||
const data: ZulipUsersResponse = await response.json();
|
||||
|
||||
// 检查用户是否在列表中
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
const userExists = data.members.some((user: any) =>
|
||||
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
this.logger.debug('用户存在性检查完成', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
exists: userExists,
|
||||
});
|
||||
|
||||
return userExists;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('检查用户存在性失败', {
|
||||
operation: 'checkUserExists',
|
||||
email,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
// 如果检查失败,假设用户不存在,允许继续注册
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Zulip用户
|
||||
*
|
||||
* 功能描述:
|
||||
* 通过Zulip API创建新用户账户
|
||||
*
|
||||
* @param request 用户注册请求
|
||||
* @returns Promise<{success: boolean, userId?: number, error?: string}>
|
||||
* @private
|
||||
*/
|
||||
private async createZulipUser(request: UserRegistrationRequest): Promise<{
|
||||
success: boolean;
|
||||
userId?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
this.logger.log('开始创建Zulip用户', {
|
||||
operation: 'createZulipUser',
|
||||
email: request.email,
|
||||
fullName: request.fullName,
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||
|
||||
// 构建认证头
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.append('email', request.email);
|
||||
requestBody.append('full_name', request.fullName);
|
||||
|
||||
if (request.password) {
|
||||
requestBody.append('password', request.password);
|
||||
}
|
||||
|
||||
if (request.shortName) {
|
||||
requestBody.append('short_name', request.shortName);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody.toString(),
|
||||
});
|
||||
|
||||
const data: ZulipCreateUserResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn('Zulip用户创建失败', {
|
||||
operation: 'createZulipUser',
|
||||
email: request.email,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: data.msg || data.message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: data.msg || data.message || '创建用户失败',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log('Zulip用户创建成功', {
|
||||
operation: 'createZulipUser',
|
||||
email: request.email,
|
||||
userId: data.user_id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: data.user_id,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('创建Zulip用户异常', {
|
||||
operation: 'createZulipUser',
|
||||
email: request.email,
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '系统错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户生成API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新创建的用户生成API Key,用于后续的Zulip API调用
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param email 用户邮箱
|
||||
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
|
||||
* @private
|
||||
*/
|
||||
private async generateApiKey(userId: number, email: string): Promise<{
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
this.logger.log('开始生成用户API Key', {
|
||||
operation: 'generateApiKey',
|
||||
userId,
|
||||
email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL
|
||||
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异常', {
|
||||
operation: 'generateApiKey',
|
||||
userId,
|
||||
email,
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '系统错误,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
708
src/core/zulip/services/zulip_account.service.ts
Normal file
708
src/core/zulip/services/zulip_account.service.ts
Normal file
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ApiKeySecurityService } from './services/api_key_security.service';
|
||||
import { ErrorHandlerService } from './services/error_handler.service';
|
||||
import { MonitoringService } from './services/monitoring.service';
|
||||
import { StreamInitializerService } from './services/stream_initializer.service';
|
||||
import { ZulipAccountService } from './services/zulip_account.service';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
|
||||
@Module({
|
||||
@@ -46,6 +47,7 @@ import { RedisModule } from '../redis/redis.module';
|
||||
ErrorHandlerService,
|
||||
MonitoringService,
|
||||
StreamInitializerService,
|
||||
ZulipAccountService,
|
||||
|
||||
// 直接提供类(用于内部依赖)
|
||||
ZulipClientService,
|
||||
@@ -63,6 +65,7 @@ import { RedisModule } from '../redis/redis.module';
|
||||
ErrorHandlerService,
|
||||
MonitoringService,
|
||||
StreamInitializerService,
|
||||
ZulipAccountService,
|
||||
],
|
||||
})
|
||||
export class ZulipCoreModule {}
|
||||
11
src/main.ts
11
src/main.ts
@@ -40,10 +40,17 @@ async function bootstrap() {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
// 允许前端后台(如Vite/React)跨域访问
|
||||
// 允许前端后台(如Vite/React)跨域访问,包括WebSocket
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173', // Vite默认端口
|
||||
'https://whaletownend.xinghangee.icu',
|
||||
/^https:\/\/.*\.xinghangee\.icu$/
|
||||
],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
});
|
||||
|
||||
// 全局启用校验管道(核心配置)
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
# Comprehensive API Test Script
|
||||
# 综合API测试脚本 - 完整的后端功能测试
|
||||
#
|
||||
# 🧪 测试内容:
|
||||
# 1. 基础API功能(应用状态、注册、登录)
|
||||
# 2. 邮箱验证码流程(发送、验证、冲突检测)
|
||||
# 3. 验证码冷却时间清除功能
|
||||
# 4. 限流保护机制
|
||||
# 5. 密码重置流程
|
||||
# 6. 验证码登录功能
|
||||
# 7. 错误处理和边界条件
|
||||
#
|
||||
# 🚀 使用方法:
|
||||
# .\test-comprehensive.ps1 # 运行完整测试
|
||||
# .\test-comprehensive.ps1 -SkipThrottleTest # 跳过限流测试
|
||||
# .\test-comprehensive.ps1 -SkipCooldownTest # 跳过冷却测试
|
||||
# .\test-comprehensive.ps1 -BaseUrl "https://your-server.com" # 测试远程服务器
|
||||
|
||||
param(
|
||||
[string]$BaseUrl = "http://localhost:3000",
|
||||
[switch]$SkipThrottleTest = $false,
|
||||
[switch]$SkipCooldownTest = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
Write-Host "🧪 Comprehensive API Test Suite" -ForegroundColor Green
|
||||
Write-Host "===============================" -ForegroundColor Green
|
||||
Write-Host "Base URL: $BaseUrl" -ForegroundColor Yellow
|
||||
Write-Host "Skip Throttle Test: $SkipThrottleTest" -ForegroundColor Yellow
|
||||
Write-Host "Skip Cooldown Test: $SkipCooldownTest" -ForegroundColor Yellow
|
||||
|
||||
# Helper function to handle API responses
|
||||
function Test-ApiCall {
|
||||
param(
|
||||
[string]$TestName,
|
||||
[string]$Url,
|
||||
[string]$Body,
|
||||
[string]$Method = "POST",
|
||||
[int]$ExpectedStatus = 200,
|
||||
[switch]$Silent = $false
|
||||
)
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-Host "`n📋 $TestName" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $Url -Method $Method -Body $Body -ContentType "application/json" -ErrorAction Stop
|
||||
if (-not $Silent) {
|
||||
Write-Host "✅ SUCCESS ($(if ($response.success) { 'true' } else { 'false' }))" -ForegroundColor Green
|
||||
Write-Host "Message: $($response.message)" -ForegroundColor Cyan
|
||||
}
|
||||
return $response
|
||||
} catch {
|
||||
$statusCode = $_.Exception.Response.StatusCode.value__
|
||||
if (-not $Silent) {
|
||||
Write-Host "❌ FAILED ($statusCode)" -ForegroundColor $(if ($statusCode -eq $ExpectedStatus) { "Yellow" } else { "Red" })
|
||||
}
|
||||
|
||||
if ($_.Exception.Response) {
|
||||
$stream = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$responseBody = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
$stream.Close()
|
||||
|
||||
if ($responseBody) {
|
||||
try {
|
||||
$errorResponse = $responseBody | ConvertFrom-Json
|
||||
if (-not $Silent) {
|
||||
Write-Host "Message: $($errorResponse.message)" -ForegroundColor Cyan
|
||||
Write-Host "Error Code: $($errorResponse.error_code)" -ForegroundColor Gray
|
||||
}
|
||||
return $errorResponse
|
||||
} catch {
|
||||
if (-not $Silent) {
|
||||
Write-Host "Raw Response: $responseBody" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Clear throttle first
|
||||
Write-Host "`n🔄 Clearing throttle records..." -ForegroundColor Blue
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$BaseUrl/auth/debug-clear-throttle" -Method POST | Out-Null
|
||||
Write-Host "✅ Throttle cleared" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "⚠️ Could not clear throttle" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Test Results Tracking
|
||||
$testResults = @{
|
||||
AppStatus = $false
|
||||
BasicAPI = $false
|
||||
EmailConflict = $false
|
||||
VerificationCodeLogin = $false
|
||||
CooldownClearing = $false
|
||||
ThrottleProtection = $false
|
||||
PasswordReset = $false
|
||||
}
|
||||
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 0: Application Status" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
# Test application status
|
||||
$result0 = Test-ApiCall -TestName "Check application status" -Url "$BaseUrl" -Method "GET" -Body ""
|
||||
|
||||
if ($result0 -and $result0.service -eq "Pixel Game Server") {
|
||||
$testResults.AppStatus = $true
|
||||
Write-Host "✅ PASS: Application is running" -ForegroundColor Green
|
||||
Write-Host " Service: $($result0.service)" -ForegroundColor Cyan
|
||||
Write-Host " Version: $($result0.version)" -ForegroundColor Cyan
|
||||
Write-Host " Environment: $($result0.environment)" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Application status check failed" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 1: Basic API Functionality" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
# Generate unique test data
|
||||
$testEmail = "comprehensive_test_$(Get-Random)@example.com"
|
||||
$testUsername = "comp_test_$(Get-Random)"
|
||||
|
||||
# Test 1: Send verification code
|
||||
$result1 = Test-ApiCall -TestName "Send email verification code" -Url "$BaseUrl/auth/send-email-verification" -Body (@{
|
||||
email = $testEmail
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result1 -and $result1.data.verification_code) {
|
||||
$verificationCode = $result1.data.verification_code
|
||||
Write-Host "Got verification code: $verificationCode" -ForegroundColor Green
|
||||
|
||||
# Test 2: Register user
|
||||
$result2 = Test-ApiCall -TestName "Register new user" -Url "$BaseUrl/auth/register" -Body (@{
|
||||
username = $testUsername
|
||||
password = "password123"
|
||||
nickname = "Comprehensive Test User"
|
||||
email = $testEmail
|
||||
email_verification_code = $verificationCode
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result2 -and $result2.success) {
|
||||
# Test 3: Login user
|
||||
$result3 = Test-ApiCall -TestName "Login with registered user" -Url "$BaseUrl/auth/login" -Body (@{
|
||||
identifier = $testUsername
|
||||
password = "password123"
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result3 -and $result3.success) {
|
||||
$testResults.BasicAPI = $true
|
||||
Write-Host "✅ PASS: Basic API functionality working" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 2: Email Conflict Detection" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
# Test email conflict detection
|
||||
$result4 = Test-ApiCall -TestName "Test email conflict detection" -Url "$BaseUrl/auth/send-email-verification" -Body (@{
|
||||
email = $testEmail
|
||||
} | ConvertTo-Json) -ExpectedStatus 409
|
||||
|
||||
if ($result4 -and $result4.message -like "*已被注册*") {
|
||||
$testResults.EmailConflict = $true
|
||||
Write-Host "✅ PASS: Email conflict detection working" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Email conflict detection not working" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 3: Verification Code Login" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
# Test verification code login
|
||||
if ($result2 -and $result2.success) {
|
||||
$userEmail = $result2.data.user.email
|
||||
|
||||
# Send login verification code
|
||||
$result4a = Test-ApiCall -TestName "Send login verification code" -Url "$BaseUrl/auth/send-login-verification-code" -Body (@{
|
||||
identifier = $userEmail
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result4a -and $result4a.data.verification_code) {
|
||||
$loginCode = $result4a.data.verification_code
|
||||
|
||||
# Login with verification code
|
||||
$result4b = Test-ApiCall -TestName "Login with verification code" -Url "$BaseUrl/auth/verification-code-login" -Body (@{
|
||||
identifier = $userEmail
|
||||
verification_code = $loginCode
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result4b -and $result4b.success) {
|
||||
$testResults.VerificationCodeLogin = $true
|
||||
Write-Host "✅ PASS: Verification code login working" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Verification code login failed" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipCooldownTest) {
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 4: Cooldown Clearing & Password Reset" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
# Test cooldown clearing with password reset
|
||||
if ($result2 -and $result2.success) {
|
||||
$userEmail = $result2.data.user.email
|
||||
|
||||
# Send password reset code
|
||||
$result5 = Test-ApiCall -TestName "Send password reset code" -Url "$BaseUrl/auth/forgot-password" -Body (@{
|
||||
identifier = $userEmail
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result5 -and $result5.data.verification_code) {
|
||||
$resetCode = $result5.data.verification_code
|
||||
|
||||
# Reset password
|
||||
$result6 = Test-ApiCall -TestName "Reset password (should clear cooldown)" -Url "$BaseUrl/auth/reset-password" -Body (@{
|
||||
identifier = $userEmail
|
||||
verification_code = $resetCode
|
||||
new_password = "newpassword123"
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result6 -and $result6.success) {
|
||||
$testResults.PasswordReset = $true
|
||||
Write-Host "✅ PASS: Password reset working" -ForegroundColor Green
|
||||
|
||||
# Test immediate code sending (should work if cooldown cleared)
|
||||
Start-Sleep -Seconds 1
|
||||
$result7 = Test-ApiCall -TestName "Send reset code immediately (test cooldown clearing)" -Url "$BaseUrl/auth/forgot-password" -Body (@{
|
||||
identifier = $userEmail
|
||||
} | ConvertTo-Json)
|
||||
|
||||
if ($result7 -and $result7.success) {
|
||||
$testResults.CooldownClearing = $true
|
||||
Write-Host "✅ PASS: Cooldown clearing working" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Cooldown not cleared properly" -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Password reset failed" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipThrottleTest) {
|
||||
Write-Host "`n" + "="*60 -ForegroundColor Cyan
|
||||
Write-Host "🧪 Test Suite 5: Throttle Protection" -ForegroundColor Cyan
|
||||
Write-Host "="*60 -ForegroundColor Cyan
|
||||
|
||||
$successCount = 0
|
||||
$throttleCount = 0
|
||||
|
||||
Write-Host "Testing throttle limits (making 12 registration requests)..." -ForegroundColor Yellow
|
||||
|
||||
for ($i = 1; $i -le 12; $i++) {
|
||||
$result = Test-ApiCall -TestName "Registration attempt $i" -Url "$BaseUrl/auth/register" -Body (@{
|
||||
username = "throttle_test_$i"
|
||||
password = "password123"
|
||||
nickname = "Throttle Test $i"
|
||||
} | ConvertTo-Json) -Silent
|
||||
|
||||
if ($result -and $result.success) {
|
||||
$successCount++
|
||||
Write-Host " Request $i`: ✅ Success" -ForegroundColor Green
|
||||
} else {
|
||||
$throttleCount++
|
||||
Write-Host " Request $i`: 🚦 Throttled" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
|
||||
Write-Host "`nThrottle Results: $successCount success, $throttleCount throttled" -ForegroundColor Cyan
|
||||
|
||||
if ($successCount -ge 8 -and $throttleCount -ge 1) {
|
||||
$testResults.ThrottleProtection = $true
|
||||
Write-Host "✅ PASS: Throttle protection working" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Throttle protection not working properly" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n🎯 Test Results Summary" -ForegroundColor Green
|
||||
Write-Host "=======================" -ForegroundColor Green
|
||||
|
||||
$passCount = 0
|
||||
$totalTests = 0
|
||||
|
||||
foreach ($test in $testResults.GetEnumerator()) {
|
||||
$totalTests++
|
||||
if ($test.Value) {
|
||||
$passCount++
|
||||
Write-Host "✅ $($test.Key): PASS" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "❌ $($test.Key): FAIL" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n📊 Overall Result: $passCount/$totalTests tests passed" -ForegroundColor $(if ($passCount -eq $totalTests) { "Green" } else { "Yellow" })
|
||||
|
||||
if ($passCount -eq $totalTests) {
|
||||
Write-Host "🎉 All tests passed! API is working correctly." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "⚠️ Some tests failed. Please check the implementation." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n💡 Usage Tips:" -ForegroundColor Cyan
|
||||
Write-Host " • Use -SkipThrottleTest to skip throttle testing" -ForegroundColor White
|
||||
Write-Host " • Use -SkipCooldownTest to skip cooldown testing" -ForegroundColor White
|
||||
Write-Host " • Check server logs for detailed error information" -ForegroundColor White
|
||||
Write-Host " • For production testing: .\test-comprehensive.ps1 -BaseUrl 'https://your-server.com'" -ForegroundColor White
|
||||
|
||||
Write-Host "`n📋 Test Coverage:" -ForegroundColor Cyan
|
||||
Write-Host " ✓ Application Status & Health Check" -ForegroundColor White
|
||||
Write-Host " ✓ User Registration & Login Flow" -ForegroundColor White
|
||||
Write-Host " ✓ Email Verification & Conflict Detection" -ForegroundColor White
|
||||
Write-Host " ✓ Verification Code Login" -ForegroundColor White
|
||||
Write-Host " ✓ Password Reset Flow" -ForegroundColor White
|
||||
Write-Host " ✓ Cooldown Time Clearing" -ForegroundColor White
|
||||
Write-Host " ✓ Rate Limiting & Throttle Protection" -ForegroundColor White
|
||||
131
test_zulip.js
Normal file
131
test_zulip.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 使用用户 API Key 测试 Zulip 集成
|
||||
async function testWithUserApiKey() {
|
||||
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
||||
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
||||
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
||||
console.log('📡 游戏服务器: https://whaletownend.xinghangee.icu/game');
|
||||
|
||||
const socket = io('wss://whaletownend.xinghangee.icu/game', {
|
||||
transports: ['websocket', 'polling'], // WebSocket优先,polling备用
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用包含用户 API Key 的 token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 1 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
console.log(' 当前地图:', data.currentMap);
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||
'使用用户 API Key 发送此消息。',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
// 等待位置更新后发送消息
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('📨 收到来自 Zulip 的消息:');
|
||||
console.log(' 发送者:', data.from);
|
||||
console.log(' 内容:', data.txt);
|
||||
console.log(' Stream:', data.stream || '未知');
|
||||
console.log(' Topic:', data.topic || '未知');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 WebSocket 连接已关闭');
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
console.log(' 完成步骤:', testStep, '/ 4');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
console.log('🔧 准备测试环境...');
|
||||
testWithUserApiKey().catch(console.error);
|
||||
196
test_zulip_registration.js
Normal file
196
test_zulip_registration.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Zulip用户注册真实环境测试脚本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试Zulip用户注册功能在真实环境下的表现
|
||||
* - 验证API调用是否正常工作
|
||||
* - 检查配置是否正确
|
||||
*
|
||||
* 使用方法:
|
||||
* node test_zulip_registration.js
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const { URLSearchParams } = require('url');
|
||||
|
||||
// 配置信息
|
||||
const config = {
|
||||
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否存在
|
||||
*/
|
||||
async function checkUserExists(email) {
|
||||
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`📊 获取到 ${data.members?.length || 0} 个用户`);
|
||||
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
const userExists = data.members.some(user =>
|
||||
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||
return userExists;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试用户
|
||||
*/
|
||||
async function createTestUser(email, fullName, password) {
|
||||
console.log(`🚀 开始创建用户: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.append('email', email);
|
||||
requestBody.append('full_name', fullName);
|
||||
|
||||
if (password) {
|
||||
requestBody.append('password', password);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 用户创建失败: ${response.status} ${response.statusText}`);
|
||||
console.log(`📝 错误信息: ${data.msg || data.message || '未知错误'}`);
|
||||
return { success: false, error: data.msg || data.message };
|
||||
}
|
||||
|
||||
console.log(`✅ 用户创建成功! 用户ID: ${data.user_id}`);
|
||||
return { success: true, userId: data.user_id };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 创建用户异常:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async function testConnection() {
|
||||
console.log('🔗 测试Zulip服务器连接...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ 连接成功! 服务器版本: ${data.zulip_version || '未知'}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎯 开始Zulip用户注册测试');
|
||||
console.log('=' * 50);
|
||||
|
||||
// 1. 测试连接
|
||||
const connected = await testConnection();
|
||||
if (!connected) {
|
||||
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 2. 生成测试用户信息
|
||||
const timestamp = Date.now();
|
||||
const testEmail = `test_user_${timestamp}@example.com`;
|
||||
const testFullName = `Test User ${timestamp}`;
|
||||
const testPassword = 'test123456';
|
||||
|
||||
console.log(`📋 测试用户信息:`);
|
||||
console.log(` 邮箱: ${testEmail}`);
|
||||
console.log(` 姓名: ${testFullName}`);
|
||||
console.log(` 密码: ${testPassword}`);
|
||||
console.log('');
|
||||
|
||||
// 3. 检查用户是否已存在
|
||||
const userExists = await checkUserExists(testEmail);
|
||||
if (userExists) {
|
||||
console.log('⚠️ 用户已存在,跳过创建测试');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 4. 创建用户
|
||||
const createResult = await createTestUser(testEmail, testFullName, testPassword);
|
||||
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
if (createResult.success) {
|
||||
console.log('✅ 用户注册功能正常工作');
|
||||
console.log(` 新用户ID: ${createResult.userId}`);
|
||||
} else {
|
||||
console.log('❌ 用户注册功能存在问题');
|
||||
console.log(` 错误信息: ${createResult.error}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 测试完成');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
main().catch(error => {
|
||||
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
275
test_zulip_user_management.js
Normal file
275
test_zulip_user_management.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Zulip用户管理真实环境测试脚本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试Zulip用户管理功能在真实环境下的表现
|
||||
* - 验证用户查询、验证等API调用是否正常工作
|
||||
* - 检查配置是否正确
|
||||
*
|
||||
* 使用方法:
|
||||
* node test_zulip_user_management.js
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// 配置信息
|
||||
const config = {
|
||||
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有用户列表
|
||||
*/
|
||||
async function getAllUsers() {
|
||||
console.log('📋 获取所有用户列表...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||
return { success: false, error: `${response.status} ${response.statusText}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const users = data.members?.map(user => ({
|
||||
userId: user.user_id,
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
isActive: user.is_active,
|
||||
isAdmin: user.is_admin,
|
||||
isBot: user.is_bot,
|
||||
})) || [];
|
||||
|
||||
console.log(`✅ 成功获取 ${users.length} 个用户`);
|
||||
|
||||
// 显示前几个用户信息
|
||||
console.log('👥 用户列表预览:');
|
||||
users.slice(0, 5).forEach((user, index) => {
|
||||
console.log(` ${index + 1}. ${user.fullName} (${user.email})`);
|
||||
console.log(` ID: ${user.userId}, 活跃: ${user.isActive}, 管理员: ${user.isAdmin}, 机器人: ${user.isBot}`);
|
||||
});
|
||||
|
||||
if (users.length > 5) {
|
||||
console.log(` ... 还有 ${users.length - 5} 个用户`);
|
||||
}
|
||||
|
||||
return { success: true, users, totalCount: users.length };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 获取用户列表异常:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定用户是否存在
|
||||
*/
|
||||
async function checkUserExists(email) {
|
||||
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||
|
||||
try {
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userExists = usersResult.users.some(user =>
|
||||
user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||
return userExists;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详细信息
|
||||
*/
|
||||
async function getUserInfo(email) {
|
||||
console.log(`📝 获取用户信息: ${email}`);
|
||||
|
||||
try {
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||
return { success: false, error: usersResult.error };
|
||||
}
|
||||
|
||||
const user = usersResult.users.find(u =>
|
||||
u.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
console.log(`❌ 用户不存在: ${email}`);
|
||||
return { success: false, error: '用户不存在' };
|
||||
}
|
||||
|
||||
console.log(`✅ 用户信息获取成功:`);
|
||||
console.log(` 用户ID: ${user.userId}`);
|
||||
console.log(` 邮箱: ${user.email}`);
|
||||
console.log(` 姓名: ${user.fullName}`);
|
||||
console.log(` 状态: ${user.isActive ? '活跃' : '非活跃'}`);
|
||||
console.log(` 权限: ${user.isAdmin ? '管理员' : '普通用户'}`);
|
||||
console.log(` 类型: ${user.isBot ? '机器人' : '真实用户'}`);
|
||||
|
||||
return { success: true, user };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 获取用户信息失败:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户API Key
|
||||
*/
|
||||
async function testUserApiKey(email, apiKey) {
|
||||
console.log(`🔑 测试用户API Key: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users/me`;
|
||||
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const isValid = response.ok;
|
||||
|
||||
if (isValid) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ API Key有效! 用户信息:`);
|
||||
console.log(` 用户ID: ${data.user_id}`);
|
||||
console.log(` 邮箱: ${data.email}`);
|
||||
console.log(` 姓名: ${data.full_name}`);
|
||||
} else {
|
||||
console.log(`❌ API Key无效: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 测试API Key异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async function testConnection() {
|
||||
console.log('🔗 测试Zulip服务器连接...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ 连接成功! 服务器信息:`);
|
||||
console.log(` 版本: ${data.zulip_version || '未知'}`);
|
||||
console.log(` 服务器: ${data.realm_name || '未知'}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎯 开始Zulip用户管理测试');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 1. 测试连接
|
||||
const connected = await testConnection();
|
||||
if (!connected) {
|
||||
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 2. 获取所有用户列表
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log('❌ 无法获取用户列表,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 3. 测试用户存在性检查
|
||||
const testEmails = [
|
||||
'angjustinl@mail.angforever.top', // 应该存在
|
||||
'nonexistent@example.com', // 应该不存在
|
||||
];
|
||||
|
||||
console.log('🔍 测试用户存在性检查:');
|
||||
for (const email of testEmails) {
|
||||
const exists = await checkUserExists(email);
|
||||
console.log(` ${email}: ${exists ? '✅ 存在' : '❌ 不存在'}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 4. 测试获取用户信息
|
||||
console.log('📝 测试获取用户信息:');
|
||||
const existingEmail = 'angjustinl@mail.angforever.top';
|
||||
const userInfoResult = await getUserInfo(existingEmail);
|
||||
|
||||
console.log('');
|
||||
|
||||
// 5. 测试API Key验证(如果有的话)
|
||||
console.log('🔑 测试API Key验证:');
|
||||
const testApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; // 这是我们的测试API Key
|
||||
const apiKeyValid = await testUserApiKey(existingEmail, testApiKey);
|
||||
|
||||
console.log('');
|
||||
console.log('📊 测试结果总结:');
|
||||
console.log(`✅ 服务器连接: 正常`);
|
||||
console.log(`✅ 用户列表获取: 正常 (${usersResult.totalCount} 个用户)`);
|
||||
console.log(`✅ 用户存在性检查: 正常`);
|
||||
console.log(`✅ 用户信息获取: ${userInfoResult.success ? '正常' : '异常'}`);
|
||||
console.log(`✅ API Key验证: ${apiKeyValid ? '正常' : '异常'}`);
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 用户管理功能测试完成');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
main().catch(error => {
|
||||
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,5 +21,5 @@
|
||||
"typeRoots": ["./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "client"]
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
// 配置 - 复制此文件为 webhook-handler.js 并修改配置
|
||||
const PORT = 9000;
|
||||
const SECRET = 'your_webhook_secret_change_this'; // 与 Gitea 中配置的密钥一致
|
||||
const DEPLOY_SCRIPT = '/var/www/pixel-game-server/deploy.sh'; // 修改为实际路径
|
||||
|
||||
// 验证 Gitea 签名
|
||||
function verifySignature(payload, signature, secret) {
|
||||
const hmac = crypto.createHmac('sha256', secret);
|
||||
hmac.update(payload);
|
||||
const calculatedSignature = hmac.digest('hex');
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature, 'hex'),
|
||||
Buffer.from(calculatedSignature, 'hex')
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 HTTP 服务器
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'text/plain' });
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
try {
|
||||
// 验证签名
|
||||
const signature = req.headers['x-gitea-signature'];
|
||||
if (!signature || !verifySignature(body, signature.replace('sha256=', ''), SECRET)) {
|
||||
console.log('签名验证失败');
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
||||
res.end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(body);
|
||||
|
||||
// 检查是否是推送到 main 分支
|
||||
if (payload.ref === 'refs/heads/main') {
|
||||
console.log('收到 main 分支推送,开始部署...');
|
||||
|
||||
// 执行部署脚本
|
||||
exec(`bash ${DEPLOY_SCRIPT}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error('部署失败:', error);
|
||||
console.error('stderr:', stderr);
|
||||
} else {
|
||||
console.log('部署成功:', stdout);
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Deployment triggered');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not main branch, ignored');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理 webhook 失败:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Webhook 处理器运行在端口 ${PORT}`);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('收到 SIGTERM,正在关闭服务器...');
|
||||
server.close(() => {
|
||||
console.log('服务器已关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user