feat: 支持获取我创建的分享挑战列表以及详情数据
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user