perf: 支持分享提交答案的接口
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
<claude-mem-context>
|
<claude-mem-context>
|
||||||
# Memory Context
|
# Memory Context
|
||||||
|
|
||||||
# $CMEM MemeMind-Server 2026-05-03 10:13pm GMT+8
|
# $CMEM MemeMind-Server 2026-05-10 3:41pm GMT+8
|
||||||
|
|
||||||
No previous sessions found.
|
No previous sessions found.
|
||||||
</claude-mem-context>
|
</claude-mem-context>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
> 本文档面向微信小游戏客户端(Cocos Creator)开发人员
|
> 本文档面向微信小游戏客户端(Cocos Creator)开发人员
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- 2026-05-10: 移除 `POST /api/v1/share/progress` 单关进度上报接口;新增 `POST /api/v1/share/{code}/submit`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- [概述](#概述)
|
- [概述](#概述)
|
||||||
@@ -16,13 +20,13 @@
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
分享挑战功能允许用户创建包含 6 个关卡的挑战链接,分享给好友。好友通过分享码加入挑战,独立完成关卡并上报进度。
|
分享挑战功能允许用户创建包含 6 个关卡的挑战链接,分享给好友。好友通过分享码加入挑战,独立完成关卡,并在挑战结束后一次性提交每一关的答案与耗时。服务端校验答案后,会返回当前用户排名、答对题数、已提交挑战结果的参与人数,以及分享中每一关的完整内容和正确答案。
|
||||||
|
|
||||||
### 新增功能点(当前分支)
|
### 新增功能点(当前分支)
|
||||||
|
|
||||||
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
||||||
2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
|
2. **整场挑战提交**:`POST /api/v1/share/{code}/submit` 接口,用于一次性提交分享挑战中每一关的答案和耗时
|
||||||
3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
|
3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
|
||||||
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
|
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
|
||||||
5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
||||||
|
|
||||||
@@ -234,8 +238,12 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
id: string; // 关卡 ID
|
id: string; // 关卡 ID
|
||||||
level: number; // 关卡序号(1-6)
|
level: number; // 关卡序号(1-6)
|
||||||
imageUrl: string; // 关卡图片 URL
|
image1Url: string; // 图片 1 URL
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string; // 图片 2 URL
|
||||||
|
image2Description: string | null;
|
||||||
answer: string; // 正确答案
|
answer: string; // 正确答案
|
||||||
|
punchline: string | null;
|
||||||
hint1: string | null; // 提示 1
|
hint1: string | null; // 提示 1
|
||||||
hint2: string | null; // 提示 2
|
hint2: string | null; // 提示 2
|
||||||
hint3: string | null; // 提示 3
|
hint3: string | null; // 提示 3
|
||||||
@@ -257,12 +265,16 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"id": "level_001",
|
"id": "level_001",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"imageUrl": "https://example.com/levels/1.png",
|
"image1Url": "https://example.com/levels/1-a.png",
|
||||||
|
"image1Description": null,
|
||||||
|
"image2Url": "https://example.com/levels/1-b.png",
|
||||||
|
"image2Description": null,
|
||||||
"answer": "答案1",
|
"answer": "答案1",
|
||||||
|
"punchline": null,
|
||||||
"hint1": "提示1",
|
"hint1": "提示1",
|
||||||
"hint2": null,
|
"hint2": null,
|
||||||
"hint3": null,
|
"hint3": null,
|
||||||
"sortOrder": 1
|
"sortOrder": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -308,8 +320,8 @@ Authorization: Bearer <token>
|
|||||||
shareCode: string; // 分享码
|
shareCode: string; // 分享码
|
||||||
title: string; // 分享标题
|
title: string; // 分享标题
|
||||||
levelCount: number; // 关卡数量
|
levelCount: number; // 关卡数量
|
||||||
participantCount: number; // 参与挑战人数
|
participantCount: number; // 已提交结果的参与人数
|
||||||
userRank: number | null; // 当前用户在该挑战中的排名;未完成全部关卡时为 null
|
userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null
|
||||||
createdAt: string; // 创建时间,ISO 8601 字符串
|
createdAt: string; // 创建时间,ISO 8601 字符串
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -350,15 +362,15 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
**排名规则**:
|
**排名规则**:
|
||||||
|
|
||||||
1. 只有完成该分享挑战全部关卡的用户才会进入排名。
|
1. 只有已经调用 `POST /api/v1/share/{code}/submit` 提交整场挑战结果的用户才会进入排名。
|
||||||
2. 排名按通关总耗时升序计算,总耗时越短排名越高。
|
2. 排名按答对题数降序计算,答对越多排名越高。
|
||||||
3. 当总耗时相同时,服务端会按 `participantId` 做稳定排序,保证返回顺序可重复。
|
3. 答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。
|
||||||
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未完成全部关卡,则返回 `null`。
|
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未提交挑战结果,则返回 `null`。
|
||||||
|
|
||||||
**参与人数统计规则**:
|
**参与人数统计规则**:
|
||||||
|
|
||||||
- 统计 `share_participants` 中该挑战的参与者数量。
|
- 统计该挑战中已提交完整挑战结果的用户数量。
|
||||||
- 创建者本人在调用创建接口时不会自动写入参与记录;只有真正以参与者身份产生挑战进度后,才可能出现在排名内。
|
- 创建者本人如果调用提交接口,也会作为参与用户进入统计和排名。
|
||||||
|
|
||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
@@ -367,41 +379,80 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. 上报单关进度
|
### 5. 提交分享挑战结果
|
||||||
|
|
||||||
用户在分享挑战中完成单关后,上报进度。
|
用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
|
||||||
|
|
||||||
**接口地址**:`POST /api/v1/share/progress`
|
**接口地址**:`POST /api/v1/share/{code}/submit`
|
||||||
|
|
||||||
**是否需要认证**:是(JWT Bearer Token)
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| ---- | ------ | ---- | -------------- |
|
||||||
|
| code | string | 是 | 分享码(8 位) |
|
||||||
|
|
||||||
**请求体**:
|
**请求体**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"shareCode": "abc12345", // 分享码
|
"levels": [
|
||||||
"levelId": "level_001", // 关卡 ID
|
{
|
||||||
"passed": true, // 是否通过(true/false)
|
"levelId": "level_001",
|
||||||
"timeSpent": 30 // 通关时间(秒)
|
"answer": "答案1",
|
||||||
|
"timeSpent": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"levelId": "level_002",
|
||||||
|
"answer": "答案2",
|
||||||
|
"timeSpent": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**字段说明**:
|
**字段说明**:
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
| --------- | ------- | ---- | -------------------------- |
|
| ------------------ | ------ | ---- | ---------------------------------------------- |
|
||||||
| shareCode | string | 是 | 分享码 |
|
| levels | array | 是 | 每一关的提交结果,必须覆盖分享挑战中的全部关卡 |
|
||||||
| levelId | string | 是 | 关卡 ID |
|
| levels[].levelId | string | 是 | 关卡 ID |
|
||||||
| passed | boolean | 是 | 是否通过 |
|
| levels[].answer | string | 是 | 用户提交的答案,空字符串表示未作答 |
|
||||||
| timeSpent | number | 是 | 通关时间(秒),最小值为 0 |
|
| levels[].timeSpent | number | 是 | 本关耗时(秒),最小值为 0 |
|
||||||
|
|
||||||
**响应数据**:
|
**响应数据**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
passed: boolean; // 是否通过
|
shareCode: string;
|
||||||
timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制
|
title: string;
|
||||||
withinTimeLimit: boolean; // 是否在时间限制内通过
|
rank: number; // 当前用户在该挑战中的排名
|
||||||
|
correctCount: number; // 当前用户答对题数
|
||||||
|
levelCount: number; // 挑战关卡总数
|
||||||
|
participantCount: number; // 已提交结果的参与用户总数
|
||||||
|
totalTimeSpent: number; // 当前用户本次提交总耗时(秒)
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
submittedAnswer: string;
|
||||||
|
timeSpent: number;
|
||||||
|
isCorrect: boolean;
|
||||||
|
timeLimit: number | null;
|
||||||
|
withinTimeLimit: boolean;
|
||||||
|
}
|
||||||
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -411,9 +462,34 @@ Authorization: Bearer <token>
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"passed": true,
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"rank": 2,
|
||||||
|
"correctCount": 5,
|
||||||
|
"levelCount": 6,
|
||||||
|
"participantCount": 8,
|
||||||
|
"totalTimeSpent": 180,
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": "level_001",
|
||||||
|
"level": 1,
|
||||||
|
"image1Url": "https://example.com/levels/1-a.png",
|
||||||
|
"image1Description": null,
|
||||||
|
"image2Url": "https://example.com/levels/1-b.png",
|
||||||
|
"image2Description": null,
|
||||||
|
"answer": "答案1",
|
||||||
|
"punchline": null,
|
||||||
|
"hint1": "提示1",
|
||||||
|
"hint2": null,
|
||||||
|
"hint3": null,
|
||||||
|
"sortOrder": 0,
|
||||||
|
"submittedAnswer": "答案1",
|
||||||
|
"timeSpent": 30,
|
||||||
|
"isCorrect": true,
|
||||||
"timeLimit": 60,
|
"timeLimit": 60,
|
||||||
"withinTimeLimit": true
|
"withinTimeLimit": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"message": null,
|
"message": null,
|
||||||
"timestamp": "2026-04-08T12:00:00.000Z"
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
@@ -422,34 +498,37 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
**业务逻辑说明**:
|
**业务逻辑说明**:
|
||||||
|
|
||||||
1. **参与者登记**:非创建者首次上报进度时会自动写入 `share_participants`,后续重复上报已存在则忽略;创建者本人不会被登记为参与者。
|
1. **完整提交**:`levels` 必须覆盖分享中的全部关卡,不能缺少、重复或提交不属于该分享的关卡。
|
||||||
2. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间
|
2. **答案校验**:服务端按去除首尾空白并忽略大小写后的答案进行比对,返回每关 `isCorrect`。
|
||||||
3. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖
|
3. **排名规则**:按答对题数降序排名;答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序稳定排序。
|
||||||
4. **未通过**:可以多次上报 `passed=false`,更新通关时间记录
|
4. **参与者登记**:提交成功后会写入或更新 `share_participants` 的聚合成绩;创建者本人提交时也会计入参与人数和排名。
|
||||||
5. **时间限制判断**:
|
5. **结果持久化**:每关的 `submittedAnswer`、`timeSpent`、`isCorrect` 会保存到 `share_level_progress`,整场的 `correctCount`、`totalTimeSpent`、`submittedAt` 会保存到 `share_participants`。
|
||||||
- 如果关卡 `timeLimit` 为 `null`,`withinTimeLimit` 始终为 `true`
|
6. **重复提交**:同一用户重复提交同一挑战时,服务端会覆盖该用户本挑战的上一份结果,并重新计算排名。
|
||||||
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true`
|
7. **时间限制判断**:`withinTimeLimit` 只表示耗时是否不超过关卡 `timeLimit`;答题正确与否由 `isCorrect` 表示。
|
||||||
6. **跨挑战独立**:进度按 `(shareConfigId, participantId, levelId)` 唯一记录,同一用户在不同分享挑战中对同一关卡的进度互不影响。
|
8. **跨挑战独立**:结果按 `(shareConfigId, participantId, levelId)` 唯一记录,同一用户在不同分享挑战中对同一关卡的提交互不影响。
|
||||||
|
|
||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
- 用户完成一个关卡后调用
|
- 用户完成整场分享挑战后调用
|
||||||
- 无论通关还是失败都需要调用
|
- 结果页需要展示排名、答对题数、完整答案解析时调用
|
||||||
- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 错误码说明
|
## 错误码说明
|
||||||
|
|
||||||
| HTTP Status | message | 说明 |
|
| HTTP Status | message | 说明 |
|
||||||
| ----------- | ------------------------------------- | ---------------------------- |
|
| ----------- | -------------------------------------- | ---------------------------- |
|
||||||
| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
|
| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
|
||||||
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
|
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
|
||||||
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
|
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
|
||||||
|
| 400 | 提交关卡数量必须与分享挑战关卡数量一致 | 提交挑战结果时关卡数量不完整 |
|
||||||
|
| 400 | 关卡 xxx 重复提交 | 提交挑战结果时同一关卡重复 |
|
||||||
|
| 400 | 关卡 xxx 不属于此分享挑战 | 提交挑战结果时包含外部关卡 |
|
||||||
|
| 400 | 缺少关卡提交: xxx | 提交挑战结果时缺少指定关卡 |
|
||||||
| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
|
| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
|
||||||
| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 |
|
| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 |
|
||||||
| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 |
|
| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 |
|
||||||
| 404 | 关卡不存在 | levelId 不存在于 levels 表 |
|
| 404 | 以下关卡不存在: xxx | 分享配置中的关卡不存在 |
|
||||||
| 500 | Internal server error | 服务器内部错误 |
|
| 500 | Internal server error | 服务器内部错误 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -477,7 +556,7 @@ Authorization: Bearer <token>
|
|||||||
│ 3. 调用 POST /api/v1/auth/wx-login 获取/确认身份 │
|
│ 3. 调用 POST /api/v1/auth/wx-login 获取/确认身份 │
|
||||||
│ 4. 调用 POST /api/v1/share/{code}/join 加入挑战 │
|
│ 4. 调用 POST /api/v1/share/{code}/join 加入挑战 │
|
||||||
│ 5. 获取 6 个关卡数据,开始挑战 │
|
│ 5. 获取 6 个关卡数据,开始挑战 │
|
||||||
│ 6. 每完成一关,调用 POST /api/v1/share/progress 上报进度 │
|
│ 6. 完成整场挑战后,调用 POST /api/v1/share/{code}/submit 提交结果 │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -491,13 +570,13 @@ interface ShareChallengeState {
|
|||||||
shareCode: string | null; // 当前分享码
|
shareCode: string | null; // 当前分享码
|
||||||
currentLevelIndex: number; // 当前关卡索引(0-5)
|
currentLevelIndex: number; // 当前关卡索引(0-5)
|
||||||
levels: LevelData[]; // 关卡数据
|
levels: LevelData[]; // 关卡数据
|
||||||
progress: {
|
submissions: {
|
||||||
[levelId: string]: {
|
[levelId: string]: {
|
||||||
passed: boolean;
|
answer: string;
|
||||||
timeSpent: number;
|
timeSpent: number;
|
||||||
withinTimeLimit: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
result: SubmitChallengeResponse | null;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -681,8 +760,12 @@ console.log('分享码:', share.shareCode);
|
|||||||
interface LevelData {
|
interface LevelData {
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
imageUrl: string;
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
hint1: string | null;
|
hint1: string | null;
|
||||||
hint2: string | null;
|
hint2: string | null;
|
||||||
hint3: string | null;
|
hint3: string | null;
|
||||||
@@ -720,62 +803,70 @@ const shareData = await joinShare(shareCode);
|
|||||||
// 保存关卡数据,开始游戏
|
// 保存关卡数据,开始游戏
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 上报关卡进度
|
### 5. 提交挑战结果
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ReportProgressResponse {
|
interface SubmitChallengeLevel {
|
||||||
passed: boolean;
|
levelId: string;
|
||||||
|
answer: string;
|
||||||
|
timeSpent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmittedLevelResult extends LevelData {
|
||||||
|
submittedAnswer: string;
|
||||||
|
timeSpent: number;
|
||||||
|
isCorrect: boolean;
|
||||||
timeLimit: number | null;
|
timeLimit: number | null;
|
||||||
withinTimeLimit: boolean;
|
withinTimeLimit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportLevelProgress(
|
interface SubmitChallengeResponse {
|
||||||
|
shareCode: string;
|
||||||
|
title: string;
|
||||||
|
rank: number;
|
||||||
|
correctCount: number;
|
||||||
|
levelCount: number;
|
||||||
|
participantCount: number;
|
||||||
|
totalTimeSpent: number;
|
||||||
|
levels: SubmittedLevelResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShareChallenge(
|
||||||
shareCode: string,
|
shareCode: string,
|
||||||
levelId: string,
|
levels: SubmitChallengeLevel[],
|
||||||
passed: boolean,
|
): Promise<SubmitChallengeResponse> {
|
||||||
timeSpent: number,
|
|
||||||
): Promise<ReportProgressResponse> {
|
|
||||||
// 确保已登录
|
// 确保已登录
|
||||||
if (!httpManager.getToken()) {
|
if (!httpManager.getToken()) {
|
||||||
await wxLogin();
|
await wxLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await httpManager.post<ReportProgressResponse>(
|
const response = await httpManager.post<SubmitChallengeResponse>(
|
||||||
'/v1/share/progress',
|
`/v1/share/${shareCode}/submit`,
|
||||||
{
|
{ levels },
|
||||||
shareCode,
|
|
||||||
levelId,
|
|
||||||
passed,
|
|
||||||
timeSpent,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
console.log('进度上报成功:', response.data);
|
console.log('挑战结果提交成功:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || '进度上报失败');
|
throw new Error(response.message || '挑战结果提交失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用示例
|
// 使用示例
|
||||||
async function onLevelComplete(levelId: string, timeSpent: number) {
|
async function onChallengeFinished() {
|
||||||
const passed = true; // 根据游戏逻辑判断是否通过
|
const result = await submitShareChallenge(this.shareCode, [
|
||||||
const result = await reportLevelProgress(
|
{ levelId: 'level_001', answer: '答案1', timeSpent: 30 },
|
||||||
this.shareCode,
|
{ levelId: 'level_002', answer: '答案2', timeSpent: 45 },
|
||||||
levelId,
|
{ levelId: 'level_003', answer: '', timeSpent: 60 },
|
||||||
passed,
|
{ levelId: 'level_004', answer: '答案4', timeSpent: 20 },
|
||||||
timeSpent,
|
{ levelId: 'level_005', answer: '答案5', timeSpent: 15 },
|
||||||
);
|
{ levelId: 'level_006', answer: '答案6', timeSpent: 10 },
|
||||||
|
]);
|
||||||
|
|
||||||
if (result.passed) {
|
console.log(`当前排名: 第 ${result.rank} 名`);
|
||||||
console.log(
|
console.log(`答对: ${result.correctCount}/${result.levelCount}`);
|
||||||
`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`,
|
console.log(`参与人数: ${result.participantCount}`);
|
||||||
);
|
|
||||||
if (result.timeLimit) {
|
|
||||||
console.log(`本关时间限制: ${result.timeLimit}秒`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -822,32 +913,34 @@ export class GameEntry extends Component {
|
|||||||
### LevelData 完整字段
|
### LevelData 完整字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --------- | -------------- | --------------- |
|
| ----------------- | -------------- | --------------- |
|
||||||
| id | string | 关卡唯一标识 |
|
| id | string | 关卡唯一标识 |
|
||||||
| level | number | 关卡序号(1-6) |
|
| level | number | 关卡序号(1-6) |
|
||||||
| imageUrl | string | 关卡图片 URL |
|
| image1Url | string | 图片 1 URL |
|
||||||
|
| image1Description | string \| null | 图片 1 文本说明 |
|
||||||
|
| image2Url | string | 图片 2 URL |
|
||||||
|
| image2Description | string \| null | 图片 2 文本说明 |
|
||||||
| answer | string | 正确答案 |
|
| answer | string | 正确答案 |
|
||||||
|
| punchline | string \| null | 谐音梗说明 |
|
||||||
| hint1 | string \| null | 第 1 个提示 |
|
| hint1 | string \| null | 第 1 个提示 |
|
||||||
| hint2 | string \| null | 第 2 个提示 |
|
| hint2 | string \| null | 第 2 个提示 |
|
||||||
| hint3 | string \| null | 第 3 个提示 |
|
| hint3 | string \| null | 第 3 个提示 |
|
||||||
| sortOrder | number | 排序顺序 |
|
| sortOrder | number | 全局排序顺序 |
|
||||||
|
|
||||||
### 关卡时间限制说明
|
### 关卡时间限制说明
|
||||||
|
|
||||||
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
|
`POST /api/v1/share/{code}/join` 返回的关卡数据不包含 `timeLimit`。挑战结束后调用 `POST /api/v1/share/{code}/submit`,返回的每关结果会包含:
|
||||||
|
|
||||||
如果需要在前端判断时间限制:
|
- `timeLimit`:该关卡时间限制,`null` 表示无限制。
|
||||||
|
- `withinTimeLimit`:用户提交的 `timeSpent` 是否不超过 `timeLimit`。
|
||||||
1. 用户完成关卡后调用 `reportLevelProgress`
|
- `isCorrect`:用户提交答案是否正确。
|
||||||
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
|
|
||||||
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. **Token 有效期**:JWT Token 有效期为 7 天,客户端应缓存并在启动时使用
|
1. **Token 有效期**:JWT Token 有效期为 7 天,客户端应缓存并在启动时使用
|
||||||
2. **重复通关**:首次通关记录会被保留,重复通关不会覆盖之前的记录
|
2. **重复提交**:同一用户重复提交同一分享挑战会覆盖上一份提交结果,并重新计算排名
|
||||||
3. **分享码格式**:8 位字母数字组合,大小写敏感
|
3. **分享码格式**:8 位字母数字组合,大小写敏感
|
||||||
4. **关卡数量**:每次分享挑战固定包含 6 个关卡
|
4. **关卡数量**:每次分享挑战固定包含 6 个关卡
|
||||||
5. **网络异常**:建议在调用接口时显示 loading 状态,并处理网络异常情况
|
5. **网络异常**:建议在调用接口时显示 loading 状态,并处理网络异常情况
|
||||||
|
|||||||
37
src/database/migrations/006_share_challenge_submission.sql
Normal file
37
src/database/migrations/006_share_challenge_submission.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Description: Persist full share challenge submissions.
|
||||||
|
--
|
||||||
|
-- The new share challenge submit flow receives every level answer and time
|
||||||
|
-- spent in one request. Keep per-level answers in share_level_progress and
|
||||||
|
-- store aggregate ranking fields on share_participants for later analytics.
|
||||||
|
|
||||||
|
ALTER TABLE share_participants
|
||||||
|
ADD COLUMN correct_count INT NOT NULL DEFAULT 0
|
||||||
|
COMMENT '提交结果中答对题数'
|
||||||
|
AFTER participant_id,
|
||||||
|
ADD COLUMN total_time_spent INT NOT NULL DEFAULT 0
|
||||||
|
COMMENT '提交结果总耗时(秒)'
|
||||||
|
AFTER correct_count,
|
||||||
|
ADD COLUMN submitted_at DATETIME DEFAULT NULL
|
||||||
|
COMMENT '最近一次提交挑战结果的时间'
|
||||||
|
AFTER total_time_spent,
|
||||||
|
ADD COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
COMMENT '更新时间'
|
||||||
|
AFTER created_at;
|
||||||
|
|
||||||
|
CREATE INDEX idx_share_participants_submitted
|
||||||
|
ON share_participants (share_config_id, submitted_at);
|
||||||
|
|
||||||
|
CREATE INDEX idx_share_participants_ranking
|
||||||
|
ON share_participants (
|
||||||
|
share_config_id,
|
||||||
|
correct_count,
|
||||||
|
total_time_spent,
|
||||||
|
submitted_at,
|
||||||
|
participant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE share_level_progress
|
||||||
|
ADD COLUMN submitted_answer VARCHAR(191) NOT NULL DEFAULT ''
|
||||||
|
COMMENT '用户提交的答案'
|
||||||
|
AFTER level_id;
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import {
|
|
||||||
IsBoolean,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsNumber,
|
|
||||||
IsString,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class ReportLevelProgressDto {
|
|
||||||
@ApiProperty({ description: '分享码' })
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
shareCode!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '关卡 ID' })
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
levelId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '是否通过' })
|
|
||||||
@IsBoolean()
|
|
||||||
passed!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '通关时间(秒)' })
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
timeSpent!: number;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class ReportLevelProgressResponseDto {
|
|
||||||
@ApiProperty({ description: '是否通过' })
|
|
||||||
passed!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
|
||||||
timeLimit!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '是否在时间限制内通过' })
|
|
||||||
withinTimeLimit!: boolean;
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,52 @@ export class JoinShareResponseDto {
|
|||||||
levels!: ShareLevelDto[];
|
levels!: ShareLevelDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SubmittedShareLevelDto extends ShareLevelDto {
|
||||||
|
@ApiProperty({ description: '用户提交的答案' })
|
||||||
|
submittedAnswer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '本关耗时(秒)' })
|
||||||
|
timeSpent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '答案是否正确' })
|
||||||
|
isCorrect!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||||
|
timeLimit!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否在时间限制内完成' })
|
||||||
|
withinTimeLimit!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitShareChallengeResponseDto {
|
||||||
|
@ApiProperty({ description: '分享码' })
|
||||||
|
shareCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '分享标题' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前用户在该挑战中的排名' })
|
||||||
|
rank!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前用户答对题数' })
|
||||||
|
correctCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '挑战关卡总数' })
|
||||||
|
levelCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已提交结果的参与用户总数' })
|
||||||
|
participantCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前用户本次提交的总耗时(秒)' })
|
||||||
|
totalTimeSpent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '分享中的每一关内容、正确答案和当前用户提交结果',
|
||||||
|
type: [SubmittedShareLevelDto],
|
||||||
|
})
|
||||||
|
levels!: SubmittedShareLevelDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CreatedShareItemDto {
|
export class CreatedShareItemDto {
|
||||||
@ApiProperty({ description: '分享 ID' })
|
@ApiProperty({ description: '分享 ID' })
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -77,7 +123,7 @@ export class CreatedShareItemDto {
|
|||||||
participantCount!: number;
|
participantCount!: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
|
description: '当前用户在该挑战中的排名,尚未提交挑战结果时为 null',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
userRank!: number | null;
|
userRank!: number | null;
|
||||||
|
|||||||
41
src/modules/share/dto/submit-share-challenge.dto.ts
Normal file
41
src/modules/share/dto/submit-share-challenge.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class SubmitShareLevelAnswerDto {
|
||||||
|
@ApiProperty({ description: '关卡 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
levelId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用户提交的答案,空字符串表示未作答' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(191)
|
||||||
|
answer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '本关耗时(秒)' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
timeSpent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitShareChallengeDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '本次挑战中每一关的答案和耗时,必须覆盖分享中的全部关卡',
|
||||||
|
type: [SubmitShareLevelAnswerDto],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubmitShareLevelAnswerDto)
|
||||||
|
levels!: SubmitShareLevelAnswerDto[];
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { ShareConfig } from './share-config.entity';
|
|||||||
import { Level } from '../../wechat-game/entities/level.entity';
|
import { Level } from '../../wechat-game/entities/level.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分享挑战内的单关进度。
|
* 分享挑战内的单关提交结果。
|
||||||
*
|
*
|
||||||
* (share_config_id, participant_id, level_id) 三元组保证:
|
* (share_config_id, participant_id, level_id) 三元组保证:
|
||||||
* 同一用户在不同分享挑战中对同一关的记录互不干扰。
|
* 同一用户在不同分享挑战中对同一关的记录互不干扰。
|
||||||
@@ -44,6 +44,14 @@ export class ShareLevelProgress {
|
|||||||
@Column({ type: 'char', length: 191, name: 'level_id' })
|
@Column({ type: 'char', length: 191, name: 'level_id' })
|
||||||
levelId!: string;
|
levelId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 191,
|
||||||
|
name: 'submitted_answer',
|
||||||
|
default: '',
|
||||||
|
})
|
||||||
|
submittedAnswer!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Level)
|
@ManyToOne(() => Level)
|
||||||
@JoinColumn({ name: 'level_id' })
|
@JoinColumn({ name: 'level_id' })
|
||||||
level!: Level;
|
level!: Level;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Column,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
Index,
|
Index,
|
||||||
@@ -17,6 +19,14 @@ import { ShareConfig } from './share-config.entity';
|
|||||||
* - participant_id 直接存储 wx_users.id(用户 UUID)
|
* - participant_id 直接存储 wx_users.id(用户 UUID)
|
||||||
*/
|
*/
|
||||||
@Entity('share_participants')
|
@Entity('share_participants')
|
||||||
|
@Index('idx_share_participants_submitted', ['shareConfigId', 'submittedAt'])
|
||||||
|
@Index('idx_share_participants_ranking', [
|
||||||
|
'shareConfigId',
|
||||||
|
'correctCount',
|
||||||
|
'totalTimeSpent',
|
||||||
|
'submittedAt',
|
||||||
|
'participantId',
|
||||||
|
])
|
||||||
export class ShareParticipant {
|
export class ShareParticipant {
|
||||||
@PrimaryColumn({ type: 'varchar', length: 191, name: 'share_config_id' })
|
@PrimaryColumn({ type: 'varchar', length: 191, name: 'share_config_id' })
|
||||||
@Index('idx_share_config_id')
|
@Index('idx_share_config_id')
|
||||||
@@ -25,6 +35,20 @@ export class ShareParticipant {
|
|||||||
@PrimaryColumn({ type: 'varchar', length: 191, name: 'participant_id' })
|
@PrimaryColumn({ type: 'varchar', length: 191, name: 'participant_id' })
|
||||||
participantId!: string;
|
participantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'correct_count' })
|
||||||
|
correctCount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'total_time_spent' })
|
||||||
|
totalTimeSpent!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
name: 'submitted_at',
|
||||||
|
nullable: true,
|
||||||
|
default: null,
|
||||||
|
})
|
||||||
|
submittedAt!: Date | null;
|
||||||
|
|
||||||
@ManyToOne(() => ShareConfig, (sc) => sc.participants)
|
@ManyToOne(() => ShareConfig, (sc) => sc.participants)
|
||||||
@JoinColumn({ name: 'share_config_id' })
|
@JoinColumn({ name: 'share_config_id' })
|
||||||
shareConfig!: ShareConfig;
|
shareConfig!: ShareConfig;
|
||||||
@@ -35,4 +59,7 @@ export class ShareParticipant {
|
|||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,4 +63,10 @@ export class ShareLevelProgressRepository {
|
|||||||
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||||
return this.repository.save(progress);
|
return this.repository.save(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveMany(
|
||||||
|
progressList: ShareLevelProgress[],
|
||||||
|
): Promise<ShareLevelProgress[]> {
|
||||||
|
return this.repository.save(progressList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ type ShareParticipantCountRow = {
|
|||||||
participantCount: string;
|
participantCount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShareParticipantRankingRow = {
|
||||||
|
shareConfigId: string;
|
||||||
|
participantId: string;
|
||||||
|
correctCount: number;
|
||||||
|
totalTimeSpent: number;
|
||||||
|
submittedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareParticipantRepository {
|
export class ShareParticipantRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -55,6 +63,107 @@ export class ShareParticipantRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertSubmissionSummary(data: {
|
||||||
|
shareConfigId: string;
|
||||||
|
participantId: string;
|
||||||
|
correctCount: number;
|
||||||
|
totalTimeSpent: number;
|
||||||
|
submittedAt: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.repository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into(ShareParticipant)
|
||||||
|
.values(data)
|
||||||
|
.orUpdate(
|
||||||
|
['correctCount', 'totalTimeSpent', 'submittedAt'],
|
||||||
|
['shareConfigId', 'participantId'],
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countSubmittedByShareConfigId(shareConfigId: string): Promise<number> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('participant')
|
||||||
|
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
||||||
|
.andWhere('participant.submittedAt IS NOT NULL')
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countSubmittedByShareConfigIds(
|
||||||
|
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(*)', 'participantCount')
|
||||||
|
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||||
|
shareConfigIds,
|
||||||
|
})
|
||||||
|
.andWhere('participant.submittedAt IS NOT NULL')
|
||||||
|
.groupBy('participant.shareConfigId')
|
||||||
|
.getRawMany<ShareParticipantCountRow>();
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [row.shareConfigId, Number(row.participantCount)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSubmittedRankingsByShareConfigId(
|
||||||
|
shareConfigId: string,
|
||||||
|
): Promise<ShareParticipantRankingRow[]> {
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('participant')
|
||||||
|
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
||||||
|
.andWhere('participant.submittedAt IS NOT NULL')
|
||||||
|
.orderBy('participant.correctCount', 'DESC')
|
||||||
|
.addOrderBy('participant.totalTimeSpent', 'ASC')
|
||||||
|
.addOrderBy('participant.submittedAt', 'ASC')
|
||||||
|
.addOrderBy('participant.participantId', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
shareConfigId: row.shareConfigId,
|
||||||
|
participantId: row.participantId,
|
||||||
|
correctCount: row.correctCount,
|
||||||
|
totalTimeSpent: row.totalTimeSpent,
|
||||||
|
submittedAt: row.submittedAt!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSubmittedRankingsByShareConfigIds(
|
||||||
|
shareConfigIds: string[],
|
||||||
|
): Promise<ShareParticipantRankingRow[]> {
|
||||||
|
if (shareConfigIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('participant')
|
||||||
|
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||||
|
shareConfigIds,
|
||||||
|
})
|
||||||
|
.andWhere('participant.submittedAt IS NOT NULL')
|
||||||
|
.orderBy('participant.shareConfigId', 'ASC')
|
||||||
|
.addOrderBy('participant.correctCount', 'DESC')
|
||||||
|
.addOrderBy('participant.totalTimeSpent', 'ASC')
|
||||||
|
.addOrderBy('participant.submittedAt', 'ASC')
|
||||||
|
.addOrderBy('participant.participantId', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
shareConfigId: row.shareConfigId,
|
||||||
|
participantId: row.participantId,
|
||||||
|
correctCount: row.correctCount,
|
||||||
|
totalTimeSpent: row.totalTimeSpent,
|
||||||
|
submittedAt: row.submittedAt!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async existsByShareConfigAndParticipant(
|
async existsByShareConfigAndParticipant(
|
||||||
shareConfigId: string,
|
shareConfigId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('ShareController', () => {
|
|||||||
createShare: jest.fn(),
|
createShare: jest.fn(),
|
||||||
getCreatedShares: jest.fn(),
|
getCreatedShares: jest.fn(),
|
||||||
joinShare: jest.fn(),
|
joinShare: jest.fn(),
|
||||||
reportLevelProgress: jest.fn(),
|
submitChallenge: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -99,8 +99,12 @@ describe('ShareController', () => {
|
|||||||
{
|
{
|
||||||
id: 'l1',
|
id: 'l1',
|
||||||
level: 1,
|
level: 1,
|
||||||
imageUrl: 'https://example.com/1.jpg',
|
image1Url: 'https://example.com/1-a.jpg',
|
||||||
|
image1Description: null,
|
||||||
|
image2Url: 'https://example.com/1-b.jpg',
|
||||||
|
image2Description: null,
|
||||||
answer: '答案',
|
answer: '答案',
|
||||||
|
punchline: null,
|
||||||
hint1: null,
|
hint1: null,
|
||||||
hint2: null,
|
hint2: null,
|
||||||
hint3: null,
|
hint3: null,
|
||||||
@@ -121,27 +125,54 @@ describe('ShareController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reportLevelProgress', () => {
|
describe('submitChallenge', () => {
|
||||||
it('should return success response with progress result', async () => {
|
it('should return success response with challenge result', async () => {
|
||||||
const progressResponse = {
|
const submitResponse = {
|
||||||
passed: true,
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
rank: 1,
|
||||||
|
correctCount: 1,
|
||||||
|
levelCount: 1,
|
||||||
|
participantCount: 3,
|
||||||
|
totalTimeSpent: 30,
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
id: 'level-1',
|
||||||
|
level: 1,
|
||||||
|
image1Url: 'https://example.com/1-a.jpg',
|
||||||
|
image1Description: null,
|
||||||
|
image2Url: 'https://example.com/1-b.jpg',
|
||||||
|
image2Description: null,
|
||||||
|
answer: '答案',
|
||||||
|
punchline: null,
|
||||||
|
hint1: null,
|
||||||
|
hint2: null,
|
||||||
|
hint3: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
submittedAnswer: '答案',
|
||||||
|
timeSpent: 30,
|
||||||
|
isCorrect: true,
|
||||||
timeLimit: 60,
|
timeLimit: 60,
|
||||||
withinTimeLimit: true,
|
withinTimeLimit: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
mockShareService.reportLevelProgress.mockResolvedValue(progressResponse);
|
mockShareService.submitChallenge.mockResolvedValue(submitResponse);
|
||||||
|
|
||||||
const dto = {
|
const dto = {
|
||||||
shareCode: 'ABCD1234',
|
levels: [{ levelId: 'level-1', answer: '答案', timeSpent: 30 }],
|
||||||
levelId: 'level-1',
|
|
||||||
passed: true,
|
|
||||||
timeSpent: 30,
|
|
||||||
};
|
};
|
||||||
const result = await controller.reportLevelProgress(mockUser, dto);
|
const result = await controller.submitChallenge(
|
||||||
|
mockUser,
|
||||||
|
'ABCD1234',
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data).toEqual(progressResponse);
|
expect(result.data).toEqual(submitResponse);
|
||||||
expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith(
|
expect(mockShareService.submitChallenge).toHaveBeenCalledWith(
|
||||||
'user-uuid-1',
|
'user-uuid-1',
|
||||||
|
'ABCD1234',
|
||||||
dto,
|
dto,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
|
SubmitShareChallengeResponseDto,
|
||||||
} 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';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto';
|
||||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
|
||||||
|
|
||||||
@ApiTags('分享挑战')
|
@ApiTags('分享挑战')
|
||||||
@Controller('v1/share')
|
@Controller('v1/share')
|
||||||
@@ -73,20 +73,22 @@ export class ShareController {
|
|||||||
return ApiResponseDto.success(data);
|
return ApiResponseDto.success(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('progress')
|
@Post(':code/submit')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '上报单关进度',
|
summary: '提交分享挑战结果',
|
||||||
description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效',
|
description:
|
||||||
|
'客户端一次性提交分享挑战中每一关的耗时和答案,服务端校验后返回排名、答对题数、参与人数和完整关卡答案',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '成功' })
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
||||||
async reportLevelProgress(
|
async submitChallenge(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Body() dto: ReportLevelProgressDto,
|
@Param('code') code: string,
|
||||||
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>> {
|
@Body() dto: SubmitShareChallengeDto,
|
||||||
const data = await this.shareService.reportLevelProgress(user.sub, dto);
|
): Promise<ApiResponseDto<SubmitShareChallengeResponseDto>> {
|
||||||
|
const data = await this.shareService.submitChallenge(user.sub, code, dto);
|
||||||
return ApiResponseDto.success(data);
|
return ApiResponseDto.success(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,21 @@ describe('ShareService', () => {
|
|||||||
addParticipant: jest.fn(),
|
addParticipant: jest.fn(),
|
||||||
countByShareConfigId: jest.fn(),
|
countByShareConfigId: jest.fn(),
|
||||||
countByShareConfigIds: jest.fn(),
|
countByShareConfigIds: jest.fn(),
|
||||||
|
countSubmittedByShareConfigId: jest.fn(),
|
||||||
|
countSubmittedByShareConfigIds: jest.fn(),
|
||||||
existsByShareConfigAndParticipant: jest.fn(),
|
existsByShareConfigAndParticipant: jest.fn(),
|
||||||
|
upsertSubmissionSummary: jest.fn(),
|
||||||
|
findSubmittedRankingsByShareConfigId: jest.fn(),
|
||||||
|
findSubmittedRankingsByShareConfigIds: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockShareLevelProgressRepository = {
|
const mockShareLevelProgressRepository = {
|
||||||
|
findByShareConfigAndParticipant: jest.fn(),
|
||||||
findByShareConfigParticipantAndLevel: jest.fn(),
|
findByShareConfigParticipantAndLevel: jest.fn(),
|
||||||
summarizeByShareConfigIds: jest.fn(),
|
summarizeByShareConfigIds: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
saveMany: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockLevelRepository = {
|
const mockLevelRepository = {
|
||||||
@@ -214,10 +221,10 @@ describe('ShareService', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ items: [] });
|
expect(result).toEqual({ items: [] });
|
||||||
expect(
|
expect(
|
||||||
mockShareParticipantRepository.countByShareConfigIds,
|
mockShareParticipantRepository.countSubmittedByShareConfigIds,
|
||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
mockShareLevelProgressRepository.summarizeByShareConfigIds,
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds,
|
||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,31 +241,34 @@ describe('ShareService', () => {
|
|||||||
otherShareConfig,
|
otherShareConfig,
|
||||||
mockShareConfig,
|
mockShareConfig,
|
||||||
]);
|
]);
|
||||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||||
new Map([
|
new Map([
|
||||||
['share-uuid-1', 3],
|
['share-uuid-1', 3],
|
||||||
['share-uuid-2', 1],
|
['share-uuid-2', 1],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-1',
|
participantId: 'user-uuid-2',
|
||||||
totalTimeSpent: '120',
|
correctCount: 6,
|
||||||
passedLevelCount: '6',
|
totalTimeSpent: 100,
|
||||||
|
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-2',
|
participantId: 'user-uuid-1',
|
||||||
totalTimeSpent: '100',
|
correctCount: 6,
|
||||||
passedLevelCount: '6',
|
totalTimeSpent: 120,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-3',
|
participantId: 'user-uuid-3',
|
||||||
totalTimeSpent: '200',
|
correctCount: 5,
|
||||||
passedLevelCount: '5',
|
totalTimeSpent: 200,
|
||||||
|
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -289,235 +299,249 @@ describe('ShareService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use participantId as deterministic tie breaker for rank', async () => {
|
it('should use submitted ranking order for rank', async () => {
|
||||||
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||||
mockShareConfig,
|
mockShareConfig,
|
||||||
]);
|
]);
|
||||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||||
new Map([['share-uuid-1', 2]]),
|
new Map([['share-uuid-1', 2]]),
|
||||||
);
|
);
|
||||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-2',
|
participantId: 'user-uuid-2',
|
||||||
totalTimeSpent: '120',
|
correctCount: 6,
|
||||||
passedLevelCount: '6',
|
totalTimeSpent: 120,
|
||||||
|
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-1',
|
participantId: 'user-uuid-1',
|
||||||
totalTimeSpent: '120',
|
correctCount: 6,
|
||||||
passedLevelCount: '6',
|
totalTimeSpent: 120,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.getCreatedShares('user-uuid-1');
|
const result = await service.getCreatedShares('user-uuid-1');
|
||||||
|
|
||||||
expect(result.items[0].userRank).toBe(1);
|
expect(result.items[0].userRank).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reportLevelProgress', () => {
|
describe('submitChallenge', () => {
|
||||||
const reportDto = {
|
const submitDto = {
|
||||||
shareCode: 'ABCD1234',
|
levels: mockLevels.map((level, index) => ({
|
||||||
levelId: 'level-1',
|
levelId: level.id,
|
||||||
passed: true,
|
answer: index < 4 ? level.answer : '错误答案',
|
||||||
timeSpent: 30,
|
timeSpent: 10 + index,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should create new progress record for first attempt', async () => {
|
beforeEach(() => {
|
||||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
mockShareConfig,
|
mockShareConfig,
|
||||||
);
|
);
|
||||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
mockShareParticipantRepository.addParticipant.mockResolvedValue(
|
mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.create.mockImplementation(
|
||||||
|
(data: Partial<ShareLevelProgress>) => data,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.saveMany.mockResolvedValue([]);
|
||||||
|
mockShareParticipantRepository.upsertSubmissionSummary.mockResolvedValue(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||||
null,
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 4,
|
||||||
|
totalTimeSpent: 75,
|
||||||
|
submittedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
correctCount: 3,
|
||||||
|
totalTimeSpent: 60,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const newProgress: Partial<ShareLevelProgress> = {
|
});
|
||||||
|
|
||||||
|
it('should persist all submitted answers and return challenge result', async () => {
|
||||||
|
const result = await service.submitChallenge(
|
||||||
|
'user-uuid-2',
|
||||||
|
'ABCD1234',
|
||||||
|
submitDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.shareCode).toBe('ABCD1234');
|
||||||
|
expect(result.correctCount).toBe(4);
|
||||||
|
expect(result.levelCount).toBe(6);
|
||||||
|
expect(result.participantCount).toBe(2);
|
||||||
|
expect(result.rank).toBe(1);
|
||||||
|
expect(result.totalTimeSpent).toBe(75);
|
||||||
|
expect(result.levels).toHaveLength(6);
|
||||||
|
expect(result.levels[0]).toMatchObject({
|
||||||
|
id: 'level-1',
|
||||||
|
submittedAnswer: '答案1',
|
||||||
|
isCorrect: true,
|
||||||
|
timeSpent: 10,
|
||||||
|
timeLimit: 60,
|
||||||
|
withinTimeLimit: true,
|
||||||
|
});
|
||||||
|
expect(result.levels[4]).toMatchObject({
|
||||||
|
id: 'level-5',
|
||||||
|
submittedAnswer: '错误答案',
|
||||||
|
isCorrect: false,
|
||||||
|
});
|
||||||
|
expect(mockShareLevelProgressRepository.saveMany).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-2',
|
participantId: 'user-uuid-2',
|
||||||
levelId: 'level-1',
|
levelId: 'level-1',
|
||||||
|
submittedAnswer: '答案1',
|
||||||
passed: true,
|
passed: true,
|
||||||
timeSpent: 30,
|
timeSpent: 10,
|
||||||
};
|
}),
|
||||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
]),
|
||||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
|
||||||
|
|
||||||
const result = await service.reportLevelProgress(
|
|
||||||
'user-uuid-2',
|
|
||||||
reportDto,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.passed).toBe(true);
|
|
||||||
expect(result.timeLimit).toBe(60);
|
|
||||||
expect(result.withinTimeLimit).toBe(true);
|
|
||||||
expect(
|
expect(
|
||||||
mockShareParticipantRepository.addParticipant,
|
mockShareParticipantRepository.upsertSubmissionSummary,
|
||||||
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
|
).toHaveBeenCalledWith(
|
||||||
expect(
|
expect.objectContaining({
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel,
|
shareConfigId: 'share-uuid-1',
|
||||||
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2', 'level-1');
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 4,
|
||||||
|
totalTimeSpent: 75,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not register sharer themselves as participant when reporting progress', async () => {
|
it('should accept the sharer as a participant when they submit', async () => {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
await service.submitChallenge('user-uuid-1', 'ABCD1234', submitDto);
|
||||||
mockShareConfig,
|
|
||||||
);
|
|
||||||
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
mockShareLevelProgressRepository.create.mockReturnValue({} as any);
|
|
||||||
mockShareLevelProgressRepository.save.mockResolvedValue({} as any);
|
|
||||||
|
|
||||||
await service.reportLevelProgress('user-uuid-1', reportDto);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
mockShareParticipantRepository.addParticipant,
|
mockShareParticipantRepository.upsertSubmissionSummary,
|
||||||
).not.toHaveBeenCalled();
|
).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return existing result when already passed (idempotent)', async () => {
|
it('should update existing per-level progress on resubmission', async () => {
|
||||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
const existingProgress = {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
|
||||||
mockShareConfig,
|
|
||||||
);
|
|
||||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
|
||||||
{
|
|
||||||
id: 'progress-uuid-1',
|
id: 'progress-uuid-1',
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
participantId: 'user-uuid-2',
|
participantId: 'user-uuid-2',
|
||||||
levelId: 'level-1',
|
levelId: 'level-1',
|
||||||
passed: true,
|
submittedAnswer: '旧答案',
|
||||||
timeSpent: 25,
|
passed: false,
|
||||||
completedAt: new Date(),
|
timeSpent: 99,
|
||||||
} as ShareLevelProgress,
|
completedAt: new Date('2026-01-01'),
|
||||||
|
} as ShareLevelProgress;
|
||||||
|
mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
[existingProgress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.reportLevelProgress(
|
await service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto);
|
||||||
|
|
||||||
|
expect(mockShareLevelProgressRepository.saveMany).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'progress-uuid-1',
|
||||||
|
submittedAnswer: '答案1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 10,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rank by the persisted challenge ranking', async () => {
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 80,
|
||||||
|
submittedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 4,
|
||||||
|
totalTimeSpent: 75,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.submitChallenge(
|
||||||
'user-uuid-2',
|
'user-uuid-2',
|
||||||
reportDto,
|
'ABCD1234',
|
||||||
|
submitDto,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.passed).toBe(true);
|
expect(result.rank).toBe(2);
|
||||||
expect(result.withinTimeLimit).toBe(true);
|
expect(result.participantCount).toBe(2);
|
||||||
expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report withinTimeLimit=false when time exceeds limit', async () => {
|
it('should throw BadRequestException when submitted levels are incomplete', async () => {
|
||||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 };
|
await expect(
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||||
mockShareConfig,
|
levels: submitDto.levels.slice(0, 5),
|
||||||
);
|
}),
|
||||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
).rejects.toThrow(BadRequestException);
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const newProgress: Partial<ShareLevelProgress> = {
|
|
||||||
shareConfigId: 'share-uuid-1',
|
|
||||||
participantId: 'user-uuid-2',
|
|
||||||
levelId: 'level-1',
|
|
||||||
passed: true,
|
|
||||||
timeSpent: 30,
|
|
||||||
};
|
|
||||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
|
||||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
|
||||||
|
|
||||||
const result = await service.reportLevelProgress('user-uuid-2', {
|
|
||||||
...reportDto,
|
|
||||||
timeSpent: 30,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.passed).toBe(true);
|
it('should throw BadRequestException when a level is submitted twice', async () => {
|
||||||
expect(result.withinTimeLimit).toBe(false);
|
await expect(
|
||||||
|
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||||
|
levels: [
|
||||||
|
...submitDto.levels.slice(0, 5),
|
||||||
|
{ levelId: 'level-1', answer: '答案1', timeSpent: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should report withinTimeLimit=true when level has no time limit', async () => {
|
it('should throw BadRequestException when a level does not belong to the share', async () => {
|
||||||
const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null };
|
await expect(
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||||
mockShareConfig,
|
levels: [
|
||||||
);
|
...submitDto.levels.slice(0, 5),
|
||||||
mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit);
|
{ levelId: 'other-level', answer: '答案', timeSpent: 1 },
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
],
|
||||||
null,
|
}),
|
||||||
);
|
).rejects.toThrow(BadRequestException);
|
||||||
const newProgress: Partial<ShareLevelProgress> = {
|
|
||||||
shareConfigId: 'share-uuid-1',
|
|
||||||
participantId: 'user-uuid-2',
|
|
||||||
levelId: 'level-2',
|
|
||||||
passed: true,
|
|
||||||
timeSpent: 999,
|
|
||||||
};
|
|
||||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
|
||||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
|
||||||
|
|
||||||
const result = await service.reportLevelProgress('user-uuid-2', {
|
|
||||||
shareCode: 'ABCD1234',
|
|
||||||
levelId: 'level-2',
|
|
||||||
passed: true,
|
|
||||||
timeSpent: 999,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.withinTimeLimit).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException when share not found', async () => {
|
it('should throw NotFoundException when share not found', async () => {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.reportLevelProgress('user-uuid-2', reportDto),
|
service.submitChallenge('user-uuid-2', 'INVALID', submitDto),
|
||||||
).rejects.toThrow(NotFoundException);
|
).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException when level not found', async () => {
|
it('should throw NotFoundException when a configured level is missing', async () => {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 5));
|
||||||
mockShareConfig,
|
|
||||||
);
|
|
||||||
mockLevelRepository.findById.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.reportLevelProgress('user-uuid-2', reportDto),
|
service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto),
|
||||||
).rejects.toThrow(NotFoundException);
|
).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update existing progress when not yet passed', async () => {
|
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
|
||||||
mockShareConfig,
|
|
||||||
);
|
|
||||||
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
|
||||||
const existingProgress = {
|
|
||||||
id: 'progress-uuid-1',
|
|
||||||
shareConfigId: 'share-uuid-1',
|
|
||||||
participantId: 'user-uuid-2',
|
|
||||||
levelId: 'level-1',
|
|
||||||
passed: false,
|
|
||||||
timeSpent: 15,
|
|
||||||
completedAt: new Date('2026-01-01'),
|
|
||||||
} as ShareLevelProgress;
|
|
||||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
|
||||||
existingProgress,
|
|
||||||
);
|
|
||||||
mockShareLevelProgressRepository.create.mockReturnValue(existingProgress);
|
|
||||||
mockShareLevelProgressRepository.save.mockResolvedValue({
|
|
||||||
...existingProgress,
|
|
||||||
passed: true,
|
|
||||||
timeSpent: 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.reportLevelProgress(
|
|
||||||
'user-uuid-2',
|
|
||||||
reportDto,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.passed).toBe(true);
|
|
||||||
expect(mockShareLevelProgressRepository.save).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import { ShareConfigRepository } from './repositories/share-config.repository';
|
|||||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||||
|
import { Level } from '../wechat-game/entities/level.entity';
|
||||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||||
import { CreateShareDto } from './dto/create-share.dto';
|
import { CreateShareDto } from './dto/create-share.dto';
|
||||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto';
|
||||||
import {
|
import {
|
||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
ShareLevelDto,
|
ShareLevelDto,
|
||||||
|
SubmittedShareLevelDto,
|
||||||
|
SubmitShareChallengeResponseDto,
|
||||||
} from './dto/share-response.dto';
|
} from './dto/share-response.dto';
|
||||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareService {
|
export class ShareService {
|
||||||
@@ -84,23 +86,7 @@ export class ShareService {
|
|||||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single query, then reorder to match levelIds sequence
|
const levels = await this.buildShareLevels(config.levelIds);
|
||||||
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
|
||||||
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
|
||||||
|
|
||||||
const levels: ShareLevelDto[] = config.levelIds.map((id, index) => {
|
|
||||||
const level = levelMap.get(id);
|
|
||||||
if (!level) {
|
|
||||||
throw new NotFoundException(`关卡 ${id} 不存在`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: level.id,
|
|
||||||
level: index + 1,
|
|
||||||
...pickLevelImageFields(level),
|
|
||||||
answer: level.answer,
|
|
||||||
sortOrder: level.sortOrder,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shareCode: config.shareCode,
|
shareCode: config.shareCode,
|
||||||
@@ -117,32 +103,21 @@ export class ShareService {
|
|||||||
|
|
||||||
const shareConfigIds = configs.map((config) => config.id);
|
const shareConfigIds = configs.map((config) => config.id);
|
||||||
const [participantCountMap, rankingRows] = await Promise.all([
|
const [participantCountMap, rankingRows] = await Promise.all([
|
||||||
this.shareParticipantRepository.countByShareConfigIds(shareConfigIds),
|
this.shareParticipantRepository.countSubmittedByShareConfigIds(
|
||||||
this.shareLevelProgressRepository.summarizeByShareConfigIds(
|
shareConfigIds,
|
||||||
|
),
|
||||||
|
this.shareParticipantRepository.findSubmittedRankingsByShareConfigIds(
|
||||||
shareConfigIds,
|
shareConfigIds,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rankingsByShareConfigId = new Map<string, string[]>();
|
const rankingsByShareConfigId = new Map<string, string[]>();
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const completedRankings = rankingRows
|
const rankings = rankingRows
|
||||||
.filter(
|
.filter((row) => row.shareConfigId === config.id)
|
||||||
(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);
|
.map((row) => row.participantId);
|
||||||
|
|
||||||
rankingsByShareConfigId.set(config.id, completedRankings);
|
rankingsByShareConfigId.set(config.id, rankings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -165,77 +140,189 @@ export class ShareService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async reportLevelProgress(
|
async submitChallenge(
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: ReportLevelProgressDto,
|
code: string,
|
||||||
): Promise<ReportLevelProgressResponseDto> {
|
dto: SubmitShareChallengeDto,
|
||||||
const [config, level] = await Promise.all([
|
): Promise<SubmitShareChallengeResponseDto> {
|
||||||
this.shareConfigRepository.findByShareCode(dto.shareCode),
|
const config = await this.shareConfigRepository.findByShareCode(code);
|
||||||
this.levelRepository.findById(dto.levelId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new NotFoundException('分享不存在或已过期');
|
throw new NotFoundException('分享不存在或已过期');
|
||||||
}
|
}
|
||||||
if (!level) {
|
|
||||||
throw new NotFoundException('关卡不存在');
|
this.validateSubmittedLevels(config.levelIds, dto.levels);
|
||||||
|
|
||||||
|
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||||
|
if (allLevels.length !== config.levelIds.length) {
|
||||||
|
const foundIds = new Set(allLevels.map((level) => level.id));
|
||||||
|
const missing = config.levelIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new NotFoundException(`以下关卡不存在: ${missing.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.levelIds.includes(dto.levelId)) {
|
const levelMap = new Map(allLevels.map((level) => [level.id, level]));
|
||||||
throw new BadRequestException('该关卡不属于此分享挑战');
|
const submittedMap = new Map(
|
||||||
}
|
dto.levels.map((item) => [item.levelId, item]),
|
||||||
|
);
|
||||||
// 自动登记参与者(创建者本人不计入)。已存在则忽略。
|
const submittedAt = new Date();
|
||||||
if (userId !== config.sharerId) {
|
const existingProgress =
|
||||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
await this.shareLevelProgressRepository.findByShareConfigAndParticipant(
|
||||||
}
|
|
||||||
|
|
||||||
const progress =
|
|
||||||
await this.shareLevelProgressRepository.findByShareConfigParticipantAndLevel(
|
|
||||||
config.id,
|
config.id,
|
||||||
userId,
|
userId,
|
||||||
dto.levelId,
|
);
|
||||||
|
const existingProgressMap = new Map(
|
||||||
|
existingProgress.map((progress) => [progress.levelId, progress]),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (dto.passed && progress?.passed) {
|
let correctCount = 0;
|
||||||
return {
|
let totalTimeSpent = 0;
|
||||||
passed: true,
|
const responseLevels: SubmittedShareLevelDto[] = [];
|
||||||
timeLimit: level.timeLimit,
|
|
||||||
withinTimeLimit: this.isWithinTimeLimit(
|
const progressList = config.levelIds.map((levelId, index) => {
|
||||||
|
const level = levelMap.get(levelId)!;
|
||||||
|
const submitted = submittedMap.get(levelId)!;
|
||||||
|
const submittedAnswer = submitted.answer;
|
||||||
|
const isCorrect = this.isCorrectAnswer(submittedAnswer, level.answer);
|
||||||
|
const withinTimeLimit = this.isWithinTimeLimit(
|
||||||
level.timeLimit,
|
level.timeLimit,
|
||||||
progress.timeSpent,
|
submitted.timeSpent,
|
||||||
),
|
);
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
correctCount++;
|
||||||
|
}
|
||||||
|
totalTimeSpent += submitted.timeSpent;
|
||||||
|
|
||||||
|
const progress = Object.assign(
|
||||||
|
existingProgressMap.get(levelId) ??
|
||||||
|
this.shareLevelProgressRepository.create({
|
||||||
|
shareConfigId: config.id,
|
||||||
|
participantId: userId,
|
||||||
|
levelId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
shareConfigId: config.id,
|
||||||
|
participantId: userId,
|
||||||
|
levelId,
|
||||||
|
submittedAnswer,
|
||||||
|
passed: isCorrect,
|
||||||
|
timeSpent: submitted.timeSpent,
|
||||||
|
completedAt: submittedAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
responseLevels.push({
|
||||||
|
id: level.id,
|
||||||
|
level: index + 1,
|
||||||
|
...pickLevelImageFields(level),
|
||||||
|
answer: level.answer,
|
||||||
|
punchline: level.punchline,
|
||||||
|
hint1: level.hint1,
|
||||||
|
hint2: level.hint2,
|
||||||
|
hint3: level.hint3,
|
||||||
|
sortOrder: level.sortOrder,
|
||||||
|
submittedAnswer,
|
||||||
|
timeSpent: submitted.timeSpent,
|
||||||
|
isCorrect,
|
||||||
|
timeLimit: level.timeLimit,
|
||||||
|
withinTimeLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return progress;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.shareLevelProgressRepository.saveMany(progressList);
|
||||||
|
await this.shareParticipantRepository.upsertSubmissionSummary({
|
||||||
|
shareConfigId: config.id,
|
||||||
|
participantId: userId,
|
||||||
|
correctCount,
|
||||||
|
totalTimeSpent,
|
||||||
|
submittedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankings =
|
||||||
|
await this.shareParticipantRepository.findSubmittedRankingsByShareConfigId(
|
||||||
|
config.id,
|
||||||
|
);
|
||||||
|
const rankingIndex = rankings.findIndex(
|
||||||
|
(row) => row.participantId === userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareCode: config.shareCode,
|
||||||
|
title: config.title,
|
||||||
|
rank: rankingIndex >= 0 ? rankingIndex + 1 : rankings.length,
|
||||||
|
correctCount,
|
||||||
|
levelCount: config.levelIds.length,
|
||||||
|
participantCount: rankings.length,
|
||||||
|
totalTimeSpent,
|
||||||
|
levels: responseLevels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const withinTimeLimit = dto.passed
|
private async buildShareLevels(levelIds: string[]): Promise<ShareLevelDto[]> {
|
||||||
? this.isWithinTimeLimit(level.timeLimit, dto.timeSpent)
|
const allLevels = await this.levelRepository.findByIds(levelIds);
|
||||||
: false;
|
const levelMap = new Map(allLevels.map((level) => [level.id, level]));
|
||||||
|
|
||||||
const updatedProgress = progress
|
return levelIds.map((id, index) => {
|
||||||
? Object.assign(this.shareLevelProgressRepository.create(progress), {
|
const level = levelMap.get(id);
|
||||||
passed: dto.passed,
|
if (!level) {
|
||||||
timeSpent: dto.timeSpent,
|
throw new NotFoundException(`关卡 ${id} 不存在`);
|
||||||
completedAt: dto.passed ? new Date() : progress.completedAt,
|
}
|
||||||
})
|
return this.toShareLevelDto(level, index);
|
||||||
: this.shareLevelProgressRepository.create({
|
|
||||||
shareConfigId: config.id,
|
|
||||||
participantId: userId,
|
|
||||||
levelId: dto.levelId,
|
|
||||||
passed: dto.passed,
|
|
||||||
timeSpent: dto.timeSpent,
|
|
||||||
completedAt: dto.passed ? new Date() : null,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.shareLevelProgressRepository.save(updatedProgress);
|
private toShareLevelDto(level: Level, index: number): ShareLevelDto {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
passed: dto.passed,
|
id: level.id,
|
||||||
timeLimit: level.timeLimit,
|
level: index + 1,
|
||||||
withinTimeLimit,
|
...pickLevelImageFields(level),
|
||||||
|
answer: level.answer,
|
||||||
|
punchline: level.punchline,
|
||||||
|
hint1: level.hint1,
|
||||||
|
hint2: level.hint2,
|
||||||
|
hint3: level.hint3,
|
||||||
|
sortOrder: level.sortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateSubmittedLevels(
|
||||||
|
expectedLevelIds: string[],
|
||||||
|
submittedLevels: SubmitShareChallengeDto['levels'],
|
||||||
|
): void {
|
||||||
|
if (submittedLevels.length !== expectedLevelIds.length) {
|
||||||
|
throw new BadRequestException('提交关卡数量必须与分享挑战关卡数量一致');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedIdSet = new Set(expectedLevelIds);
|
||||||
|
const submittedIdSet = new Set<string>();
|
||||||
|
for (const item of submittedLevels) {
|
||||||
|
if (submittedIdSet.has(item.levelId)) {
|
||||||
|
throw new BadRequestException(`关卡 ${item.levelId} 重复提交`);
|
||||||
|
}
|
||||||
|
submittedIdSet.add(item.levelId);
|
||||||
|
|
||||||
|
if (!expectedIdSet.has(item.levelId)) {
|
||||||
|
throw new BadRequestException(`关卡 ${item.levelId} 不属于此分享挑战`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = expectedLevelIds.filter((id) => !submittedIdSet.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(`缺少关卡提交: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCorrectAnswer(submittedAnswer: string, answer: string): boolean {
|
||||||
|
return (
|
||||||
|
this.normalizeAnswer(submittedAnswer) === this.normalizeAnswer(answer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAnswer(answer: string): string {
|
||||||
|
return answer.trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
private isWithinTimeLimit(
|
private isWithinTimeLimit(
|
||||||
timeLimit: number | null,
|
timeLimit: number | null,
|
||||||
timeSpent: number,
|
timeSpent: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user