feat: 支持获取我创建的分享挑战列表以及详情数据

This commit is contained in:
richarjiang
2026-04-13 09:08:11 +08:00
parent fe2c13258e
commit 1d6cd0cdc0
10 changed files with 569 additions and 82 deletions

42
AGENTS.md Normal file
View File

@@ -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-caseDTO 文件名要具备明确语义,例如 `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因此执行前需要先检查其中的服务器相关配置。

View File

@@ -23,6 +23,7 @@
1. **关卡时间限制**`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制 1. **关卡时间限制**`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
2. **单关进度上报**`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间 2. **单关进度上报**`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
3. **进度查询**`reportLevelProgress` 返回是否在时间限制内通过 3. **进度查询**`reportLevelProgress` 返回是否在时间限制内通过
4. **我创建的挑战列表**`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
--- ---
@@ -131,6 +132,7 @@ Authorization: Bearer <token>
``` ```
**客户端调用时机** **客户端调用时机**
- 用户首次进入游戏时调用 - 用户首次进入游戏时调用
- 小游戏冷启动时调用(建议缓存 token - 小游戏冷启动时调用(建议缓存 token
@@ -145,6 +147,7 @@ Authorization: Bearer <token>
**是否需要认证**JWT Bearer Token **是否需要认证**JWT Bearer Token
**请求头** **请求头**
``` ```
Authorization: Bearer <token> Authorization: Bearer <token>
Content-Type: application/json Content-Type: application/json
@@ -155,7 +158,8 @@ Content-Type: application/json
```json ```json
{ {
"title": "我的挑战", // 分享标题,不超过 100 字符 "title": "我的挑战", // 分享标题,不超过 100 字符
"levelIds": [ // 恰好 6 个关卡 ID "levelIds": [
// 恰好 6 个关卡 ID
"level_id_1", "level_id_1",
"level_id_2", "level_id_2",
"level_id_3", "level_id_3",
@@ -192,11 +196,13 @@ Content-Type: application/json
``` ```
**分享码生成规则** **分享码生成规则**
- 使用 nanoid 生成 8 位字符 - 使用 nanoid 生成 8 位字符
- 字符集为 a-z, A-Z, 0-9 - 字符集为 a-z, A-Z, 0-9
- 发生碰撞时最多重试 3 次 - 发生碰撞时最多重试 3 次
**客户端调用场景** **客户端调用场景**
- 用户点击「分享挑战」按钮时调用 - 用户点击「分享挑战」按钮时调用
- 用户选择 6 个关卡后,生成分享码 - 用户选择 6 个关卡后,生成分享码
- 将分享码拼接为分享链接或二维码 - 将分享码拼接为分享链接或二维码
@@ -214,7 +220,7 @@ Content-Type: application/json
**路径参数** **路径参数**
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| | ---- | ------ | ---- | -------------- |
| code | string | 是 | 分享码8 位) | | code | string | 是 | 分享码8 位) |
**响应数据** **响应数据**
@@ -265,16 +271,101 @@ Content-Type: application/json
``` ```
**特殊逻辑** **特殊逻辑**
- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录 - 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录
- 返回的关卡列表按 `levelIds` 创建时的顺序排列 - 返回的关卡列表按 `levelIds` 创建时的顺序排列
**客户端调用场景** **客户端调用场景**
- 用户通过分享码/链接进入游戏时调用 - 用户通过分享码/链接进入游戏时调用
- 解析 URL 参数中的分享码,调用此接口获取关卡数据 - 解析 URL 参数中的分享码,调用此接口获取关卡数据
--- ---
### 4. 上报单关进度 ### 4. 获取我创建的分享挑战
获取当前登录用户创建过的分享挑战列表。
**接口地址**`GET /api/v1/share/created`
**是否需要认证**JWT Bearer Token
**请求头**
```
Authorization: Bearer <token>
```
**响应数据**
```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. 上报单关进度
用户在分享挑战中完成单关后,上报进度。 用户在分享挑战中完成单关后,上报进度。
@@ -296,7 +387,7 @@ Content-Type: application/json
**字段说明** **字段说明**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| | --------- | ------- | ---- | -------------------------- |
| shareCode | string | 是 | 分享码 | | shareCode | string | 是 | 分享码 |
| levelId | string | 是 | 关卡 ID | | levelId | string | 是 | 关卡 ID |
| passed | boolean | 是 | 是否通过 | | passed | boolean | 是 | 是否通过 |
@@ -337,6 +428,7 @@ Content-Type: application/json
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit``withinTimeLimit` 才为 `true` - 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit``withinTimeLimit` 才为 `true`
**客户端调用场景** **客户端调用场景**
- 用户完成一个关卡后调用 - 用户完成一个关卡后调用
- 无论通关还是失败都需要调用 - 无论通关还是失败都需要调用
- 失败时 `passed=false``timeSpent` 可以传入实际用时或关卡时间上限 - 失败时 `passed=false``timeSpent` 可以传入实际用时或关卡时间上限
@@ -346,7 +438,7 @@ Content-Type: application/json
## 错误码说明 ## 错误码说明
| HTTP Status | message | 说明 | | HTTP Status | message | 说明 |
|-------------|---------|------| | ----------- | ------------------------------------- | ---------------------------- |
| 400 | 关卡ID不能重复需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 | | 400 | 关卡ID不能重复需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 | | 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 | | 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
@@ -400,7 +492,7 @@ interface ShareChallengeState {
passed: boolean; passed: boolean;
timeSpent: number; timeSpent: number;
withinTimeLimit: boolean; withinTimeLimit: boolean;
} };
}; };
} }
``` ```
@@ -445,7 +537,7 @@ export class HttpManager {
async request<T>( async request<T>(
method: 'GET' | 'POST', method: 'GET' | 'POST',
url: string, url: string,
body?: object body?: object,
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -504,7 +596,7 @@ async function wxLogin() {
const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => { const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => {
wx.login({ wx.login({
success: (res) => resolve({ code: res.code }), success: (res) => resolve({ code: res.code }),
fail: reject fail: reject,
}); });
}); });
@@ -514,7 +606,7 @@ async function wxLogin() {
token: string; token: string;
user: { id: string; nickname: string | null; stamina: number }; user: { id: string; nickname: string | null; stamina: number };
}>('/v1/auth/wx-login', { }>('/v1/auth/wx-login', {
code: wxLoginRes.code code: wxLoginRes.code,
}); });
if (response.success && response.data) { if (response.success && response.data) {
@@ -543,7 +635,10 @@ interface CreateShareResponse {
levelCount: number; levelCount: number;
} }
async function createShare(title: string, levelIds: string[]): Promise<CreateShareResponse> { async function createShare(
title: string,
levelIds: string[],
): Promise<CreateShareResponse> {
// 确保已登录 // 确保已登录
if (!httpManager.getToken()) { if (!httpManager.getToken()) {
await wxLogin(); await wxLogin();
@@ -551,7 +646,7 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
const response = await httpManager.post<CreateShareResponse>('/v1/share', { const response = await httpManager.post<CreateShareResponse>('/v1/share', {
title, title,
levelIds levelIds,
}); });
if (response.success && response.data) { if (response.success && response.data) {
@@ -563,7 +658,14 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
} }
// 使用示例 // 使用示例
const levelIds = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6']; const levelIds = [
'level_1',
'level_2',
'level_3',
'level_4',
'level_5',
'level_6',
];
const share = await createShare('一起来挑战!', levelIds); const share = await createShare('一起来挑战!', levelIds);
console.log('分享码:', share.shareCode); console.log('分享码:', share.shareCode);
// 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}` // 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}`
@@ -596,7 +698,7 @@ async function joinShare(shareCode: string): Promise<JoinShareResponse> {
} }
const response = await httpManager.post<JoinShareResponse>( const response = await httpManager.post<JoinShareResponse>(
`/v1/share/${shareCode}/join` `/v1/share/${shareCode}/join`,
); );
if (response.success && response.data) { if (response.success && response.data) {
@@ -627,7 +729,7 @@ async function reportLevelProgress(
shareCode: string, shareCode: string,
levelId: string, levelId: string,
passed: boolean, passed: boolean,
timeSpent: number timeSpent: number,
): Promise<ReportProgressResponse> { ): Promise<ReportProgressResponse> {
// 确保已登录 // 确保已登录
if (!httpManager.getToken()) { if (!httpManager.getToken()) {
@@ -640,8 +742,8 @@ async function reportLevelProgress(
shareCode, shareCode,
levelId, levelId,
passed, passed,
timeSpent timeSpent,
} },
); );
if (response.success && response.data) { if (response.success && response.data) {
@@ -655,10 +757,17 @@ async function reportLevelProgress(
// 使用示例 // 使用示例
async function onLevelComplete(levelId: string, timeSpent: number) { async function onLevelComplete(levelId: string, timeSpent: number) {
const passed = true; // 根据游戏逻辑判断是否通过 const passed = true; // 根据游戏逻辑判断是否通过
const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent); const result = await reportLevelProgress(
this.shareCode,
levelId,
passed,
timeSpent,
);
if (result.passed) { if (result.passed) {
console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`); console.log(
`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`,
);
if (result.timeLimit) { if (result.timeLimit) {
console.log(`本关时间限制: ${result.timeLimit}`); console.log(`本关时间限制: ${result.timeLimit}`);
} }
@@ -709,7 +818,7 @@ export class GameEntry extends Component {
### LevelData 完整字段 ### LevelData 完整字段
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| | --------- | -------------- | --------------- |
| id | string | 关卡唯一标识 | | id | string | 关卡唯一标识 |
| level | number | 关卡序号1-6 | | level | number | 关卡序号1-6 |
| imageUrl | string | 关卡图片 URL | | imageUrl | string | 关卡图片 URL |
@@ -724,6 +833,7 @@ export class GameEntry extends Component {
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。 `timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
如果需要在前端判断时间限制: 如果需要在前端判断时间限制:
1. 用户完成关卡后调用 `reportLevelProgress` 1. 用户完成关卡后调用 `reportLevelProgress`
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制 2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成 3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成

View File

@@ -47,3 +47,37 @@ export class JoinShareResponseDto {
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] }) @ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
levels!: 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[];
}

View File

@@ -18,4 +18,11 @@ export class ShareConfigRepository {
async findByShareCode(code: string): Promise<ShareConfig | null> { async findByShareCode(code: string): Promise<ShareConfig | null> {
return this.repository.findOne({ where: { shareCode: code } }); return this.repository.findOne({ where: { shareCode: code } });
} }
async findBySharerId(sharerId: string): Promise<ShareConfig[]> {
return this.repository.find({
where: { sharerId },
order: { createdAt: 'DESC' },
});
}
} }

View File

@@ -3,6 +3,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ShareLevelProgress } from '../entities/share-level-progress.entity'; import { ShareLevelProgress } from '../entities/share-level-progress.entity';
export type ShareChallengeRankingRow = {
shareConfigId: string;
participantId: string;
totalTimeSpent: string;
passedLevelCount: string;
};
@Injectable() @Injectable()
export class ShareLevelProgressRepository { export class ShareLevelProgressRepository {
constructor( constructor(
@@ -10,7 +17,9 @@ export class ShareLevelProgressRepository {
private readonly repository: Repository<ShareLevelProgress>, private readonly repository: Repository<ShareLevelProgress>,
) {} ) {}
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> { async findByParticipantId(
participantId: string,
): Promise<ShareLevelProgress[]> {
return this.repository.find({ where: { participantId } }); return this.repository.find({ where: { participantId } });
} }
@@ -21,6 +30,29 @@ export class ShareLevelProgressRepository {
return this.repository.findOne({ where: { participantId, levelId } }); return this.repository.findOne({ where: { participantId, levelId } });
} }
async summarizeByShareConfigIds(
shareConfigIds: string[],
): Promise<ShareChallengeRankingRow[]> {
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<ShareChallengeRankingRow>();
}
create(data: Partial<ShareLevelProgress>): ShareLevelProgress { create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
return this.repository.create(data); return this.repository.create(data);
} }

View File

@@ -3,6 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ShareParticipant } from '../entities/share-participant.entity'; import { ShareParticipant } from '../entities/share-participant.entity';
type ShareParticipantCountRow = {
shareConfigId: string;
participantCount: string;
};
@Injectable() @Injectable()
export class ShareParticipantRepository { export class ShareParticipantRepository {
constructor( constructor(
@@ -28,6 +33,28 @@ export class ShareParticipantRepository {
return this.repository.count({ where: { shareConfigId } }); return this.repository.count({ where: { shareConfigId } });
} }
async countByShareConfigIds(
shareConfigIds: string[],
): Promise<Map<string, number>> {
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<ShareParticipantCountRow>();
return new Map(
rows.map((row) => [row.shareConfigId, Number(row.participantCount)]),
);
}
async findByShareConfigAndParticipant( async findByShareConfigAndParticipant(
shareConfigId: string, shareConfigId: string,
participantId: string, participantId: string,

View File

@@ -15,6 +15,7 @@ describe('ShareController', () => {
const mockShareService = { const mockShareService = {
createShare: jest.fn(), createShare: jest.fn(),
getCreatedShares: jest.fn(),
joinShare: jest.fn(), joinShare: jest.fn(),
reportLevelProgress: 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', () => { describe('joinShare', () => {
it('should return success response with share levels', async () => { it('should return success response with share levels', async () => {
const joinResponse = { const joinResponse = {

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
@@ -9,6 +9,7 @@ import { ShareService } from './share.service';
import { CreateShareDto } from './dto/create-share.dto'; import { CreateShareDto } from './dto/create-share.dto';
import { import {
CreateShareResponseDto, CreateShareResponseDto,
CreatedShareListResponseDto,
JoinShareResponseDto, JoinShareResponseDto,
} from './dto/share-response.dto'; } from './dto/share-response.dto';
import { ApiResponseDto } from '../../common/dto/api-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 { export class ShareController {
constructor(private readonly shareService: ShareService) {} constructor(private readonly shareService: ShareService) {}
@Get('created')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取我创建的分享挑战',
description: '返回当前用户创建过的分享挑战,并统计参与人数和用户排名',
})
@ApiResponse({ status: 200, description: '成功' })
async getCreatedShares(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<CreatedShareListResponseDto>> {
const data = await this.shareService.getCreatedShares(user.sub);
return ApiResponseDto.success(data);
}
@Post() @Post()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()

View File

@@ -1,8 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { import { NotFoundException, BadRequestException } from '@nestjs/common';
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { ShareService } from './share.service'; import { ShareService } from './share.service';
import { ShareConfigRepository } from './repositories/share-config.repository'; import { ShareConfigRepository } from './repositories/share-config.repository';
import { ShareParticipantRepository } from './repositories/share-participant.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 { ShareConfig } from './entities/share-config.entity';
import { ShareParticipant } from './entities/share-participant.entity'; import { ShareParticipant } from './entities/share-participant.entity';
import { ShareLevelProgress } from './entities/share-level-progress.entity'; import { ShareLevelProgress } from './entities/share-level-progress.entity';
import { User } from '../auth/entities/user.entity';
// Mock nanoid to return predictable values // Mock nanoid to return predictable values
jest.mock('nanoid', () => ({ jest.mock('nanoid', () => ({
@@ -40,7 +38,7 @@ describe('ShareService', () => {
title: '我的挑战', title: '我的挑战',
sharerId: 'user-uuid-1', sharerId: 'user-uuid-1',
levelIds: mockLevels.map((l) => l.id), levelIds: mockLevels.map((l) => l.id),
sharer: {} as any, sharer: {} as User,
participants: [], participants: [],
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
@@ -50,19 +48,21 @@ describe('ShareService', () => {
id: 'participant-uuid-1', id: 'participant-uuid-1',
shareConfigId: 'share-uuid-1', shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2', participantId: 'user-uuid-2',
shareConfig: {} as any, shareConfig: {} as ShareConfig,
participant: {} as any, participant: {} as User,
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
}; };
const mockShareConfigRepository = { const mockShareConfigRepository = {
create: jest.fn(), create: jest.fn(),
findByShareCode: jest.fn(), findByShareCode: jest.fn(),
findBySharerId: jest.fn(),
}; };
const mockShareParticipantRepository = { const mockShareParticipantRepository = {
addParticipant: jest.fn(), addParticipant: jest.fn(),
countByShareConfigId: jest.fn(), countByShareConfigId: jest.fn(),
countByShareConfigIds: jest.fn(),
findByShareConfigAndParticipant: jest.fn(), findByShareConfigAndParticipant: jest.fn(),
create: jest.fn(), create: jest.fn(),
save: jest.fn(), save: jest.fn(),
@@ -70,6 +70,7 @@ describe('ShareService', () => {
const mockShareLevelProgressRepository = { const mockShareLevelProgressRepository = {
findByParticipantAndLevel: jest.fn(), findByParticipantAndLevel: jest.fn(),
summarizeByShareConfigIds: jest.fn(),
create: jest.fn(), create: jest.fn(),
save: jest.fn(), save: jest.fn(),
}; };
@@ -106,7 +107,14 @@ describe('ShareService', () => {
describe('createShare', () => { describe('createShare', () => {
const createDto = { const createDto = {
title: '我的挑战', 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 () => { 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 () => { it('should throw BadRequestException when level IDs have duplicates', async () => {
const duplicateDto = { const duplicateDto = {
title: '测试', 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( await expect(
@@ -158,7 +173,9 @@ describe('ShareService', () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue( mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig, mockShareConfig,
); );
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined); mockShareParticipantRepository.addParticipant.mockResolvedValue(
undefined,
);
mockLevelRepository.findByIds.mockResolvedValue(mockLevels); mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
const result = await service.joinShare('user-uuid-2', 'ABCD1234'); const result = await service.joinShare('user-uuid-2', 'ABCD1234');
@@ -168,10 +185,9 @@ describe('ShareService', () => {
expect(result.levels).toHaveLength(6); expect(result.levels).toHaveLength(6);
expect(result.levels[0].level).toBe(1); expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1'); expect(result.levels[0].id).toBe('level-1');
expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith( expect(
'share-uuid-1', mockShareParticipantRepository.addParticipant,
'user-uuid-2', ).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
);
}); });
it('should not add participant when user is the sharer', async () => { 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 () => { it('should throw NotFoundException when share code not found', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(null); mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
await expect( await expect(service.joinShare('user-uuid-2', 'INVALID')).rejects.toThrow(
service.joinShare('user-uuid-2', 'INVALID'), NotFoundException,
).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);
}); });
}); });

View File

@@ -12,6 +12,7 @@ import { CreateShareDto } from './dto/create-share.dto';
import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
import { import {
CreateShareResponseDto, CreateShareResponseDto,
CreatedShareListResponseDto,
JoinShareResponseDto, JoinShareResponseDto,
ShareLevelDto, ShareLevelDto,
} from './dto/share-response.dto'; } from './dto/share-response.dto';
@@ -110,6 +111,62 @@ export class ShareService {
}; };
} }
async getCreatedShares(userId: string): Promise<CreatedShareListResponseDto> {
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<string, string[]>();
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( async reportLevelProgress(
userId: string, userId: string,
dto: ReportLevelProgressDto, dto: ReportLevelProgressDto,
@@ -153,7 +210,10 @@ export class ShareService {
return { return {
passed: true, passed: true,
timeLimit: level.timeLimit, timeLimit: level.timeLimit,
withinTimeLimit: this.isWithinTimeLimit(level.timeLimit, progress.timeSpent), withinTimeLimit: this.isWithinTimeLimit(
level.timeLimit,
progress.timeSpent,
),
}; };
} }