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:
174
apps/api/src/domains/catalog/catalog-archive.service.spec.ts
Normal file
174
apps/api/src/domains/catalog/catalog-archive.service.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { CatalogArchiveService } from './catalog-archive.service';
|
||||
|
||||
describe('CatalogArchiveService', () => {
|
||||
const matchId = BigInt(10);
|
||||
const leagueId = BigInt(1);
|
||||
|
||||
let prisma: {
|
||||
match: { findFirst: jest.Mock; update: jest.Mock; findMany: jest.Mock; updateMany: jest.Mock };
|
||||
league: { findFirst: jest.Mock; update: jest.Mock };
|
||||
bet: { findMany: jest.Mock };
|
||||
settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock };
|
||||
market: { updateMany: jest.Mock };
|
||||
marketSelection: { updateMany: jest.Mock };
|
||||
entityTranslation: { findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let matches: { betStatsForMatches: jest.Mock };
|
||||
let service: CatalogArchiveService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
match: {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
league: { findFirst: jest.fn(), update: jest.fn() },
|
||||
bet: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
settlementBatch: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]) },
|
||||
market: { updateMany: jest.fn() },
|
||||
marketSelection: { updateMany: jest.fn() },
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
|
||||
};
|
||||
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matches as never);
|
||||
});
|
||||
|
||||
const baseMatch = {
|
||||
id: matchId,
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
matchName: null,
|
||||
homeTeamId: BigInt(2),
|
||||
awayTeamId: BigInt(3),
|
||||
homeTeam: { code: 'A' },
|
||||
awayTeam: { code: 'B' },
|
||||
league: { id: leagueId },
|
||||
};
|
||||
|
||||
it('preview flags pending bets and unsettled match', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue(baseMatch);
|
||||
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(50) }, { stake: new Decimal(25) }]);
|
||||
|
||||
const preview = await service.getMatchArchivePreview(matchId);
|
||||
|
||||
expect(preview.pendingBetCount).toBe(2);
|
||||
expect(preview.pendingStake).toBe('75');
|
||||
expect(preview.requiresForce).toBe(true);
|
||||
expect(preview.warnings).toEqual(expect.arrayContaining(['PENDING_BETS', 'UNSETTLED_MATCH']));
|
||||
});
|
||||
|
||||
it('archive without force throws ARCHIVE_BLOCKED when warnings exist', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue(baseMatch);
|
||||
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(10) }]);
|
||||
|
||||
await expect(service.archiveMatch(matchId, { force: false })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'ARCHIVE_BLOCKED' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('archive with force soft-deletes and cancels match', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
|
||||
prisma.match.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.archiveMatch(matchId, { force: true });
|
||||
|
||||
expect(result.matchId).toBe(matchId.toString());
|
||||
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.market.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.match.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: matchId },
|
||||
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('league preview blocks when child match is not terminal', async () => {
|
||||
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
|
||||
prisma.match.findMany.mockResolvedValue([
|
||||
{
|
||||
id: matchId,
|
||||
status: 'CLOSED',
|
||||
isOutright: false,
|
||||
matchName: null,
|
||||
homeTeamId: BigInt(2),
|
||||
awayTeamId: BigInt(3),
|
||||
homeTeam: { code: 'A' },
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
const preview = await service.getLeagueArchivePreview(leagueId);
|
||||
|
||||
expect(preview.canArchive).toBe(false);
|
||||
expect(preview.blockingMatches).toHaveLength(1);
|
||||
expect(preview.blockingMatches[0].status).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('league archive cascades when all children are settled', async () => {
|
||||
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
|
||||
prisma.match.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: matchId,
|
||||
status: 'SETTLED',
|
||||
isOutright: false,
|
||||
matchName: null,
|
||||
homeTeamId: BigInt(2),
|
||||
awayTeamId: BigInt(3),
|
||||
homeTeam: { code: 'A' },
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
const result = await service.archiveLeague(leagueId);
|
||||
|
||||
expect(result.leagueId).toBe(leagueId.toString());
|
||||
expect(prisma.match.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { in: [matchId] } },
|
||||
data: { deletedAt: expect.any(Date) },
|
||||
}),
|
||||
);
|
||||
expect(prisma.league.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { deletedAt: expect.any(Date), isActive: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('league archive throws LEAGUE_ARCHIVE_NOT_READY when blocked', async () => {
|
||||
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
|
||||
prisma.match.findMany.mockResolvedValue([
|
||||
{
|
||||
id: matchId,
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
matchName: null,
|
||||
homeTeamId: BigInt(2),
|
||||
awayTeamId: BigInt(3),
|
||||
homeTeam: { code: 'A' },
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
await expect(service.archiveLeague(leagueId)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'LEAGUE_ARCHIVE_NOT_READY' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
302
apps/api/src/domains/catalog/catalog-archive.service.ts
Normal file
302
apps/api/src/domains/catalog/catalog-archive.service.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
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 ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketsModule } from '../odds/markets.module';
|
||||
import { CatalogArchiveService } from './catalog-archive.service';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
@Module({
|
||||
imports: [MarketsModule],
|
||||
providers: [MatchesService, OutrightService],
|
||||
exports: [MatchesService, OutrightService],
|
||||
providers: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
|
||||
115
apps/api/src/domains/catalog/matches.service.spec.ts
Normal file
115
apps/api/src/domains/catalog/matches.service.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { MatchesService } from './matches.service';
|
||||
|
||||
describe('MatchesService publish/unpublish', () => {
|
||||
const leagueId = BigInt(1);
|
||||
const matchId = BigInt(10);
|
||||
|
||||
let prisma: {
|
||||
league: { findFirst: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock };
|
||||
match: { findFirst: jest.Mock; update: jest.Mock };
|
||||
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
|
||||
settlementBatch: { deleteMany: jest.Mock };
|
||||
};
|
||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
league: {
|
||||
findFirst: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
match: {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new MatchesService(prisma as never, outright as never);
|
||||
});
|
||||
|
||||
describe('updatePlatformLeague unpublish', () => {
|
||||
const baseLeague = { id: leagueId, code: 'EPL', isActive: true, logoUrl: null, displayOrder: 0 };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.league.findFirst.mockResolvedValue(baseLeague);
|
||||
prisma.league.update.mockResolvedValue({});
|
||||
prisma.league.findUniqueOrThrow.mockResolvedValue({ ...baseLeague, isActive: false });
|
||||
});
|
||||
|
||||
it('rejects unpublish when outright is settled', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ status: 'SETTLED' });
|
||||
|
||||
await expect(
|
||||
service.updatePlatformLeague(leagueId, {
|
||||
leagueEn: 'EPL',
|
||||
leagueZh: '英超',
|
||||
isActive: false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'LEAGUE_UNPUBLISH_SETTLED' }),
|
||||
});
|
||||
expect(prisma.league.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows unpublish when outright is not settled', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ status: 'PUBLISHED' });
|
||||
|
||||
const result = await service.updatePlatformLeague(leagueId, {
|
||||
leagueEn: 'EPL',
|
||||
leagueZh: '英超',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
expect(prisma.league.update).toHaveBeenCalledWith({
|
||||
where: { id: leagueId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(result.isPublished).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishMatch', () => {
|
||||
const baseMatch = {
|
||||
id: matchId,
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.match.findFirst.mockResolvedValue(baseMatch);
|
||||
prisma.match.update.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
|
||||
});
|
||||
|
||||
it('unpublishes published fixture to draft', async () => {
|
||||
await service.unpublishMatch(matchId);
|
||||
|
||||
expect(prisma.match.update).toHaveBeenCalledWith({
|
||||
where: { id: matchId },
|
||||
data: { status: 'DRAFT', closeTime: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unpublish when settled', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' });
|
||||
|
||||
await expect(service.unpublishMatch(matchId)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'MATCH_UNPUBLISH_FORBIDDEN' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears preview settlement batch when pending settlement', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'PENDING_SETTLEMENT' });
|
||||
|
||||
await service.unpublishMatch(matchId);
|
||||
|
||||
expect(prisma.settlementBatch.deleteMany).toHaveBeenCalledWith({
|
||||
where: { matchId, status: 'PREVIEW' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -66,6 +66,7 @@ export class OutrightService {
|
||||
m.status,
|
||||
market,
|
||||
openCount,
|
||||
league.isActive,
|
||||
);
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(m.id, 'zh-CN'),
|
||||
@@ -141,6 +142,7 @@ export class OutrightService {
|
||||
: sel.selectionName;
|
||||
return {
|
||||
id: sel.id.toString(),
|
||||
teamId: team?.id.toString() ?? null,
|
||||
teamCode: sel.selectionCode,
|
||||
rank: sel.sortOrder + 1 || index + 1,
|
||||
teamZh: teamZh || sel.selectionName,
|
||||
@@ -159,6 +161,11 @@ export class OutrightService {
|
||||
fullMarket.selections.filter(
|
||||
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
|
||||
),
|
||||
league.isActive,
|
||||
);
|
||||
|
||||
const unsettledFixtureCount = await this.countUnsettledLeagueFixtures(
|
||||
match.leagueId,
|
||||
);
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
@@ -178,6 +185,8 @@ export class OutrightService {
|
||||
titleEn: titleEn || match.matchName || '',
|
||||
titleMs,
|
||||
status: match.status,
|
||||
leagueIsPublished: league.isActive,
|
||||
unsettledFixtureCount,
|
||||
marketId: fullMarket.id.toString(),
|
||||
marketStatus: fullMarket.status,
|
||||
canImportCanonical: league.code === WC2026_LEAGUE_CODE,
|
||||
@@ -191,15 +200,16 @@ export class OutrightService {
|
||||
|
||||
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
|
||||
async getOrCreateAndSyncForLeague(leagueId: bigint) {
|
||||
const league = await this.prisma.league.findUnique({
|
||||
where: { id: leagueId },
|
||||
});
|
||||
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
||||
|
||||
let match = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!match) {
|
||||
const league = await this.prisma.league.findUnique({
|
||||
where: { id: leagueId },
|
||||
});
|
||||
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
||||
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
|
||||
this.getTranslation('LEAGUE', leagueId, 'en-US'),
|
||||
@@ -210,12 +220,17 @@ export class OutrightService {
|
||||
titleZh: leagueZh || league.code,
|
||||
titleEn: leagueEn || league.code,
|
||||
titleMs: leagueMs || undefined,
|
||||
status: 'DRAFT',
|
||||
status: league.isActive ? 'PUBLISHED' : 'DRAFT',
|
||||
});
|
||||
match = await this.prisma.match.findFirstOrThrow({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
} else {
|
||||
await this.syncOutrightStatusWithLeague(match, league);
|
||||
match = await this.prisma.match.findFirstOrThrow({
|
||||
where: { id: match.id },
|
||||
});
|
||||
}
|
||||
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
|
||||
const data = await this.getForAdmin(match.id);
|
||||
@@ -226,6 +241,47 @@ export class OutrightService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */
|
||||
async syncWithLeaguePublished(leagueId: bigint) {
|
||||
const league = await this.prisma.league.findUnique({
|
||||
where: { id: leagueId },
|
||||
});
|
||||
if (!league?.isActive) return;
|
||||
|
||||
const existing = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!existing) {
|
||||
await this.getOrCreateAndSyncForLeague(leagueId);
|
||||
return;
|
||||
}
|
||||
await this.syncOutrightStatusWithLeague(existing, league);
|
||||
}
|
||||
|
||||
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
|
||||
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
|
||||
return this.prisma.match.count({
|
||||
where: {
|
||||
leagueId,
|
||||
isOutright: false,
|
||||
deletedAt: null,
|
||||
status: { notIn: ['SETTLED', 'CANCELLED'] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async syncOutrightStatusWithLeague(
|
||||
match: { id: bigint; status: string },
|
||||
league: { isActive: boolean },
|
||||
) {
|
||||
if (!league.isActive || match.status !== 'DRAFT') return;
|
||||
await this.prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: { status: 'PUBLISHED', publishTime: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
|
||||
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
@@ -635,6 +691,7 @@ export class OutrightService {
|
||||
isOutright: true,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
league: { isActive: true, deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
markets: {
|
||||
@@ -804,18 +861,28 @@ export class OutrightService {
|
||||
matchStatus: string,
|
||||
market: { status: string } | null | undefined,
|
||||
selections: Array<{ selectionCode: string; status: string }>,
|
||||
leagueIsActive = true,
|
||||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||||
const openCount = selections.filter(
|
||||
(s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE,
|
||||
).length;
|
||||
return this.playerVisibilityByCounts(matchStatus, market, openCount);
|
||||
return this.playerVisibilityByCounts(
|
||||
matchStatus,
|
||||
market,
|
||||
openCount,
|
||||
leagueIsActive,
|
||||
);
|
||||
}
|
||||
|
||||
private playerVisibilityByCounts(
|
||||
matchStatus: string,
|
||||
market: { status: string } | null | undefined,
|
||||
openSelectionCount: number,
|
||||
leagueIsActive = true,
|
||||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||||
if (!leagueIsActive) {
|
||||
return { playerVisible: false, playerHiddenReason: 'LEAGUE_INACTIVE' };
|
||||
}
|
||||
if (matchStatus !== 'PUBLISHED') {
|
||||
return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function syncWc2026OutrightMarket(
|
||||
const forceCanonical = options.forceCanonical ?? false;
|
||||
const league = await prisma.league.findUnique({ where: { code: WC2026_LEAGUE_CODE } });
|
||||
if (!league) {
|
||||
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedSportsDemo first`);
|
||||
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedCatalog first`);
|
||||
}
|
||||
|
||||
const placeholder = await upsertTeam(prisma, {
|
||||
@@ -96,10 +96,14 @@ export async function syncWc2026OutrightMarket(
|
||||
displayOrder: 0,
|
||||
},
|
||||
});
|
||||
} else if (match.status === 'DRAFT') {
|
||||
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
publishTime: match.publishTime ?? new Date(),
|
||||
closeTime: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user