diff --git a/AGENTS.md b/AGENTS.md index 1960dab..9f16037 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,35 @@ # 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. \ No newline at end of file diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index 34bd534..8d7f02e 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -4,6 +4,7 @@ ## Changelog +- 2026-05-14: 新增 `GET /api/v1/share/participated` 我参与的挑战列表接口,返回挑战名称、已提交参与人数和当前用户名次。 - 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`,客户端一次提交整场挑战的每关答案和耗时,服务端校验答案后返回排名、答对题数、参与人数和完整关卡答案,并持久化提交结果。 @@ -31,7 +32,8 @@ 3. **挑战结果返回**:提交后返回当前用户排名、答对题数、参与人数、总耗时和每关校验结果 4. **分享挑战详情**:`GET /api/v1/share/{code}` 接口,用于查询单个挑战的基本信息和完整排行榜 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 --- -### 6. 提交分享挑战结果 +### 6. 获取我参与的分享挑战 + +获取当前登录用户参与过的分享挑战列表。 + +**接口地址**:`GET /api/v1/share/participated` + +**是否需要认证**:是(JWT Bearer Token) + +**请求头**: + +``` +Authorization: Bearer +``` + +**响应数据**: + +```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}`); ``` -### 6. 提交挑战结果 +### 6. 获取我参与的挑战列表 + +```typescript +interface ParticipatedShareItem { + title: string; + participantCount: number; + userRank: number | null; +} + +interface ParticipatedShareListResponse { + items: ParticipatedShareItem[]; +} + +async function getParticipatedShares(): Promise { + // 确保已登录 + if (!httpManager.getToken()) { + await wxLogin(); + } + + const response = await httpManager.get( + '/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 interface SubmitChallengeLevel { @@ -1033,7 +1148,7 @@ async function onChallengeFinished() { } ``` -### 7. 启动流程示例 +### 8. 启动流程示例 ```typescript // GameEntry.ts - 游戏入口脚本 diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts index 2eb92ea..3a11704 100644 --- a/src/modules/share/dto/share-response.dto.ts +++ b/src/modules/share/dto/share-response.dto.ts @@ -206,3 +206,25 @@ export class CreatedShareListResponseDto { }) 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[]; +} diff --git a/src/modules/share/repositories/share-participant.repository.ts b/src/modules/share/repositories/share-participant.repository.ts index e175e83..58d4c97 100644 --- a/src/modules/share/repositories/share-participant.repository.ts +++ b/src/modules/share/repositories/share-participant.repository.ts @@ -65,6 +65,15 @@ export class ShareParticipantRepository { ); } + async findByParticipantId(participantId: string): Promise { + return this.repository + .createQueryBuilder('participant') + .leftJoinAndSelect('participant.shareConfig', 'shareConfig') + .where('participant.participantId = :participantId', { participantId }) + .orderBy('shareConfig.createdAt', 'DESC') + .getMany(); + } + async upsertSubmissionSummary(data: { shareConfigId: string; participantId: string; diff --git a/src/modules/share/share.controller.spec.ts b/src/modules/share/share.controller.spec.ts index b7fec13..f9990d6 100644 --- a/src/modules/share/share.controller.spec.ts +++ b/src/modules/share/share.controller.spec.ts @@ -16,6 +16,7 @@ describe('ShareController', () => { const mockShareService = { createShare: jest.fn(), getCreatedShares: jest.fn(), + getParticipatedShares: jest.fn(), getShareChallengeDetail: jest.fn(), joinShare: 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', () => { it('should return success response with challenge detail and rankings', async () => { const detailResponse = { diff --git a/src/modules/share/share.controller.ts b/src/modules/share/share.controller.ts index c106f08..1743049 100644 --- a/src/modules/share/share.controller.ts +++ b/src/modules/share/share.controller.ts @@ -11,6 +11,7 @@ import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, + ParticipatedShareListResponseDto, ShareChallengeDetailResponseDto, SubmitShareChallengeResponseDto, } from './dto/share-response.dto'; @@ -40,6 +41,21 @@ export class ShareController { return ApiResponseDto.success(data); } + @Get('participated') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: '获取我参与的分享挑战', + description: '返回当前用户参与过的分享挑战、参与人数和用户排名', + }) + @ApiResponse({ status: 200, description: '成功' }) + async getParticipatedShares( + @CurrentUser() user: JwtPayload, + ): Promise> { + const data = await this.shareService.getParticipatedShares(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 871d21f..3b29b21 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -58,6 +58,7 @@ describe('ShareService', () => { addParticipant: jest.fn(), countByShareConfigId: jest.fn(), countByShareConfigIds: jest.fn(), + findByParticipantId: jest.fn(), countSubmittedByShareConfigId: jest.fn(), countSubmittedByShareConfigIds: 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', () => { it('should return challenge detail with all submitted rankings', async () => { mockShareConfigRepository.findByShareCode.mockResolvedValue( diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index 564a42f..a164cff 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -16,6 +16,7 @@ import { CreateShareResponseDto, CreatedShareListResponseDto, JoinShareResponseDto, + ParticipatedShareListResponseDto, ShareChallengeDetailResponseDto, ShareLevelDto, SubmittedShareLevelDto, @@ -185,6 +186,51 @@ export class ShareService { }; } + async getParticipatedShares( + userId: string, + ): Promise { + 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(); + 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( userId: string, code: string,