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> { + 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(); + + return new Map( + rows.map((row) => [row.shareConfigId, Number(row.participantCount)]), + ); + } + + async findSubmittedRankingsByShareConfigId( + shareConfigId: string, + ): Promise { + 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 { + 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( shareConfigId: string, participantId: string, diff --git a/src/modules/share/share.controller.spec.ts b/src/modules/share/share.controller.spec.ts index 679639a..2e0fcde 100644 --- a/src/modules/share/share.controller.spec.ts +++ b/src/modules/share/share.controller.spec.ts @@ -17,7 +17,7 @@ describe('ShareController', () => { createShare: jest.fn(), getCreatedShares: jest.fn(), joinShare: jest.fn(), - reportLevelProgress: jest.fn(), + submitChallenge: jest.fn(), }; beforeEach(async () => { @@ -99,8 +99,12 @@ describe('ShareController', () => { { id: 'l1', 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: '答案', + punchline: null, hint1: null, hint2: null, hint3: null, @@ -121,27 +125,54 @@ describe('ShareController', () => { }); }); - describe('reportLevelProgress', () => { - it('should return success response with progress result', async () => { - const progressResponse = { - passed: true, - timeLimit: 60, - withinTimeLimit: true, + describe('submitChallenge', () => { + it('should return success response with challenge result', async () => { + const submitResponse = { + 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, + withinTimeLimit: true, + }, + ], }; - mockShareService.reportLevelProgress.mockResolvedValue(progressResponse); + mockShareService.submitChallenge.mockResolvedValue(submitResponse); const dto = { - shareCode: 'ABCD1234', - levelId: 'level-1', - passed: true, - timeSpent: 30, + levels: [{ levelId: 'level-1', answer: '答案', timeSpent: 30 }], }; - const result = await controller.reportLevelProgress(mockUser, dto); + const result = await controller.submitChallenge( + mockUser, + 'ABCD1234', + dto, + ); expect(result.success).toBe(true); - expect(result.data).toEqual(progressResponse); - expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith( + expect(result.data).toEqual(submitResponse); + expect(mockShareService.submitChallenge).toHaveBeenCalledWith( 'user-uuid-1', + 'ABCD1234', dto, ); }); diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts index b98f621..b732299 100644 --- a/src/modules/share/share.controller.ts +++ b/src/modules/share/share.controller.ts @@ -11,13 +11,13 @@ import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, + SubmitShareChallengeResponseDto, } from './dto/share-response.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; -import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; -import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto'; +import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto'; @ApiTags('分享挑战') @Controller('v1/share') @@ -73,20 +73,22 @@ export class ShareController { return ApiResponseDto.success(data); } - @Post('progress') + @Post(':code/submit') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ - summary: '上报单关进度', - description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效', + summary: '提交分享挑战结果', + description: + '客户端一次性提交分享挑战中每一关的耗时和答案,服务端校验后返回排名、答对题数、参与人数和完整关卡答案', }) @ApiResponse({ status: 200, description: '成功' }) @ApiResponse({ status: 404, description: '分享或关卡不存在' }) - async reportLevelProgress( + async submitChallenge( @CurrentUser() user: JwtPayload, - @Body() dto: ReportLevelProgressDto, - ): Promise> { - const data = await this.shareService.reportLevelProgress(user.sub, dto); + @Param('code') code: string, + @Body() dto: SubmitShareChallengeDto, + ): Promise> { + const data = await this.shareService.submitChallenge(user.sub, code, dto); return ApiResponseDto.success(data); } } diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index ef54fd3..48eee77 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -58,14 +58,21 @@ describe('ShareService', () => { addParticipant: jest.fn(), countByShareConfigId: jest.fn(), countByShareConfigIds: jest.fn(), + countSubmittedByShareConfigId: jest.fn(), + countSubmittedByShareConfigIds: jest.fn(), existsByShareConfigAndParticipant: jest.fn(), + upsertSubmissionSummary: jest.fn(), + findSubmittedRankingsByShareConfigId: jest.fn(), + findSubmittedRankingsByShareConfigIds: jest.fn(), }; const mockShareLevelProgressRepository = { + findByShareConfigAndParticipant: jest.fn(), findByShareConfigParticipantAndLevel: jest.fn(), summarizeByShareConfigIds: jest.fn(), create: jest.fn(), save: jest.fn(), + saveMany: jest.fn(), }; const mockLevelRepository = { @@ -214,10 +221,10 @@ describe('ShareService', () => { expect(result).toEqual({ items: [] }); expect( - mockShareParticipantRepository.countByShareConfigIds, + mockShareParticipantRepository.countSubmittedByShareConfigIds, ).not.toHaveBeenCalled(); expect( - mockShareLevelProgressRepository.summarizeByShareConfigIds, + mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds, ).not.toHaveBeenCalled(); }); @@ -234,31 +241,34 @@ describe('ShareService', () => { otherShareConfig, mockShareConfig, ]); - mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue( + mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue( new Map([ ['share-uuid-1', 3], ['share-uuid-2', 1], ]), ); - mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue( + mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue( [ { shareConfigId: 'share-uuid-1', - participantId: 'user-uuid-1', - totalTimeSpent: '120', - passedLevelCount: '6', + participantId: 'user-uuid-2', + correctCount: 6, + totalTimeSpent: 100, + submittedAt: new Date('2026-01-01T00:02:00.000Z'), }, { shareConfigId: 'share-uuid-1', - participantId: 'user-uuid-2', - totalTimeSpent: '100', - passedLevelCount: '6', + participantId: 'user-uuid-1', + correctCount: 6, + totalTimeSpent: 120, + submittedAt: new Date('2026-01-01T00:01:00.000Z'), }, { shareConfigId: 'share-uuid-1', participantId: 'user-uuid-3', - totalTimeSpent: '200', - passedLevelCount: '5', + correctCount: 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([ mockShareConfig, ]); - mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue( + mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue( new Map([['share-uuid-1', 2]]), ); - mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue( + mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue( [ { shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', - totalTimeSpent: '120', - passedLevelCount: '6', + correctCount: 6, + totalTimeSpent: 120, + submittedAt: new Date('2026-01-01T00:02:00.000Z'), }, { shareConfigId: 'share-uuid-1', participantId: 'user-uuid-1', - totalTimeSpent: '120', - passedLevelCount: '6', + correctCount: 6, + totalTimeSpent: 120, + submittedAt: new Date('2026-01-01T00:01:00.000Z'), }, ], ); const result = await service.getCreatedShares('user-uuid-1'); - expect(result.items[0].userRank).toBe(1); + expect(result.items[0].userRank).toBe(2); }); }); - describe('reportLevelProgress', () => { - const reportDto = { - shareCode: 'ABCD1234', - levelId: 'level-1', - passed: true, - timeSpent: 30, + describe('submitChallenge', () => { + const submitDto = { + levels: mockLevels.map((level, index) => ({ + levelId: level.id, + answer: index < 4 ? level.answer : '错误答案', + timeSpent: 10 + index, + })), }; - it('should create new progress record for first attempt', async () => { - const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; + beforeEach(() => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); - mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareParticipantRepository.addParticipant.mockResolvedValue( + mockLevelRepository.findByIds.mockResolvedValue(mockLevels); + mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue( + [], + ); + mockShareLevelProgressRepository.create.mockImplementation( + (data: Partial) => data, + ); + mockShareLevelProgressRepository.saveMany.mockResolvedValue([]); + mockShareParticipantRepository.upsertSubmissionSummary.mockResolvedValue( undefined, ); - mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( - null, + mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue( + [ + { + 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 = { - 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( + it('should persist all submitted answers and return challenge result', async () => { + const result = await service.submitChallenge( 'user-uuid-2', - reportDto, + 'ABCD1234', + submitDto, ); - expect(result.passed).toBe(true); - expect(result.timeLimit).toBe(60); - expect(result.withinTimeLimit).toBe(true); + 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', + participantId: 'user-uuid-2', + levelId: 'level-1', + submittedAnswer: '答案1', + passed: true, + timeSpent: 10, + }), + ]), + ); expect( - mockShareParticipantRepository.addParticipant, - ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2'); - expect( - mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel, - ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2', 'level-1'); - }); - - it('should not register sharer themselves as participant when reporting progress', async () => { - mockShareConfigRepository.findByShareCode.mockResolvedValue( - 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( - mockShareParticipantRepository.addParticipant, - ).not.toHaveBeenCalled(); - }); - - it('should return existing result when already passed (idempotent)', async () => { - const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 }; - mockShareConfigRepository.findByShareCode.mockResolvedValue( - mockShareConfig, - ); - mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( - { - id: 'progress-uuid-1', + mockShareParticipantRepository.upsertSubmissionSummary, + ).toHaveBeenCalledWith( + expect.objectContaining({ shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', - levelId: 'level-1', - passed: true, - timeSpent: 25, - completedAt: new Date(), - } as ShareLevelProgress, + correctCount: 4, + totalTimeSpent: 75, + }), ); - - const result = await service.reportLevelProgress( - 'user-uuid-2', - reportDto, - ); - - expect(result.passed).toBe(true); - expect(result.withinTimeLimit).toBe(true); - expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled(); }); - it('should report withinTimeLimit=false when time exceeds limit', async () => { - const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 }; - mockShareConfigRepository.findByShareCode.mockResolvedValue( - mockShareConfig, + it('should accept the sharer as a participant when they submit', async () => { + await service.submitChallenge('user-uuid-1', 'ABCD1234', submitDto); + + expect( + mockShareParticipantRepository.upsertSubmissionSummary, + ).toHaveBeenCalledWith( + expect.objectContaining({ + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-1', + }), ); - mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit); - mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( - null, - ); - const newProgress: Partial = { + }); + + it('should update existing per-level progress on resubmission', async () => { + const existingProgress = { + id: 'progress-uuid-1', shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', levelId: 'level-1', - passed: true, - timeSpent: 30, - }; - mockShareLevelProgressRepository.create.mockReturnValue(newProgress); - mockShareLevelProgressRepository.save.mockResolvedValue(newProgress); + submittedAnswer: '旧答案', + passed: false, + timeSpent: 99, + completedAt: new Date('2026-01-01'), + } as ShareLevelProgress; + mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue( + [existingProgress], + ); - const result = await service.reportLevelProgress('user-uuid-2', { - ...reportDto, - timeSpent: 30, - }); + await service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto); - expect(result.passed).toBe(true); - expect(result.withinTimeLimit).toBe(false); + expect(mockShareLevelProgressRepository.saveMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'progress-uuid-1', + submittedAnswer: '答案1', + passed: true, + timeSpent: 10, + }), + ]), + ); }); - it('should report withinTimeLimit=true when level has no time limit', async () => { - const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null }; - mockShareConfigRepository.findByShareCode.mockResolvedValue( - mockShareConfig, + 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'), + }, + ], ); - mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit); - mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue( - null, + + const result = await service.submitChallenge( + 'user-uuid-2', + 'ABCD1234', + submitDto, ); - const newProgress: Partial = { - 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.rank).toBe(2); + expect(result.participantCount).toBe(2); + }); - expect(result.withinTimeLimit).toBe(true); + it('should throw BadRequestException when submitted levels are incomplete', async () => { + await expect( + service.submitChallenge('user-uuid-2', 'ABCD1234', { + levels: submitDto.levels.slice(0, 5), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when a level is submitted twice', async () => { + 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 throw BadRequestException when a level does not belong to the share', async () => { + await expect( + service.submitChallenge('user-uuid-2', 'ABCD1234', { + levels: [ + ...submitDto.levels.slice(0, 5), + { levelId: 'other-level', answer: '答案', timeSpent: 1 }, + ], + }), + ).rejects.toThrow(BadRequestException); }); it('should throw NotFoundException when share not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue(null); await expect( - service.reportLevelProgress('user-uuid-2', reportDto), + service.submitChallenge('user-uuid-2', 'INVALID', submitDto), ).rejects.toThrow(NotFoundException); }); - it('should throw NotFoundException when level not found', async () => { - mockShareConfigRepository.findByShareCode.mockResolvedValue( - mockShareConfig, - ); - mockLevelRepository.findById.mockResolvedValue(null); + it('should throw NotFoundException when a configured level is missing', async () => { + mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 5)); await expect( - service.reportLevelProgress('user-uuid-2', reportDto), + service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto), ).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(); - }); }); }); diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index aaadb17..a5a3eee 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -8,16 +8,18 @@ import { ShareConfigRepository } from './repositories/share-config.repository'; import { ShareParticipantRepository } from './repositories/share-participant.repository'; import { ShareLevelProgressRepository } from './repositories/share-level-progress.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 { CreateShareDto } from './dto/create-share.dto'; -import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; +import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto'; import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, ShareLevelDto, + SubmittedShareLevelDto, + SubmitShareChallengeResponseDto, } from './dto/share-response.dto'; -import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto'; @Injectable() export class ShareService { @@ -84,23 +86,7 @@ export class ShareService { await this.shareParticipantRepository.addParticipant(config.id, userId); } - // Single query, then reorder to match levelIds sequence - 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, - }; - }); + const levels = await this.buildShareLevels(config.levelIds); return { shareCode: config.shareCode, @@ -117,32 +103,21 @@ export class ShareService { const shareConfigIds = configs.map((config) => config.id); const [participantCountMap, rankingRows] = await Promise.all([ - this.shareParticipantRepository.countByShareConfigIds(shareConfigIds), - this.shareLevelProgressRepository.summarizeByShareConfigIds( + this.shareParticipantRepository.countSubmittedByShareConfigIds( + shareConfigIds, + ), + this.shareParticipantRepository.findSubmittedRankingsByShareConfigIds( shareConfigIds, ), ]); const rankingsByShareConfigId = new Map(); for (const config of configs) { - const completedRankings = rankingRows - .filter( - (row) => - row.shareConfigId === config.id && - Number(row.passedLevelCount) === config.levelIds.length, - ) - .sort((a, b) => { - const totalTimeDiff = - Number(a.totalTimeSpent) - Number(b.totalTimeSpent); - if (totalTimeDiff !== 0) { - return totalTimeDiff; - } - - return a.participantId.localeCompare(b.participantId); - }) + const rankings = rankingRows + .filter((row) => row.shareConfigId === config.id) .map((row) => row.participantId); - rankingsByShareConfigId.set(config.id, completedRankings); + rankingsByShareConfigId.set(config.id, rankings); } return { @@ -165,77 +140,189 @@ export class ShareService { }; } - async reportLevelProgress( + async submitChallenge( userId: string, - dto: ReportLevelProgressDto, - ): Promise { - const [config, level] = await Promise.all([ - this.shareConfigRepository.findByShareCode(dto.shareCode), - this.levelRepository.findById(dto.levelId), - ]); - + code: string, + dto: SubmitShareChallengeDto, + ): Promise { + const config = await this.shareConfigRepository.findByShareCode(code); if (!config) { 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)) { - throw new BadRequestException('该关卡不属于此分享挑战'); - } - - // 自动登记参与者(创建者本人不计入)。已存在则忽略。 - if (userId !== config.sharerId) { - await this.shareParticipantRepository.addParticipant(config.id, userId); - } - - const progress = - await this.shareLevelProgressRepository.findByShareConfigParticipantAndLevel( + const levelMap = new Map(allLevels.map((level) => [level.id, level])); + const submittedMap = new Map( + dto.levels.map((item) => [item.levelId, item]), + ); + const submittedAt = new Date(); + const existingProgress = + await this.shareLevelProgressRepository.findByShareConfigAndParticipant( config.id, userId, - dto.levelId, + ); + const existingProgressMap = new Map( + existingProgress.map((progress) => [progress.levelId, progress]), + ); + + let correctCount = 0; + let totalTimeSpent = 0; + const responseLevels: SubmittedShareLevelDto[] = []; + + 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, + submitted.timeSpent, ); - if (dto.passed && progress?.passed) { - return { - passed: true, - timeLimit: level.timeLimit, - withinTimeLimit: this.isWithinTimeLimit( - level.timeLimit, - progress.timeSpent, - ), - }; - } + if (isCorrect) { + correctCount++; + } + totalTimeSpent += submitted.timeSpent; - const withinTimeLimit = dto.passed - ? this.isWithinTimeLimit(level.timeLimit, dto.timeSpent) - : false; - - const updatedProgress = progress - ? Object.assign(this.shareLevelProgressRepository.create(progress), { - passed: dto.passed, - timeSpent: dto.timeSpent, - completedAt: dto.passed ? new Date() : progress.completedAt, - }) - : this.shareLevelProgressRepository.create({ + const progress = Object.assign( + existingProgressMap.get(levelId) ?? + this.shareLevelProgressRepository.create({ + shareConfigId: config.id, + participantId: userId, + levelId, + }), + { shareConfigId: config.id, participantId: userId, - levelId: dto.levelId, - passed: dto.passed, - timeSpent: dto.timeSpent, - completedAt: dto.passed ? new Date() : null, - }); + levelId, + submittedAnswer, + passed: isCorrect, + timeSpent: submitted.timeSpent, + completedAt: submittedAt, + }, + ); - await this.shareLevelProgressRepository.save(updatedProgress); + 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 { - passed: dto.passed, - timeLimit: level.timeLimit, - withinTimeLimit, + shareCode: config.shareCode, + title: config.title, + rank: rankingIndex >= 0 ? rankingIndex + 1 : rankings.length, + correctCount, + levelCount: config.levelIds.length, + participantCount: rankings.length, + totalTimeSpent, + levels: responseLevels, }; } + private async buildShareLevels(levelIds: string[]): Promise { + const allLevels = await this.levelRepository.findByIds(levelIds); + const levelMap = new Map(allLevels.map((level) => [level.id, level])); + + return levelIds.map((id, index) => { + const level = levelMap.get(id); + if (!level) { + throw new NotFoundException(`关卡 ${id} 不存在`); + } + return this.toShareLevelDto(level, index); + }); + } + + private toShareLevelDto(level: Level, index: number): ShareLevelDto { + return { + 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, + }; + } + + 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(); + 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( timeLimit: number | null, timeSpent: number,