fix: 优化关卡排序
This commit is contained in:
194
.agents/skills/api-doc-maintainer/SKILL.md
Normal file
194
.agents/skills/api-doc-maintainer/SKILL.md
Normal file
@@ -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<T> {
|
||||
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. 是否有需要客户端配合调整的地方
|
||||
@@ -40,3 +40,12 @@
|
||||
## 配置与部署说明
|
||||
|
||||
环境变量在 `src/config/env.validation.ts` 中做校验。敏感信息应保存在 `.env.local` 或部署环境专用配置中,不能写入源码。无论本地还是生产环境,API 统一暴露在 `/api` 下,Swagger 暴露在 `/api/docs`。`pnpm run deploy` 会调用 `deploy.sh`、`rsync` 和 PM2,因此执行前需要先检查其中的服务器相关配置。
|
||||
|
||||
|
||||
<claude-mem-context>
|
||||
# Memory Context
|
||||
|
||||
# $CMEM MemeMind-Server 2026-05-03 10:13pm GMT+8
|
||||
|
||||
No previous sessions found.
|
||||
</claude-mem-context>
|
||||
@@ -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`(关卡列表接口),客户端不再需要拉取全量关卡列表
|
||||
@@ -42,7 +45,7 @@
|
||||
MemeMind 核心玩法接口分为以下模块:
|
||||
|
||||
| 模块 | 路由前缀 | 说明 |
|
||||
|------|----------|------|
|
||||
| -------- | ---------------------- | -------------------------- |
|
||||
| 认证 | `/api/v1/auth` | 微信登录、JWT 签发 |
|
||||
| 用户 | `/api/v1/user` | 用户资料、体力值、游戏数据 |
|
||||
| 关卡 | `/api/v1/levels` | 进入关卡、通关上报 |
|
||||
@@ -51,7 +54,7 @@ MemeMind 核心玩法接口分为以下模块:
|
||||
### 与旧版 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` | 已删除,通关不再奖励积分 |
|
||||
@@ -85,14 +88,14 @@ Authorization: Bearer <token>
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "..." : "..." },
|
||||
"data": { "...": "..." },
|
||||
"message": null,
|
||||
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| --------- | -------------- | --------------------------------- |
|
||||
| success | boolean | 请求是否成功 |
|
||||
| data | T \| null | 成功时返回业务数据,失败时为 null |
|
||||
| message | string \| null | 失败时的错误信息,成功时为 null |
|
||||
@@ -105,7 +108,7 @@ Authorization: Bearer <token>
|
||||
体力值(stamina)替代了原有的积分系统,用于控制用户进入关卡的频率。
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| ---------- | ----------------------------- |
|
||||
| 默认体力 | 50(新用户注册时) |
|
||||
| 上限 | 50 |
|
||||
| 恢复速度 | 每 **10 分钟** 恢复 1 点 |
|
||||
@@ -162,6 +165,7 @@ interface NextLevel {
|
||||
```
|
||||
|
||||
**出现位置**:
|
||||
|
||||
- `GET /api/v1/user/game-data` → `nextLevel`
|
||||
- `POST /api/v1/levels/:id/enter` → `preloadNextLevel`
|
||||
- `POST /api/v1/levels/:id/complete` → `nextLevel`
|
||||
@@ -187,7 +191,7 @@ interface NextLevel {
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| ---- | ------ | ---- | ---------------------------------- |
|
||||
| code | string | 是 | 微信 `wx.login` 返回的临时登录凭证 |
|
||||
|
||||
**响应数据**:
|
||||
@@ -222,6 +226,7 @@ interface NextLevel {
|
||||
```
|
||||
|
||||
**客户端调用时机**:
|
||||
|
||||
- 小游戏冷启动时
|
||||
- 缓存的 token 过期后(收到 401 响应时)
|
||||
|
||||
@@ -267,6 +272,7 @@ interface NextLevel {
|
||||
```
|
||||
|
||||
**客户端调用时机**:
|
||||
|
||||
- 需要刷新用户体力显示时
|
||||
- 从后台切回前台时
|
||||
|
||||
@@ -289,7 +295,7 @@ interface NextLevel {
|
||||
user: {
|
||||
id: string;
|
||||
stamina: StaminaInfo;
|
||||
};
|
||||
}
|
||||
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 页面
|
||||
- 进入主页面前
|
||||
|
||||
@@ -369,7 +378,7 @@ interface NextLevel {
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| ---- | ------ | ---- | ------- |
|
||||
| id | string | 是 | 关卡 ID |
|
||||
|
||||
**请求体**:无
|
||||
@@ -439,7 +448,7 @@ interface NextLevel {
|
||||
**业务逻辑**:
|
||||
|
||||
| 场景 | 是否消耗体力 | 说明 |
|
||||
|------|-------------|------|
|
||||
| --------------------- | ------------ | ----------------- |
|
||||
| 首次进入未通关关卡 | ✅ 消耗 1 点 | 正常扣减 |
|
||||
| 再次进入已通关关卡 | ❌ 不消耗 | 直接返回关卡详情 |
|
||||
| 体力为 0 且关卡未通关 | ❌ 返回错误 | 返回 401 体力不足 |
|
||||
@@ -449,6 +458,7 @@ interface NextLevel {
|
||||
- 客户端可在用户答题时后台预加载 `preloadNextLevel` 中的图片资源
|
||||
|
||||
**客户端调用时机**:
|
||||
|
||||
- 用户在关卡选择页面点击某个关卡进入时
|
||||
- **必须**调用此接口获取关卡详情后才能开始游戏
|
||||
- 客户端应在调用前检查体力是否足够,体力不足时提示用户等待恢复
|
||||
@@ -466,7 +476,7 @@ interface NextLevel {
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| ---- | ------ | ---- | ------- |
|
||||
| id | string | 是 | 关卡 ID |
|
||||
|
||||
**请求体**:
|
||||
@@ -478,7 +488,7 @@ interface NextLevel {
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| --------- | ------ | ---- | ------------------- |
|
||||
| timeSpent | number | 是 | 通关时长(秒),≥ 0 |
|
||||
|
||||
**响应数据**:
|
||||
@@ -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` 即可)
|
||||
|
||||
@@ -744,7 +759,7 @@ interface GameConfig {
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| ---- | ------ | ---- | -------- |
|
||||
| key | string | 是 | 配置键名 |
|
||||
|
||||
**响应数据**:
|
||||
@@ -766,7 +781,7 @@ interface GameConfig {
|
||||
## 错误码说明
|
||||
|
||||
| HTTP Status | message | 说明 |
|
||||
|-------------|---------|------|
|
||||
| ----------- | ------------------------------------ | -------------------------------- |
|
||||
| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
|
||||
| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期,需重新登录 |
|
||||
| 401 | 微信登录失败,请重试 | 微信 code 无效 |
|
||||
@@ -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<T>(
|
||||
method: 'GET' | 'POST',
|
||||
url: string,
|
||||
body?: object
|
||||
body?: object,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -980,8 +997,12 @@ class HttpManager {
|
||||
});
|
||||
}
|
||||
|
||||
get<T>(url: string) { return this.request<T>('GET', url); }
|
||||
post<T>(url: string, body?: object) { return this.request<T>('POST', url, body); }
|
||||
get<T>(url: string) {
|
||||
return this.request<T>('GET', url);
|
||||
}
|
||||
post<T>(url: string, body?: object) {
|
||||
return this.request<T>('POST', url, body);
|
||||
}
|
||||
}
|
||||
|
||||
export const http = new HttpManager();
|
||||
@@ -1047,7 +1068,9 @@ interface EnterLevelResponse {
|
||||
}
|
||||
|
||||
async function enterLevel(levelId: string): Promise<EnterLevelResponse> {
|
||||
const resp = await http.post<EnterLevelResponse>(`/v1/levels/${levelId}/enter`);
|
||||
const resp = await http.post<EnterLevelResponse>(
|
||||
`/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<CompleteLevelResponse> {
|
||||
const resp = await http.post<CompleteLevelResponse>(
|
||||
`/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();
|
||||
|
||||
@@ -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` 创建时的顺序排列
|
||||
|
||||
**客户端调用场景**:
|
||||
|
||||
8
src/database/migrations/004_add_level_sort_key.sql
Normal file
8
src/database/migrations/004_add_level_sort_key.sql
Normal file
@@ -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);
|
||||
@@ -28,7 +28,7 @@ export class LevelController {
|
||||
@ApiOperation({
|
||||
summary: '获取已通关关卡列表',
|
||||
description:
|
||||
'返回当前用户所有已通关的关卡,按关卡顺序(sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
|
||||
'返回当前用户所有已通关的关卡,按关卡顺序(sortKey 字节序)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
|
||||
@@ -130,7 +130,7 @@ export class LevelService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户已通关的关卡列表,按关卡顺序(sortOrder)升序返回
|
||||
* 获取用户已通关的关卡列表,按关卡顺序(sortKey 字节序)升序返回
|
||||
*/
|
||||
async getCompletedLevels(userId: string): Promise<CompletedLevelDto[]> {
|
||||
const progressList =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Level>);
|
||||
|
||||
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<Level>);
|
||||
|
||||
const result = await repository.findAllOrdered();
|
||||
|
||||
expect(result.map((level) => level.id)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<Level[]> {
|
||||
return this.repository.find();
|
||||
return this.findAllOrdered();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Level | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
const levels = await this.findAllOrdered();
|
||||
return levels.find((level) => level.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<Level[]> {
|
||||
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<Level[]> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user