perf: 支持分享提交答案的接口

This commit is contained in:
richarjiang
2026-05-10 16:04:21 +08:00
parent 8443f8844d
commit 642ccd31d3
15 changed files with 917 additions and 447 deletions

View File

@@ -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 <token>
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 <token>
**排名规则**
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 <token>
---
### 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 <token>
{
"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 <token>
**业务逻辑说明**
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 <token>
│ 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<ReportProgressResponse> {
levels: SubmitChallengeLevel[],
): Promise<SubmitChallengeResponse> {
// 确保已登录
if (!httpManager.getToken()) {
await wxLogin();
}
const response = await httpManager.post<ReportProgressResponse>(
'/v1/share/progress',
{
shareCode,
levelId,
passed,
timeSpent,
},
const response = await httpManager.post<SubmitChallengeResponse>(
`/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 状态,并处理网络异常情况