feat: 支持获取我创建的分享挑战列表以及详情数据
This commit is contained in:
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 仓库协作指南
|
||||||
|
|
||||||
|
## 项目结构与模块组织
|
||||||
|
|
||||||
|
本仓库是 MemeMind 的 NestJS 后端服务。应用代码位于 `src/`。通用守卫、过滤器、装饰器和基础 DTO 位于 `src/common/`。运行时配置和 TypeORM 配置位于 `src/config/` 与 `src/database/`。业务代码按领域划分在 `src/modules/` 下,包括 `auth/`、`level/`、`share/`、`user/` 和 `game-config/`。新增代码应放入对应模块,并遵循 Nest 的常规结构:`*.controller.ts`、`*.service.ts`、`*.module.ts`,以及按需补充 `dto/`、`entities/`、`repositories/`。单元测试与源码相邻,命名为 `*.spec.ts`;端到端测试位于 `test/`。
|
||||||
|
|
||||||
|
## 构建、测试与开发命令
|
||||||
|
|
||||||
|
使用 `pnpm`,仓库已包含 `pnpm-lock.yaml`。
|
||||||
|
|
||||||
|
- `pnpm install`:安装依赖。
|
||||||
|
- `pnpm run start:dev`:以 watch 模式启动本地开发服务。
|
||||||
|
- `pnpm run build`:将 TypeScript 编译到 `dist/`。
|
||||||
|
- `pnpm run start:prod`:从 `dist/main` 启动生产构建。
|
||||||
|
- `pnpm run lint`:运行 ESLint 并自动修复可修复问题。
|
||||||
|
- `pnpm run format`:使用 Prettier 格式化 `src/` 与 `test/`。
|
||||||
|
- `pnpm run test`、`pnpm run test:cov`、`pnpm run test:e2e`:运行单测、覆盖率测试和 e2e 测试。
|
||||||
|
|
||||||
|
## 编码风格与命名规范
|
||||||
|
|
||||||
|
使用严格类型的 TypeScript;除非处于明确的边界场景,否则避免新增 `any`。Prettier 约束为单引号和尾随逗号。遵循现有的 2 空格缩进和 NestJS 命名习惯:类使用 `PascalCase`,方法与变量使用 `camelCase`,目录使用 kebab-case,DTO 文件名要具备明确语义,例如 `wx-login.dto.ts` 或 `share-response.dto.ts`。
|
||||||
|
|
||||||
|
## 测试规范
|
||||||
|
|
||||||
|
单元测试和 e2e 测试均使用 Jest。单元测试文件命名为 `*.spec.ts`,并与被测源码放在一起。只要 controller 契约、service 逻辑、repository 行为或鉴权流程发生变化,就需要新增或更新测试。提交 PR 前应运行 `pnpm run test:cov`,覆盖率结果输出到 `coverage/`。
|
||||||
|
|
||||||
|
## 接口文档规范
|
||||||
|
|
||||||
|
只要接口发生改动,必须同步更新 `docs/api/` 目录中的对应文档,确保客户端可以直接使用该目录下的文档进行联调。
|
||||||
|
|
||||||
|
- 新增接口:在对应模块文档中新增接口章节,并补充请求参数、响应结构、示例和调用场景。
|
||||||
|
- 修改接口:同步更新字段、鉴权方式、错误码、业务规则和示例。
|
||||||
|
- 删除或废弃接口:在文档中明确标记,并说明客户端迁移方式。
|
||||||
|
- 如果一次改动涉及多个模块接口,相关文档都要一并更新,不能只改代码不改文档。
|
||||||
|
|
||||||
|
## 提交与合并请求规范
|
||||||
|
|
||||||
|
最近的提交历史使用 Conventional Commit 前缀,例如 `feat:`、`perf:`、`refactor:`,也包含带作用域的形式,例如 `feat(share): ...`。请保持提交聚焦,并沿用同样格式。PR 需要说明行为变更、配置或迁移影响、关联的 issue(如果有),并在请求或响应结构发生变化时附上 API 示例。
|
||||||
|
|
||||||
|
## 配置与部署说明
|
||||||
|
|
||||||
|
环境变量在 `src/config/env.validation.ts` 中做校验。敏感信息应保存在 `.env.local` 或部署环境专用配置中,不能写入源码。无论本地还是生产环境,API 统一暴露在 `/api` 下,Swagger 暴露在 `/api/docs`。`pnpm run deploy` 会调用 `deploy.sh`、`rsync` 和 PM2,因此执行前需要先检查其中的服务器相关配置。
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
||||||
2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
|
2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
|
||||||
3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
|
3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
|
||||||
|
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,6 +132,7 @@ Authorization: Bearer <token>
|
|||||||
```
|
```
|
||||||
|
|
||||||
**客户端调用时机**:
|
**客户端调用时机**:
|
||||||
|
|
||||||
- 用户首次进入游戏时调用
|
- 用户首次进入游戏时调用
|
||||||
- 小游戏冷启动时调用(建议缓存 token)
|
- 小游戏冷启动时调用(建议缓存 token)
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ Authorization: Bearer <token>
|
|||||||
**是否需要认证**:是(JWT Bearer Token)
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
**请求头**:
|
**请求头**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@@ -155,7 +158,8 @@ Content-Type: application/json
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "我的挑战", // 分享标题,不超过 100 字符
|
"title": "我的挑战", // 分享标题,不超过 100 字符
|
||||||
"levelIds": [ // 恰好 6 个关卡 ID
|
"levelIds": [
|
||||||
|
// 恰好 6 个关卡 ID
|
||||||
"level_id_1",
|
"level_id_1",
|
||||||
"level_id_2",
|
"level_id_2",
|
||||||
"level_id_3",
|
"level_id_3",
|
||||||
@@ -192,11 +196,13 @@ Content-Type: application/json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**分享码生成规则**:
|
**分享码生成规则**:
|
||||||
|
|
||||||
- 使用 nanoid 生成 8 位字符
|
- 使用 nanoid 生成 8 位字符
|
||||||
- 字符集为 a-z, A-Z, 0-9
|
- 字符集为 a-z, A-Z, 0-9
|
||||||
- 发生碰撞时最多重试 3 次
|
- 发生碰撞时最多重试 3 次
|
||||||
|
|
||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
- 用户点击「分享挑战」按钮时调用
|
- 用户点击「分享挑战」按钮时调用
|
||||||
- 用户选择 6 个关卡后,生成分享码
|
- 用户选择 6 个关卡后,生成分享码
|
||||||
- 将分享码拼接为分享链接或二维码
|
- 将分享码拼接为分享链接或二维码
|
||||||
@@ -214,7 +220,7 @@ Content-Type: application/json
|
|||||||
**路径参数**:
|
**路径参数**:
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| ---- | ------ | ---- | -------------- |
|
||||||
| code | string | 是 | 分享码(8 位) |
|
| code | string | 是 | 分享码(8 位) |
|
||||||
|
|
||||||
**响应数据**:
|
**响应数据**:
|
||||||
@@ -265,16 +271,101 @@ Content-Type: application/json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**特殊逻辑**:
|
**特殊逻辑**:
|
||||||
|
|
||||||
- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录
|
- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录
|
||||||
- 返回的关卡列表按 `levelIds` 创建时的顺序排列
|
- 返回的关卡列表按 `levelIds` 创建时的顺序排列
|
||||||
|
|
||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
- 用户通过分享码/链接进入游戏时调用
|
- 用户通过分享码/链接进入游戏时调用
|
||||||
- 解析 URL 参数中的分享码,调用此接口获取关卡数据
|
- 解析 URL 参数中的分享码,调用此接口获取关卡数据
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. 上报单关进度
|
### 4. 获取我创建的分享挑战
|
||||||
|
|
||||||
|
获取当前登录用户创建过的分享挑战列表。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/share/created`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**请求头**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: string; // 分享挑战 ID
|
||||||
|
shareCode: string; // 分享码
|
||||||
|
title: string; // 分享标题
|
||||||
|
levelCount: number; // 关卡数量
|
||||||
|
participantCount: number; // 参与挑战人数
|
||||||
|
userRank: number | null; // 当前用户在该挑战中的排名;未完成全部关卡时为 null
|
||||||
|
createdAt: string; // 创建时间,ISO 8601 字符串
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "share_001",
|
||||||
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"levelCount": 6,
|
||||||
|
"participantCount": 8,
|
||||||
|
"userRank": 2,
|
||||||
|
"createdAt": "2026-04-13T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "share_002",
|
||||||
|
"shareCode": "xyz67890",
|
||||||
|
"title": "速度挑战",
|
||||||
|
"levelCount": 6,
|
||||||
|
"participantCount": 1,
|
||||||
|
"userRank": null,
|
||||||
|
"createdAt": "2026-04-12T09:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-13T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**排名规则**:
|
||||||
|
|
||||||
|
1. 只有完成该分享挑战全部关卡的用户才会进入排名。
|
||||||
|
2. 排名按通关总耗时升序计算,总耗时越短排名越高。
|
||||||
|
3. 当总耗时相同时,服务端会按 `participantId` 做稳定排序,保证返回顺序可重复。
|
||||||
|
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未完成全部关卡,则返回 `null`。
|
||||||
|
|
||||||
|
**参与人数统计规则**:
|
||||||
|
|
||||||
|
- 统计 `share_participants` 中该挑战的参与者数量。
|
||||||
|
- 创建者本人在调用创建接口时不会自动写入参与记录;只有真正以参与者身份产生挑战进度后,才可能出现在排名内。
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
|
||||||
|
- 用户进入「我发起的挑战」页面时调用。
|
||||||
|
- 用于展示每个分享挑战的传播效果和本人当前成绩。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 上报单关进度
|
||||||
|
|
||||||
用户在分享挑战中完成单关后,上报进度。
|
用户在分享挑战中完成单关后,上报进度。
|
||||||
|
|
||||||
@@ -296,7 +387,7 @@ Content-Type: application/json
|
|||||||
**字段说明**:
|
**字段说明**:
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
| --------- | ------- | ---- | -------------------------- |
|
||||||
| shareCode | string | 是 | 分享码 |
|
| shareCode | string | 是 | 分享码 |
|
||||||
| levelId | string | 是 | 关卡 ID |
|
| levelId | string | 是 | 关卡 ID |
|
||||||
| passed | boolean | 是 | 是否通过 |
|
| passed | boolean | 是 | 是否通过 |
|
||||||
@@ -337,6 +428,7 @@ Content-Type: application/json
|
|||||||
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true`
|
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true`
|
||||||
|
|
||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
- 用户完成一个关卡后调用
|
- 用户完成一个关卡后调用
|
||||||
- 无论通关还是失败都需要调用
|
- 无论通关还是失败都需要调用
|
||||||
- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限
|
- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限
|
||||||
@@ -346,7 +438,7 @@ Content-Type: application/json
|
|||||||
## 错误码说明
|
## 错误码说明
|
||||||
|
|
||||||
| HTTP Status | message | 说明 |
|
| HTTP Status | message | 说明 |
|
||||||
|-------------|---------|------|
|
| ----------- | ------------------------------------- | ---------------------------- |
|
||||||
| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
|
| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
|
||||||
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
|
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
|
||||||
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
|
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
|
||||||
@@ -400,7 +492,7 @@ interface ShareChallengeState {
|
|||||||
passed: boolean;
|
passed: boolean;
|
||||||
timeSpent: number;
|
timeSpent: number;
|
||||||
withinTimeLimit: boolean;
|
withinTimeLimit: boolean;
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -445,7 +537,7 @@ export class HttpManager {
|
|||||||
async request<T>(
|
async request<T>(
|
||||||
method: 'GET' | 'POST',
|
method: 'GET' | 'POST',
|
||||||
url: string,
|
url: string,
|
||||||
body?: object
|
body?: object,
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
@@ -504,7 +596,7 @@ async function wxLogin() {
|
|||||||
const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => {
|
const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => {
|
||||||
wx.login({
|
wx.login({
|
||||||
success: (res) => resolve({ code: res.code }),
|
success: (res) => resolve({ code: res.code }),
|
||||||
fail: reject
|
fail: reject,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,7 +606,7 @@ async function wxLogin() {
|
|||||||
token: string;
|
token: string;
|
||||||
user: { id: string; nickname: string | null; stamina: number };
|
user: { id: string; nickname: string | null; stamina: number };
|
||||||
}>('/v1/auth/wx-login', {
|
}>('/v1/auth/wx-login', {
|
||||||
code: wxLoginRes.code
|
code: wxLoginRes.code,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -543,7 +635,10 @@ interface CreateShareResponse {
|
|||||||
levelCount: number;
|
levelCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createShare(title: string, levelIds: string[]): Promise<CreateShareResponse> {
|
async function createShare(
|
||||||
|
title: string,
|
||||||
|
levelIds: string[],
|
||||||
|
): Promise<CreateShareResponse> {
|
||||||
// 确保已登录
|
// 确保已登录
|
||||||
if (!httpManager.getToken()) {
|
if (!httpManager.getToken()) {
|
||||||
await wxLogin();
|
await wxLogin();
|
||||||
@@ -551,7 +646,7 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
|
|||||||
|
|
||||||
const response = await httpManager.post<CreateShareResponse>('/v1/share', {
|
const response = await httpManager.post<CreateShareResponse>('/v1/share', {
|
||||||
title,
|
title,
|
||||||
levelIds
|
levelIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -563,7 +658,14 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用示例
|
// 使用示例
|
||||||
const levelIds = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6'];
|
const levelIds = [
|
||||||
|
'level_1',
|
||||||
|
'level_2',
|
||||||
|
'level_3',
|
||||||
|
'level_4',
|
||||||
|
'level_5',
|
||||||
|
'level_6',
|
||||||
|
];
|
||||||
const share = await createShare('一起来挑战!', levelIds);
|
const share = await createShare('一起来挑战!', levelIds);
|
||||||
console.log('分享码:', share.shareCode);
|
console.log('分享码:', share.shareCode);
|
||||||
// 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}`
|
// 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}`
|
||||||
@@ -596,7 +698,7 @@ async function joinShare(shareCode: string): Promise<JoinShareResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await httpManager.post<JoinShareResponse>(
|
const response = await httpManager.post<JoinShareResponse>(
|
||||||
`/v1/share/${shareCode}/join`
|
`/v1/share/${shareCode}/join`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -627,7 +729,7 @@ async function reportLevelProgress(
|
|||||||
shareCode: string,
|
shareCode: string,
|
||||||
levelId: string,
|
levelId: string,
|
||||||
passed: boolean,
|
passed: boolean,
|
||||||
timeSpent: number
|
timeSpent: number,
|
||||||
): Promise<ReportProgressResponse> {
|
): Promise<ReportProgressResponse> {
|
||||||
// 确保已登录
|
// 确保已登录
|
||||||
if (!httpManager.getToken()) {
|
if (!httpManager.getToken()) {
|
||||||
@@ -640,8 +742,8 @@ async function reportLevelProgress(
|
|||||||
shareCode,
|
shareCode,
|
||||||
levelId,
|
levelId,
|
||||||
passed,
|
passed,
|
||||||
timeSpent
|
timeSpent,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -655,10 +757,17 @@ async function reportLevelProgress(
|
|||||||
// 使用示例
|
// 使用示例
|
||||||
async function onLevelComplete(levelId: string, timeSpent: number) {
|
async function onLevelComplete(levelId: string, timeSpent: number) {
|
||||||
const passed = true; // 根据游戏逻辑判断是否通过
|
const passed = true; // 根据游戏逻辑判断是否通过
|
||||||
const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent);
|
const result = await reportLevelProgress(
|
||||||
|
this.shareCode,
|
||||||
|
levelId,
|
||||||
|
passed,
|
||||||
|
timeSpent,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.passed) {
|
if (result.passed) {
|
||||||
console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`);
|
console.log(
|
||||||
|
`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`,
|
||||||
|
);
|
||||||
if (result.timeLimit) {
|
if (result.timeLimit) {
|
||||||
console.log(`本关时间限制: ${result.timeLimit}秒`);
|
console.log(`本关时间限制: ${result.timeLimit}秒`);
|
||||||
}
|
}
|
||||||
@@ -709,7 +818,7 @@ export class GameEntry extends Component {
|
|||||||
### LevelData 完整字段
|
### LevelData 完整字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| --------- | -------------- | --------------- |
|
||||||
| id | string | 关卡唯一标识 |
|
| id | string | 关卡唯一标识 |
|
||||||
| level | number | 关卡序号(1-6) |
|
| level | number | 关卡序号(1-6) |
|
||||||
| imageUrl | string | 关卡图片 URL |
|
| imageUrl | string | 关卡图片 URL |
|
||||||
@@ -724,6 +833,7 @@ export class GameEntry extends Component {
|
|||||||
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
|
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
|
||||||
|
|
||||||
如果需要在前端判断时间限制:
|
如果需要在前端判断时间限制:
|
||||||
|
|
||||||
1. 用户完成关卡后调用 `reportLevelProgress`
|
1. 用户完成关卡后调用 `reportLevelProgress`
|
||||||
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
|
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
|
||||||
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成
|
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成
|
||||||
|
|||||||
@@ -47,3 +47,37 @@ export class JoinShareResponseDto {
|
|||||||
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
|
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
|
||||||
levels!: ShareLevelDto[];
|
levels!: ShareLevelDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreatedShareItemDto {
|
||||||
|
@ApiProperty({ description: '分享 ID' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分享码' })
|
||||||
|
shareCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分享标题' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关卡数量' })
|
||||||
|
levelCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '参与挑战人数' })
|
||||||
|
participantCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
userRank!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '创建时间' })
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatedShareListResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '当前用户创建的分享挑战列表',
|
||||||
|
type: [CreatedShareItemDto],
|
||||||
|
})
|
||||||
|
items!: CreatedShareItemDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,4 +18,11 @@ export class ShareConfigRepository {
|
|||||||
async findByShareCode(code: string): Promise<ShareConfig | null> {
|
async findByShareCode(code: string): Promise<ShareConfig | null> {
|
||||||
return this.repository.findOne({ where: { shareCode: code } });
|
return this.repository.findOne({ where: { shareCode: code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findBySharerId(sharerId: string): Promise<ShareConfig[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { sharerId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
||||||
|
|
||||||
|
export type ShareChallengeRankingRow = {
|
||||||
|
shareConfigId: string;
|
||||||
|
participantId: string;
|
||||||
|
totalTimeSpent: string;
|
||||||
|
passedLevelCount: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareLevelProgressRepository {
|
export class ShareLevelProgressRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -10,7 +17,9 @@ export class ShareLevelProgressRepository {
|
|||||||
private readonly repository: Repository<ShareLevelProgress>,
|
private readonly repository: Repository<ShareLevelProgress>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> {
|
async findByParticipantId(
|
||||||
|
participantId: string,
|
||||||
|
): Promise<ShareLevelProgress[]> {
|
||||||
return this.repository.find({ where: { participantId } });
|
return this.repository.find({ where: { participantId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +30,29 @@ export class ShareLevelProgressRepository {
|
|||||||
return this.repository.findOne({ where: { participantId, levelId } });
|
return this.repository.findOne({ where: { participantId, levelId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async summarizeByShareConfigIds(
|
||||||
|
shareConfigIds: string[],
|
||||||
|
): Promise<ShareChallengeRankingRow[]> {
|
||||||
|
if (shareConfigIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('progress')
|
||||||
|
.innerJoin('progress.participant', 'participant')
|
||||||
|
.select('participant.shareConfigId', 'shareConfigId')
|
||||||
|
.addSelect('participant.participantId', 'participantId')
|
||||||
|
.addSelect('SUM(progress.timeSpent)', 'totalTimeSpent')
|
||||||
|
.addSelect('COUNT(DISTINCT progress.levelId)', 'passedLevelCount')
|
||||||
|
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||||
|
shareConfigIds,
|
||||||
|
})
|
||||||
|
.andWhere('progress.passed = :passed', { passed: true })
|
||||||
|
.groupBy('participant.shareConfigId')
|
||||||
|
.addGroupBy('participant.participantId')
|
||||||
|
.getRawMany<ShareChallengeRankingRow>();
|
||||||
|
}
|
||||||
|
|
||||||
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
||||||
return this.repository.create(data);
|
return this.repository.create(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ShareParticipant } from '../entities/share-participant.entity';
|
import { ShareParticipant } from '../entities/share-participant.entity';
|
||||||
|
|
||||||
|
type ShareParticipantCountRow = {
|
||||||
|
shareConfigId: string;
|
||||||
|
participantCount: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareParticipantRepository {
|
export class ShareParticipantRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -28,6 +33,28 @@ export class ShareParticipantRepository {
|
|||||||
return this.repository.count({ where: { shareConfigId } });
|
return this.repository.count({ where: { shareConfigId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countByShareConfigIds(
|
||||||
|
shareConfigIds: string[],
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
if (shareConfigIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('participant')
|
||||||
|
.select('participant.shareConfigId', 'shareConfigId')
|
||||||
|
.addSelect('COUNT(participant.id)', 'participantCount')
|
||||||
|
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||||
|
shareConfigIds,
|
||||||
|
})
|
||||||
|
.groupBy('participant.shareConfigId')
|
||||||
|
.getRawMany<ShareParticipantCountRow>();
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [row.shareConfigId, Number(row.participantCount)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async findByShareConfigAndParticipant(
|
async findByShareConfigAndParticipant(
|
||||||
shareConfigId: string,
|
shareConfigId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('ShareController', () => {
|
|||||||
|
|
||||||
const mockShareService = {
|
const mockShareService = {
|
||||||
createShare: jest.fn(),
|
createShare: jest.fn(),
|
||||||
|
getCreatedShares: jest.fn(),
|
||||||
joinShare: jest.fn(),
|
joinShare: jest.fn(),
|
||||||
reportLevelProgress: jest.fn(),
|
reportLevelProgress: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -60,6 +61,35 @@ describe('ShareController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCreatedShares', () => {
|
||||||
|
it('should return success response with created share list', async () => {
|
||||||
|
const createdSharesResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'share-uuid-1',
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
participantCount: 8,
|
||||||
|
userRank: 2,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockShareService.getCreatedShares.mockResolvedValue(
|
||||||
|
createdSharesResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getCreatedShares(mockUser);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(createdSharesResponse);
|
||||||
|
expect(mockShareService.getCreatedShares).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('joinShare', () => {
|
describe('joinShare', () => {
|
||||||
it('should return success response with share levels', async () => {
|
it('should return success response with share levels', async () => {
|
||||||
const joinResponse = {
|
const joinResponse = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -9,6 +9,7 @@ import { ShareService } from './share.service';
|
|||||||
import { CreateShareDto } from './dto/create-share.dto';
|
import { CreateShareDto } from './dto/create-share.dto';
|
||||||
import {
|
import {
|
||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
} from './dto/share-response.dto';
|
} from './dto/share-response.dto';
|
||||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
@@ -23,6 +24,21 @@ import { ReportLevelProgressResponseDto } from './dto/share-level-progress-respo
|
|||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(private readonly shareService: ShareService) {}
|
constructor(private readonly shareService: ShareService) {}
|
||||||
|
|
||||||
|
@Get('created')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取我创建的分享挑战',
|
||||||
|
description: '返回当前用户创建过的分享挑战,并统计参与人数和用户排名',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
async getCreatedShares(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<ApiResponseDto<CreatedShareListResponseDto>> {
|
||||||
|
const data = await this.shareService.getCreatedShares(user.sub);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import {
|
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
@@ -12,6 +9,7 @@ import { Level } from '../wechat-game/entities/level.entity';
|
|||||||
import { ShareConfig } from './entities/share-config.entity';
|
import { ShareConfig } from './entities/share-config.entity';
|
||||||
import { ShareParticipant } from './entities/share-participant.entity';
|
import { ShareParticipant } from './entities/share-participant.entity';
|
||||||
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||||
|
import { User } from '../auth/entities/user.entity';
|
||||||
|
|
||||||
// Mock nanoid to return predictable values
|
// Mock nanoid to return predictable values
|
||||||
jest.mock('nanoid', () => ({
|
jest.mock('nanoid', () => ({
|
||||||
@@ -40,7 +38,7 @@ describe('ShareService', () => {
|
|||||||
title: '我的挑战',
|
title: '我的挑战',
|
||||||
sharerId: 'user-uuid-1',
|
sharerId: 'user-uuid-1',
|
||||||
levelIds: mockLevels.map((l) => l.id),
|
levelIds: mockLevels.map((l) => l.id),
|
||||||
sharer: {} as any,
|
sharer: {} as User,
|
||||||
participants: [],
|
participants: [],
|
||||||
createdAt: new Date('2026-01-01'),
|
createdAt: new Date('2026-01-01'),
|
||||||
updatedAt: new Date('2026-01-01'),
|
updatedAt: new Date('2026-01-01'),
|
||||||
@@ -50,19 +48,21 @@ describe('ShareService', () => {
|
|||||||
id: 'participant-uuid-1',
|
id: 'participant-uuid-1',
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-2',
|
participantId: 'user-uuid-2',
|
||||||
shareConfig: {} as any,
|
shareConfig: {} as ShareConfig,
|
||||||
participant: {} as any,
|
participant: {} as User,
|
||||||
createdAt: new Date('2026-01-01'),
|
createdAt: new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockShareConfigRepository = {
|
const mockShareConfigRepository = {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
findByShareCode: jest.fn(),
|
findByShareCode: jest.fn(),
|
||||||
|
findBySharerId: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockShareParticipantRepository = {
|
const mockShareParticipantRepository = {
|
||||||
addParticipant: jest.fn(),
|
addParticipant: jest.fn(),
|
||||||
countByShareConfigId: jest.fn(),
|
countByShareConfigId: jest.fn(),
|
||||||
|
countByShareConfigIds: jest.fn(),
|
||||||
findByShareConfigAndParticipant: jest.fn(),
|
findByShareConfigAndParticipant: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
@@ -70,6 +70,7 @@ describe('ShareService', () => {
|
|||||||
|
|
||||||
const mockShareLevelProgressRepository = {
|
const mockShareLevelProgressRepository = {
|
||||||
findByParticipantAndLevel: jest.fn(),
|
findByParticipantAndLevel: jest.fn(),
|
||||||
|
summarizeByShareConfigIds: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -106,7 +107,14 @@ describe('ShareService', () => {
|
|||||||
describe('createShare', () => {
|
describe('createShare', () => {
|
||||||
const createDto = {
|
const createDto = {
|
||||||
title: '我的挑战',
|
title: '我的挑战',
|
||||||
levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'],
|
levelIds: [
|
||||||
|
'level-1',
|
||||||
|
'level-2',
|
||||||
|
'level-3',
|
||||||
|
'level-4',
|
||||||
|
'level-5',
|
||||||
|
'level-6',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should create a share with valid 6 unique levels', async () => {
|
it('should create a share with valid 6 unique levels', async () => {
|
||||||
@@ -124,7 +132,14 @@ describe('ShareService', () => {
|
|||||||
it('should throw BadRequestException when level IDs have duplicates', async () => {
|
it('should throw BadRequestException when level IDs have duplicates', async () => {
|
||||||
const duplicateDto = {
|
const duplicateDto = {
|
||||||
title: '测试',
|
title: '测试',
|
||||||
levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'],
|
levelIds: [
|
||||||
|
'level-1',
|
||||||
|
'level-1',
|
||||||
|
'level-2',
|
||||||
|
'level-3',
|
||||||
|
'level-4',
|
||||||
|
'level-5',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -158,7 +173,9 @@ describe('ShareService', () => {
|
|||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
mockShareConfig,
|
mockShareConfig,
|
||||||
);
|
);
|
||||||
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined);
|
mockShareParticipantRepository.addParticipant.mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
|
|
||||||
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
|
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
|
||||||
@@ -168,10 +185,9 @@ describe('ShareService', () => {
|
|||||||
expect(result.levels).toHaveLength(6);
|
expect(result.levels).toHaveLength(6);
|
||||||
expect(result.levels[0].level).toBe(1);
|
expect(result.levels[0].level).toBe(1);
|
||||||
expect(result.levels[0].id).toBe('level-1');
|
expect(result.levels[0].id).toBe('level-1');
|
||||||
expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith(
|
expect(
|
||||||
'share-uuid-1',
|
mockShareParticipantRepository.addParticipant,
|
||||||
'user-uuid-2',
|
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not add participant when user is the sharer', async () => {
|
it('should not add participant when user is the sharer', async () => {
|
||||||
@@ -191,9 +207,122 @@ describe('ShareService', () => {
|
|||||||
it('should throw NotFoundException when share code not found', async () => {
|
it('should throw NotFoundException when share code not found', async () => {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(service.joinShare('user-uuid-2', 'INVALID')).rejects.toThrow(
|
||||||
service.joinShare('user-uuid-2', 'INVALID'),
|
NotFoundException,
|
||||||
).rejects.toThrow(NotFoundException);
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCreatedShares', () => {
|
||||||
|
it('should return empty list when user has not created any share', async () => {
|
||||||
|
mockShareConfigRepository.findBySharerId.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getCreatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ items: [] });
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.countByShareConfigIds,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mockShareLevelProgressRepository.summarizeByShareConfigIds,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return created shares with participant count and user rank', async () => {
|
||||||
|
const otherShareConfig: ShareConfig = {
|
||||||
|
...mockShareConfig,
|
||||||
|
id: 'share-uuid-2',
|
||||||
|
shareCode: 'WXYZ5678',
|
||||||
|
title: '第二个挑战',
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||||
|
otherShareConfig,
|
||||||
|
mockShareConfig,
|
||||||
|
]);
|
||||||
|
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['share-uuid-1', 3],
|
||||||
|
['share-uuid-2', 1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
totalTimeSpent: '120',
|
||||||
|
passedLevelCount: '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
totalTimeSpent: '100',
|
||||||
|
passedLevelCount: '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
totalTimeSpent: '200',
|
||||||
|
passedLevelCount: '5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getCreatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'share-uuid-2',
|
||||||
|
shareCode: 'WXYZ5678',
|
||||||
|
title: '第二个挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
participantCount: 1,
|
||||||
|
userRank: null,
|
||||||
|
createdAt: '2026-01-02T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-uuid-1',
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
participantCount: 3,
|
||||||
|
userRank: 2,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use participantId as deterministic tie breaker for rank', async () => {
|
||||||
|
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||||
|
mockShareConfig,
|
||||||
|
]);
|
||||||
|
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||||
|
new Map([['share-uuid-1', 2]]),
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
totalTimeSpent: '120',
|
||||||
|
passedLevelCount: '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
totalTimeSpent: '120',
|
||||||
|
passedLevelCount: '6',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getCreatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result.items[0].userRank).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CreateShareDto } from './dto/create-share.dto';
|
|||||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
import {
|
import {
|
||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
ShareLevelDto,
|
ShareLevelDto,
|
||||||
} from './dto/share-response.dto';
|
} from './dto/share-response.dto';
|
||||||
@@ -110,6 +111,62 @@ export class ShareService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCreatedShares(userId: string): Promise<CreatedShareListResponseDto> {
|
||||||
|
const configs = await this.shareConfigRepository.findBySharerId(userId);
|
||||||
|
if (configs.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareConfigIds = configs.map((config) => config.id);
|
||||||
|
const [participantCountMap, rankingRows] = await Promise.all([
|
||||||
|
this.shareParticipantRepository.countByShareConfigIds(shareConfigIds),
|
||||||
|
this.shareLevelProgressRepository.summarizeByShareConfigIds(
|
||||||
|
shareConfigIds,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rankingsByShareConfigId = new Map<string, string[]>();
|
||||||
|
for (const config of configs) {
|
||||||
|
const completedRankings = rankingRows
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.shareConfigId === config.id &&
|
||||||
|
Number(row.passedLevelCount) === config.levelIds.length,
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const totalTimeDiff =
|
||||||
|
Number(a.totalTimeSpent) - Number(b.totalTimeSpent);
|
||||||
|
if (totalTimeDiff !== 0) {
|
||||||
|
return totalTimeDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.participantId.localeCompare(b.participantId);
|
||||||
|
})
|
||||||
|
.map((row) => row.participantId);
|
||||||
|
|
||||||
|
rankingsByShareConfigId.set(config.id, completedRankings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: configs.map((config) => {
|
||||||
|
const rankings = rankingsByShareConfigId.get(config.id) ?? [];
|
||||||
|
const rankingIndex = rankings.findIndex(
|
||||||
|
(participantId) => participantId === userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: config.id,
|
||||||
|
shareCode: config.shareCode,
|
||||||
|
title: config.title,
|
||||||
|
levelCount: config.levelIds.length,
|
||||||
|
participantCount: participantCountMap.get(config.id) ?? 0,
|
||||||
|
userRank: rankingIndex >= 0 ? rankingIndex + 1 : null,
|
||||||
|
createdAt: config.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async reportLevelProgress(
|
async reportLevelProgress(
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: ReportLevelProgressDto,
|
dto: ReportLevelProgressDto,
|
||||||
@@ -153,7 +210,10 @@ export class ShareService {
|
|||||||
return {
|
return {
|
||||||
passed: true,
|
passed: true,
|
||||||
timeLimit: level.timeLimit,
|
timeLimit: level.timeLimit,
|
||||||
withinTimeLimit: this.isWithinTimeLimit(level.timeLimit, progress.timeSpent),
|
withinTimeLimit: this.isWithinTimeLimit(
|
||||||
|
level.timeLimit,
|
||||||
|
progress.timeSpent,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user