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

View File

@@ -47,3 +47,37 @@ export class JoinShareResponseDto {
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
levels!: ShareLevelDto[];
}
export class CreatedShareItemDto {
@ApiProperty({ description: '分享 ID' })
id!: string;
@ApiProperty({ description: '分享码' })
shareCode!: string;
@ApiProperty({ description: '分享标题' })
title!: string;
@ApiProperty({ description: '关卡数量' })
levelCount!: number;
@ApiProperty({ description: '参与挑战人数' })
participantCount!: number;
@ApiProperty({
description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
nullable: true,
})
userRank!: number | null;
@ApiProperty({ description: '创建时间' })
createdAt!: string;
}
export class CreatedShareListResponseDto {
@ApiProperty({
description: '当前用户创建的分享挑战列表',
type: [CreatedShareItemDto],
})
items!: CreatedShareItemDto[];
}

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ describe('ShareController', () => {
const mockShareService = {
createShare: jest.fn(),
getCreatedShares: jest.fn(),
joinShare: jest.fn(),
reportLevelProgress: jest.fn(),
};
@@ -60,6 +61,35 @@ describe('ShareController', () => {
});
});
describe('getCreatedShares', () => {
it('should return success response with created share list', async () => {
const createdSharesResponse = {
items: [
{
id: 'share-uuid-1',
shareCode: 'ABCD1234',
title: '我的挑战',
levelCount: 6,
participantCount: 8,
userRank: 2,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
};
mockShareService.getCreatedShares.mockResolvedValue(
createdSharesResponse,
);
const result = await controller.getCreatedShares(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual(createdSharesResponse);
expect(mockShareService.getCreatedShares).toHaveBeenCalledWith(
'user-uuid-1',
);
});
});
describe('joinShare', () => {
it('should return success response with share levels', async () => {
const joinResponse = {

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

View File

@@ -1,8 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { ShareService } from './share.service';
import { ShareConfigRepository } from './repositories/share-config.repository';
import { ShareParticipantRepository } from './repositories/share-participant.repository';
@@ -12,6 +9,7 @@ import { Level } from '../wechat-game/entities/level.entity';
import { ShareConfig } from './entities/share-config.entity';
import { ShareParticipant } from './entities/share-participant.entity';
import { ShareLevelProgress } from './entities/share-level-progress.entity';
import { User } from '../auth/entities/user.entity';
// Mock nanoid to return predictable values
jest.mock('nanoid', () => ({
@@ -40,7 +38,7 @@ describe('ShareService', () => {
title: '我的挑战',
sharerId: 'user-uuid-1',
levelIds: mockLevels.map((l) => l.id),
sharer: {} as any,
sharer: {} as User,
participants: [],
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
@@ -50,19 +48,21 @@ describe('ShareService', () => {
id: 'participant-uuid-1',
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
shareConfig: {} as any,
participant: {} as any,
shareConfig: {} as ShareConfig,
participant: {} as User,
createdAt: new Date('2026-01-01'),
};
const mockShareConfigRepository = {
create: jest.fn(),
findByShareCode: jest.fn(),
findBySharerId: jest.fn(),
};
const mockShareParticipantRepository = {
addParticipant: jest.fn(),
countByShareConfigId: jest.fn(),
countByShareConfigIds: jest.fn(),
findByShareConfigAndParticipant: jest.fn(),
create: jest.fn(),
save: jest.fn(),
@@ -70,6 +70,7 @@ describe('ShareService', () => {
const mockShareLevelProgressRepository = {
findByParticipantAndLevel: jest.fn(),
summarizeByShareConfigIds: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
@@ -106,7 +107,14 @@ describe('ShareService', () => {
describe('createShare', () => {
const createDto = {
title: '我的挑战',
levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'],
levelIds: [
'level-1',
'level-2',
'level-3',
'level-4',
'level-5',
'level-6',
],
};
it('should create a share with valid 6 unique levels', async () => {
@@ -124,7 +132,14 @@ describe('ShareService', () => {
it('should throw BadRequestException when level IDs have duplicates', async () => {
const duplicateDto = {
title: '测试',
levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'],
levelIds: [
'level-1',
'level-1',
'level-2',
'level-3',
'level-4',
'level-5',
],
};
await expect(
@@ -158,7 +173,9 @@ describe('ShareService', () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined);
mockShareParticipantRepository.addParticipant.mockResolvedValue(
undefined,
);
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
@@ -168,10 +185,9 @@ describe('ShareService', () => {
expect(result.levels).toHaveLength(6);
expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1');
expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith(
'share-uuid-1',
'user-uuid-2',
);
expect(
mockShareParticipantRepository.addParticipant,
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
});
it('should not add participant when user is the sharer', async () => {
@@ -191,9 +207,122 @@ describe('ShareService', () => {
it('should throw NotFoundException when share code not found', async () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
await expect(
service.joinShare('user-uuid-2', 'INVALID'),
).rejects.toThrow(NotFoundException);
await expect(service.joinShare('user-uuid-2', 'INVALID')).rejects.toThrow(
NotFoundException,
);
});
});
describe('getCreatedShares', () => {
it('should return empty list when user has not created any share', async () => {
mockShareConfigRepository.findBySharerId.mockResolvedValue([]);
const result = await service.getCreatedShares('user-uuid-1');
expect(result).toEqual({ items: [] });
expect(
mockShareParticipantRepository.countByShareConfigIds,
).not.toHaveBeenCalled();
expect(
mockShareLevelProgressRepository.summarizeByShareConfigIds,
).not.toHaveBeenCalled();
});
it('should return created shares with participant count and user rank', async () => {
const otherShareConfig: ShareConfig = {
...mockShareConfig,
id: 'share-uuid-2',
shareCode: 'WXYZ5678',
title: '第二个挑战',
createdAt: new Date('2026-01-02T00:00:00.000Z'),
};
mockShareConfigRepository.findBySharerId.mockResolvedValue([
otherShareConfig,
mockShareConfig,
]);
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
new Map([
['share-uuid-1', 3],
['share-uuid-2', 1],
]),
);
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
[
{
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-1',
totalTimeSpent: '120',
passedLevelCount: '6',
},
{
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
totalTimeSpent: '100',
passedLevelCount: '6',
},
{
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-3',
totalTimeSpent: '200',
passedLevelCount: '5',
},
],
);
const result = await service.getCreatedShares('user-uuid-1');
expect(result).toEqual({
items: [
{
id: 'share-uuid-2',
shareCode: 'WXYZ5678',
title: '第二个挑战',
levelCount: 6,
participantCount: 1,
userRank: null,
createdAt: '2026-01-02T00:00:00.000Z',
},
{
id: 'share-uuid-1',
shareCode: 'ABCD1234',
title: '我的挑战',
levelCount: 6,
participantCount: 3,
userRank: 2,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
});
});
it('should use participantId as deterministic tie breaker for rank', async () => {
mockShareConfigRepository.findBySharerId.mockResolvedValue([
mockShareConfig,
]);
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
new Map([['share-uuid-1', 2]]),
);
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
[
{
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
totalTimeSpent: '120',
passedLevelCount: '6',
},
{
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-1',
totalTimeSpent: '120',
passedLevelCount: '6',
},
],
);
const result = await service.getCreatedShares('user-uuid-1');
expect(result.items[0].userRank).toBe(1);
});
});

View File

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