diff --git a/AGENTS.md b/AGENTS.md
index 37dfc63..96816a2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,7 +45,7 @@
# 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 newline at end of file
diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md
index e3b8d57..57d2782 100644
--- a/docs/api/share-challenge-api.md
+++ b/docs/api/share-challenge-api.md
@@ -2,6 +2,10 @@
> 本文档面向微信小游戏客户端(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` 字段,支持关卡通关时间限制
-2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
-3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
+2. **整场挑战提交**:`POST /api/v1/share/{code}/submit` 接口,用于一次性提交分享挑战中每一关的答案和耗时
+3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
@@ -234,12 +238,16 @@ Content-Type: application/json
{
id: string; // 关卡 ID
level: number; // 关卡序号(1-6)
- imageUrl: string; // 关卡图片 URL
- answer: string; // 正确答案
+ image1Url: string; // 图片 1 URL
+ image1Description: string | null;
+ image2Url: string; // 图片 2 URL
+ image2Description: string | null;
+ answer: string; // 正确答案
+ punchline: string | null;
hint1: string | null; // 提示 1
hint2: string | null; // 提示 2
hint3: string | null; // 提示 3
- sortOrder: number; // 排序顺序
+ sortOrder: number; // 排序顺序
}
]
}
@@ -257,12 +265,16 @@ Content-Type: application/json
{
"id": "level_001",
"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",
+ "punchline": null,
"hint1": "提示1",
"hint2": null,
"hint3": null,
- "sortOrder": 1
+ "sortOrder": 0
}
]
},
@@ -308,8 +320,8 @@ Authorization: Bearer
shareCode: string; // 分享码
title: string; // 分享标题
levelCount: number; // 关卡数量
- participantCount: number; // 参与挑战人数
- userRank: number | null; // 当前用户在该挑战中的排名;未完成全部关卡时为 null
+ participantCount: number; // 已提交结果的参与人数
+ userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null
createdAt: string; // 创建时间,ISO 8601 字符串
}
]
@@ -350,15 +362,15 @@ Authorization: Bearer
**排名规则**:
-1. 只有完成该分享挑战全部关卡的用户才会进入排名。
-2. 排名按通关总耗时升序计算,总耗时越短排名越高。
-3. 当总耗时相同时,服务端会按 `participantId` 做稳定排序,保证返回顺序可重复。
-4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未完成全部关卡,则返回 `null`。
+1. 只有已经调用 `POST /api/v1/share/{code}/submit` 提交整场挑战结果的用户才会进入排名。
+2. 排名按答对题数降序计算,答对越多排名越高。
+3. 答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。
+4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未提交挑战结果,则返回 `null`。
**参与人数统计规则**:
-- 统计 `share_participants` 中该挑战的参与者数量。
-- 创建者本人在调用创建接口时不会自动写入参与记录;只有真正以参与者身份产生挑战进度后,才可能出现在排名内。
+- 统计该挑战中已提交完整挑战结果的用户数量。
+- 创建者本人如果调用提交接口,也会作为参与用户进入统计和排名。
**客户端调用场景**:
@@ -367,41 +379,80 @@ Authorization: Bearer
---
-### 5. 上报单关进度
+### 5. 提交分享挑战结果
-用户在分享挑战中完成单关后,上报进度。
+用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
-**接口地址**:`POST /api/v1/share/progress`
+**接口地址**:`POST /api/v1/share/{code}/submit`
**是否需要认证**:是(JWT Bearer Token)
+**路径参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+| ---- | ------ | ---- | -------------- |
+| code | string | 是 | 分享码(8 位) |
+
**请求体**:
```json
{
- "shareCode": "abc12345", // 分享码
- "levelId": "level_001", // 关卡 ID
- "passed": true, // 是否通过(true/false)
- "timeSpent": 30 // 通关时间(秒)
+ "levels": [
+ {
+ "levelId": "level_001",
+ "answer": "答案1",
+ "timeSpent": 30
+ },
+ {
+ "levelId": "level_002",
+ "answer": "答案2",
+ "timeSpent": 45
+ }
+ ]
}
```
**字段说明**:
-| 字段 | 类型 | 必填 | 说明 |
-| --------- | ------- | ---- | -------------------------- |
-| shareCode | string | 是 | 分享码 |
-| levelId | string | 是 | 关卡 ID |
-| passed | boolean | 是 | 是否通过 |
-| timeSpent | number | 是 | 通关时间(秒),最小值为 0 |
+| 字段 | 类型 | 必填 | 说明 |
+| ------------------ | ------ | ---- | ---------------------------------------------- |
+| levels | array | 是 | 每一关的提交结果,必须覆盖分享挑战中的全部关卡 |
+| levels[].levelId | string | 是 | 关卡 ID |
+| levels[].answer | string | 是 | 用户提交的答案,空字符串表示未作答 |
+| levels[].timeSpent | number | 是 | 本关耗时(秒),最小值为 0 |
**响应数据**:
```typescript
{
- passed: boolean; // 是否通过
- timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制
- withinTimeLimit: boolean; // 是否在时间限制内通过
+ shareCode: string;
+ title: string;
+ 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
{
"success": true,
"data": {
- "passed": true,
- "timeLimit": 60,
- "withinTimeLimit": 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,
+ "withinTimeLimit": true
+ }
+ ]
},
"message": null,
"timestamp": "2026-04-08T12:00:00.000Z"
@@ -422,35 +498,38 @@ Authorization: Bearer
**业务逻辑说明**:
-1. **参与者登记**:非创建者首次上报进度时会自动写入 `share_participants`,后续重复上报已存在则忽略;创建者本人不会被登记为参与者。
-2. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间
-3. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖
-4. **未通过**:可以多次上报 `passed=false`,更新通关时间记录
-5. **时间限制判断**:
- - 如果关卡 `timeLimit` 为 `null`,`withinTimeLimit` 始终为 `true`
- - 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true`
-6. **跨挑战独立**:进度按 `(shareConfigId, participantId, levelId)` 唯一记录,同一用户在不同分享挑战中对同一关卡的进度互不影响。
+1. **完整提交**:`levels` 必须覆盖分享中的全部关卡,不能缺少、重复或提交不属于该分享的关卡。
+2. **答案校验**:服务端按去除首尾空白并忽略大小写后的答案进行比对,返回每关 `isCorrect`。
+3. **排名规则**:按答对题数降序排名;答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序稳定排序。
+4. **参与者登记**:提交成功后会写入或更新 `share_participants` 的聚合成绩;创建者本人提交时也会计入参与人数和排名。
+5. **结果持久化**:每关的 `submittedAnswer`、`timeSpent`、`isCorrect` 会保存到 `share_level_progress`,整场的 `correctCount`、`totalTimeSpent`、`submittedAt` 会保存到 `share_participants`。
+6. **重复提交**:同一用户重复提交同一挑战时,服务端会覆盖该用户本挑战的上一份结果,并重新计算排名。
+7. **时间限制判断**:`withinTimeLimit` 只表示耗时是否不超过关卡 `timeLimit`;答题正确与否由 `isCorrect` 表示。
+8. **跨挑战独立**:结果按 `(shareConfigId, participantId, levelId)` 唯一记录,同一用户在不同分享挑战中对同一关卡的提交互不影响。
**客户端调用场景**:
-- 用户完成一个关卡后调用
-- 无论通关还是失败都需要调用
-- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限
+- 用户完成整场分享挑战后调用
+- 结果页需要展示排名、答对题数、完整答案解析时调用
---
## 错误码说明
-| HTTP Status | message | 说明 |
-| ----------- | ------------------------------------- | ---------------------------- |
-| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
-| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
-| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
-| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
-| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 |
-| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 |
-| 404 | 关卡不存在 | levelId 不存在于 levels 表 |
-| 500 | Internal server error | 服务器内部错误 |
+| HTTP Status | message | 说明 |
+| ----------- | -------------------------------------- | ---------------------------- |
+| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
+| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
+| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
+| 400 | 提交关卡数量必须与分享挑战关卡数量一致 | 提交挑战结果时关卡数量不完整 |
+| 400 | 关卡 xxx 重复提交 | 提交挑战结果时同一关卡重复 |
+| 400 | 关卡 xxx 不属于此分享挑战 | 提交挑战结果时包含外部关卡 |
+| 400 | 缺少关卡提交: xxx | 提交挑战结果时缺少指定关卡 |
+| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
+| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 |
+| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 |
+| 404 | 以下关卡不存在: xxx | 分享配置中的关卡不存在 |
+| 500 | Internal server error | 服务器内部错误 |
---
@@ -477,7 +556,7 @@ Authorization: Bearer
│ 3. 调用 POST /api/v1/auth/wx-login 获取/确认身份 │
│ 4. 调用 POST /api/v1/share/{code}/join 加入挑战 │
│ 5. 获取 6 个关卡数据,开始挑战 │
-│ 6. 每完成一关,调用 POST /api/v1/share/progress 上报进度 │
+│ 6. 完成整场挑战后,调用 POST /api/v1/share/{code}/submit 提交结果 │
└─────────────────────────────────────────────────────────────────┘
```
@@ -491,13 +570,13 @@ interface ShareChallengeState {
shareCode: string | null; // 当前分享码
currentLevelIndex: number; // 当前关卡索引(0-5)
levels: LevelData[]; // 关卡数据
- progress: {
+ submissions: {
[levelId: string]: {
- passed: boolean;
+ answer: string;
timeSpent: number;
- withinTimeLimit: boolean;
};
};
+ result: SubmitChallengeResponse | null;
}
```
@@ -681,8 +760,12 @@ console.log('分享码:', share.shareCode);
interface LevelData {
id: string;
level: number;
- imageUrl: string;
+ image1Url: string;
+ image1Description: string | null;
+ image2Url: string;
+ image2Description: string | null;
answer: string;
+ punchline: string | null;
hint1: string | null;
hint2: string | null;
hint3: string | null;
@@ -720,62 +803,70 @@ const shareData = await joinShare(shareCode);
// 保存关卡数据,开始游戏
```
-### 5. 上报关卡进度
+### 5. 提交挑战结果
```typescript
-interface ReportProgressResponse {
- passed: boolean;
+interface SubmitChallengeLevel {
+ levelId: string;
+ answer: string;
+ timeSpent: number;
+}
+
+interface SubmittedLevelResult extends LevelData {
+ submittedAnswer: string;
+ timeSpent: number;
+ isCorrect: boolean;
timeLimit: number | null;
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,
- levelId: string,
- passed: boolean,
- timeSpent: number,
-): Promise {
+ levels: SubmitChallengeLevel[],
+): Promise {
// 确保已登录
if (!httpManager.getToken()) {
await wxLogin();
}
- const response = await httpManager.post(
- '/v1/share/progress',
- {
- shareCode,
- levelId,
- passed,
- timeSpent,
- },
+ const response = await httpManager.post(
+ `/v1/share/${shareCode}/submit`,
+ { levels },
);
if (response.success && response.data) {
- console.log('进度上报成功:', response.data);
+ console.log('挑战结果提交成功:', response.data);
return response.data;
} else {
- throw new Error(response.message || '进度上报失败');
+ throw new Error(response.message || '挑战结果提交失败');
}
}
// 使用示例
-async function onLevelComplete(levelId: string, timeSpent: number) {
- const passed = true; // 根据游戏逻辑判断是否通过
- const result = await reportLevelProgress(
- this.shareCode,
- levelId,
- passed,
- timeSpent,
- );
+async function onChallengeFinished() {
+ const result = await submitShareChallenge(this.shareCode, [
+ { levelId: 'level_001', answer: '答案1', timeSpent: 30 },
+ { levelId: 'level_002', answer: '答案2', timeSpent: 45 },
+ { levelId: 'level_003', answer: '', timeSpent: 60 },
+ { levelId: 'level_004', answer: '答案4', timeSpent: 20 },
+ { levelId: 'level_005', answer: '答案5', timeSpent: 15 },
+ { levelId: 'level_006', answer: '答案6', timeSpent: 10 },
+ ]);
- if (result.passed) {
- console.log(
- `通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`,
- );
- if (result.timeLimit) {
- console.log(`本关时间限制: ${result.timeLimit}秒`);
- }
- }
+ console.log(`当前排名: 第 ${result.rank} 名`);
+ console.log(`答对: ${result.correctCount}/${result.levelCount}`);
+ console.log(`参与人数: ${result.participantCount}`);
}
```
@@ -821,33 +912,35 @@ export class GameEntry extends Component {
### LevelData 完整字段
-| 字段 | 类型 | 说明 |
-| --------- | -------------- | --------------- |
-| id | string | 关卡唯一标识 |
-| level | number | 关卡序号(1-6) |
-| imageUrl | string | 关卡图片 URL |
-| answer | string | 正确答案 |
-| hint1 | string \| null | 第 1 个提示 |
-| hint2 | string \| null | 第 2 个提示 |
-| hint3 | string \| null | 第 3 个提示 |
-| sortOrder | number | 排序顺序 |
+| 字段 | 类型 | 说明 |
+| ----------------- | -------------- | --------------- |
+| id | string | 关卡唯一标识 |
+| level | number | 关卡序号(1-6) |
+| image1Url | string | 图片 1 URL |
+| image1Description | string \| null | 图片 1 文本说明 |
+| image2Url | string | 图片 2 URL |
+| image2Description | string \| null | 图片 2 文本说明 |
+| answer | string | 正确答案 |
+| punchline | string \| null | 谐音梗说明 |
+| hint1 | string \| null | 第 1 个提示 |
+| hint2 | string \| null | 第 2 个提示 |
+| hint3 | string \| null | 第 3 个提示 |
+| sortOrder | number | 全局排序顺序 |
### 关卡时间限制说明
-`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
+`POST /api/v1/share/{code}/join` 返回的关卡数据不包含 `timeLimit`。挑战结束后调用 `POST /api/v1/share/{code}/submit`,返回的每关结果会包含:
-如果需要在前端判断时间限制:
-
-1. 用户完成关卡后调用 `reportLevelProgress`
-2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
-3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成
+- `timeLimit`:该关卡时间限制,`null` 表示无限制。
+- `withinTimeLimit`:用户提交的 `timeSpent` 是否不超过 `timeLimit`。
+- `isCorrect`:用户提交答案是否正确。
---
## 注意事项
1. **Token 有效期**:JWT Token 有效期为 7 天,客户端应缓存并在启动时使用
-2. **重复通关**:首次通关记录会被保留,重复通关不会覆盖之前的记录
+2. **重复提交**:同一用户重复提交同一分享挑战会覆盖上一份提交结果,并重新计算排名
3. **分享码格式**:8 位字母数字组合,大小写敏感
4. **关卡数量**:每次分享挑战固定包含 6 个关卡
5. **网络异常**:建议在调用接口时显示 loading 状态,并处理网络异常情况
diff --git a/src/database/migrations/006_share_challenge_submission.sql b/src/database/migrations/006_share_challenge_submission.sql
new file mode 100644
index 0000000..f0b1d22
--- /dev/null
+++ b/src/database/migrations/006_share_challenge_submission.sql
@@ -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;
diff --git a/src/modules/share/dto/report-level-progress.dto.ts b/src/modules/share/dto/report-level-progress.dto.ts
deleted file mode 100644
index d9cd0e3..0000000
--- a/src/modules/share/dto/report-level-progress.dto.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/modules/share/dto/share-level-progress-response.dto.ts b/src/modules/share/dto/share-level-progress-response.dto.ts
deleted file mode 100644
index de373e5..0000000
--- a/src/modules/share/dto/share-level-progress-response.dto.ts
+++ /dev/null
@@ -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;
-}
diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts
index 76bdc92..aaa0ed5 100644
--- a/src/modules/share/dto/share-response.dto.ts
+++ b/src/modules/share/dto/share-response.dto.ts
@@ -60,6 +60,52 @@ export class JoinShareResponseDto {
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 {
@ApiProperty({ description: '分享 ID' })
id!: string;
@@ -77,7 +123,7 @@ export class CreatedShareItemDto {
participantCount!: number;
@ApiProperty({
- description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
+ description: '当前用户在该挑战中的排名,尚未提交挑战结果时为 null',
nullable: true,
})
userRank!: number | null;
diff --git a/src/modules/share/dto/submit-share-challenge.dto.ts b/src/modules/share/dto/submit-share-challenge.dto.ts
new file mode 100644
index 0000000..37d1fb4
--- /dev/null
+++ b/src/modules/share/dto/submit-share-challenge.dto.ts
@@ -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[];
+}
diff --git a/src/modules/share/entities/share-level-progress.entity.ts b/src/modules/share/entities/share-level-progress.entity.ts
index 60982bc..bef8353 100644
--- a/src/modules/share/entities/share-level-progress.entity.ts
+++ b/src/modules/share/entities/share-level-progress.entity.ts
@@ -11,7 +11,7 @@ import { ShareConfig } from './share-config.entity';
import { Level } from '../../wechat-game/entities/level.entity';
/**
- * 分享挑战内的单关进度。
+ * 分享挑战内的单关提交结果。
*
* (share_config_id, participant_id, level_id) 三元组保证:
* 同一用户在不同分享挑战中对同一关的记录互不干扰。
@@ -44,6 +44,14 @@ export class ShareLevelProgress {
@Column({ type: 'char', length: 191, name: 'level_id' })
levelId!: string;
+ @Column({
+ type: 'varchar',
+ length: 191,
+ name: 'submitted_answer',
+ default: '',
+ })
+ submittedAnswer!: string;
+
@ManyToOne(() => Level)
@JoinColumn({ name: 'level_id' })
level!: Level;
diff --git a/src/modules/share/entities/share-participant.entity.ts b/src/modules/share/entities/share-participant.entity.ts
index c06449b..b120d22 100644
--- a/src/modules/share/entities/share-participant.entity.ts
+++ b/src/modules/share/entities/share-participant.entity.ts
@@ -2,6 +2,8 @@ import {
Entity,
PrimaryColumn,
CreateDateColumn,
+ UpdateDateColumn,
+ Column,
ManyToOne,
JoinColumn,
Index,
@@ -17,6 +19,14 @@ import { ShareConfig } from './share-config.entity';
* - participant_id 直接存储 wx_users.id(用户 UUID)
*/
@Entity('share_participants')
+@Index('idx_share_participants_submitted', ['shareConfigId', 'submittedAt'])
+@Index('idx_share_participants_ranking', [
+ 'shareConfigId',
+ 'correctCount',
+ 'totalTimeSpent',
+ 'submittedAt',
+ 'participantId',
+])
export class ShareParticipant {
@PrimaryColumn({ type: 'varchar', length: 191, name: 'share_config_id' })
@Index('idx_share_config_id')
@@ -25,6 +35,20 @@ export class ShareParticipant {
@PrimaryColumn({ type: 'varchar', length: 191, name: 'participant_id' })
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)
@JoinColumn({ name: 'share_config_id' })
shareConfig!: ShareConfig;
@@ -35,4 +59,7 @@ export class ShareParticipant {
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt!: Date;
}
diff --git a/src/modules/share/repositories/share-level-progress.repository.ts b/src/modules/share/repositories/share-level-progress.repository.ts
index bff7b80..42e2c58 100644
--- a/src/modules/share/repositories/share-level-progress.repository.ts
+++ b/src/modules/share/repositories/share-level-progress.repository.ts
@@ -63,4 +63,10 @@ export class ShareLevelProgressRepository {
async save(progress: ShareLevelProgress): Promise {
return this.repository.save(progress);
}
+
+ async saveMany(
+ progressList: ShareLevelProgress[],
+ ): Promise {
+ return this.repository.save(progressList);
+ }
}
diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts
index d58de77..fb65456 100644
--- a/src/modules/share/repositories/share-participant.repository.ts
+++ b/src/modules/share/repositories/share-participant.repository.ts
@@ -8,6 +8,14 @@ type ShareParticipantCountRow = {
participantCount: string;
};
+export type ShareParticipantRankingRow = {
+ shareConfigId: string;
+ participantId: string;
+ correctCount: number;
+ totalTimeSpent: number;
+ submittedAt: Date;
+};
+
@Injectable()
export class ShareParticipantRepository {
constructor(
@@ -55,6 +63,107 @@ export class ShareParticipantRepository {
);
}
+ async upsertSubmissionSummary(data: {
+ shareConfigId: string;
+ participantId: string;
+ correctCount: number;
+ totalTimeSpent: number;
+ submittedAt: Date;
+ }): Promise {
+ await this.repository
+ .createQueryBuilder()
+ .insert()
+ .into(ShareParticipant)
+ .values(data)
+ .orUpdate(
+ ['correctCount', 'totalTimeSpent', 'submittedAt'],
+ ['shareConfigId', 'participantId'],
+ )
+ .execute();
+ }
+
+ async countSubmittedByShareConfigId(shareConfigId: string): Promise {
+ return this.repository
+ .createQueryBuilder('participant')
+ .where('participant.shareConfigId = :shareConfigId', { shareConfigId })
+ .andWhere('participant.submittedAt IS NOT NULL')
+ .getCount();
+ }
+
+ async countSubmittedByShareConfigIds(
+ shareConfigIds: string[],
+ ): Promise