diff --git a/.agents/skills/api-doc-maintainer/SKILL.md b/.agents/skills/api-doc-maintainer/SKILL.md new file mode 100644 index 0000000..531b7f7 --- /dev/null +++ b/.agents/skills/api-doc-maintainer/SKILL.md @@ -0,0 +1,194 @@ +--- +name: api-doc-maintainer +description: > + 当用户修改、新增或删除任何 API 接口时,同步更新 docs/api/ 下的 Markdown 文档。 + 触发场景:修改 Controller、DTO、Service 中的接口逻辑或参数,新增/删除 endpoint, + 变更响应结构,添加错误码。只要涉及 src/modules/*/ 下的接口代码改动,都应触发此技能。 +--- + +# API 文档维护技能 + +## 为什么文档同步如此重要 + +MemeMind 的客户端是 Cocos Creator 小游戏,客户端开发者依赖 `docs/api/` 下的文档来接入后端接口。文档滞后意味着客户端开发者要去读源码才能对接,这会严重拖慢开发节奏。所以每次接口变更,文档必须同步更新。 + +## 代码结构索引 + +在更新文档前,先确认变更涉及哪些文件: + +``` +src/modules/ +├── auth/ # 认证模块:wx-login、user assets、game-data +│ ├── auth.controller.ts +│ ├── dto/ +│ └── auth.service.ts +├── wechat-game/ # 游戏模块:levels、configs +│ ├── wechat-game.controller.ts +│ ├── dto/ +│ └── wechat-game.service.ts +└── share/ # 分享挑战模块:创建分享、加入、进度上报 + ├── share.controller.ts + ├── dto/ + └── share.service.ts +``` + +Controller 定义了路由和参数,DTO 定义了请求/响应的数据结构,Service 包含业务逻辑。三者共同决定文档内容。 + +## 文档存放与组织 + +文档统一放在 `docs/api/` 目录下,按功能模块分文件: + +| 模块 | 文档文件 | 对应代码 | +|------|----------|----------| +| 用户认证 | `auth-api.md` | `src/modules/auth/` | +| 游戏关卡 | `game-api.md` | `src/modules/wechat-game/` | +| 分享挑战 | `share-challenge-api.md` | `src/modules/share/` | +| 排行榜 | `leaderboard-api.md` | (预留) | + +新增模块时,创建对应的文档文件并在 `docs/api/README.md` 中注册索引。 + +## 文档结构 + +每个模块的 API 文档包含以下章节: + +```markdown +# 模块名称 API + +## Changelog +- YYYY-MM-DD: 变更说明 + +## 概述 +功能说明、前置依赖 + +## 认证方式 +(如需要)说明鉴权方式 + +## 通用响应格式 +(引用统一响应格式即可) + +## 接口列表 +### 1. 接口名称 +(按下方模板) + +## 错误码说明 +| 错误码 | HTTP 状态码 | 说明 | 触发条件 | + +## 接入流程 +典型业务场景的调用顺序 + +## 客户端示例 +Cocos Creator TypeScript 调用代码 +``` + +## 接口文档模板 + +根据接口类型智能选择模板,不需要填写不适用的字段。 + +### 查询类接口(GET,无请求体) + +```markdown +### N. 接口名称 + +**接口地址**:`GET /api/v1/{module}/{endpoint}` + +**是否需要认证**:是/否 + +**路径参数**(如有): +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| + +**查询参数**(如有): +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| + +**成功响应示例**: +```json +{ "success": true, "data": { ... }, "message": null, "timestamp": "..." } +``` + +**业务逻辑说明**: +- 规则 1 +``` + +### 写入类接口(POST/PUT/PATCH/DELETE,有请求体) + +```markdown +### N. 接口名称 + +**接口地址**:`POST /api/v1/{module}/{endpoint}` + +**是否需要认证**:是/否 + +**请求体**: +```json +{ + "field": "value" +} +``` + +**字段说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| + +**成功响应示例**: +```json +{ "success": true, "data": { ... }, "message": null, "timestamp": "..." } +``` + +**失败响应示例**(如有特殊错误场景): +```json +{ "success": false, "data": null, "message": "错误描述", "timestamp": "..." } +``` + +**业务逻辑说明**: +- 规则 1 +``` + +如果某个字段(如路径参数、查询参数)不适用于当前接口,直接省略该小节。 + +## 统一响应格式 + +所有 API 响应使用统一包装: + +```typescript +interface ApiResponse { + success: boolean; // 请求是否成功 + data: T | null; // 响应数据 + message: string | null; // 错误信息(成功时为 null) + timestamp: string; // ISO 8601 时间戳 +} +``` + +## 更新工作流 + +当接口代码发生变更时,按以下步骤操作: + +### 新增接口 +1. 在对应模块文档中按模板添加接口章节 +2. 从 Controller 读取路由路径和方法,从 DTO 读取参数定义 +3. 补充业务逻辑说明(来自 Service) +4. 添加 Cocos Creator 调用示例 +5. 更新 Changelog +6. 在 `docs/api/README.md` 索引中确认已注册 + +### 修改接口 +1. 对比代码变更,确定文档中哪些内容需要更新 +2. 更新受影响的字段:路径、参数、响应结构、业务逻辑 +3. 更新示例响应(确保与实际代码一致) +4. 在 Changelog 中记录变更 + +### 删除接口 +1. 在文档中标记为「已废弃」并注明替代方案,或直接移除 +2. 更新索引 +3. 在 Changelog 中记录 + +### 错误码变更 +1. 在错误码表中新增/修改/删除条目 +2. 注明触发条件 + +## 完成后输出 + +文档更新完成后,向用户简要报告: +1. 哪些文档被修改了 +2. 具体变更了什么内容 +3. 是否有需要客户端配合调整的地方 diff --git a/AGENTS.md b/AGENTS.md index 66b95a0..37dfc63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,12 @@ ## 配置与部署说明 环境变量在 `src/config/env.validation.ts` 中做校验。敏感信息应保存在 `.env.local` 或部署环境专用配置中,不能写入源码。无论本地还是生产环境,API 统一暴露在 `/api` 下,Swagger 暴露在 `/api/docs`。`pnpm run deploy` 会调用 `deploy.sh`、`rsync` 和 PM2,因此执行前需要先检查其中的服务器相关配置。 + + + +# Memory Context + +# $CMEM MemeMind-Server 2026-05-03 10:13pm GMT+8 + +No previous sessions found. + \ No newline at end of file diff --git a/docs/api/game-api.md b/docs/api/game-api.md index d63d5b1..21abe98 100644 --- a/docs/api/game-api.md +++ b/docs/api/game-api.md @@ -26,8 +26,11 @@ ## Changelog +- **2026-05-03**: + - **变更** 关卡顺序规则:所有下一关、预加载下一关、已通关列表均按 `levels.sort_key` 的应用层字节序排序;响应中的 `level` / `sortOrder` 仍为排序后的连续序号 + - **2026-04-30**: - - **新增** `GET /api/v1/levels/completed`:获取当前用户已通关关卡列表(按 sortOrder 升序,含完整关卡信息 + 通关时长 + 通关时间) + - **新增** `GET /api/v1/levels/completed`:获取当前用户已通关关卡列表(含完整关卡信息 + 通关时长 + 通关时间) - **2026-04-26**: - **删除** `GET /api/v1/levels`(关卡列表接口),客户端不再需要拉取全量关卡列表 @@ -41,26 +44,26 @@ MemeMind 核心玩法接口分为以下模块: -| 模块 | 路由前缀 | 说明 | -|------|----------|------| -| 认证 | `/api/v1/auth` | 微信登录、JWT 签发 | -| 用户 | `/api/v1/user` | 用户资料、体力值、游戏数据 | -| 关卡 | `/api/v1/levels` | 进入关卡、通关上报 | -| 游戏配置 | `/api/v1/game-configs` | 游戏全局配置 | +| 模块 | 路由前缀 | 说明 | +| -------- | ---------------------- | -------------------------- | +| 认证 | `/api/v1/auth` | 微信登录、JWT 签发 | +| 用户 | `/api/v1/user` | 用户资料、体力值、游戏数据 | +| 关卡 | `/api/v1/levels` | 进入关卡、通关上报 | +| 游戏配置 | `/api/v1/game-configs` | 游戏全局配置 | ### 与旧版 API 的变更(⚠️ Breaking Changes) -| 废弃接口 | 替代方案 | -|----------|---------| -| `GET /api/v1/user/assets` | `GET /api/v1/user/profile` | -| `POST /api/v1/user/assets/consume` | 已删除,体力在「进入关卡」时自动扣减 | -| `POST /api/v1/user/assets/earn` | 已删除,通关不再奖励积分 | +| 废弃接口 | 替代方案 | +| ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| `GET /api/v1/user/assets` | `GET /api/v1/user/profile` | +| `POST /api/v1/user/assets/consume` | 已删除,体力在「进入关卡」时自动扣减 | +| `POST /api/v1/user/assets/earn` | 已删除,通关不再奖励积分 | | `GET /api/v1/user/game-data`(旧版返回 completedLevelIds) | `GET /api/v1/user/game-data`(新版返回 completedLevelCount + nextLevel) | -| `GET /api/v1/levels` | 已删除,下一关数据由 `game-data` / `enter` / `complete` 接口直接返回 | -| `GET /api/v1/wechat-game/levels` | 已删除 | -| `GET /api/v1/wechat-game/levels/:id` | `POST /api/v1/levels/:id/enter`(需鉴权 + 消耗体力) | -| `GET /api/v1/wechat-game/configs` | `GET /api/v1/game-configs` | -| `GET /api/v1/wechat-game/configs/:key` | `GET /api/v1/game-configs/:key` | +| `GET /api/v1/levels` | 已删除,下一关数据由 `game-data` / `enter` / `complete` 接口直接返回 | +| `GET /api/v1/wechat-game/levels` | 已删除 | +| `GET /api/v1/wechat-game/levels/:id` | `POST /api/v1/levels/:id/enter`(需鉴权 + 消耗体力) | +| `GET /api/v1/wechat-game/configs` | `GET /api/v1/game-configs` | +| `GET /api/v1/wechat-game/configs/:key` | `GET /api/v1/game-configs/:key` | --- @@ -85,18 +88,18 @@ Authorization: Bearer ```json { "success": true, - "data": { "..." : "..." }, + "data": { "...": "..." }, "message": null, "timestamp": "2026-04-10T12:00:00.000Z" } ``` -| 字段 | 类型 | 说明 | -|------|------|------| -| success | boolean | 请求是否成功 | -| data | T \| null | 成功时返回业务数据,失败时为 null | -| message | string \| null | 失败时的错误信息,成功时为 null | -| timestamp | string | 服务器响应时间(ISO 8601) | +| 字段 | 类型 | 说明 | +| --------- | -------------- | --------------------------------- | +| success | boolean | 请求是否成功 | +| data | T \| null | 成功时返回业务数据,失败时为 null | +| message | string \| null | 失败时的错误信息,成功时为 null | +| timestamp | string | 服务器响应时间(ISO 8601) | --- @@ -104,13 +107,13 @@ Authorization: Bearer 体力值(stamina)替代了原有的积分系统,用于控制用户进入关卡的频率。 -| 属性 | 值 | -|------|-----| -| 默认体力 | 50(新用户注册时) | -| 上限 | 50 | -| 恢复速度 | 每 **10 分钟** 恢复 1 点 | -| 消耗 | 进入**未通关**关卡时消耗 1 点 | -| 已通关关卡 | 再次进入不消耗体力 | +| 属性 | 值 | +| ---------- | ----------------------------- | +| 默认体力 | 50(新用户注册时) | +| 上限 | 50 | +| 恢复速度 | 每 **10 分钟** 恢复 1 点 | +| 消耗 | 进入**未通关**关卡时消耗 1 点 | +| 已通关关卡 | 再次进入不消耗体力 | ### 体力值数据结构 @@ -118,8 +121,8 @@ Authorization: Bearer ```typescript interface StaminaInfo { - current: number; // 当前体力值(已计算恢复) - max: number; // 体力上限,固定为 50 + current: number; // 当前体力值(已计算恢复) + max: number; // 体力上限,固定为 50 nextRecoverAt: string | null; // 下一点体力恢复的时间(ISO 8601),满体力时为 null } ``` @@ -146,22 +149,23 @@ interface StaminaInfo { ```typescript interface NextLevel { - id: string; // 关卡 ID - level: number; // 关卡编号(sortOrder) - image1Url: string; // 图片1 URL + id: string; // 关卡 ID + level: number; // 关卡编号(sortOrder) + image1Url: string; // 图片1 URL image1Description: string | null; // 图片1 文本说明 - image2Url: string; // 图片2 URL + 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 - timeLimit: number | null; // 限时(秒),null 表示不限时 + answer: string; // 答案 + punchline: string | null; // 谐音梗说明 + hint1: string | null; // 线索1 + hint2: string | null; // 线索2 + hint3: string | null; // 线索3 + timeLimit: number | null; // 限时(秒),null 表示不限时 } ``` **出现位置**: + - `GET /api/v1/user/game-data` → `nextLevel` - `POST /api/v1/levels/:id/enter` → `preloadNextLevel` - `POST /api/v1/levels/:id/complete` → `nextLevel` @@ -186,9 +190,9 @@ interface NextLevel { } ``` -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| code | string | 是 | 微信 `wx.login` 返回的临时登录凭证 | +| 字段 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | ---------------------------------- | +| code | string | 是 | 微信 `wx.login` 返回的临时登录凭证 | **响应数据**: @@ -198,7 +202,7 @@ interface NextLevel { user: { id: string; nickname: string | null; - stamina: number; // 当前体力值(数据库原始值,不含实时恢复计算) + stamina: number; // 当前体力值(数据库原始值,不含实时恢复计算) } } ``` @@ -222,6 +226,7 @@ interface NextLevel { ``` **客户端调用时机**: + - 小游戏冷启动时 - 缓存的 token 过期后(收到 401 响应时) @@ -243,7 +248,7 @@ interface NextLevel { { id: string; nickname: string | null; - stamina: StaminaInfo; // 实时体力信息 + stamina: StaminaInfo; // 实时体力信息 } ``` @@ -267,6 +272,7 @@ interface NextLevel { ``` **客户端调用时机**: + - 需要刷新用户体力显示时 - 从后台切回前台时 @@ -289,9 +295,9 @@ interface NextLevel { user: { id: string; stamina: StaminaInfo; - }; - completedLevelCount: number; // 已通关的关卡数量 - nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) + } + completedLevelCount: number; // 已通关的关卡数量 + nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) } ``` @@ -349,10 +355,13 @@ interface NextLevel { ``` **业务逻辑**: -- `nextLevel` 为按 `sortOrder` 排序的第一个用户未通关的关卡 + +- `nextLevel` 为按 `levels.sort_key` 应用层字节序排序后的第一个用户未通关关卡 +- 响应中的 `nextLevel.level` 是按当前排序回填的 0-based 连续序号,客户端不需要感知数据库内部的 `sort_key` - 全部通关时 `nextLevel` 为 `null`,客户端应展示通关庆祝页面 **客户端调用时机**: + - 游戏 Loading 页面 - 进入主页面前 @@ -368,9 +377,9 @@ interface NextLevel { **路径参数**: -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 关卡 ID | +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | ------- | +| id | string | 是 | 关卡 ID | **请求体**:无 @@ -389,7 +398,7 @@ interface NextLevel { hint1: string | null; hint2: string | null; hint3: string | null; - stamina: StaminaInfo; // 消耗后的体力信息 + stamina: StaminaInfo; // 消耗后的体力信息 preloadNextLevel: NextLevel | null; // 预加载的下一关数据(无下一关时为 null) } ``` @@ -438,17 +447,18 @@ interface NextLevel { **业务逻辑**: -| 场景 | 是否消耗体力 | 说明 | -|------|-------------|------| -| 首次进入未通关关卡 | ✅ 消耗 1 点 | 正常扣减 | -| 再次进入已通关关卡 | ❌ 不消耗 | 直接返回关卡详情 | -| 体力为 0 且关卡未通关 | ❌ 返回错误 | 返回 401 体力不足 | +| 场景 | 是否消耗体力 | 说明 | +| --------------------- | ------------ | ----------------- | +| 首次进入未通关关卡 | ✅ 消耗 1 点 | 正常扣减 | +| 再次进入已通关关卡 | ❌ 不消耗 | 直接返回关卡详情 | +| 体力为 0 且关卡未通关 | ❌ 返回错误 | 返回 401 体力不足 | - `preloadNextLevel` 为按 `sortOrder` 排在当前关卡之后的第一个未完成关卡 - 当前关卡是最后一关时,`preloadNextLevel` 为 `null` - 客户端可在用户答题时后台预加载 `preloadNextLevel` 中的图片资源 **客户端调用时机**: + - 用户在关卡选择页面点击某个关卡进入时 - **必须**调用此接口获取关卡详情后才能开始游戏 - 客户端应在调用前检查体力是否足够,体力不足时提示用户等待恢复 @@ -465,9 +475,9 @@ interface NextLevel { **路径参数**: -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | string | 是 | 关卡 ID | +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | ------- | +| id | string | 是 | 关卡 ID | **请求体**: @@ -477,18 +487,18 @@ interface NextLevel { } ``` -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| timeSpent | number | 是 | 通关时长(秒),≥ 0 | +| 字段 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ------------------- | +| timeSpent | number | 是 | 通关时长(秒),≥ 0 | **响应数据**: ```typescript { - firstClear: boolean; // 是否为首次通关 - levelId: string; // 关卡 ID - timeSpent: number; // 记录的通关时长(秒) - nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) + firstClear: boolean; // 是否为首次通关 + levelId: string; // 关卡 ID + timeSpent: number; // 记录的通关时长(秒) + nextLevel: NextLevel | null; // 下一个待通关的关卡(全部通关时为 null) } ``` @@ -567,12 +577,14 @@ interface NextLevel { ``` **业务逻辑**: + - 首次通关:记录 `timeSpent`,返回 `firstClear: true` - 重复通关:不覆盖记录,返回首次通关的 `timeSpent`,`firstClear: false` - `nextLevel` 为通关后按 `sortOrder` 排序的第一个未完成关卡 - 全部通关时 `nextLevel` 为 `null` **客户端调用时机**: + - 用户成功回答正确答案后调用 - 只在通关成功时调用,答错不需要上报 - 收到响应后,可直接使用 `nextLevel` 数据进入下一关(调用 `enter` 接口) @@ -668,11 +680,14 @@ interface CompletedLevel { ``` **业务逻辑**: -- 返回列表按关卡顺序(`sortOrder`)升序排列 + +- 返回列表按 `levels.sort_key` 应用层字节序升序排列 +- 响应中的 `level` 是按当前排序回填的 0-based 连续序号,客户端不需要感知数据库内部的 `sort_key` - `timeSpent` 为**首次通关**时上报的时长,重复通关不会覆盖 - 每项包含完整关卡信息,客户端可直接在回看页面渲染图片、答案、线索 **客户端调用时机**: + - 用户打开「关卡回看 / 成就墙」页面时 - 不建议在 Loading 阶段调用(Loading 使用 `game-data` 即可) @@ -743,9 +758,9 @@ interface GameConfig { **路径参数**: -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| key | string | 是 | 配置键名 | +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | -------- | +| key | string | 是 | 配置键名 | **响应数据**: @@ -765,15 +780,15 @@ interface GameConfig { ## 错误码说明 -| HTTP Status | message | 说明 | -|-------------|---------|------| -| 401 | 未提供访问令牌 | 请求头缺少 Authorization | -| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期,需重新登录 | -| 401 | 微信登录失败,请重试 | 微信 code 无效 | -| 401 | 用户不存在 | 用户 ID 在数据库中不存在 | -| 401 | 体力不足 | 进入关卡时体力为 0 | -| 404 | 关卡 {id} 不存在 | 关卡 ID 不存在 | -| 404 | Game config with key "xxx" not found | 配置 key 不存在 | +| HTTP Status | message | 说明 | +| ----------- | ------------------------------------ | -------------------------------- | +| 401 | 未提供访问令牌 | 请求头缺少 Authorization | +| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期,需重新登录 | +| 401 | 微信登录失败,请重试 | 微信 code 无效 | +| 401 | 用户不存在 | 用户 ID 在数据库中不存在 | +| 401 | 体力不足 | 进入关卡时体力为 0 | +| 404 | 关卡 {id} 不存在 | 关卡 ID 不存在 | +| 404 | Game config with key "xxx" not found | 配置 key 不存在 | --- @@ -848,13 +863,13 @@ interface GameState { completedLevelCount: number; // 当前关卡 - currentLevel: NextLevel | null; // 来自 game-data.nextLevel 或 complete.nextLevel + currentLevel: NextLevel | null; // 来自 game-data.nextLevel 或 complete.nextLevel // 预加载的下一关 preloadedLevel: NextLevel | null; // 来自 enter.preloadNextLevel // 游戏中状态 - startTime: number | null; // 开始时间戳,用于计算 timeSpent + startTime: number | null; // 开始时间戳,用于计算 timeSpent } ``` @@ -879,7 +894,9 @@ function startStaminaTimer(staminaInfo: StaminaInfo) { if (staminaInfo.current < staminaInfo.max) { // 继续下一轮倒计时 - staminaInfo.nextRecoverAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + staminaInfo.nextRecoverAt = new Date( + Date.now() + 10 * 60 * 1000, + ).toISOString(); startStaminaTimer(staminaInfo); } else { staminaInfo.nextRecoverAt = null; @@ -947,7 +964,7 @@ class HttpManager { async request( method: 'GET' | 'POST', url: string, - body?: object + body?: object, ): Promise> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -980,8 +997,12 @@ class HttpManager { }); } - get(url: string) { return this.request('GET', url); } - post(url: string, body?: object) { return this.request('POST', url, body); } + get(url: string) { + return this.request('GET', url); + } + post(url: string, body?: object) { + return this.request('POST', url, body); + } } export const http = new HttpManager(); @@ -1047,7 +1068,9 @@ interface EnterLevelResponse { } async function enterLevel(levelId: string): Promise { - const resp = await http.post(`/v1/levels/${levelId}/enter`); + const resp = await http.post( + `/v1/levels/${levelId}/enter`, + ); if (resp.success && resp.data) { // 更新本地体力状态 @@ -1079,11 +1102,11 @@ interface CompleteLevelResponse { async function completeLevel( levelId: string, - timeSpent: number + timeSpent: number, ): Promise { const resp = await http.post( `/v1/levels/${levelId}/complete`, - { timeSpent } + { timeSpent }, ); if (resp.success && resp.data) { @@ -1149,7 +1172,6 @@ export class GameEntry extends Component { // 全部通关 showCelebration(); } - } catch (error) { console.error('启动失败:', error); showRetryDialog(); diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index 7a2fa9e..2f468c3 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -24,6 +24,7 @@ 2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间 3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过 4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名 +5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号 --- @@ -273,6 +274,7 @@ Content-Type: application/json **特殊逻辑**: - 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录 +- `levels` 数组顺序保持分享创建时传入的 `levelIds` 顺序;每个关卡的 `sortOrder` 字段为按 `levels.sort_key` 全局排序后回填的 0-based 连续序号 - 返回的关卡列表按 `levelIds` 创建时的顺序排列 **客户端调用场景**: diff --git a/src/database/migrations/004_add_level_sort_key.sql b/src/database/migrations/004_add_level_sort_key.sql new file mode 100644 index 0000000..f1a774f --- /dev/null +++ b/src/database/migrations/004_add_level_sort_key.sql @@ -0,0 +1,8 @@ +-- Description: Add fractional-indexing sort key for level ordering. +-- Sort by this field in application code, not with MySQL ORDER BY, because the +-- default utf8mb4 collation is case-insensitive and can misorder keys. + +ALTER TABLE levels +ADD COLUMN sort_key VARCHAR(64) NOT NULL DEFAULT 'a0' AFTER hint3; + +CREATE INDEX levels_sort_key_idx ON levels (sort_key); diff --git a/src/modules/level/level.controller.ts b/src/modules/level/level.controller.ts index fb72ac0..326bb74 100644 --- a/src/modules/level/level.controller.ts +++ b/src/modules/level/level.controller.ts @@ -28,7 +28,7 @@ export class LevelController { @ApiOperation({ summary: '获取已通关关卡列表', description: - '返回当前用户所有已通关的关卡,按关卡顺序(sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。', + '返回当前用户所有已通关的关卡,按关卡顺序(sortKey 字节序)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。', }) @ApiResponse({ status: 200, description: '成功' }) @ApiResponse({ status: 401, description: '未授权' }) diff --git a/src/modules/level/level.service.ts b/src/modules/level/level.service.ts index 758bc39..0fcfff7 100644 --- a/src/modules/level/level.service.ts +++ b/src/modules/level/level.service.ts @@ -130,7 +130,7 @@ export class LevelService { } /** - * 获取用户已通关的关卡列表,按关卡顺序(sortOrder)升序返回 + * 获取用户已通关的关卡列表,按关卡顺序(sortKey 字节序)升序返回 */ async getCompletedLevels(userId: string): Promise { const progressList = diff --git a/src/modules/level/next-level.helper.ts b/src/modules/level/next-level.helper.ts index d7178ea..f949d6c 100644 --- a/src/modules/level/next-level.helper.ts +++ b/src/modules/level/next-level.helper.ts @@ -16,7 +16,7 @@ export function toNextLevelDto(level: Level): NextLevelDto { } /** - * Given all levels (sorted by sortOrder ASC) and the set of completed level IDs, + * Given all levels (sorted by sortKey byte order) and the set of completed level IDs, * return the next `count` uncompleted levels. */ export function findNextUncompletedLevels( diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index ebc3139..1c32966 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -30,6 +30,7 @@ describe('ShareService', () => { hint1: `提示${i + 1}`, hint2: null, hint3: null, + sortKey: `a${i}`, sortOrder: i, timeLimit: i === 0 ? 60 : null, createdAt: new Date('2026-01-01'), diff --git a/src/modules/wechat-game/entities/level.entity.ts b/src/modules/wechat-game/entities/level.entity.ts index 4b4a5f1..3d0c4aa 100644 --- a/src/modules/wechat-game/entities/level.entity.ts +++ b/src/modules/wechat-game/entities/level.entity.ts @@ -48,6 +48,9 @@ export class Level { @Column({ type: 'varchar', length: 191, nullable: true }) hint3!: string | null; + @Column({ type: 'varchar', length: 64, name: 'sort_key', default: 'a0' }) + sortKey!: string; + @Column({ type: 'int', name: 'sort_order', default: 0 }) sortOrder!: number; diff --git a/src/modules/wechat-game/repositories/level.repository.spec.ts b/src/modules/wechat-game/repositories/level.repository.spec.ts new file mode 100644 index 0000000..36b302e --- /dev/null +++ b/src/modules/wechat-game/repositories/level.repository.spec.ts @@ -0,0 +1,74 @@ +import { Repository } from 'typeorm'; +import { Level } from '../entities/level.entity'; +import { compareSortKey, LevelRepository } from './level.repository'; + +describe('LevelRepository', () => { + const makeLevel = ( + id: string, + sortKey: string, + sortOrder: number, + createdAt: string, + ): Level => + ({ + id, + image1Url: `https://example.com/${id}_1.png`, + image1Description: null, + image2Url: `https://example.com/${id}_2.png`, + image2Description: null, + answer: id, + punchline: null, + hint1: null, + hint2: null, + hint3: null, + sortKey, + sortOrder, + timeLimit: null, + createdAt: new Date(createdAt), + updatedAt: new Date(createdAt), + }) as Level; + + it('compares sortKey with JS byte-order semantics', () => { + expect(compareSortKey('Zz', 'a0')).toBeLessThan(0); + expect(compareSortKey('a0', 'Zz')).toBeGreaterThan(0); + expect(compareSortKey('a0', 'a0')).toBe(0); + }); + + it('orders levels by sortKey in application code and normalizes sortOrder', async () => { + const rows = [ + makeLevel('middle', 'a0', 10, '2026-01-02T00:00:00.000Z'), + makeLevel('first', 'Zz', 20, '2026-01-03T00:00:00.000Z'), + makeLevel('last', 'z0', 30, '2026-01-01T00:00:00.000Z'), + ]; + const repository = new LevelRepository({ + find: jest.fn().mockResolvedValue(rows), + } as unknown as Repository); + + const result = await repository.findAllOrdered(); + + expect(result.map((level) => level.id)).toEqual([ + 'first', + 'middle', + 'last', + ]); + expect(result.map((level) => level.sortOrder)).toEqual([0, 1, 2]); + }); + + it('uses sortOrder and createdAt as deterministic tie breakers', async () => { + const rows = [ + makeLevel('third', 'a0', 2, '2026-01-01T00:00:00.000Z'), + makeLevel('second', 'a0', 1, '2026-01-02T00:00:00.000Z'), + makeLevel('first', 'a0', 1, '2026-01-01T00:00:00.000Z'), + ]; + const repository = new LevelRepository({ + find: jest.fn().mockResolvedValue(rows), + } as unknown as Repository); + + const result = await repository.findAllOrdered(); + + expect(result.map((level) => level.id)).toEqual([ + 'first', + 'second', + 'third', + ]); + }); +}); diff --git a/src/modules/wechat-game/repositories/level.repository.ts b/src/modules/wechat-game/repositories/level.repository.ts index e9f9fcd..d49908d 100644 --- a/src/modules/wechat-game/repositories/level.repository.ts +++ b/src/modules/wechat-game/repositories/level.repository.ts @@ -1,9 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { Level } from '../entities/level.entity'; import { ILevelRepository } from './level.repository.interface'; +export function compareSortKey(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + @Injectable() export class LevelRepository implements ILevelRepository { constructor( @@ -12,21 +16,39 @@ export class LevelRepository implements ILevelRepository { ) {} async findAll(): Promise { - return this.repository.find(); + return this.findAllOrdered(); } async findById(id: string): Promise { - return this.repository.findOne({ where: { id } }); + const levels = await this.findAllOrdered(); + return levels.find((level) => level.id === id) ?? null; } async findByIds(ids: string[]): Promise { if (ids.length === 0) return []; - return this.repository.find({ where: { id: In(ids) } }); + const idSet = new Set(ids); + const levels = await this.findAllOrdered(); + return levels.filter((level) => idSet.has(level.id)); } async findAllOrdered(): Promise { - return this.repository.find({ - order: { sortOrder: 'ASC' }, - }); + const levels = await this.repository.find(); + return this.orderBySortKey(levels); + } + + private orderBySortKey(levels: Level[]): Level[] { + return levels + .slice() + .sort((a, b) => { + const sortKeyCompare = compareSortKey(a.sortKey, b.sortKey); + if (sortKeyCompare !== 0) return sortKeyCompare; + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; + return a.createdAt.getTime() - b.createdAt.getTime(); + }) + .map((level, index) => + Object.assign(Object.create(Object.getPrototypeOf(level)), level, { + sortOrder: index, + }), + ); } }