feat: 支持我参与的接口
This commit is contained in:
32
AGENTS.md
32
AGENTS.md
@@ -45,7 +45,35 @@
|
|||||||
<claude-mem-context>
|
<claude-mem-context>
|
||||||
# Memory Context
|
# Memory Context
|
||||||
|
|
||||||
# $CMEM MemeMind-Server 2026-05-13 8:45am GMT+8
|
# [MemeMind-Server] recent context, 2026-05-14 4:25pm GMT+8
|
||||||
|
|
||||||
No previous sessions found.
|
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note
|
||||||
|
Format: ID TIME TYPE TITLE
|
||||||
|
Fetch details: get_observations([IDs]) | Search: mem-search skill
|
||||||
|
|
||||||
|
Stats: 16 obs (3,405t read) | 446,647t work | 99% savings
|
||||||
|
|
||||||
|
### Apr 26, 2026
|
||||||
|
1306 3:45p 🟣 Added completedLevelCount field to game-data API
|
||||||
|
1307 " 🔵 Located game-data endpoint controller
|
||||||
|
1308 " 🔵 Mapped game-data implementation and DTO structure
|
||||||
|
S1302 Add completedLevelCount to api/v1/user/game-data endpoint with efficient COUNT query (Apr 26 at 3:45 PM)
|
||||||
|
S1301 Add completedLevelCount field to api/v1/user/game-data endpoint (Apr 26 at 3:45 PM)
|
||||||
|
1310 " 🔄 Removed completedLevelIds, kept only completedLevelCount
|
||||||
|
1311 3:46p 🟣 Added countByUserId to repository interface
|
||||||
|
1309 " 🟣 Added completedLevelCount to game-data API
|
||||||
|
1312 3:47p 🔄 Refactored getGameData to use COUNT query directly
|
||||||
|
1313 3:52p 🟣 Game data endpoint refactored with next level logic
|
||||||
|
1315 3:53p 🟣 Game Level API Redesign for Client-Side Level Progression
|
||||||
|
1314 3:57p ⚖️ 幽默始祖等级系统设计规则
|
||||||
|
1316 4:03p ⚖️ Level API Redesign Implementation Plan
|
||||||
|
S1303 MemeMind-Server API 变更实现:删除 GET /v1/levels、三个接口新增 nextLevel/preloadNextLevel 字段 (Apr 26 at 4:11 PM)
|
||||||
|
### May 14, 2026
|
||||||
|
1618 4:20p ✅ Migrating Claude Code settings into Codex
|
||||||
|
1619 " 🔵 MemeMind-Server already partially migrated to Codex
|
||||||
|
1620 4:22p 🔵 javascript-typescript-jest skill symlinked from .claude to .agents
|
||||||
|
1621 " ✅ Migrate-to-codex dry-run completed for MemeMind-Server
|
||||||
|
1622 4:24p ✅ MemeMind-Server migration to Codex completed and validated
|
||||||
|
|
||||||
|
Access 447k tokens of past work via get_observations([IDs]) or mem-search skill.
|
||||||
</claude-mem-context>
|
</claude-mem-context>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
- 2026-05-14: 新增 `GET /api/v1/share/participated` 我参与的挑战列表接口,返回挑战名称、已提交参与人数和当前用户名次。
|
||||||
- 2026-05-13: 新增 `GET /api/v1/share/{code}` 分享挑战详情接口,返回挑战基本信息和所有已提交用户的排行榜;排行榜项包含用户头像、昵称、答对题数和总耗时。
|
- 2026-05-13: 新增 `GET /api/v1/share/{code}` 分享挑战详情接口,返回挑战基本信息和所有已提交用户的排行榜;排行榜项包含用户头像、昵称、答对题数和总耗时。
|
||||||
- 2026-05-12: `GET /api/v1/share/created` 响应新增 `firstPlaceUser`,返回每个挑战当前第一名用户的昵称和头像;暂无提交结果时为 `null`。
|
- 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`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
|
3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果
|
||||||
4. **分享挑战详情**:`GET /api/v1/share/{code}` 接口,用于查询单个挑战的基本信息和完整排行榜
|
4. **分享挑战详情**:`GET /api/v1/share/{code}` 接口,用于查询单个挑战的基本信息和完整排行榜
|
||||||
5. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数、本人排名和当前第一名用户信息
|
5. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数、本人排名和当前第一名用户信息
|
||||||
6. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
6. **我参与的挑战列表**:`GET /api/v1/share/participated` 接口,用于查询当前用户参与过的分享挑战、参与人数和本人排名
|
||||||
|
7. **关卡排序**:关卡全局顺序按 `levels.sort_key` 的应用层字节序计算,接口中的 `sortOrder` 为排序后的 0-based 连续序号
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -393,7 +395,7 @@ Content-Type: application/json
|
|||||||
**客户端调用场景**:
|
**客户端调用场景**:
|
||||||
|
|
||||||
- 用户打开单个挑战详情页或结算页时调用。
|
- 用户打开单个挑战详情页或结算页时调用。
|
||||||
- 需要展示完整排行榜时调用;列表页仍使用 `GET /api/v1/share/created`。
|
- 需要展示完整排行榜时调用;列表页使用 `GET /api/v1/share/created` 或 `GET /api/v1/share/participated`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -493,7 +495,78 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. 提交分享挑战结果
|
### 6. 获取我参与的分享挑战
|
||||||
|
|
||||||
|
获取当前登录用户参与过的分享挑战列表。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/share/participated`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**请求头**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: string; // 挑战名称
|
||||||
|
participantCount: number; // 已提交结果的参与人数
|
||||||
|
userRank: number | null; // 当前用户在该挑战中的排名;尚未提交结果时为 null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "好友挑战",
|
||||||
|
"participantCount": 5,
|
||||||
|
"userRank": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "速度挑战",
|
||||||
|
"participantCount": 1,
|
||||||
|
"userRank": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-05-14T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**排名规则**:
|
||||||
|
|
||||||
|
1. 列表只返回当前用户在 `share_participants` 中有参与记录的挑战。
|
||||||
|
2. 只有已经调用 `POST /api/v1/share/{code}/submit` 提交整场挑战结果的用户才会进入排名。
|
||||||
|
3. 排名按答对题数降序计算,答对题数相同时,按总耗时升序、提交时间升序、`participantId` 升序做稳定排序。
|
||||||
|
4. `userRank` 表示当前登录用户在该挑战中的排名。如果当前用户已加入但尚未提交挑战结果,则返回 `null`。
|
||||||
|
|
||||||
|
**参与人数统计规则**:
|
||||||
|
|
||||||
|
- 统计该挑战中已提交完整挑战结果的用户数量。
|
||||||
|
- 创建者本人如果调用提交接口,也会作为参与用户进入统计和排名。
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
|
||||||
|
- 用户进入「我参与的挑战」页面时调用。
|
||||||
|
- 列表只展示挑战名称、参与人数和当前用户名次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 提交分享挑战结果
|
||||||
|
|
||||||
用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
|
用户完成分享挑战后,一次性提交分享中每一关的耗时和答案。服务端校验答案、持久化结果,并返回当前用户的排名和完整关卡答案。
|
||||||
|
|
||||||
@@ -966,7 +1039,49 @@ console.log(`当前排名: ${detail.userRank ?? '暂未上榜'}`);
|
|||||||
console.log(`已提交人数: ${detail.participantCount}`);
|
console.log(`已提交人数: ${detail.participantCount}`);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 提交挑战结果
|
### 6. 获取我参与的挑战列表
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ParticipatedShareItem {
|
||||||
|
title: string;
|
||||||
|
participantCount: number;
|
||||||
|
userRank: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParticipatedShareListResponse {
|
||||||
|
items: ParticipatedShareItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getParticipatedShares(): Promise<ParticipatedShareListResponse> {
|
||||||
|
// 确保已登录
|
||||||
|
if (!httpManager.getToken()) {
|
||||||
|
await wxLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpManager.get<ParticipatedShareListResponse>(
|
||||||
|
'/v1/share/participated',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log('我参与的挑战:', response.data.items);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取我参与的挑战失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const participatedShares = await getParticipatedShares();
|
||||||
|
participatedShares.items.forEach((item) => {
|
||||||
|
console.log(
|
||||||
|
`${item.title}: ${item.participantCount} 人参与,当前排名 ${
|
||||||
|
item.userRank ?? '暂未上榜'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 提交挑战结果
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SubmitChallengeLevel {
|
interface SubmitChallengeLevel {
|
||||||
@@ -1033,7 +1148,7 @@ async function onChallengeFinished() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 启动流程示例
|
### 8. 启动流程示例
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// GameEntry.ts - 游戏入口脚本
|
// GameEntry.ts - 游戏入口脚本
|
||||||
|
|||||||
@@ -206,3 +206,25 @@ export class CreatedShareListResponseDto {
|
|||||||
})
|
})
|
||||||
items!: CreatedShareItemDto[];
|
items!: CreatedShareItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ParticipatedShareItemDto {
|
||||||
|
@ApiProperty({ description: '挑战名称' })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '参与挑战人数' })
|
||||||
|
participantCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '当前用户在该挑战中的排名,尚未提交挑战结果时为 null',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
userRank!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParticipatedShareListResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '当前用户参与过的分享挑战列表',
|
||||||
|
type: [ParticipatedShareItemDto],
|
||||||
|
})
|
||||||
|
items!: ParticipatedShareItemDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export class ShareParticipantRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByParticipantId(participantId: string): Promise<ShareParticipant[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('participant')
|
||||||
|
.leftJoinAndSelect('participant.shareConfig', 'shareConfig')
|
||||||
|
.where('participant.participantId = :participantId', { participantId })
|
||||||
|
.orderBy('shareConfig.createdAt', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
async upsertSubmissionSummary(data: {
|
async upsertSubmissionSummary(data: {
|
||||||
shareConfigId: string;
|
shareConfigId: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('ShareController', () => {
|
|||||||
const mockShareService = {
|
const mockShareService = {
|
||||||
createShare: jest.fn(),
|
createShare: jest.fn(),
|
||||||
getCreatedShares: jest.fn(),
|
getCreatedShares: jest.fn(),
|
||||||
|
getParticipatedShares: jest.fn(),
|
||||||
getShareChallengeDetail: jest.fn(),
|
getShareChallengeDetail: jest.fn(),
|
||||||
joinShare: jest.fn(),
|
joinShare: jest.fn(),
|
||||||
submitChallenge: jest.fn(),
|
submitChallenge: jest.fn(),
|
||||||
@@ -95,6 +96,31 @@ describe('ShareController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getParticipatedShares', () => {
|
||||||
|
it('should return success response with participated share list', async () => {
|
||||||
|
const participatedSharesResponse = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '好友挑战',
|
||||||
|
participantCount: 5,
|
||||||
|
userRank: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockShareService.getParticipatedShares.mockResolvedValue(
|
||||||
|
participatedSharesResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getParticipatedShares(mockUser);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(participatedSharesResponse);
|
||||||
|
expect(mockShareService.getParticipatedShares).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getShareChallengeDetail', () => {
|
describe('getShareChallengeDetail', () => {
|
||||||
it('should return success response with challenge detail and rankings', async () => {
|
it('should return success response with challenge detail and rankings', async () => {
|
||||||
const detailResponse = {
|
const detailResponse = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
|
ParticipatedShareListResponseDto,
|
||||||
ShareChallengeDetailResponseDto,
|
ShareChallengeDetailResponseDto,
|
||||||
SubmitShareChallengeResponseDto,
|
SubmitShareChallengeResponseDto,
|
||||||
} from './dto/share-response.dto';
|
} from './dto/share-response.dto';
|
||||||
@@ -40,6 +41,21 @@ export class ShareController {
|
|||||||
return ApiResponseDto.success(data);
|
return ApiResponseDto.success(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('participated')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取我参与的分享挑战',
|
||||||
|
description: '返回当前用户参与过的分享挑战、参与人数和用户排名',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
async getParticipatedShares(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<ApiResponseDto<ParticipatedShareListResponseDto>> {
|
||||||
|
const data = await this.shareService.getParticipatedShares(user.sub);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ShareService', () => {
|
|||||||
addParticipant: jest.fn(),
|
addParticipant: jest.fn(),
|
||||||
countByShareConfigId: jest.fn(),
|
countByShareConfigId: jest.fn(),
|
||||||
countByShareConfigIds: jest.fn(),
|
countByShareConfigIds: jest.fn(),
|
||||||
|
findByParticipantId: jest.fn(),
|
||||||
countSubmittedByShareConfigId: jest.fn(),
|
countSubmittedByShareConfigId: jest.fn(),
|
||||||
countSubmittedByShareConfigIds: jest.fn(),
|
countSubmittedByShareConfigIds: jest.fn(),
|
||||||
existsByShareConfigAndParticipant: jest.fn(),
|
existsByShareConfigAndParticipant: jest.fn(),
|
||||||
@@ -342,6 +343,126 @@ describe('ShareService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getParticipatedShares', () => {
|
||||||
|
it('should return empty list when user has not participated in any share', async () => {
|
||||||
|
mockShareParticipantRepository.findByParticipantId.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getParticipatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ items: [] });
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.countSubmittedByShareConfigIds,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return participated shares with title, participant count and user rank', async () => {
|
||||||
|
const friendShareConfig: ShareConfig = {
|
||||||
|
...mockShareConfig,
|
||||||
|
id: 'share-uuid-2',
|
||||||
|
shareCode: 'WXYZ5678',
|
||||||
|
title: '好友挑战',
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockShareParticipantRepository.findByParticipantId.mockResolvedValue([
|
||||||
|
{ shareConfig: friendShareConfig },
|
||||||
|
{ shareConfig: mockShareConfig },
|
||||||
|
]);
|
||||||
|
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['share-uuid-1', 3],
|
||||||
|
['share-uuid-2', 2],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 100,
|
||||||
|
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
correctCount: 5,
|
||||||
|
totalTimeSpent: 120,
|
||||||
|
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-2',
|
||||||
|
participantId: 'user-uuid-3',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 90,
|
||||||
|
submittedAt: new Date('2026-01-02T00:01:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-2',
|
||||||
|
participantId: 'user-uuid-1',
|
||||||
|
correctCount: 6,
|
||||||
|
totalTimeSpent: 110,
|
||||||
|
submittedAt: new Date('2026-01-02T00:02:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getParticipatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '好友挑战',
|
||||||
|
participantCount: 2,
|
||||||
|
userRank: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '我的挑战',
|
||||||
|
participantCount: 3,
|
||||||
|
userRank: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.countSubmittedByShareConfigIds,
|
||||||
|
).toHaveBeenCalledWith(['share-uuid-2', 'share-uuid-1']);
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds,
|
||||||
|
).toHaveBeenCalledWith(['share-uuid-2', 'share-uuid-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null userRank when user joined but has not submitted', async () => {
|
||||||
|
mockShareParticipantRepository.findByParticipantId.mockResolvedValue([
|
||||||
|
{ shareConfig: mockShareConfig },
|
||||||
|
]);
|
||||||
|
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||||
|
new Map([['share-uuid-1', 1]]),
|
||||||
|
);
|
||||||
|
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
correctCount: 4,
|
||||||
|
totalTimeSpent: 80,
|
||||||
|
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getParticipatedShares('user-uuid-1');
|
||||||
|
|
||||||
|
expect(result.items[0]).toEqual({
|
||||||
|
title: '我的挑战',
|
||||||
|
participantCount: 1,
|
||||||
|
userRank: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getShareChallengeDetail', () => {
|
describe('getShareChallengeDetail', () => {
|
||||||
it('should return challenge detail with all submitted rankings', async () => {
|
it('should return challenge detail with all submitted rankings', async () => {
|
||||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
CreatedShareListResponseDto,
|
CreatedShareListResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
|
ParticipatedShareListResponseDto,
|
||||||
ShareChallengeDetailResponseDto,
|
ShareChallengeDetailResponseDto,
|
||||||
ShareLevelDto,
|
ShareLevelDto,
|
||||||
SubmittedShareLevelDto,
|
SubmittedShareLevelDto,
|
||||||
@@ -185,6 +186,51 @@ export class ShareService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getParticipatedShares(
|
||||||
|
userId: string,
|
||||||
|
): Promise<ParticipatedShareListResponseDto> {
|
||||||
|
const participants =
|
||||||
|
await this.shareParticipantRepository.findByParticipantId(userId);
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = participants.map((participant) => participant.shareConfig);
|
||||||
|
const shareConfigIds = configs.map((config) => config.id);
|
||||||
|
const [participantCountMap, rankingRows] = await Promise.all([
|
||||||
|
this.shareParticipantRepository.countSubmittedByShareConfigIds(
|
||||||
|
shareConfigIds,
|
||||||
|
),
|
||||||
|
this.shareParticipantRepository.findSubmittedRankingsByShareConfigIds(
|
||||||
|
shareConfigIds,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rankingsByShareConfigId = new Map<string, typeof rankingRows>();
|
||||||
|
for (const config of configs) {
|
||||||
|
const rankings = rankingRows.filter(
|
||||||
|
(row) => row.shareConfigId === config.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
rankingsByShareConfigId.set(config.id, rankings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: configs.map((config) => {
|
||||||
|
const rankings = rankingsByShareConfigId.get(config.id) ?? [];
|
||||||
|
const rankingIndex = rankings.findIndex(
|
||||||
|
(ranking) => ranking.participantId === userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: config.title,
|
||||||
|
participantCount: participantCountMap.get(config.id) ?? 0,
|
||||||
|
userRank: rankingIndex >= 0 ? rankingIndex + 1 : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async submitChallenge(
|
async submitChallenge(
|
||||||
userId: string,
|
userId: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user