Files
thebet365/apps/api/src/domains/catalog/catalog-archive.service.ts
Mars e7e938f261 feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档
重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:17:00 +08:00

303 lines
9.4 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
import { MatchesService } from './matches.service';
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
export type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH';
export type MatchArchivePreview = {
matchId: string;
matchStatus: string;
isOutright: boolean;
title: string;
pendingBetCount: number;
pendingStake: string;
hasPreviewSettlementBatch: boolean;
requiresForce: boolean;
warnings: MatchArchiveWarning[];
};
export type LeagueBlockingMatch = {
id: string;
status: string;
isOutright: boolean;
title: string;
pendingCount: number;
};
export type LeagueArchivePreview = {
leagueId: string;
canArchive: boolean;
blockingMatches: LeagueBlockingMatch[];
totalPendingBets: number;
};
@Injectable()
export class CatalogArchiveService {
constructor(
private prisma: PrismaService,
private matches: MatchesService,
) {}
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
const match = await this.requireActiveMatch(matchId);
const [pending, hasPreviewBatch] = await Promise.all([
this.pendingBetSummary(matchId),
this.hasPreviewBatch(matchId),
]);
const warnings = this.buildMatchWarnings(match.status, pending.pendingBetCount, hasPreviewBatch);
const requiresForce = warnings.length > 0;
const title = await this.matchTitle(match);
return {
matchId: match.id.toString(),
matchStatus: match.status,
isOutright: match.isOutright,
title,
pendingBetCount: pending.pendingBetCount,
pendingStake: pending.pendingStake,
hasPreviewSettlementBatch: hasPreviewBatch,
requiresForce,
warnings,
};
}
async archiveMatch(matchId: bigint, opts: { force: boolean }) {
const match = await this.requireActiveMatch(matchId);
if (match.status === 'DRAFT') {
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
if (match.status === 'SETTLED') {
throw appBadRequest('ARCHIVE_BLOCKED');
}
const preview = await this.getMatchArchivePreview(matchId);
if (preview.requiresForce && !opts.force) {
throw appConflict('ARCHIVE_BLOCKED', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.marketSelection.updateMany({
where: { market: { matchId } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId },
data: { status: 'CLOSED' },
});
await tx.match.update({
where: { id: matchId },
data: {
deletedAt: now,
status:
match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED',
},
});
});
return { matchId: matchId.toString(), archivedAt: now.toISOString() };
}
async getLeagueArchivePreview(leagueId: bigint): Promise<LeagueArchivePreview> {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const matches = await this.prisma.match.findMany({
where: { leagueId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
orderBy: [{ isOutright: 'desc' }, { id: 'asc' }],
});
const matchIds = matches.map((m) => m.id);
const stats = await this.matches.betStatsForMatches(matchIds);
const previewBatches = matchIds.length
? await this.prisma.settlementBatch.findMany({
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
select: { matchId: true },
})
: [];
const previewBatchMatchIds = new Set(previewBatches.map((b) => b.matchId?.toString()));
const blockingMatches: LeagueBlockingMatch[] = [];
let totalPendingBets = 0;
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid) ?? { betCount: 0, totalStake: '0', pendingCount: 0 };
totalPendingBets += stat.pendingCount;
const hasPreview = previewBatchMatchIds.has(mid);
const blocks = this.isLeagueMatchBlocking(match.status, stat.betCount, stat.pendingCount, hasPreview);
if (blocks) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
if (totalPendingBets > 0 && !blockingMatches.length) {
// Pending bets exist but each match might be terminal — still block league archive
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid)!;
if (stat.pendingCount > 0) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
}
const canArchive = blockingMatches.length === 0 && totalPendingBets === 0;
return {
leagueId: leagueId.toString(),
canArchive,
blockingMatches,
totalPendingBets,
};
}
async archiveLeague(leagueId: bigint) {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const preview = await this.getLeagueArchivePreview(leagueId);
if (!preview.canArchive) {
throw appConflict('LEAGUE_ARCHIVE_NOT_READY', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const matches = await tx.match.findMany({
where: { leagueId, deletedAt: null },
select: { id: true, status: true },
});
const matchIds = matches.map((m) => m.id);
if (matchIds.length) {
await tx.marketSelection.updateMany({
where: { market: { matchId: { in: matchIds } } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId: { in: matchIds } },
data: { status: 'CLOSED' },
});
await tx.match.updateMany({
where: { id: { in: matchIds } },
data: { deletedAt: now },
});
}
await tx.league.update({
where: { id: leagueId },
data: { deletedAt: now, isActive: false },
});
});
return { leagueId: leagueId.toString(), archivedAt: now.toISOString() };
}
private async requireActiveMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true, league: true },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return match;
}
private async pendingBetSummary(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
select: { stake: true },
});
let pendingStake = new Decimal(0);
for (const bet of bets) {
pendingStake = pendingStake.add(bet.stake);
}
return {
pendingBetCount: bets.length,
pendingStake: pendingStake.toString(),
};
}
private async hasPreviewBatch(matchId: bigint) {
const batch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
select: { id: true },
});
return batch != null;
}
private buildMatchWarnings(
status: string,
pendingBetCount: number,
hasPreviewBatch: boolean,
): MatchArchiveWarning[] {
const warnings: MatchArchiveWarning[] = [];
if (pendingBetCount > 0) warnings.push('PENDING_BETS');
if (!TERMINAL_MATCH_STATUSES.has(status) && status !== 'DRAFT') {
warnings.push('UNSETTLED_MATCH');
}
if (hasPreviewBatch) warnings.push('PREVIEW_BATCH');
return warnings;
}
private isLeagueMatchBlocking(
status: string,
betCount: number,
pendingCount: number,
hasPreviewBatch: boolean,
): boolean {
if (pendingCount > 0) return true;
if (hasPreviewBatch) return true;
if (TERMINAL_MATCH_STATUSES.has(status)) return false;
if (status === 'DRAFT' && betCount === 0) return false;
return true;
}
private async matchTitle(match: {
id: bigint;
isOutright: boolean;
matchName: string | null;
homeTeamId: bigint;
awayTeamId: bigint;
homeTeam?: { code: string } | null;
awayTeam?: { code: string } | null;
}) {
if (match.isOutright) {
const name = match.matchName?.trim();
if (name) return name;
return `Outright #${match.id}`;
}
const [home, away] = await Promise.all([
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
]);
if (home && away) return `${home} vs ${away}`;
return `${match.homeTeam?.code ?? '?'} vs ${match.awayTeam?.code ?? '?'}`;
}
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
const row = await this.prisma.entityTranslation.findFirst({
where: { entityType, entityId, locale, fieldName: 'name' },
});
return row?.value ?? '';
}
}