From 1d6cd0cdc0fd8db75609cb9c4e9263c0ab8c588e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 13 Apr 2026 09:08:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=88=91=E5=88=9B=E5=BB=BA=E7=9A=84=E5=88=86=E4=BA=AB=E6=8C=91?= =?UTF-8?q?=E6=88=98=E5=88=97=E8=A1=A8=E4=BB=A5=E5=8F=8A=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 42 ++++ docs/api/share-challenge-api.md | 234 +++++++++++++----- src/modules/share/dto/share-response.dto.ts | 34 +++ .../repositories/share-config.repository.ts | 7 + .../share-level-progress.repository.ts | 34 ++- .../share-participant.repository.ts | 27 ++ src/modules/share/share.controller.spec.ts | 30 +++ src/modules/share/share.controller.ts | 18 +- src/modules/share/share.service.spec.ts | 163 ++++++++++-- src/modules/share/share.service.ts | 62 ++++- 10 files changed, 569 insertions(+), 82 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..66b95a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# 仓库协作指南 + +## 项目结构与模块组织 + +本仓库是 MemeMind 的 NestJS 后端服务。应用代码位于 `src/`。通用守卫、过滤器、装饰器和基础 DTO 位于 `src/common/`。运行时配置和 TypeORM 配置位于 `src/config/` 与 `src/database/`。业务代码按领域划分在 `src/modules/` 下,包括 `auth/`、`level/`、`share/`、`user/` 和 `game-config/`。新增代码应放入对应模块,并遵循 Nest 的常规结构:`*.controller.ts`、`*.service.ts`、`*.module.ts`,以及按需补充 `dto/`、`entities/`、`repositories/`。单元测试与源码相邻,命名为 `*.spec.ts`;端到端测试位于 `test/`。 + +## 构建、测试与开发命令 + +使用 `pnpm`,仓库已包含 `pnpm-lock.yaml`。 + +- `pnpm install`:安装依赖。 +- `pnpm run start:dev`:以 watch 模式启动本地开发服务。 +- `pnpm run build`:将 TypeScript 编译到 `dist/`。 +- `pnpm run start:prod`:从 `dist/main` 启动生产构建。 +- `pnpm run lint`:运行 ESLint 并自动修复可修复问题。 +- `pnpm run format`:使用 Prettier 格式化 `src/` 与 `test/`。 +- `pnpm run test`、`pnpm run test:cov`、`pnpm run test:e2e`:运行单测、覆盖率测试和 e2e 测试。 + +## 编码风格与命名规范 + +使用严格类型的 TypeScript;除非处于明确的边界场景,否则避免新增 `any`。Prettier 约束为单引号和尾随逗号。遵循现有的 2 空格缩进和 NestJS 命名习惯:类使用 `PascalCase`,方法与变量使用 `camelCase`,目录使用 kebab-case,DTO 文件名要具备明确语义,例如 `wx-login.dto.ts` 或 `share-response.dto.ts`。 + +## 测试规范 + +单元测试和 e2e 测试均使用 Jest。单元测试文件命名为 `*.spec.ts`,并与被测源码放在一起。只要 controller 契约、service 逻辑、repository 行为或鉴权流程发生变化,就需要新增或更新测试。提交 PR 前应运行 `pnpm run test:cov`,覆盖率结果输出到 `coverage/`。 + +## 接口文档规范 + +只要接口发生改动,必须同步更新 `docs/api/` 目录中的对应文档,确保客户端可以直接使用该目录下的文档进行联调。 + +- 新增接口:在对应模块文档中新增接口章节,并补充请求参数、响应结构、示例和调用场景。 +- 修改接口:同步更新字段、鉴权方式、错误码、业务规则和示例。 +- 删除或废弃接口:在文档中明确标记,并说明客户端迁移方式。 +- 如果一次改动涉及多个模块接口,相关文档都要一并更新,不能只改代码不改文档。 + +## 提交与合并请求规范 + +最近的提交历史使用 Conventional Commit 前缀,例如 `feat:`、`perf:`、`refactor:`,也包含带作用域的形式,例如 `feat(share): ...`。请保持提交聚焦,并沿用同样格式。PR 需要说明行为变更、配置或迁移影响、关联的 issue(如果有),并在请求或响应结构发生变化时附上 API 示例。 + +## 配置与部署说明 + +环境变量在 `src/config/env.validation.ts` 中做校验。敏感信息应保存在 `.env.local` 或部署环境专用配置中,不能写入源码。无论本地还是生产环境,API 统一暴露在 `/api` 下,Swagger 暴露在 `/api/docs`。`pnpm run deploy` 会调用 `deploy.sh`、`rsync` 和 PM2,因此执行前需要先检查其中的服务器相关配置。 diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index 9790c30..7a2fa9e 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -23,6 +23,7 @@ 1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制 2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间 3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过 +4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名 --- @@ -103,11 +104,11 @@ Authorization: Bearer ```typescript { - token: string; // JWT 访问令牌,有效期 7 天 + token: string; // JWT 访问令牌,有效期 7 天 user: { - id: string; // 用户 ID - nickname: string | null; // 用户昵称(微信昵称) - stamina: number; // 当前体力值 + id: string; // 用户 ID + nickname: string | null; // 用户昵称(微信昵称) + stamina: number; // 当前体力值 } } ``` @@ -131,6 +132,7 @@ Authorization: Bearer ``` **客户端调用时机**: + - 用户首次进入游戏时调用 - 小游戏冷启动时调用(建议缓存 token) @@ -145,6 +147,7 @@ Authorization: Bearer **是否需要认证**:是(JWT Bearer Token) **请求头**: + ``` Authorization: Bearer Content-Type: application/json @@ -154,8 +157,9 @@ Content-Type: application/json ```json { - "title": "我的挑战", // 分享标题,不超过 100 字符 - "levelIds": [ // 恰好 6 个关卡 ID + "title": "我的挑战", // 分享标题,不超过 100 字符 + "levelIds": [ + // 恰好 6 个关卡 ID "level_id_1", "level_id_2", "level_id_3", @@ -170,8 +174,8 @@ Content-Type: application/json ```typescript { - shareCode: string; // 8 位分享码,用于分享和加入 - title: string; // 分享标题 + shareCode: string; // 8 位分享码,用于分享和加入 + title: string; // 分享标题 levelCount: number; // 关卡数量(固定为 6) } ``` @@ -192,11 +196,13 @@ Content-Type: application/json ``` **分享码生成规则**: + - 使用 nanoid 生成 8 位字符 - 字符集为 a-z, A-Z, 0-9 - 发生碰撞时最多重试 3 次 **客户端调用场景**: + - 用户点击「分享挑战」按钮时调用 - 用户选择 6 个关卡后,生成分享码 - 将分享码拼接为分享链接或二维码 @@ -213,9 +219,9 @@ Content-Type: application/json **路径参数**: -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| code | string | 是 | 分享码(8 位) | +| 参数 | 类型 | 必填 | 说明 | +| ---- | ------ | ---- | -------------- | +| code | string | 是 | 分享码(8 位) | **响应数据**: @@ -265,16 +271,101 @@ Content-Type: application/json ``` **特殊逻辑**: + - 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录 - 返回的关卡列表按 `levelIds` 创建时的顺序排列 **客户端调用场景**: + - 用户通过分享码/链接进入游戏时调用 - 解析 URL 参数中的分享码,调用此接口获取关卡数据 --- -### 4. 上报单关进度 +### 4. 获取我创建的分享挑战 + +获取当前登录用户创建过的分享挑战列表。 + +**接口地址**:`GET /api/v1/share/created` + +**是否需要认证**:是(JWT Bearer Token) + +**请求头**: + +``` +Authorization: Bearer +``` + +**响应数据**: + +```typescript +{ + items: [ + { + id: string; // 分享挑战 ID + shareCode: string; // 分享码 + title: string; // 分享标题 + levelCount: number; // 关卡数量 + participantCount: number; // 参与挑战人数 + userRank: number | null; // 当前用户在该挑战中的排名;未完成全部关卡时为 null + createdAt: string; // 创建时间,ISO 8601 字符串 + } + ] +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": "share_001", + "shareCode": "abc12345", + "title": "我的挑战", + "levelCount": 6, + "participantCount": 8, + "userRank": 2, + "createdAt": "2026-04-13T10:00:00.000Z" + }, + { + "id": "share_002", + "shareCode": "xyz67890", + "title": "速度挑战", + "levelCount": 6, + "participantCount": 1, + "userRank": null, + "createdAt": "2026-04-12T09:00:00.000Z" + } + ] + }, + "message": null, + "timestamp": "2026-04-13T12:00:00.000Z" +} +``` + +**排名规则**: + +1. 只有完成该分享挑战全部关卡的用户才会进入排名。 +2. 排名按通关总耗时升序计算,总耗时越短排名越高。 +3. 当总耗时相同时,服务端会按 `participantId` 做稳定排序,保证返回顺序可重复。 +4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未完成全部关卡,则返回 `null`。 + +**参与人数统计规则**: + +- 统计 `share_participants` 中该挑战的参与者数量。 +- 创建者本人在调用创建接口时不会自动写入参与记录;只有真正以参与者身份产生挑战进度后,才可能出现在排名内。 + +**客户端调用场景**: + +- 用户进入「我发起的挑战」页面时调用。 +- 用于展示每个分享挑战的传播效果和本人当前成绩。 + +--- + +### 5. 上报单关进度 用户在分享挑战中完成单关后,上报进度。 @@ -286,29 +377,29 @@ Content-Type: application/json ```json { - "shareCode": "abc12345", // 分享码 - "levelId": "level_001", // 关卡 ID - "passed": true, // 是否通过(true/false) - "timeSpent": 30 // 通关时间(秒) + "shareCode": "abc12345", // 分享码 + "levelId": "level_001", // 关卡 ID + "passed": true, // 是否通过(true/false) + "timeSpent": 30 // 通关时间(秒) } ``` **字段说明**: -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| shareCode | string | 是 | 分享码 | -| levelId | string | 是 | 关卡 ID | -| passed | boolean | 是 | 是否通过 | -| timeSpent | number | 是 | 通关时间(秒),最小值为 0 | +| 字段 | 类型 | 必填 | 说明 | +| --------- | ------- | ---- | -------------------------- | +| shareCode | string | 是 | 分享码 | +| levelId | string | 是 | 关卡 ID | +| passed | boolean | 是 | 是否通过 | +| timeSpent | number | 是 | 通关时间(秒),最小值为 0 | **响应数据**: ```typescript { - passed: boolean; // 是否通过 - timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制 - withinTimeLimit: boolean; // 是否在时间限制内通过 + passed: boolean; // 是否通过 + timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制 + withinTimeLimit: boolean; // 是否在时间限制内通过 } ``` @@ -337,6 +428,7 @@ Content-Type: application/json - 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true` **客户端调用场景**: + - 用户完成一个关卡后调用 - 无论通关还是失败都需要调用 - 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限 @@ -345,16 +437,16 @@ Content-Type: application/json ## 错误码说明 -| 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 | 生成分享码失败,请重试 | 服务器生成分享码失败 | +| 401 | 未提供访问令牌 | 请求头缺少 Authorization | +| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 | +| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 | +| 404 | 关卡不存在 | levelId 不存在于 levels 表 | +| 500 | Internal server error | 服务器内部错误 | --- @@ -391,16 +483,16 @@ Content-Type: application/json // 建议在客户端维护以下状态 interface ShareChallengeState { - isInChallenge: boolean; // 是否正在参与分享挑战 - shareCode: string | null; // 当前分享码 - currentLevelIndex: number; // 当前关卡索引(0-5) - levels: LevelData[]; // 关卡数据 + isInChallenge: boolean; // 是否正在参与分享挑战 + shareCode: string | null; // 当前分享码 + currentLevelIndex: number; // 当前关卡索引(0-5) + levels: LevelData[]; // 关卡数据 progress: { [levelId: string]: { passed: boolean; timeSpent: number; withinTimeLimit: boolean; - } + }; }; } ``` @@ -445,7 +537,7 @@ export class HttpManager { async request( method: 'GET' | 'POST', url: string, - body?: object + body?: object, ): Promise> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -504,7 +596,7 @@ async function wxLogin() { const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => { wx.login({ success: (res) => resolve({ code: res.code }), - fail: reject + fail: reject, }); }); @@ -514,7 +606,7 @@ async function wxLogin() { token: string; user: { id: string; nickname: string | null; stamina: number }; }>('/v1/auth/wx-login', { - code: wxLoginRes.code + code: wxLoginRes.code, }); if (response.success && response.data) { @@ -543,7 +635,10 @@ interface CreateShareResponse { levelCount: number; } -async function createShare(title: string, levelIds: string[]): Promise { +async function createShare( + title: string, + levelIds: string[], +): Promise { // 确保已登录 if (!httpManager.getToken()) { await wxLogin(); @@ -551,7 +646,7 @@ async function createShare(title: string, levelIds: string[]): Promise('/v1/share', { title, - levelIds + levelIds, }); if (response.success && response.data) { @@ -563,7 +658,14 @@ async function createShare(title: string, levelIds: string[]): Promise { } const response = await httpManager.post( - `/v1/share/${shareCode}/join` + `/v1/share/${shareCode}/join`, ); if (response.success && response.data) { @@ -627,7 +729,7 @@ async function reportLevelProgress( shareCode: string, levelId: string, passed: boolean, - timeSpent: number + timeSpent: number, ): Promise { // 确保已登录 if (!httpManager.getToken()) { @@ -640,8 +742,8 @@ async function reportLevelProgress( shareCode, levelId, passed, - timeSpent - } + timeSpent, + }, ); if (response.success && response.data) { @@ -655,10 +757,17 @@ async function reportLevelProgress( // 使用示例 async function onLevelComplete(levelId: string, timeSpent: number) { const passed = true; // 根据游戏逻辑判断是否通过 - const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent); + const result = await reportLevelProgress( + this.shareCode, + levelId, + passed, + timeSpent, + ); if (result.passed) { - console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`); + console.log( + `通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`, + ); if (result.timeLimit) { console.log(`本关时间限制: ${result.timeLimit}秒`); } @@ -708,22 +817,23 @@ 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) | +| imageUrl | string | 关卡图片 URL | +| answer | string | 正确答案 | +| hint1 | string \| null | 第 1 个提示 | +| hint2 | string \| null | 第 2 个提示 | +| hint3 | string \| null | 第 3 个提示 | +| sortOrder | number | 排序顺序 | ### 关卡时间限制说明 `timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。 如果需要在前端判断时间限制: + 1. 用户完成关卡后调用 `reportLevelProgress` 2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制 3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成 diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts index 43e427f..61d64bb 100644 --- a/src/modules/share/dto/share-response.dto.ts +++ b/src/modules/share/dto/share-response.dto.ts @@ -47,3 +47,37 @@ export class JoinShareResponseDto { @ApiProperty({ description: '关卡列表', type: [ShareLevelDto] }) levels!: ShareLevelDto[]; } + +export class CreatedShareItemDto { + @ApiProperty({ description: '分享 ID' }) + id!: string; + + @ApiProperty({ description: '分享码' }) + shareCode!: string; + + @ApiProperty({ description: '分享标题' }) + title!: string; + + @ApiProperty({ description: '关卡数量' }) + levelCount!: number; + + @ApiProperty({ description: '参与挑战人数' }) + participantCount!: number; + + @ApiProperty({ + description: '当前用户在该挑战中的排名,未完成全部关卡时为 null', + nullable: true, + }) + userRank!: number | null; + + @ApiProperty({ description: '创建时间' }) + createdAt!: string; +} + +export class CreatedShareListResponseDto { + @ApiProperty({ + description: '当前用户创建的分享挑战列表', + type: [CreatedShareItemDto], + }) + items!: CreatedShareItemDto[]; +} diff --git a/src/modules/share/repositories/share-config.repository.ts b/src/modules/share/repositories/share-config.repository.ts index b72ccf8..aac3203 100644 --- a/src/modules/share/repositories/share-config.repository.ts +++ b/src/modules/share/repositories/share-config.repository.ts @@ -18,4 +18,11 @@ export class ShareConfigRepository { async findByShareCode(code: string): Promise { return this.repository.findOne({ where: { shareCode: code } }); } + + async findBySharerId(sharerId: string): Promise { + return this.repository.find({ + where: { sharerId }, + order: { createdAt: 'DESC' }, + }); + } } diff --git a/src/modules/share/repositories/share-level-progress.repository.ts b/src/modules/share/repositories/share-level-progress.repository.ts index 63fcf25..46a710a 100644 --- a/src/modules/share/repositories/share-level-progress.repository.ts +++ b/src/modules/share/repositories/share-level-progress.repository.ts @@ -3,6 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ShareLevelProgress } from '../entities/share-level-progress.entity'; +export type ShareChallengeRankingRow = { + shareConfigId: string; + participantId: string; + totalTimeSpent: string; + passedLevelCount: string; +}; + @Injectable() export class ShareLevelProgressRepository { constructor( @@ -10,7 +17,9 @@ export class ShareLevelProgressRepository { private readonly repository: Repository, ) {} - async findByParticipantId(participantId: string): Promise { + async findByParticipantId( + participantId: string, + ): Promise { return this.repository.find({ where: { participantId } }); } @@ -21,6 +30,29 @@ export class ShareLevelProgressRepository { return this.repository.findOne({ where: { participantId, levelId } }); } + async summarizeByShareConfigIds( + shareConfigIds: string[], + ): Promise { + if (shareConfigIds.length === 0) { + return []; + } + + return this.repository + .createQueryBuilder('progress') + .innerJoin('progress.participant', 'participant') + .select('participant.shareConfigId', 'shareConfigId') + .addSelect('participant.participantId', 'participantId') + .addSelect('SUM(progress.timeSpent)', 'totalTimeSpent') + .addSelect('COUNT(DISTINCT progress.levelId)', 'passedLevelCount') + .where('participant.shareConfigId IN (:...shareConfigIds)', { + shareConfigIds, + }) + .andWhere('progress.passed = :passed', { passed: true }) + .groupBy('participant.shareConfigId') + .addGroupBy('participant.participantId') + .getRawMany(); + } + create(data: Partial): ShareLevelProgress { return this.repository.create(data); } diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts index b3b017d..f7f0df1 100644 --- a/src/modules/share/repositories/share-participant.repository.ts +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -3,6 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ShareParticipant } from '../entities/share-participant.entity'; +type ShareParticipantCountRow = { + shareConfigId: string; + participantCount: string; +}; + @Injectable() export class ShareParticipantRepository { constructor( @@ -28,6 +33,28 @@ export class ShareParticipantRepository { return this.repository.count({ where: { shareConfigId } }); } + async countByShareConfigIds( + shareConfigIds: string[], + ): Promise> { + if (shareConfigIds.length === 0) { + return new Map(); + } + + const rows = await this.repository + .createQueryBuilder('participant') + .select('participant.shareConfigId', 'shareConfigId') + .addSelect('COUNT(participant.id)', 'participantCount') + .where('participant.shareConfigId IN (:...shareConfigIds)', { + shareConfigIds, + }) + .groupBy('participant.shareConfigId') + .getRawMany(); + + return new Map( + rows.map((row) => [row.shareConfigId, Number(row.participantCount)]), + ); + } + async findByShareConfigAndParticipant( shareConfigId: string, participantId: string, diff --git a/src/modules/share/share.controller.spec.ts b/src/modules/share/share.controller.spec.ts index 7df8638..679639a 100644 --- a/src/modules/share/share.controller.spec.ts +++ b/src/modules/share/share.controller.spec.ts @@ -15,6 +15,7 @@ describe('ShareController', () => { const mockShareService = { createShare: jest.fn(), + getCreatedShares: jest.fn(), joinShare: jest.fn(), reportLevelProgress: jest.fn(), }; @@ -60,6 +61,35 @@ describe('ShareController', () => { }); }); + describe('getCreatedShares', () => { + it('should return success response with created share list', async () => { + const createdSharesResponse = { + items: [ + { + id: 'share-uuid-1', + shareCode: 'ABCD1234', + title: '我的挑战', + levelCount: 6, + participantCount: 8, + userRank: 2, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }; + mockShareService.getCreatedShares.mockResolvedValue( + createdSharesResponse, + ); + + const result = await controller.getCreatedShares(mockUser); + + expect(result.success).toBe(true); + expect(result.data).toEqual(createdSharesResponse); + expect(mockShareService.getCreatedShares).toHaveBeenCalledWith( + 'user-uuid-1', + ); + }); + }); + describe('joinShare', () => { it('should return success response with share levels', async () => { const joinResponse = { diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts index 33b4497..b98f621 100644 --- a/src/modules/share/share.controller.ts +++ b/src/modules/share/share.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -9,6 +9,7 @@ import { ShareService } from './share.service'; import { CreateShareDto } from './dto/create-share.dto'; import { CreateShareResponseDto, + CreatedShareListResponseDto, JoinShareResponseDto, } from './dto/share-response.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; @@ -23,6 +24,21 @@ import { ReportLevelProgressResponseDto } from './dto/share-level-progress-respo export class ShareController { constructor(private readonly shareService: ShareService) {} + @Get('created') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '获取我创建的分享挑战', + description: '返回当前用户创建过的分享挑战,并统计参与人数和用户排名', + }) + @ApiResponse({ status: 200, description: '成功' }) + async getCreatedShares( + @CurrentUser() user: JwtPayload, + ): Promise> { + const data = await this.shareService.getCreatedShares(user.sub); + return ApiResponseDto.success(data); + } + @Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index 671b4d7..59b09a7 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -1,8 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { - NotFoundException, - BadRequestException, -} from '@nestjs/common'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; import { ShareService } from './share.service'; import { ShareConfigRepository } from './repositories/share-config.repository'; import { ShareParticipantRepository } from './repositories/share-participant.repository'; @@ -12,6 +9,7 @@ import { Level } from '../wechat-game/entities/level.entity'; import { ShareConfig } from './entities/share-config.entity'; import { ShareParticipant } from './entities/share-participant.entity'; import { ShareLevelProgress } from './entities/share-level-progress.entity'; +import { User } from '../auth/entities/user.entity'; // Mock nanoid to return predictable values jest.mock('nanoid', () => ({ @@ -40,7 +38,7 @@ describe('ShareService', () => { title: '我的挑战', sharerId: 'user-uuid-1', levelIds: mockLevels.map((l) => l.id), - sharer: {} as any, + sharer: {} as User, participants: [], createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), @@ -50,19 +48,21 @@ describe('ShareService', () => { id: 'participant-uuid-1', shareConfigId: 'share-uuid-1', participantId: 'user-uuid-2', - shareConfig: {} as any, - participant: {} as any, + shareConfig: {} as ShareConfig, + participant: {} as User, createdAt: new Date('2026-01-01'), }; const mockShareConfigRepository = { create: jest.fn(), findByShareCode: jest.fn(), + findBySharerId: jest.fn(), }; const mockShareParticipantRepository = { addParticipant: jest.fn(), countByShareConfigId: jest.fn(), + countByShareConfigIds: jest.fn(), findByShareConfigAndParticipant: jest.fn(), create: jest.fn(), save: jest.fn(), @@ -70,6 +70,7 @@ describe('ShareService', () => { const mockShareLevelProgressRepository = { findByParticipantAndLevel: jest.fn(), + summarizeByShareConfigIds: jest.fn(), create: jest.fn(), save: jest.fn(), }; @@ -106,7 +107,14 @@ describe('ShareService', () => { describe('createShare', () => { const createDto = { title: '我的挑战', - levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'], + levelIds: [ + 'level-1', + 'level-2', + 'level-3', + 'level-4', + 'level-5', + 'level-6', + ], }; it('should create a share with valid 6 unique levels', async () => { @@ -124,7 +132,14 @@ describe('ShareService', () => { it('should throw BadRequestException when level IDs have duplicates', async () => { const duplicateDto = { title: '测试', - levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'], + levelIds: [ + 'level-1', + 'level-1', + 'level-2', + 'level-3', + 'level-4', + 'level-5', + ], }; await expect( @@ -158,7 +173,9 @@ describe('ShareService', () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfig, ); - mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined); + mockShareParticipantRepository.addParticipant.mockResolvedValue( + undefined, + ); mockLevelRepository.findByIds.mockResolvedValue(mockLevels); const result = await service.joinShare('user-uuid-2', 'ABCD1234'); @@ -168,10 +185,9 @@ describe('ShareService', () => { expect(result.levels).toHaveLength(6); expect(result.levels[0].level).toBe(1); expect(result.levels[0].id).toBe('level-1'); - expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith( - 'share-uuid-1', - 'user-uuid-2', - ); + expect( + mockShareParticipantRepository.addParticipant, + ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2'); }); it('should not add participant when user is the sharer', async () => { @@ -191,9 +207,122 @@ describe('ShareService', () => { it('should throw NotFoundException when share code not found', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue(null); - await expect( - service.joinShare('user-uuid-2', 'INVALID'), - ).rejects.toThrow(NotFoundException); + await expect(service.joinShare('user-uuid-2', 'INVALID')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getCreatedShares', () => { + it('should return empty list when user has not created any share', async () => { + mockShareConfigRepository.findBySharerId.mockResolvedValue([]); + + const result = await service.getCreatedShares('user-uuid-1'); + + expect(result).toEqual({ items: [] }); + expect( + mockShareParticipantRepository.countByShareConfigIds, + ).not.toHaveBeenCalled(); + expect( + mockShareLevelProgressRepository.summarizeByShareConfigIds, + ).not.toHaveBeenCalled(); + }); + + it('should return created shares with participant count and user rank', async () => { + const otherShareConfig: ShareConfig = { + ...mockShareConfig, + id: 'share-uuid-2', + shareCode: 'WXYZ5678', + title: '第二个挑战', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }; + + mockShareConfigRepository.findBySharerId.mockResolvedValue([ + otherShareConfig, + mockShareConfig, + ]); + mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue( + new Map([ + ['share-uuid-1', 3], + ['share-uuid-2', 1], + ]), + ); + mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue( + [ + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-1', + totalTimeSpent: '120', + passedLevelCount: '6', + }, + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + totalTimeSpent: '100', + passedLevelCount: '6', + }, + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-3', + totalTimeSpent: '200', + passedLevelCount: '5', + }, + ], + ); + + const result = await service.getCreatedShares('user-uuid-1'); + + expect(result).toEqual({ + items: [ + { + id: 'share-uuid-2', + shareCode: 'WXYZ5678', + title: '第二个挑战', + levelCount: 6, + participantCount: 1, + userRank: null, + createdAt: '2026-01-02T00:00:00.000Z', + }, + { + id: 'share-uuid-1', + shareCode: 'ABCD1234', + title: '我的挑战', + levelCount: 6, + participantCount: 3, + userRank: 2, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + + it('should use participantId as deterministic tie breaker for rank', async () => { + mockShareConfigRepository.findBySharerId.mockResolvedValue([ + mockShareConfig, + ]); + mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue( + new Map([['share-uuid-1', 2]]), + ); + mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue( + [ + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-2', + totalTimeSpent: '120', + passedLevelCount: '6', + }, + { + shareConfigId: 'share-uuid-1', + participantId: 'user-uuid-1', + totalTimeSpent: '120', + passedLevelCount: '6', + }, + ], + ); + + const result = await service.getCreatedShares('user-uuid-1'); + + expect(result.items[0].userRank).toBe(1); }); }); diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index dbf5ba7..e59894e 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -12,6 +12,7 @@ import { CreateShareDto } from './dto/create-share.dto'; import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; import { CreateShareResponseDto, + CreatedShareListResponseDto, JoinShareResponseDto, ShareLevelDto, } from './dto/share-response.dto'; @@ -110,6 +111,62 @@ export class ShareService { }; } + async getCreatedShares(userId: string): Promise { + const configs = await this.shareConfigRepository.findBySharerId(userId); + if (configs.length === 0) { + return { items: [] }; + } + + const shareConfigIds = configs.map((config) => config.id); + const [participantCountMap, rankingRows] = await Promise.all([ + this.shareParticipantRepository.countByShareConfigIds(shareConfigIds), + this.shareLevelProgressRepository.summarizeByShareConfigIds( + 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); + }) + .map((row) => row.participantId); + + rankingsByShareConfigId.set(config.id, completedRankings); + } + + return { + items: configs.map((config) => { + const rankings = rankingsByShareConfigId.get(config.id) ?? []; + const rankingIndex = rankings.findIndex( + (participantId) => participantId === userId, + ); + + return { + id: config.id, + shareCode: config.shareCode, + title: config.title, + levelCount: config.levelIds.length, + participantCount: participantCountMap.get(config.id) ?? 0, + userRank: rankingIndex >= 0 ? rankingIndex + 1 : null, + createdAt: config.createdAt.toISOString(), + }; + }), + }; + } + async reportLevelProgress( userId: string, dto: ReportLevelProgressDto, @@ -153,7 +210,10 @@ export class ShareService { return { passed: true, timeLimit: level.timeLimit, - withinTimeLimit: this.isWithinTimeLimit(level.timeLimit, progress.timeSpent), + withinTimeLimit: this.isWithinTimeLimit( + level.timeLimit, + progress.timeSpent, + ), }; }