perf: 支持分享提交答案的接口
This commit is contained in:
37
src/database/migrations/006_share_challenge_submission.sql
Normal file
37
src/database/migrations/006_share_challenge_submission.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Description: Persist full share challenge submissions.
|
||||
--
|
||||
-- The new share challenge submit flow receives every level answer and time
|
||||
-- spent in one request. Keep per-level answers in share_level_progress and
|
||||
-- store aggregate ranking fields on share_participants for later analytics.
|
||||
|
||||
ALTER TABLE share_participants
|
||||
ADD COLUMN correct_count INT NOT NULL DEFAULT 0
|
||||
COMMENT '提交结果中答对题数'
|
||||
AFTER participant_id,
|
||||
ADD COLUMN total_time_spent INT NOT NULL DEFAULT 0
|
||||
COMMENT '提交结果总耗时(秒)'
|
||||
AFTER correct_count,
|
||||
ADD COLUMN submitted_at DATETIME DEFAULT NULL
|
||||
COMMENT '最近一次提交挑战结果的时间'
|
||||
AFTER total_time_spent,
|
||||
ADD COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
COMMENT '更新时间'
|
||||
AFTER created_at;
|
||||
|
||||
CREATE INDEX idx_share_participants_submitted
|
||||
ON share_participants (share_config_id, submitted_at);
|
||||
|
||||
CREATE INDEX idx_share_participants_ranking
|
||||
ON share_participants (
|
||||
share_config_id,
|
||||
correct_count,
|
||||
total_time_spent,
|
||||
submitted_at,
|
||||
participant_id
|
||||
);
|
||||
|
||||
ALTER TABLE share_level_progress
|
||||
ADD COLUMN submitted_answer VARCHAR(191) NOT NULL DEFAULT ''
|
||||
COMMENT '用户提交的答案'
|
||||
AFTER level_id;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ReportLevelProgressDto {
|
||||
@ApiProperty({ description: '分享码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
shareCode!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
levelId!: string;
|
||||
|
||||
@ApiProperty({ description: '是否通过' })
|
||||
@IsBoolean()
|
||||
passed!: boolean;
|
||||
|
||||
@ApiProperty({ description: '通关时间(秒)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
timeSpent!: number;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ReportLevelProgressResponseDto {
|
||||
@ApiProperty({ description: '是否通过' })
|
||||
passed!: boolean;
|
||||
|
||||
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||
timeLimit!: number | null;
|
||||
|
||||
@ApiProperty({ description: '是否在时间限制内通过' })
|
||||
withinTimeLimit!: boolean;
|
||||
}
|
||||
@@ -60,6 +60,52 @@ export class JoinShareResponseDto {
|
||||
levels!: ShareLevelDto[];
|
||||
}
|
||||
|
||||
export class SubmittedShareLevelDto extends ShareLevelDto {
|
||||
@ApiProperty({ description: '用户提交的答案' })
|
||||
submittedAnswer!: string;
|
||||
|
||||
@ApiProperty({ description: '本关耗时(秒)' })
|
||||
timeSpent!: number;
|
||||
|
||||
@ApiProperty({ description: '答案是否正确' })
|
||||
isCorrect!: boolean;
|
||||
|
||||
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||
timeLimit!: number | null;
|
||||
|
||||
@ApiProperty({ description: '是否在时间限制内完成' })
|
||||
withinTimeLimit!: boolean;
|
||||
}
|
||||
|
||||
export class SubmitShareChallengeResponseDto {
|
||||
@ApiProperty({ description: '分享码' })
|
||||
shareCode!: string;
|
||||
|
||||
@ApiProperty({ description: '分享标题' })
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: '当前用户在该挑战中的排名' })
|
||||
rank!: number;
|
||||
|
||||
@ApiProperty({ description: '当前用户答对题数' })
|
||||
correctCount!: number;
|
||||
|
||||
@ApiProperty({ description: '挑战关卡总数' })
|
||||
levelCount!: number;
|
||||
|
||||
@ApiProperty({ description: '已提交结果的参与用户总数' })
|
||||
participantCount!: number;
|
||||
|
||||
@ApiProperty({ description: '当前用户本次提交的总耗时(秒)' })
|
||||
totalTimeSpent!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '分享中的每一关内容、正确答案和当前用户提交结果',
|
||||
type: [SubmittedShareLevelDto],
|
||||
})
|
||||
levels!: SubmittedShareLevelDto[];
|
||||
}
|
||||
|
||||
export class CreatedShareItemDto {
|
||||
@ApiProperty({ description: '分享 ID' })
|
||||
id!: string;
|
||||
@@ -77,7 +123,7 @@ export class CreatedShareItemDto {
|
||||
participantCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
|
||||
description: '当前用户在该挑战中的排名,尚未提交挑战结果时为 null',
|
||||
nullable: true,
|
||||
})
|
||||
userRank!: number | null;
|
||||
|
||||
41
src/modules/share/dto/submit-share-challenge.dto.ts
Normal file
41
src/modules/share/dto/submit-share-challenge.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SubmitShareLevelAnswerDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
levelId!: string;
|
||||
|
||||
@ApiProperty({ description: '用户提交的答案,空字符串表示未作答' })
|
||||
@IsString()
|
||||
@MaxLength(191)
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '本关耗时(秒)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
timeSpent!: number;
|
||||
}
|
||||
|
||||
export class SubmitShareChallengeDto {
|
||||
@ApiProperty({
|
||||
description: '本次挑战中每一关的答案和耗时,必须覆盖分享中的全部关卡',
|
||||
type: [SubmitShareLevelAnswerDto],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitShareLevelAnswerDto)
|
||||
levels!: SubmitShareLevelAnswerDto[];
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ShareConfig } from './share-config.entity';
|
||||
import { Level } from '../../wechat-game/entities/level.entity';
|
||||
|
||||
/**
|
||||
* 分享挑战内的单关进度。
|
||||
* 分享挑战内的单关提交结果。
|
||||
*
|
||||
* (share_config_id, participant_id, level_id) 三元组保证:
|
||||
* 同一用户在不同分享挑战中对同一关的记录互不干扰。
|
||||
@@ -44,6 +44,14 @@ export class ShareLevelProgress {
|
||||
@Column({ type: 'char', length: 191, name: 'level_id' })
|
||||
levelId!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 191,
|
||||
name: 'submitted_answer',
|
||||
default: '',
|
||||
})
|
||||
submittedAnswer!: string;
|
||||
|
||||
@ManyToOne(() => Level)
|
||||
@JoinColumn({ name: 'level_id' })
|
||||
level!: Level;
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
@@ -17,6 +19,14 @@ import { ShareConfig } from './share-config.entity';
|
||||
* - participant_id 直接存储 wx_users.id(用户 UUID)
|
||||
*/
|
||||
@Entity('share_participants')
|
||||
@Index('idx_share_participants_submitted', ['shareConfigId', 'submittedAt'])
|
||||
@Index('idx_share_participants_ranking', [
|
||||
'shareConfigId',
|
||||
'correctCount',
|
||||
'totalTimeSpent',
|
||||
'submittedAt',
|
||||
'participantId',
|
||||
])
|
||||
export class ShareParticipant {
|
||||
@PrimaryColumn({ type: 'varchar', length: 191, name: 'share_config_id' })
|
||||
@Index('idx_share_config_id')
|
||||
@@ -25,6 +35,20 @@ export class ShareParticipant {
|
||||
@PrimaryColumn({ type: 'varchar', length: 191, name: 'participant_id' })
|
||||
participantId!: string;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'correct_count' })
|
||||
correctCount!: number;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'total_time_spent' })
|
||||
totalTimeSpent!: number;
|
||||
|
||||
@Column({
|
||||
type: 'timestamp',
|
||||
name: 'submitted_at',
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
submittedAt!: Date | null;
|
||||
|
||||
@ManyToOne(() => ShareConfig, (sc) => sc.participants)
|
||||
@JoinColumn({ name: 'share_config_id' })
|
||||
shareConfig!: ShareConfig;
|
||||
@@ -35,4 +59,7 @@ export class ShareParticipant {
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@@ -63,4 +63,10 @@ export class ShareLevelProgressRepository {
|
||||
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||
return this.repository.save(progress);
|
||||
}
|
||||
|
||||
async saveMany(
|
||||
progressList: ShareLevelProgress[],
|
||||
): Promise<ShareLevelProgress[]> {
|
||||
return this.repository.save(progressList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ type ShareParticipantCountRow = {
|
||||
participantCount: string;
|
||||
};
|
||||
|
||||
export type ShareParticipantRankingRow = {
|
||||
shareConfigId: string;
|
||||
participantId: string;
|
||||
correctCount: number;
|
||||
totalTimeSpent: number;
|
||||
submittedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ShareParticipantRepository {
|
||||
constructor(
|
||||
@@ -55,6 +63,107 @@ export class ShareParticipantRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async upsertSubmissionSummary(data: {
|
||||
shareConfigId: string;
|
||||
participantId: string;
|
||||
correctCount: number;
|
||||
totalTimeSpent: number;
|
||||
submittedAt: Date;
|
||||
}): Promise<void> {
|
||||
await this.repository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(ShareParticipant)
|
||||
.values(data)
|
||||
.orUpdate(
|
||||
['correctCount', 'totalTimeSpent', 'submittedAt'],
|
||||
['shareConfigId', 'participantId'],
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async countSubmittedByShareConfigId(shareConfigId: string): Promise<number> {
|
||||
return this.repository
|
||||
.createQueryBuilder('participant')
|
||||
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
||||
.andWhere('participant.submittedAt IS NOT NULL')
|
||||
.getCount();
|
||||
}
|
||||
|
||||
async countSubmittedByShareConfigIds(
|
||||
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(*)', 'participantCount')
|
||||
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||
shareConfigIds,
|
||||
})
|
||||
.andWhere('participant.submittedAt IS NOT NULL')
|
||||
.groupBy('participant.shareConfigId')
|
||||
.getRawMany<ShareParticipantCountRow>();
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [row.shareConfigId, Number(row.participantCount)]),
|
||||
);
|
||||
}
|
||||
|
||||
async findSubmittedRankingsByShareConfigId(
|
||||
shareConfigId: string,
|
||||
): Promise<ShareParticipantRankingRow[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('participant')
|
||||
.where('participant.shareConfigId = :shareConfigId', { shareConfigId })
|
||||
.andWhere('participant.submittedAt IS NOT NULL')
|
||||
.orderBy('participant.correctCount', 'DESC')
|
||||
.addOrderBy('participant.totalTimeSpent', 'ASC')
|
||||
.addOrderBy('participant.submittedAt', 'ASC')
|
||||
.addOrderBy('participant.participantId', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return rows.map((row) => ({
|
||||
shareConfigId: row.shareConfigId,
|
||||
participantId: row.participantId,
|
||||
correctCount: row.correctCount,
|
||||
totalTimeSpent: row.totalTimeSpent,
|
||||
submittedAt: row.submittedAt!,
|
||||
}));
|
||||
}
|
||||
|
||||
async findSubmittedRankingsByShareConfigIds(
|
||||
shareConfigIds: string[],
|
||||
): Promise<ShareParticipantRankingRow[]> {
|
||||
if (shareConfigIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('participant')
|
||||
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||
shareConfigIds,
|
||||
})
|
||||
.andWhere('participant.submittedAt IS NOT NULL')
|
||||
.orderBy('participant.shareConfigId', 'ASC')
|
||||
.addOrderBy('participant.correctCount', 'DESC')
|
||||
.addOrderBy('participant.totalTimeSpent', 'ASC')
|
||||
.addOrderBy('participant.submittedAt', 'ASC')
|
||||
.addOrderBy('participant.participantId', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return rows.map((row) => ({
|
||||
shareConfigId: row.shareConfigId,
|
||||
participantId: row.participantId,
|
||||
correctCount: row.correctCount,
|
||||
totalTimeSpent: row.totalTimeSpent,
|
||||
submittedAt: row.submittedAt!,
|
||||
}));
|
||||
}
|
||||
|
||||
async existsByShareConfigAndParticipant(
|
||||
shareConfigId: string,
|
||||
participantId: string,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('ShareController', () => {
|
||||
createShare: jest.fn(),
|
||||
getCreatedShares: jest.fn(),
|
||||
joinShare: jest.fn(),
|
||||
reportLevelProgress: jest.fn(),
|
||||
submitChallenge: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -99,8 +99,12 @@ describe('ShareController', () => {
|
||||
{
|
||||
id: 'l1',
|
||||
level: 1,
|
||||
imageUrl: 'https://example.com/1.jpg',
|
||||
image1Url: 'https://example.com/1-a.jpg',
|
||||
image1Description: null,
|
||||
image2Url: 'https://example.com/1-b.jpg',
|
||||
image2Description: null,
|
||||
answer: '答案',
|
||||
punchline: null,
|
||||
hint1: null,
|
||||
hint2: null,
|
||||
hint3: null,
|
||||
@@ -121,27 +125,54 @@ describe('ShareController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportLevelProgress', () => {
|
||||
it('should return success response with progress result', async () => {
|
||||
const progressResponse = {
|
||||
passed: true,
|
||||
timeLimit: 60,
|
||||
withinTimeLimit: true,
|
||||
describe('submitChallenge', () => {
|
||||
it('should return success response with challenge result', async () => {
|
||||
const submitResponse = {
|
||||
shareCode: 'ABCD1234',
|
||||
title: '我的挑战',
|
||||
rank: 1,
|
||||
correctCount: 1,
|
||||
levelCount: 1,
|
||||
participantCount: 3,
|
||||
totalTimeSpent: 30,
|
||||
levels: [
|
||||
{
|
||||
id: 'level-1',
|
||||
level: 1,
|
||||
image1Url: 'https://example.com/1-a.jpg',
|
||||
image1Description: null,
|
||||
image2Url: 'https://example.com/1-b.jpg',
|
||||
image2Description: null,
|
||||
answer: '答案',
|
||||
punchline: null,
|
||||
hint1: null,
|
||||
hint2: null,
|
||||
hint3: null,
|
||||
sortOrder: 0,
|
||||
submittedAnswer: '答案',
|
||||
timeSpent: 30,
|
||||
isCorrect: true,
|
||||
timeLimit: 60,
|
||||
withinTimeLimit: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockShareService.reportLevelProgress.mockResolvedValue(progressResponse);
|
||||
mockShareService.submitChallenge.mockResolvedValue(submitResponse);
|
||||
|
||||
const dto = {
|
||||
shareCode: 'ABCD1234',
|
||||
levelId: 'level-1',
|
||||
passed: true,
|
||||
timeSpent: 30,
|
||||
levels: [{ levelId: 'level-1', answer: '答案', timeSpent: 30 }],
|
||||
};
|
||||
const result = await controller.reportLevelProgress(mockUser, dto);
|
||||
const result = await controller.submitChallenge(
|
||||
mockUser,
|
||||
'ABCD1234',
|
||||
dto,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(progressResponse);
|
||||
expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith(
|
||||
expect(result.data).toEqual(submitResponse);
|
||||
expect(mockShareService.submitChallenge).toHaveBeenCalledWith(
|
||||
'user-uuid-1',
|
||||
'ABCD1234',
|
||||
dto,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
CreateShareResponseDto,
|
||||
CreatedShareListResponseDto,
|
||||
JoinShareResponseDto,
|
||||
SubmitShareChallengeResponseDto,
|
||||
} from './dto/share-response.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto';
|
||||
|
||||
@ApiTags('分享挑战')
|
||||
@Controller('v1/share')
|
||||
@@ -73,20 +73,22 @@ export class ShareController {
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post('progress')
|
||||
@Post(':code/submit')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '上报单关进度',
|
||||
description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效',
|
||||
summary: '提交分享挑战结果',
|
||||
description:
|
||||
'客户端一次性提交分享挑战中每一关的耗时和答案,服务端校验后返回排名、答对题数、参与人数和完整关卡答案',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
||||
async reportLevelProgress(
|
||||
async submitChallenge(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ReportLevelProgressDto,
|
||||
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>> {
|
||||
const data = await this.shareService.reportLevelProgress(user.sub, dto);
|
||||
@Param('code') code: string,
|
||||
@Body() dto: SubmitShareChallengeDto,
|
||||
): Promise<ApiResponseDto<SubmitShareChallengeResponseDto>> {
|
||||
const data = await this.shareService.submitChallenge(user.sub, code, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,21 @@ describe('ShareService', () => {
|
||||
addParticipant: jest.fn(),
|
||||
countByShareConfigId: jest.fn(),
|
||||
countByShareConfigIds: jest.fn(),
|
||||
countSubmittedByShareConfigId: jest.fn(),
|
||||
countSubmittedByShareConfigIds: jest.fn(),
|
||||
existsByShareConfigAndParticipant: jest.fn(),
|
||||
upsertSubmissionSummary: jest.fn(),
|
||||
findSubmittedRankingsByShareConfigId: jest.fn(),
|
||||
findSubmittedRankingsByShareConfigIds: jest.fn(),
|
||||
};
|
||||
|
||||
const mockShareLevelProgressRepository = {
|
||||
findByShareConfigAndParticipant: jest.fn(),
|
||||
findByShareConfigParticipantAndLevel: jest.fn(),
|
||||
summarizeByShareConfigIds: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
saveMany: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLevelRepository = {
|
||||
@@ -214,10 +221,10 @@ describe('ShareService', () => {
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(
|
||||
mockShareParticipantRepository.countByShareConfigIds,
|
||||
mockShareParticipantRepository.countSubmittedByShareConfigIds,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds,
|
||||
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -234,31 +241,34 @@ describe('ShareService', () => {
|
||||
otherShareConfig,
|
||||
mockShareConfig,
|
||||
]);
|
||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||
new Map([
|
||||
['share-uuid-1', 3],
|
||||
['share-uuid-2', 1],
|
||||
]),
|
||||
);
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-1',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
participantId: 'user-uuid-2',
|
||||
correctCount: 6,
|
||||
totalTimeSpent: 100,
|
||||
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
totalTimeSpent: '100',
|
||||
passedLevelCount: '6',
|
||||
participantId: 'user-uuid-1',
|
||||
correctCount: 6,
|
||||
totalTimeSpent: 120,
|
||||
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-3',
|
||||
totalTimeSpent: '200',
|
||||
passedLevelCount: '5',
|
||||
correctCount: 5,
|
||||
totalTimeSpent: 200,
|
||||
submittedAt: new Date('2026-01-01T00:03:00.000Z'),
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -289,235 +299,249 @@ describe('ShareService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use participantId as deterministic tie breaker for rank', async () => {
|
||||
it('should use submitted ranking order for rank', async () => {
|
||||
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||
mockShareConfig,
|
||||
]);
|
||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||
mockShareParticipantRepository.countSubmittedByShareConfigIds.mockResolvedValue(
|
||||
new Map([['share-uuid-1', 2]]),
|
||||
);
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||
mockShareParticipantRepository.findSubmittedRankingsByShareConfigIds.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
correctCount: 6,
|
||||
totalTimeSpent: 120,
|
||||
submittedAt: new Date('2026-01-01T00:02:00.000Z'),
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-1',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
correctCount: 6,
|
||||
totalTimeSpent: 120,
|
||||
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await service.getCreatedShares('user-uuid-1');
|
||||
|
||||
expect(result.items[0].userRank).toBe(1);
|
||||
expect(result.items[0].userRank).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportLevelProgress', () => {
|
||||
const reportDto = {
|
||||
shareCode: 'ABCD1234',
|
||||
levelId: 'level-1',
|
||||
passed: true,
|
||||
timeSpent: 30,
|
||||
describe('submitChallenge', () => {
|
||||
const submitDto = {
|
||||
levels: mockLevels.map((level, index) => ({
|
||||
levelId: level.id,
|
||||
answer: index < 4 ? level.answer : '错误答案',
|
||||
timeSpent: 10 + index,
|
||||
})),
|
||||
};
|
||||
|
||||
it('should create new progress record for first attempt', async () => {
|
||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
||||
beforeEach(() => {
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||
mockShareParticipantRepository.addParticipant.mockResolvedValue(
|
||||
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||
mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
mockShareLevelProgressRepository.create.mockImplementation(
|
||||
(data: Partial<ShareLevelProgress>) => data,
|
||||
);
|
||||
mockShareLevelProgressRepository.saveMany.mockResolvedValue([]);
|
||||
mockShareParticipantRepository.upsertSubmissionSummary.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
null,
|
||||
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
correctCount: 4,
|
||||
totalTimeSpent: 75,
|
||||
submittedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-3',
|
||||
correctCount: 3,
|
||||
totalTimeSpent: 60,
|
||||
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
);
|
||||
const newProgress: Partial<ShareLevelProgress> = {
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-1',
|
||||
passed: true,
|
||||
timeSpent: 30,
|
||||
};
|
||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||
});
|
||||
|
||||
const result = await service.reportLevelProgress(
|
||||
it('should persist all submitted answers and return challenge result', async () => {
|
||||
const result = await service.submitChallenge(
|
||||
'user-uuid-2',
|
||||
reportDto,
|
||||
'ABCD1234',
|
||||
submitDto,
|
||||
);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.timeLimit).toBe(60);
|
||||
expect(result.withinTimeLimit).toBe(true);
|
||||
expect(result.shareCode).toBe('ABCD1234');
|
||||
expect(result.correctCount).toBe(4);
|
||||
expect(result.levelCount).toBe(6);
|
||||
expect(result.participantCount).toBe(2);
|
||||
expect(result.rank).toBe(1);
|
||||
expect(result.totalTimeSpent).toBe(75);
|
||||
expect(result.levels).toHaveLength(6);
|
||||
expect(result.levels[0]).toMatchObject({
|
||||
id: 'level-1',
|
||||
submittedAnswer: '答案1',
|
||||
isCorrect: true,
|
||||
timeSpent: 10,
|
||||
timeLimit: 60,
|
||||
withinTimeLimit: true,
|
||||
});
|
||||
expect(result.levels[4]).toMatchObject({
|
||||
id: 'level-5',
|
||||
submittedAnswer: '错误答案',
|
||||
isCorrect: false,
|
||||
});
|
||||
expect(mockShareLevelProgressRepository.saveMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-1',
|
||||
submittedAnswer: '答案1',
|
||||
passed: true,
|
||||
timeSpent: 10,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
mockShareParticipantRepository.addParticipant,
|
||||
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
|
||||
expect(
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel,
|
||||
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2', 'level-1');
|
||||
});
|
||||
|
||||
it('should not register sharer themselves as participant when reporting progress', async () => {
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
mockShareLevelProgressRepository.create.mockReturnValue({} as any);
|
||||
mockShareLevelProgressRepository.save.mockResolvedValue({} as any);
|
||||
|
||||
await service.reportLevelProgress('user-uuid-1', reportDto);
|
||||
|
||||
expect(
|
||||
mockShareParticipantRepository.addParticipant,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return existing result when already passed (idempotent)', async () => {
|
||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
{
|
||||
id: 'progress-uuid-1',
|
||||
mockShareParticipantRepository.upsertSubmissionSummary,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-1',
|
||||
passed: true,
|
||||
timeSpent: 25,
|
||||
completedAt: new Date(),
|
||||
} as ShareLevelProgress,
|
||||
correctCount: 4,
|
||||
totalTimeSpent: 75,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.reportLevelProgress(
|
||||
'user-uuid-2',
|
||||
reportDto,
|
||||
);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.withinTimeLimit).toBe(true);
|
||||
expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report withinTimeLimit=false when time exceeds limit', async () => {
|
||||
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 };
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
it('should accept the sharer as a participant when they submit', async () => {
|
||||
await service.submitChallenge('user-uuid-1', 'ABCD1234', submitDto);
|
||||
|
||||
expect(
|
||||
mockShareParticipantRepository.upsertSubmissionSummary,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-1',
|
||||
}),
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
const newProgress: Partial<ShareLevelProgress> = {
|
||||
});
|
||||
|
||||
it('should update existing per-level progress on resubmission', async () => {
|
||||
const existingProgress = {
|
||||
id: 'progress-uuid-1',
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-1',
|
||||
passed: true,
|
||||
timeSpent: 30,
|
||||
};
|
||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||
submittedAnswer: '旧答案',
|
||||
passed: false,
|
||||
timeSpent: 99,
|
||||
completedAt: new Date('2026-01-01'),
|
||||
} as ShareLevelProgress;
|
||||
mockShareLevelProgressRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||
[existingProgress],
|
||||
);
|
||||
|
||||
const result = await service.reportLevelProgress('user-uuid-2', {
|
||||
...reportDto,
|
||||
timeSpent: 30,
|
||||
});
|
||||
await service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.withinTimeLimit).toBe(false);
|
||||
expect(mockShareLevelProgressRepository.saveMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'progress-uuid-1',
|
||||
submittedAnswer: '答案1',
|
||||
passed: true,
|
||||
timeSpent: 10,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report withinTimeLimit=true when level has no time limit', async () => {
|
||||
const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null };
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
it('should rank by the persisted challenge ranking', async () => {
|
||||
mockShareParticipantRepository.findSubmittedRankingsByShareConfigId.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-3',
|
||||
correctCount: 5,
|
||||
totalTimeSpent: 80,
|
||||
submittedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
correctCount: 4,
|
||||
totalTimeSpent: 75,
|
||||
submittedAt: new Date('2026-01-01T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit);
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
null,
|
||||
|
||||
const result = await service.submitChallenge(
|
||||
'user-uuid-2',
|
||||
'ABCD1234',
|
||||
submitDto,
|
||||
);
|
||||
const newProgress: Partial<ShareLevelProgress> = {
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-2',
|
||||
passed: true,
|
||||
timeSpent: 999,
|
||||
};
|
||||
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||
|
||||
const result = await service.reportLevelProgress('user-uuid-2', {
|
||||
shareCode: 'ABCD1234',
|
||||
levelId: 'level-2',
|
||||
passed: true,
|
||||
timeSpent: 999,
|
||||
});
|
||||
expect(result.rank).toBe(2);
|
||||
expect(result.participantCount).toBe(2);
|
||||
});
|
||||
|
||||
expect(result.withinTimeLimit).toBe(true);
|
||||
it('should throw BadRequestException when submitted levels are incomplete', async () => {
|
||||
await expect(
|
||||
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||
levels: submitDto.levels.slice(0, 5),
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when a level is submitted twice', async () => {
|
||||
await expect(
|
||||
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||
levels: [
|
||||
...submitDto.levels.slice(0, 5),
|
||||
{ levelId: 'level-1', answer: '答案1', timeSpent: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when a level does not belong to the share', async () => {
|
||||
await expect(
|
||||
service.submitChallenge('user-uuid-2', 'ABCD1234', {
|
||||
levels: [
|
||||
...submitDto.levels.slice(0, 5),
|
||||
{ levelId: 'other-level', answer: '答案', timeSpent: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when share not found', async () => {
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.reportLevelProgress('user-uuid-2', reportDto),
|
||||
service.submitChallenge('user-uuid-2', 'INVALID', submitDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when level not found', async () => {
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(null);
|
||||
it('should throw NotFoundException when a configured level is missing', async () => {
|
||||
mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 5));
|
||||
|
||||
await expect(
|
||||
service.reportLevelProgress('user-uuid-2', reportDto),
|
||||
service.submitChallenge('user-uuid-2', 'ABCD1234', submitDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update existing progress when not yet passed', async () => {
|
||||
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||
mockShareConfig,
|
||||
);
|
||||
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
||||
const existingProgress = {
|
||||
id: 'progress-uuid-1',
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
levelId: 'level-1',
|
||||
passed: false,
|
||||
timeSpent: 15,
|
||||
completedAt: new Date('2026-01-01'),
|
||||
} as ShareLevelProgress;
|
||||
mockShareLevelProgressRepository.findByShareConfigParticipantAndLevel.mockResolvedValue(
|
||||
existingProgress,
|
||||
);
|
||||
mockShareLevelProgressRepository.create.mockReturnValue(existingProgress);
|
||||
mockShareLevelProgressRepository.save.mockResolvedValue({
|
||||
...existingProgress,
|
||||
passed: true,
|
||||
timeSpent: 30,
|
||||
});
|
||||
|
||||
const result = await service.reportLevelProgress(
|
||||
'user-uuid-2',
|
||||
reportDto,
|
||||
);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(mockShareLevelProgressRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,16 +8,18 @@ import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { Level } from '../wechat-game/entities/level.entity';
|
||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import { SubmitShareChallengeDto } from './dto/submit-share-challenge.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
CreatedShareListResponseDto,
|
||||
JoinShareResponseDto,
|
||||
ShareLevelDto,
|
||||
SubmittedShareLevelDto,
|
||||
SubmitShareChallengeResponseDto,
|
||||
} from './dto/share-response.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -84,23 +86,7 @@ export class ShareService {
|
||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
||||
}
|
||||
|
||||
// Single query, then reorder to match levelIds sequence
|
||||
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
||||
|
||||
const levels: ShareLevelDto[] = config.levelIds.map((id, index) => {
|
||||
const level = levelMap.get(id);
|
||||
if (!level) {
|
||||
throw new NotFoundException(`关卡 ${id} 不存在`);
|
||||
}
|
||||
return {
|
||||
id: level.id,
|
||||
level: index + 1,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
sortOrder: level.sortOrder,
|
||||
};
|
||||
});
|
||||
const levels = await this.buildShareLevels(config.levelIds);
|
||||
|
||||
return {
|
||||
shareCode: config.shareCode,
|
||||
@@ -117,32 +103,21 @@ export class ShareService {
|
||||
|
||||
const shareConfigIds = configs.map((config) => config.id);
|
||||
const [participantCountMap, rankingRows] = await Promise.all([
|
||||
this.shareParticipantRepository.countByShareConfigIds(shareConfigIds),
|
||||
this.shareLevelProgressRepository.summarizeByShareConfigIds(
|
||||
this.shareParticipantRepository.countSubmittedByShareConfigIds(
|
||||
shareConfigIds,
|
||||
),
|
||||
this.shareParticipantRepository.findSubmittedRankingsByShareConfigIds(
|
||||
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);
|
||||
})
|
||||
const rankings = rankingRows
|
||||
.filter((row) => row.shareConfigId === config.id)
|
||||
.map((row) => row.participantId);
|
||||
|
||||
rankingsByShareConfigId.set(config.id, completedRankings);
|
||||
rankingsByShareConfigId.set(config.id, rankings);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -165,77 +140,189 @@ export class ShareService {
|
||||
};
|
||||
}
|
||||
|
||||
async reportLevelProgress(
|
||||
async submitChallenge(
|
||||
userId: string,
|
||||
dto: ReportLevelProgressDto,
|
||||
): Promise<ReportLevelProgressResponseDto> {
|
||||
const [config, level] = await Promise.all([
|
||||
this.shareConfigRepository.findByShareCode(dto.shareCode),
|
||||
this.levelRepository.findById(dto.levelId),
|
||||
]);
|
||||
|
||||
code: string,
|
||||
dto: SubmitShareChallengeDto,
|
||||
): Promise<SubmitShareChallengeResponseDto> {
|
||||
const config = await this.shareConfigRepository.findByShareCode(code);
|
||||
if (!config) {
|
||||
throw new NotFoundException('分享不存在或已过期');
|
||||
}
|
||||
if (!level) {
|
||||
throw new NotFoundException('关卡不存在');
|
||||
|
||||
this.validateSubmittedLevels(config.levelIds, dto.levels);
|
||||
|
||||
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||
if (allLevels.length !== config.levelIds.length) {
|
||||
const foundIds = new Set(allLevels.map((level) => level.id));
|
||||
const missing = config.levelIds.filter((id) => !foundIds.has(id));
|
||||
throw new NotFoundException(`以下关卡不存在: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!config.levelIds.includes(dto.levelId)) {
|
||||
throw new BadRequestException('该关卡不属于此分享挑战');
|
||||
}
|
||||
|
||||
// 自动登记参与者(创建者本人不计入)。已存在则忽略。
|
||||
if (userId !== config.sharerId) {
|
||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
||||
}
|
||||
|
||||
const progress =
|
||||
await this.shareLevelProgressRepository.findByShareConfigParticipantAndLevel(
|
||||
const levelMap = new Map(allLevels.map((level) => [level.id, level]));
|
||||
const submittedMap = new Map(
|
||||
dto.levels.map((item) => [item.levelId, item]),
|
||||
);
|
||||
const submittedAt = new Date();
|
||||
const existingProgress =
|
||||
await this.shareLevelProgressRepository.findByShareConfigAndParticipant(
|
||||
config.id,
|
||||
userId,
|
||||
dto.levelId,
|
||||
);
|
||||
const existingProgressMap = new Map(
|
||||
existingProgress.map((progress) => [progress.levelId, progress]),
|
||||
);
|
||||
|
||||
let correctCount = 0;
|
||||
let totalTimeSpent = 0;
|
||||
const responseLevels: SubmittedShareLevelDto[] = [];
|
||||
|
||||
const progressList = config.levelIds.map((levelId, index) => {
|
||||
const level = levelMap.get(levelId)!;
|
||||
const submitted = submittedMap.get(levelId)!;
|
||||
const submittedAnswer = submitted.answer;
|
||||
const isCorrect = this.isCorrectAnswer(submittedAnswer, level.answer);
|
||||
const withinTimeLimit = this.isWithinTimeLimit(
|
||||
level.timeLimit,
|
||||
submitted.timeSpent,
|
||||
);
|
||||
|
||||
if (dto.passed && progress?.passed) {
|
||||
return {
|
||||
passed: true,
|
||||
timeLimit: level.timeLimit,
|
||||
withinTimeLimit: this.isWithinTimeLimit(
|
||||
level.timeLimit,
|
||||
progress.timeSpent,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (isCorrect) {
|
||||
correctCount++;
|
||||
}
|
||||
totalTimeSpent += submitted.timeSpent;
|
||||
|
||||
const withinTimeLimit = dto.passed
|
||||
? this.isWithinTimeLimit(level.timeLimit, dto.timeSpent)
|
||||
: false;
|
||||
|
||||
const updatedProgress = progress
|
||||
? Object.assign(this.shareLevelProgressRepository.create(progress), {
|
||||
passed: dto.passed,
|
||||
timeSpent: dto.timeSpent,
|
||||
completedAt: dto.passed ? new Date() : progress.completedAt,
|
||||
})
|
||||
: this.shareLevelProgressRepository.create({
|
||||
const progress = Object.assign(
|
||||
existingProgressMap.get(levelId) ??
|
||||
this.shareLevelProgressRepository.create({
|
||||
shareConfigId: config.id,
|
||||
participantId: userId,
|
||||
levelId,
|
||||
}),
|
||||
{
|
||||
shareConfigId: config.id,
|
||||
participantId: userId,
|
||||
levelId: dto.levelId,
|
||||
passed: dto.passed,
|
||||
timeSpent: dto.timeSpent,
|
||||
completedAt: dto.passed ? new Date() : null,
|
||||
});
|
||||
levelId,
|
||||
submittedAnswer,
|
||||
passed: isCorrect,
|
||||
timeSpent: submitted.timeSpent,
|
||||
completedAt: submittedAt,
|
||||
},
|
||||
);
|
||||
|
||||
await this.shareLevelProgressRepository.save(updatedProgress);
|
||||
responseLevels.push({
|
||||
id: level.id,
|
||||
level: index + 1,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
punchline: level.punchline,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
sortOrder: level.sortOrder,
|
||||
submittedAnswer,
|
||||
timeSpent: submitted.timeSpent,
|
||||
isCorrect,
|
||||
timeLimit: level.timeLimit,
|
||||
withinTimeLimit,
|
||||
});
|
||||
|
||||
return progress;
|
||||
});
|
||||
|
||||
await this.shareLevelProgressRepository.saveMany(progressList);
|
||||
await this.shareParticipantRepository.upsertSubmissionSummary({
|
||||
shareConfigId: config.id,
|
||||
participantId: userId,
|
||||
correctCount,
|
||||
totalTimeSpent,
|
||||
submittedAt,
|
||||
});
|
||||
|
||||
const rankings =
|
||||
await this.shareParticipantRepository.findSubmittedRankingsByShareConfigId(
|
||||
config.id,
|
||||
);
|
||||
const rankingIndex = rankings.findIndex(
|
||||
(row) => row.participantId === userId,
|
||||
);
|
||||
|
||||
return {
|
||||
passed: dto.passed,
|
||||
timeLimit: level.timeLimit,
|
||||
withinTimeLimit,
|
||||
shareCode: config.shareCode,
|
||||
title: config.title,
|
||||
rank: rankingIndex >= 0 ? rankingIndex + 1 : rankings.length,
|
||||
correctCount,
|
||||
levelCount: config.levelIds.length,
|
||||
participantCount: rankings.length,
|
||||
totalTimeSpent,
|
||||
levels: responseLevels,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildShareLevels(levelIds: string[]): Promise<ShareLevelDto[]> {
|
||||
const allLevels = await this.levelRepository.findByIds(levelIds);
|
||||
const levelMap = new Map(allLevels.map((level) => [level.id, level]));
|
||||
|
||||
return levelIds.map((id, index) => {
|
||||
const level = levelMap.get(id);
|
||||
if (!level) {
|
||||
throw new NotFoundException(`关卡 ${id} 不存在`);
|
||||
}
|
||||
return this.toShareLevelDto(level, index);
|
||||
});
|
||||
}
|
||||
|
||||
private toShareLevelDto(level: Level, index: number): ShareLevelDto {
|
||||
return {
|
||||
id: level.id,
|
||||
level: index + 1,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
punchline: level.punchline,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
sortOrder: level.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
private validateSubmittedLevels(
|
||||
expectedLevelIds: string[],
|
||||
submittedLevels: SubmitShareChallengeDto['levels'],
|
||||
): void {
|
||||
if (submittedLevels.length !== expectedLevelIds.length) {
|
||||
throw new BadRequestException('提交关卡数量必须与分享挑战关卡数量一致');
|
||||
}
|
||||
|
||||
const expectedIdSet = new Set(expectedLevelIds);
|
||||
const submittedIdSet = new Set<string>();
|
||||
for (const item of submittedLevels) {
|
||||
if (submittedIdSet.has(item.levelId)) {
|
||||
throw new BadRequestException(`关卡 ${item.levelId} 重复提交`);
|
||||
}
|
||||
submittedIdSet.add(item.levelId);
|
||||
|
||||
if (!expectedIdSet.has(item.levelId)) {
|
||||
throw new BadRequestException(`关卡 ${item.levelId} 不属于此分享挑战`);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = expectedLevelIds.filter((id) => !submittedIdSet.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`缺少关卡提交: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private isCorrectAnswer(submittedAnswer: string, answer: string): boolean {
|
||||
return (
|
||||
this.normalizeAnswer(submittedAnswer) === this.normalizeAnswer(answer)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAnswer(answer: string): string {
|
||||
return answer.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
private isWithinTimeLimit(
|
||||
timeLimit: number | null,
|
||||
timeSpent: number,
|
||||
|
||||
Reference in New Issue
Block a user