feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档
重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
translationsFromZhiboNames,
|
||||
} from './zhibo-match.mapper';
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
@@ -45,7 +46,10 @@ export type ListPublishedOptions = {
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private outright: OutrightService,
|
||||
) {}
|
||||
|
||||
async createLeague(code: string, translations: Record<string, string>) {
|
||||
const league = await this.prisma.league.create({ data: { code } });
|
||||
@@ -85,6 +89,7 @@ export class MatchesService {
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
status?: string;
|
||||
@@ -110,6 +115,7 @@ export class MatchesService {
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status,
|
||||
@@ -307,7 +313,13 @@ export class MatchesService {
|
||||
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||||
if (data.isActive !== undefined) {
|
||||
if (league.isActive && data.isActive === false) {
|
||||
throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN');
|
||||
const outright = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
select: { status: true },
|
||||
});
|
||||
if (outright?.status === 'SETTLED') {
|
||||
throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
|
||||
}
|
||||
}
|
||||
updates.isActive = data.isActive;
|
||||
}
|
||||
@@ -315,6 +327,10 @@ export class MatchesService {
|
||||
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
|
||||
}
|
||||
|
||||
if (data.isActive === true) {
|
||||
await this.outright.syncWithLeaguePublished(leagueId);
|
||||
}
|
||||
|
||||
const [en, zh, ms] = await Promise.all([
|
||||
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
|
||||
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
|
||||
@@ -537,7 +553,13 @@ export class MatchesService {
|
||||
|
||||
async listAdminLeagueMatches(
|
||||
leagueId: bigint,
|
||||
opts: { status?: string; keyword?: string; locale?: string },
|
||||
opts: {
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
locale?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
const where: Prisma.MatchWhereInput = {
|
||||
leagueId,
|
||||
@@ -553,15 +575,22 @@ export class MatchesService {
|
||||
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
const items = await this.prisma.match.findMany({
|
||||
where,
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
});
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
|
||||
const [total, rows] = await Promise.all([
|
||||
this.prisma.match.count({ where }),
|
||||
this.prisma.match.findMany({
|
||||
where,
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
const locale = opts.locale ?? 'zh-CN';
|
||||
const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id));
|
||||
return Promise.all(
|
||||
items.map(async (m) => {
|
||||
const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
|
||||
const items = await Promise.all(
|
||||
rows.map(async (m) => {
|
||||
const [homeTeamName, awayTeamName] = await Promise.all([
|
||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||
@@ -588,6 +617,7 @@ export class MatchesService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
@@ -688,6 +718,7 @@ export class MatchesService {
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
@@ -802,6 +833,7 @@ export class MatchesService {
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
@@ -849,6 +881,7 @@ export class MatchesService {
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
correctScoreEnabled: match.correctScoreEnabled,
|
||||
displayOrder: match.displayOrder,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -876,6 +909,7 @@ export class MatchesService {
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
@@ -915,6 +949,7 @@ export class MatchesService {
|
||||
groupName?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
correctScoreEnabled?: boolean;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -971,6 +1006,7 @@ export class MatchesService {
|
||||
matchName,
|
||||
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
@@ -1111,6 +1147,26 @@ export class MatchesService {
|
||||
});
|
||||
}
|
||||
|
||||
async unpublishMatch(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
if (match.isOutright) {
|
||||
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
|
||||
}
|
||||
const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
|
||||
if (!allowed.includes(match.status)) {
|
||||
throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
|
||||
}
|
||||
if (match.status === 'PENDING_SETTLEMENT') {
|
||||
await this.prisma.settlementBatch.deleteMany({
|
||||
where: { matchId, status: 'PREVIEW' },
|
||||
});
|
||||
}
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'DRAFT', closeTime: null },
|
||||
});
|
||||
}
|
||||
|
||||
async closeMatch(matchId: bigint) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
@@ -1120,7 +1176,24 @@ export class MatchesService {
|
||||
|
||||
async reopenMatch(matchId: bigint, startTime?: Date) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
|
||||
|
||||
if (match.isOutright) {
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||||
if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||||
const reopenable =
|
||||
match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
|
||||
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||||
if (match.status === 'PENDING_SETTLEMENT') {
|
||||
await this.prisma.settlementBatch.deleteMany({
|
||||
where: { matchId, status: 'PREVIEW' },
|
||||
});
|
||||
}
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'PUBLISHED', closeTime: null },
|
||||
});
|
||||
}
|
||||
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
|
||||
@@ -1188,6 +1261,7 @@ export class MatchesService {
|
||||
startTime: Date;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string | null;
|
||||
stage?: string | null;
|
||||
@@ -1221,6 +1295,7 @@ export class MatchesService {
|
||||
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||
startTime: m.startTime.toISOString(),
|
||||
isHot: m.isHot ?? false,
|
||||
correctScoreEnabled: m.correctScoreEnabled ?? true,
|
||||
displayOrder: m.displayOrder ?? 0,
|
||||
matchName: m.matchName ?? null,
|
||||
stage: m.stage ?? null,
|
||||
@@ -1252,9 +1327,13 @@ export class MatchesService {
|
||||
}),
|
||||
};
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
const csEnabled = m.correctScoreEnabled ?? true;
|
||||
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets.map((market) => ({
|
||||
markets: m.markets
|
||||
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
|
||||
.map((market) => ({
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
period: market.period as string,
|
||||
|
||||
Reference in New Issue
Block a user