feat: 支持分享详情接口
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
<claude-mem-context>
|
<claude-mem-context>
|
||||||
# Memory Context
|
# Memory Context
|
||||||
|
|
||||||
# $CMEM MemeMind-Server 2026-05-10 9:40pm GMT+8
|
# $CMEM MemeMind-Server 2026-05-13 8:45am GMT+8
|
||||||
|
|
||||||
No previous sessions found.
|
No previous sessions found.
|
||||||
</claude-mem-context>
|
</claude-mem-context>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
- 2026-05-13: 新增 `GET /api/v1/share/{code}` 分享挑战详情接口,返回挑战基本信息和所有已提交用户的排行榜;排行榜项包含用户头像、昵称、答对题数和总耗时。
|
||||||
|
- 2026-05-12: `GET /api/v1/share/created` 响应新增 `firstPlaceUser`,返回每个挑战当前第一名用户的昵称和头像;暂无提交结果时为 `null`。
|
||||||
- 2026-05-10: 移除 `POST /api/v1/share/progress` 单关进度上报接口;新增 `POST /api/v1/share/{code}/submit`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。
|
- 2026-05-10: 移除 `POST /api/v1/share/progress` 单关进度上报接口;新增 `POST /api/v1/share/{code}/submit`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
@@ -27,8 +29,9 @@
|
|||||||
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
||||||
2. **整场挑战提交**:`POST /api/v1/share/{code}/submit` 接口,用于一次性提交分享挑战中每一关的答案和耗时
|
2. **整场挑战提交**:`POST /api/v1/share/{code}/submit` 接口,用于一次性提交分享挑战中每一关的答案和耗时
|
||||||
3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
|
3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
|
||||||
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
|
4. **分享挑战详情**:`GET /api/v1/share/{code}` 接口,用于查询单个挑战的基本信息和完整排行榜
|
||||||
5. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
5. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数、本人排名和当前第一名用户信息
|
||||||
|
6. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -296,7 +299,105 @@ Content-Type: application/json
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. 获取我创建的分享挑战
|
### 4. 获取分享挑战详情
|
||||||
|
|
||||||
|
获取单个分享挑战的基本信息,以及该挑战下所有已提交结果用户的排行榜。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/share/{code}`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| ---- | ------ | ---- | -------------- |
|
||||||
|
| code | string | 是 | 分享码(8 位) |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string; // 分享挑战 ID
|
||||||
|
shareCode: string; // 分享码
|
||||||
|
title: string; // 分享标题
|
||||||
|
levelCount: number; // 挑战关卡总数
|
||||||
|
participantCount: number; // 已提交结果的参与用户总数
|
||||||
|
userRank: number | null; // 当前用户排名;尚未提交结果时为 null
|
||||||
|
createdAt: string; // 创建时间,ISO 8601 字符串
|
||||||
|
rankings: [
|
||||||
|
{
|
||||||
|
rank: number; // 名次,从 1 开始
|
||||||
|
participantId: string; // 参与用户 ID
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
correctCount: number; // 答对题数
|
||||||
|
totalTimeSpent: number;// 总耗时(秒)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "share_001",
|
||||||
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"levelCount": 6,
|
||||||
|
"participantCount": 3,
|
||||||
|
"userRank": 2,
|
||||||
|
"createdAt": "2026-04-13T10:00:00.000Z",
|
||||||
|
"rankings": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"participantId": "user_002",
|
||||||
|
"nickname": "速度玩家",
|
||||||
|
"avatarUrl": "https://example.com/avatar-speed.png",
|
||||||
|
"correctCount": 6,
|
||||||
|
"totalTimeSpent": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"participantId": "user_001",
|
||||||
|
"nickname": "挑战创建者",
|
||||||
|
"avatarUrl": null,
|
||||||
|
"correctCount": 6,
|
||||||
|
"totalTimeSpent": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 3,
|
||||||
|
"participantId": "user_003",
|
||||||
|
"nickname": null,
|
||||||
|
"avatarUrl": null,
|
||||||
|
"correctCount": 5,
|
||||||
|
"totalTimeSpent": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-13T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑说明**:
|
||||||
|
|
||||||
|
1. 返回挑战基本信息:分享 ID、分享码、标题、关卡数量、已提交人数、当前用户排名和创建时间。
|
||||||
|
2. `rankings` 只包含已经调用 `POST /api/v1/share/{code}/submit` 提交整场挑战结果的用户。
|
||||||
|
3. 排行榜按答对题数降序排列;答对题数相同时,按总耗时升序排列。
|
||||||
|
4. 如果答对题数和总耗时都相同,服务端继续按提交时间升序、`participantId` 升序做稳定排序。
|
||||||
|
5. `userRank` 表示当前登录用户在该挑战中的排名;当前用户尚未提交结果时为 `null`。
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
|
||||||
|
- 用户打开单个挑战详情页或结算页时调用。
|
||||||
|
- 需要展示完整排行榜时调用;列表页仍使用 `GET /api/v1/share/created`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 获取我创建的分享挑战
|
||||||
|
|
||||||
获取当前登录用户创建过的分享挑战列表。
|
获取当前登录用户创建过的分享挑战列表。
|
||||||
|
|
||||||
@@ -322,6 +423,10 @@ Authorization: Bearer <token>
|
|||||||
levelCount: number; // 关卡数量
|
levelCount: number; // 关卡数量
|
||||||
participantCount: number; // 已提交结果的参与人数
|
participantCount: number; // 已提交结果的参与人数
|
||||||
userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null
|
userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null
|
||||||
|
firstPlaceUser: { // 当前第一名用户信息;暂无提交结果时为 null
|
||||||
|
nickname: string | null; // 第一名用户昵称
|
||||||
|
avatarUrl: string | null;// 第一名用户头像 URL
|
||||||
|
} | null;
|
||||||
createdAt: string; // 创建时间,ISO 8601 字符串
|
createdAt: string; // 创建时间,ISO 8601 字符串
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -342,6 +447,10 @@ Authorization: Bearer <token>
|
|||||||
"levelCount": 6,
|
"levelCount": 6,
|
||||||
"participantCount": 8,
|
"participantCount": 8,
|
||||||
"userRank": 2,
|
"userRank": 2,
|
||||||
|
"firstPlaceUser": {
|
||||||
|
"nickname": "第一名玩家",
|
||||||
|
"avatarUrl": "https://example.com/avatar-first.png"
|
||||||
|
},
|
||||||
"createdAt": "2026-04-13T10:00:00.000Z"
|
"createdAt": "2026-04-13T10:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -351,6 +460,10 @@ Authorization: Bearer <token>
|
|||||||
"levelCount": 6,
|
"levelCount": 6,
|
||||||
"participantCount": 1,
|
"participantCount": 1,
|
||||||
"userRank": null,
|
"userRank": null,
|
||||||
|
"firstPlaceUser": {
|
||||||
|
"nickname": null,
|
||||||
|
"avatarUrl": null
|
||||||
|
},
|
||||||
"createdAt": "2026-04-12T09:00:00.000Z"
|
"createdAt": "2026-04-12T09:00:00.000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -366,6 +479,7 @@ Authorization: Bearer <token>
|
|||||||
2. 排名按答对题数降序计算,答对越多排名越高。
|
2. 排名按答对题数降序计算,答对越多排名越高。
|
||||||
3. 答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。
|
3. 答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。
|
||||||
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未提交挑战结果,则返回 `null`。
|
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未提交挑战结果,则返回 `null`。
|
||||||
|
5. `firstPlaceUser` 取当前排名第一的已提交用户资料。如果该挑战还没有任何提交结果,则返回 `null`。
|
||||||
|
|
||||||
**参与人数统计规则**:
|
**参与人数统计规则**:
|
||||||
|
|
||||||
@@ -379,7 +493,7 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. 提交分享挑战结果
|
### 6. 提交分享挑战结果
|
||||||
|
|
||||||
用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
|
用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
|
||||||
|
|
||||||
@@ -803,7 +917,56 @@ const shareData = await joinShare(shareCode);
|
|||||||
// 保存关卡数据,开始游戏
|
// 保存关卡数据,开始游戏
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 提交挑战结果
|
### 5. 获取挑战详情
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ChallengeRankingItem {
|
||||||
|
rank: number;
|
||||||
|
participantId: string;
|
||||||
|
nickname: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
correctCount: number;
|
||||||
|
totalTimeSpent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareChallengeDetailResponse {
|
||||||
|
id: string;
|
||||||
|
shareCode: string;
|
||||||
|
title: string;
|
||||||
|
levelCount: number;
|
||||||
|
participantCount: number;
|
||||||
|
userRank: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
rankings: ChallengeRankingItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShareChallengeDetail(
|
||||||
|
shareCode: string,
|
||||||
|
): Promise<ShareChallengeDetailResponse> {
|
||||||
|
// 确保已登录
|
||||||
|
if (!httpManager.getToken()) {
|
||||||
|
await wxLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpManager.get<ShareChallengeDetailResponse>(
|
||||||
|
`/v1/share/${shareCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log('挑战详情:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取挑战详情失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const detail = await getShareChallengeDetail('abc12345');
|
||||||
|
console.log(`当前排名: ${detail.userRank ?? '暂未上榜'}`);
|
||||||
|
console.log(`已提交人数: ${detail.participantCount}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 提交挑战结果
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SubmitChallengeLevel {
|
interface SubmitChallengeLevel {
|
||||||
@@ -870,7 +1033,7 @@ async function onChallengeFinished() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 启动流程示例
|
### 7. 启动流程示例
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// GameEntry.ts - 游戏入口脚本
|
// GameEntry.ts - 游戏入口脚本
|
||||||
|
|||||||
@@ -60,6 +60,58 @@ export class JoinShareResponseDto {
|
|||||||
levels!: ShareLevelDto[];
|
levels!: ShareLevelDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ShareChallengeRankingItemDto {
|
||||||
|
@ApiProperty({ description: '排名,从 1 开始' })
|
||||||
|
rank!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '参与用户 ID' })
|
||||||
|
participantId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '参与用户昵称', nullable: true })
|
||||||
|
nickname!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '参与用户头像 URL', nullable: true })
|
||||||
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '答对题数' })
|
||||||
|
correctCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '总耗时(秒)' })
|
||||||
|
totalTimeSpent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShareChallengeDetailResponseDto {
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '挑战排行榜,按答对题数降序、总耗时升序排列',
|
||||||
|
type: [ShareChallengeRankingItemDto],
|
||||||
|
})
|
||||||
|
rankings!: ShareChallengeRankingItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class SubmittedShareLevelDto extends ShareLevelDto {
|
export class SubmittedShareLevelDto extends ShareLevelDto {
|
||||||
@ApiProperty({ description: '用户提交的答案' })
|
@ApiProperty({ description: '用户提交的答案' })
|
||||||
submittedAnswer!: string;
|
submittedAnswer!: string;
|
||||||
@@ -106,6 +158,14 @@ export class SubmitShareChallengeResponseDto {
|
|||||||
levels!: SubmittedShareLevelDto[];
|
levels!: SubmittedShareLevelDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FirstPlaceUserDto {
|
||||||
|
@ApiProperty({ description: '第一名用户昵称', nullable: true })
|
||||||
|
nickname!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '第一名用户头像 URL', nullable: true })
|
||||||
|
avatarUrl!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreatedShareItemDto {
|
export class CreatedShareItemDto {
|
||||||
@ApiProperty({ description: '分享 ID' })
|
@ApiProperty({ description: '分享 ID' })
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -128,6 +188,13 @@ export class CreatedShareItemDto {
|
|||||||
})
|
})
|
||||||
userRank!: number | null;
|
userRank!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '当前挑战第一名用户信息,暂无提交结果时为 null',
|
||||||
|
nullable: true,
|
||||||
|
type: () => FirstPlaceUserDto,
|
||||||
|
})
|
||||||
|
firstPlaceUser!: FirstPlaceUserDto | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '创建时间' })
|
@ApiProperty({ description: '创建时间' })
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type ShareParticipantRankingRow = {
|
|||||||
correctCount: number;
|
correctCount: number;
|
||||||
totalTimeSpent: number;
|
totalTimeSpent: number;
|
||||||
submittedAt: Date;
|
submittedAt: Date;
|
||||||
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -120,6 +122,12 @@ export class ShareParticipantRepository {
|
|||||||
): Promise<ShareParticipantRankingRow[]> {
|
): Promise<ShareParticipantRankingRow[]> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('participant')
|
.createQueryBuilder('participant')
|
||||||
|
.leftJoin('participant.participant', 'rankingUser')
|
||||||
|
.addSelect([
|
||||||
|
'rankingUser.id',
|
||||||
|
'rankingUser.nickname',
|
||||||
|
'rankingUser.avatarUrl',
|
||||||
|
])
|
||||||
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
||||||
.andWhere('participant.submittedAt IS NOT NULL')
|
.andWhere('participant.submittedAt IS NOT NULL')
|
||||||
.orderBy('participant.correctCount', 'DESC')
|
.orderBy('participant.correctCount', 'DESC')
|
||||||
@@ -134,6 +142,8 @@ export class ShareParticipantRepository {
|
|||||||
correctCount: row.correctCount,
|
correctCount: row.correctCount,
|
||||||
totalTimeSpent: row.totalTimeSpent,
|
totalTimeSpent: row.totalTimeSpent,
|
||||||
submittedAt: row.submittedAt!,
|
submittedAt: row.submittedAt!,
|
||||||
|
nickname: row.participant?.nickname ?? null,
|
||||||
|
avatarUrl: row.participant?.avatarUrl ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +156,12 @@ export class ShareParticipantRepository {
|
|||||||
|
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('participant')
|
.createQueryBuilder('participant')
|
||||||
|
.leftJoin('participant.participant', 'rankingUser')
|
||||||
|
.addSelect([
|
||||||
|
'rankingUser.id',
|
||||||
|
'rankingUser.nickname',
|
||||||
|
'rankingUser.avatarUrl',
|
||||||
|
])
|
||||||
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||||
shareConfigIds,
|
shareConfigIds,
|
||||||
})
|
})
|
||||||
@@ -163,6 +179,8 @@ export class ShareParticipantRepository {
|
|||||||
correctCount: row.correctCount,
|
correctCount: row.correctCount,
|
||||||
totalTimeSpent: row.totalTimeSpent,
|
totalTimeSpent: row.totalTimeSpent,
|
||||||
submittedAt: row.submittedAt!,
|
submittedAt: row.submittedAt!,
|
||||||
|
nickname: row.participant?.nickname ?? null,
|
||||||
|
avatarUrl: row.participant?.avatarUrl ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('ShareController', () => {
|
|||||||
const mockShareService = {
|
const mockShareService = {
|
||||||
createShare: jest.fn(),
|
createShare: jest.fn(),
|
||||||
getCreatedShares: jest.fn(),
|
getCreatedShares: jest.fn(),
|
||||||
|
getShareChallengeDetail: jest.fn(),
|
||||||
joinShare: jest.fn(),
|
joinShare: jest.fn(),
|
||||||
submitChallenge: jest.fn(),
|
submitChallenge: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -72,6 +73,10 @@ describe('ShareController', () => {
|
|||||||
levelCount: 6,
|
levelCount: 6,
|
||||||
participantCount: 8,
|
participantCount: 8,
|
||||||
userRank: 2,
|
userRank: 2,
|
||||||
|
firstPlaceUser: {
|
||||||
|
nickname: '第一名玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-first.png',
|
||||||
|
},
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -90,6 +95,53 @@ describe('ShareController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getShareChallengeDetail', () => {
|
||||||
|
it('should return success response with challenge detail and rankings', async () => {
|
||||||
|
const detailResponse = {
|
||||||
|
id: 'share-uuid-1',
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
participantCount: 2,
|
||||||
|
userRank: 2,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
rankings: [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
nickname: '第一名玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-first.png',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 98,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
nickname: '挑战创建者',
|
||||||
|
avatarUrl: null,
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockShareService.getShareChallengeDetail.mockResolvedValue(
|
||||||
|
detailResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getShareChallengeDetail(
|
||||||
|
mockUser,
|
||||||
|
'ABCD1234',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(detailResponse);
|
||||||
|
expect(mockShareService.getShareChallengeDetail).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
'ABCD1234',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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 = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
|
ShareChallengeDetailResponseDto,
|
||||||
SubmitShareChallengeResponseDto,
|
SubmitShareChallengeResponseDto,
|
||||||
} 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';
|
||||||
@@ -56,6 +57,27 @@ export class ShareController {
|
|||||||
return ApiResponseDto.success(data);
|
return ApiResponseDto.success(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':code')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取分享挑战详情',
|
||||||
|
description:
|
||||||
|
'返回分享挑战基本信息,以及所有已提交挑战结果用户的排行榜。排行榜按答对题数降序、总耗时升序排列。',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '分享不存在' })
|
||||||
|
async getShareChallengeDetail(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('code') code: string,
|
||||||
|
): Promise<ApiResponseDto<ShareChallengeDetailResponseDto>> {
|
||||||
|
const data = await this.shareService.getShareChallengeDetail(
|
||||||
|
user.sub,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':code/join')
|
@Post(':code/join')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ describe('ShareService', () => {
|
|||||||
correctCount: 6,
|
correctCount: 6,
|
||||||
totalTimeSpent: 100,
|
totalTimeSpent: 100,
|
||||||
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||||
|
nickname: '第一名玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-first.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
@@ -262,6 +264,8 @@ describe('ShareService', () => {
|
|||||||
correctCount: 6,
|
correctCount: 6,
|
||||||
totalTimeSpent: 120,
|
totalTimeSpent: 120,
|
||||||
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
nickname: '挑战创建者',
|
||||||
|
avatarUrl: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shareConfigId: 'share-uuid-1',
|
shareConfigId: 'share-uuid-1',
|
||||||
@@ -269,6 +273,8 @@ describe('ShareService', () => {
|
|||||||
correctCount: 5,
|
correctCount: 5,
|
||||||
totalTimeSpent: 200,
|
totalTimeSpent: 200,
|
||||||
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -284,6 +290,7 @@ describe('ShareService', () => {
|
|||||||
levelCount: 6,
|
levelCount: 6,
|
||||||
participantCount: 1,
|
participantCount: 1,
|
||||||
userRank: null,
|
userRank: null,
|
||||||
|
firstPlaceUser: null,
|
||||||
createdAt: '2026-01-02T00:00:00.000Z',
|
createdAt: '2026-01-02T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -293,6 +300,10 @@ describe('ShareService', () => {
|
|||||||
levelCount: 6,
|
levelCount: 6,
|
||||||
participantCount: 3,
|
participantCount: 3,
|
||||||
userRank: 2,
|
userRank: 2,
|
||||||
|
firstPlaceUser: {
|
||||||
|
nickname: '第一名玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-first.png',
|
||||||
|
},
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -331,6 +342,124 @@ describe('ShareService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getShareChallengeDetail', () => {
|
||||||
|
it('should return challenge detail with all submitted rankings', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 90,
|
||||||
|
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
||||||
|
nickname: '速度玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-speed.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 120,
|
||||||
|
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||||
|
nickname: '挑战创建者',
|
||||||
|
avatarUrl: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 60,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getShareChallengeDetail(
|
||||||
|
'user-uuid-1',
|
||||||
|
'ABCD1234',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'share-uuid-1',
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
participantCount: 3,
|
||||||
|
userRank: 2,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
rankings: [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
nickname: '速度玩家',
|
||||||
|
avatarUrl: 'https://example.com/avatar-speed.png',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
nickname: '挑战创建者',
|
||||||
|
avatarUrl: null,
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 3,
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId,
|
||||||
|
).toHaveBeenCalledWith('share-uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null userRank when current user has not submitted', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 80,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
nickname: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getShareChallengeDetail(
|
||||||
|
'user-uuid-1',
|
||||||
|
'ABCD1234',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.userRank).toBeNull();
|
||||||
|
expect(result.participantCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when share code not found', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getShareChallengeDetail('user-uuid-1', 'INVALID'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('submitChallenge', () => {
|
describe('submitChallenge', () => {
|
||||||
const submitDto = {
|
const submitDto = {
|
||||||
levels: mockLevels.map((level, index) => ({
|
levels: mockLevels.map((level, index) => ({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
|
ShareChallengeDetailResponseDto,
|
||||||
ShareLevelDto,
|
ShareLevelDto,
|
||||||
SubmittedShareLevelDto,
|
SubmittedShareLevelDto,
|
||||||
SubmitShareChallengeResponseDto,
|
SubmitShareChallengeResponseDto,
|
||||||
@@ -95,6 +96,43 @@ export class ShareService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getShareChallengeDetail(
|
||||||
|
userId: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<ShareChallengeDetailResponseDto> {
|
||||||
|
const config = await this.shareConfigRepository.findByShareCode(code);
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('分享不存在或已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankings =
|
||||||
|
await this.shareParticipantRepository.findSubmittedRankingsByShareConfigId(
|
||||||
|
config.id,
|
||||||
|
);
|
||||||
|
const rankingItems = rankings.map((ranking, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
participantId: ranking.participantId,
|
||||||
|
nickname: ranking.nickname ?? null,
|
||||||
|
avatarUrl: ranking.avatarUrl ?? null,
|
||||||
|
correctCount: ranking.correctCount,
|
||||||
|
totalTimeSpent: ranking.totalTimeSpent,
|
||||||
|
}));
|
||||||
|
const userRanking = rankingItems.find(
|
||||||
|
(ranking) => ranking.participantId === userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: config.id,
|
||||||
|
shareCode: config.shareCode,
|
||||||
|
title: config.title,
|
||||||
|
levelCount: config.levelIds.length,
|
||||||
|
participantCount: rankingItems.length,
|
||||||
|
userRank: userRanking?.rank ?? null,
|
||||||
|
createdAt: config.createdAt.toISOString(),
|
||||||
|
rankings: rankingItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getCreatedShares(userId: string): Promise<CreatedShareListResponseDto> {
|
async getCreatedShares(userId: string): Promise<CreatedShareListResponseDto> {
|
||||||
const configs = await this.shareConfigRepository.findBySharerId(userId);
|
const configs = await this.shareConfigRepository.findBySharerId(userId);
|
||||||
if (configs.length === 0) {
|
if (configs.length === 0) {
|
||||||
@@ -111,11 +149,11 @@ export class ShareService {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rankingsByShareConfigId = new Map<string, string[]>();
|
const rankingsByShareConfigId = new Map<string, typeof rankingRows>();
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const rankings = rankingRows
|
const rankings = rankingRows.filter(
|
||||||
.filter((row) => row.shareConfigId === config.id)
|
(row) => row.shareConfigId === config.id,
|
||||||
.map((row) => row.participantId);
|
);
|
||||||
|
|
||||||
rankingsByShareConfigId.set(config.id, rankings);
|
rankingsByShareConfigId.set(config.id, rankings);
|
||||||
}
|
}
|
||||||
@@ -124,8 +162,9 @@ export class ShareService {
|
|||||||
items: configs.map((config) => {
|
items: configs.map((config) => {
|
||||||
const rankings = rankingsByShareConfigId.get(config.id) ?? [];
|
const rankings = rankingsByShareConfigId.get(config.id) ?? [];
|
||||||
const rankingIndex = rankings.findIndex(
|
const rankingIndex = rankings.findIndex(
|
||||||
(participantId) => participantId === userId,
|
(ranking) => ranking.participantId === userId,
|
||||||
);
|
);
|
||||||
|
const firstPlaceRanking = rankings[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: config.id,
|
id: config.id,
|
||||||
@@ -134,6 +173,12 @@ export class ShareService {
|
|||||||
levelCount: config.levelIds.length,
|
levelCount: config.levelIds.length,
|
||||||
participantCount: participantCountMap.get(config.id) ?? 0,
|
participantCount: participantCountMap.get(config.id) ?? 0,
|
||||||
userRank: rankingIndex >= 0 ? rankingIndex + 1 : null,
|
userRank: rankingIndex >= 0 ? rankingIndex + 1 : null,
|
||||||
|
firstPlaceUser: firstPlaceRanking
|
||||||
|
? {
|
||||||
|
nickname: firstPlaceRanking.nickname ?? null,
|
||||||
|
avatarUrl: firstPlaceRanking.avatarUrl ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
createdAt: config.createdAt.toISOString(),
|
createdAt: config.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user