252 lines
7.7 KiB
TypeScript
252 lines
7.7 KiB
TypeScript
import {
|
||
Injectable,
|
||
NotFoundException,
|
||
BadRequestException,
|
||
} from '@nestjs/common';
|
||
import { nanoid } from 'nanoid';
|
||
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 { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||
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';
|
||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||
|
||
@Injectable()
|
||
export class ShareService {
|
||
constructor(
|
||
private readonly shareConfigRepository: ShareConfigRepository,
|
||
private readonly shareParticipantRepository: ShareParticipantRepository,
|
||
private readonly levelRepository: LevelRepository,
|
||
private readonly shareLevelProgressRepository: ShareLevelProgressRepository,
|
||
) {}
|
||
|
||
async createShare(
|
||
userId: string,
|
||
dto: CreateShareDto,
|
||
): Promise<CreateShareResponseDto> {
|
||
const uniqueIds = [...new Set(dto.levelIds)];
|
||
if (uniqueIds.length !== 6) {
|
||
throw new BadRequestException('关卡ID不能重复,需要恰好6个不同的关卡');
|
||
}
|
||
|
||
// 单次查询验证所有关卡存在
|
||
const levels = await this.levelRepository.findByIds(uniqueIds);
|
||
if (levels.length !== uniqueIds.length) {
|
||
const foundIds = new Set(levels.map((l) => l.id));
|
||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||
throw new NotFoundException(`以下关卡不存在: ${missing.join(', ')}`);
|
||
}
|
||
|
||
// 生成 8 位分享码(碰撞重试)
|
||
let shareCode: string;
|
||
let attempts = 0;
|
||
do {
|
||
shareCode = nanoid(8);
|
||
const existing =
|
||
await this.shareConfigRepository.findByShareCode(shareCode);
|
||
if (!existing) break;
|
||
attempts++;
|
||
} while (attempts < 3);
|
||
|
||
if (attempts >= 3) {
|
||
throw new BadRequestException('生成分享码失败,请重试');
|
||
}
|
||
|
||
const config = await this.shareConfigRepository.create({
|
||
shareCode,
|
||
title: dto.title,
|
||
sharerId: userId,
|
||
levelIds: dto.levelIds,
|
||
});
|
||
|
||
return {
|
||
shareCode: config.shareCode,
|
||
title: config.title,
|
||
levelCount: config.levelIds.length,
|
||
};
|
||
}
|
||
|
||
async joinShare(userId: string, code: string): Promise<JoinShareResponseDto> {
|
||
const config = await this.shareConfigRepository.findByShareCode(code);
|
||
if (!config) {
|
||
throw new NotFoundException('分享不存在或已过期');
|
||
}
|
||
|
||
if (userId !== config.sharerId) {
|
||
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,
|
||
};
|
||
});
|
||
|
||
return {
|
||
shareCode: config.shareCode,
|
||
title: config.title,
|
||
levels,
|
||
};
|
||
}
|
||
|
||
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,
|
||
): Promise<ReportLevelProgressResponseDto> {
|
||
const [config, level] = await Promise.all([
|
||
this.shareConfigRepository.findByShareCode(dto.shareCode),
|
||
this.levelRepository.findById(dto.levelId),
|
||
]);
|
||
|
||
if (!config) {
|
||
throw new NotFoundException('分享不存在或已过期');
|
||
}
|
||
if (!level) {
|
||
throw new NotFoundException('关卡不存在');
|
||
}
|
||
|
||
if (!config.levelIds.includes(dto.levelId)) {
|
||
throw new BadRequestException('该关卡不属于此分享挑战');
|
||
}
|
||
|
||
let participant =
|
||
await this.shareParticipantRepository.findByShareConfigAndParticipant(
|
||
config.id,
|
||
userId,
|
||
);
|
||
if (!participant) {
|
||
participant = this.shareParticipantRepository.create({
|
||
shareConfigId: config.id,
|
||
participantId: userId,
|
||
});
|
||
participant = await this.shareParticipantRepository.save(participant);
|
||
}
|
||
|
||
const progress =
|
||
await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||
participant.id,
|
||
dto.levelId,
|
||
);
|
||
|
||
if (dto.passed && progress?.passed) {
|
||
return {
|
||
passed: true,
|
||
timeLimit: level.timeLimit,
|
||
withinTimeLimit: this.isWithinTimeLimit(
|
||
level.timeLimit,
|
||
progress.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({
|
||
participantId: participant.id,
|
||
levelId: dto.levelId,
|
||
passed: dto.passed,
|
||
timeSpent: dto.timeSpent,
|
||
completedAt: dto.passed ? new Date() : null,
|
||
});
|
||
|
||
await this.shareLevelProgressRepository.save(updatedProgress);
|
||
|
||
return {
|
||
passed: dto.passed,
|
||
timeLimit: level.timeLimit,
|
||
withinTimeLimit,
|
||
};
|
||
}
|
||
|
||
private isWithinTimeLimit(
|
||
timeLimit: number | null,
|
||
timeSpent: number,
|
||
): boolean {
|
||
return timeLimit === null || timeSpent <= timeLimit;
|
||
}
|
||
}
|