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:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -0,0 +1,242 @@
import { SettlementService } from './settlement.service';
import { Decimal } from '@prisma/client/runtime/library';
describe('SettlementService outright winner flow', () => {
const matchId = BigInt(100);
const operatorId = BigInt(1);
const winnerTeamId = BigInt(10);
const batchId = BigInt(500);
const winningBetId = BigInt(1001);
const losingBetId = BigInt(1002);
const winningSelId = BigInt(201);
const losingSelId = BigInt(202);
const outrightMatch = {
id: matchId,
isOutright: true,
status: 'CLOSED',
deletedAt: null,
};
const winnerTeam = { id: winnerTeamId, code: 'BRA' };
const winningBet = {
id: winningBetId,
betNo: 'BET-WIN',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(50),
user: { id: BigInt(50) },
selections: [
{
id: BigInt(301),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: winningSelId,
selectionNameSnapshot: '巴西',
handicapLine: null,
totalLine: null,
odds: new Decimal(3),
resultStatus: null,
sortOrder: 0,
},
],
};
const losingBet = {
id: losingBetId,
betNo: 'BET-LOSE',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(50),
agentId: null,
userId: BigInt(51),
user: { id: BigInt(51) },
selections: [
{
id: BigInt(302),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: losingSelId,
selectionNameSnapshot: '阿根廷',
handicapLine: null,
totalLine: null,
odds: new Decimal(5),
resultStatus: null,
sortOrder: 0,
},
],
};
let matchScoreUpsert: jest.Mock;
let matchFindFirst: jest.Mock;
let matchUpdate: jest.Mock;
let teamFindUnique: jest.Mock;
let marketSelectionFindFirst: jest.Mock;
let marketSelectionFindMany: jest.Mock;
let matchScoreFindUnique: jest.Mock;
let settlementBatchCreate: jest.Mock;
let settlementBatchFindUnique: jest.Mock;
let betFindMany: jest.Mock;
let transaction: jest.Mock;
let wallet: { settleBet: jest.Mock };
let agents: Record<string, jest.Mock>;
let service: SettlementService;
beforeEach(() => {
matchScoreUpsert = jest.fn().mockResolvedValue({});
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
matchUpdate = jest.fn().mockResolvedValue({});
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
marketSelectionFindFirst = jest
.fn()
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
marketSelectionFindMany = jest.fn().mockResolvedValue([
{ id: winningSelId, selectionCode: 'BRA' },
{ id: losingSelId, selectionCode: 'ARG' },
]);
matchScoreFindUnique = jest.fn();
settlementBatchCreate = jest.fn().mockResolvedValue({
id: batchId,
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
status: 'PREVIEW',
});
settlementBatchFindUnique = jest.fn();
betFindMany = jest.fn();
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: jest.fn().mockResolvedValue({}) },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
}),
);
const prisma = {
match: { findFirst: matchFindFirst, update: matchUpdate },
team: { findUnique: teamFindUnique },
marketSelection: {
findFirst: marketSelectionFindFirst,
findMany: marketSelectionFindMany,
},
matchScore: {
findUnique: matchScoreFindUnique,
upsert: matchScoreUpsert,
},
settlementBatch: {
create: settlementBatchCreate,
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
},
bet: { findMany: betFindMany },
$transaction: transaction,
};
wallet = { settleBet: jest.fn().mockResolvedValue(undefined) };
agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
service = new SettlementService(prisma as never, wallet as never, agents as never);
});
it('previewSettlement persists winnerTeamId and previews WIN/LOSE', async () => {
betFindMany.mockResolvedValue([winningBet, losingBet]);
const preview = await service.previewSettlement(matchId, operatorId, {
winnerTeamId,
});
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { matchId },
create: expect.objectContaining({ winnerTeamId }),
update: expect.objectContaining({ winnerTeamId }),
}),
);
expect(preview.winnerTeamCode).toBe('BRA');
expect(preview.items.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ betNo: 'BET-WIN', result: 'WIN', payout: '300' }),
expect.objectContaining({ betNo: 'BET-LOSE', result: 'LOSE', payout: '0' }),
]),
);
});
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
const txBetUpdate = jest.fn().mockResolvedValue({});
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
await fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: txBetUpdate },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
});
});
settlementBatchFindUnique.mockResolvedValue({
id: batchId,
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
match: { ...outrightMatch, status: 'PENDING_SETTLEMENT' },
});
matchScoreFindUnique.mockResolvedValue({
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
winnerTeamId,
});
betFindMany.mockResolvedValue([winningBet, losingBet]);
const result = await service.confirmSettlement(batchId, operatorId);
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ winnerTeamId }),
}),
);
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
expect(wallet.settleBet.mock.calls[0]).toEqual([
winningBet.userId,
expect.anything(),
expect.anything(),
'BET-WIN',
'WIN',
expect.anything(),
]);
expect(wallet.settleBet.mock.calls[1]).toEqual([
losingBet.userId,
expect.anything(),
expect.anything(),
'BET-LOSE',
'LOSE',
expect.anything(),
]);
expect(txBetUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: winningBetId },
data: expect.objectContaining({ status: 'WON' }),
}),
);
expect(txBetUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: losingBetId },
data: expect.objectContaining({ status: 'LOST' }),
}),
);
expect(result).toEqual({ success: true, batchId: batchId.toString() });
});
});

View File

@@ -46,6 +46,24 @@ export class SettlementService {
return team?.code ?? null;
}
private async assertOutrightLeagueFixturesSettled(match: {
leagueId: bigint;
isOutright: boolean;
}) {
if (!match.isOutright) return;
const unsettled = await this.prisma.match.count({
where: {
leagueId: match.leagueId,
isOutright: false,
deletedAt: null,
status: { notIn: ['SETTLED', 'CANCELLED'] },
},
});
if (unsettled > 0) {
throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED');
}
}
private buildSettleInput(
sel: BetSelectionLeg,
selectionCode: string,
@@ -107,6 +125,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
if (match.isOutright) {
if (!winnerTeamId) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
@@ -172,6 +194,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
const computation = await this.computePreviewComputation(matchId, scoreSource);
const batch = await this.prisma.settlementBatch.create({
@@ -190,6 +216,10 @@ export class SettlementService {
},
});
if (match.isOutright && scoreSource.winnerTeamId) {
await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId);
}
if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') {
await this.prisma.match.update({
where: { id: matchId },
@@ -306,6 +336,41 @@ export class SettlementService {
};
}
private async upsertMatchScoreRecord(
matchId: bigint,
scoreSource: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
operatorId: bigint,
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
) {
const client = tx ?? this.prisma;
await client.matchScore.upsert({
where: { matchId },
create: {
matchId,
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
});
}
private async resolvePreviewScoreSource(
matchId: bigint,
isOutright: boolean,
@@ -601,6 +666,10 @@ export class SettlementService {
throw appBadRequest('MATCH_NOT_SETTLEABLE');
}
if (batch.match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(batch.match);
}
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
@@ -624,25 +693,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
@@ -833,56 +895,58 @@ export class SettlementService {
return { success: true, batchId: batchId.toString() };
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
private async assertMatchReadyForBetStats(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
return match;
}
const legs = await this.prisma.betSelection.findMany({
where: { matchId },
include: {
bet: {
select: {
id: true,
betNo: true,
betType: true,
stake: true,
status: true,
settlementStatus: true,
potentialReturn: true,
actualReturn: true,
placedAt: true,
user: { select: { username: true } },
},
async getMatchBetStatsSummary(matchId: bigint) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const [
legCount,
totalBets,
singleBets,
parlayBets,
stakeAgg,
statusGroups,
legsForSelection,
] = await Promise.all([
this.prisma.betSelection.count({ where: { matchId } }),
this.prisma.bet.count({ where: betWhere }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }),
this.prisma.bet.aggregate({
where: betWhere,
_sum: { stake: true, potentialReturn: true },
}),
this.prisma.bet.groupBy({
by: ['status'],
where: betWhere,
_count: { _all: true },
}),
this.prisma.betSelection.findMany({
where: { matchId },
select: {
marketId: true,
selectionId: true,
marketType: true,
period: true,
selectionNameSnapshot: true,
bet: { select: { betType: true, stake: true } },
},
},
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
}),
]);
const betById = new Map<string, (typeof legs)[0]['bet']>();
for (const leg of legs) {
betById.set(leg.betId.toString(), leg.bet);
}
let totalStake = new Decimal(0);
let totalPotential = new Decimal(0);
let singleBets = 0;
let parlayBets = 0;
const statusCounts: Record<string, number> = {};
for (const bet of betById.values()) {
totalStake = totalStake.add(bet.stake);
if (bet.potentialReturn) {
totalPotential = totalPotential.add(bet.potentialReturn);
}
if (bet.betType === 'SINGLE') singleBets += 1;
else if (bet.betType === 'PARLAY') parlayBets += 1;
statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
for (const row of statusGroups) {
statusCounts[row.status] = row._count._all;
}
type SelAgg = {
@@ -896,7 +960,7 @@ export class SettlementService {
};
const selMap = new Map<string, SelAgg>();
for (const leg of legs) {
for (const leg of legsForSelection) {
const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
let row = selMap.get(key);
if (!row) {
@@ -935,67 +999,84 @@ export class SettlementService {
return a.selectionName.localeCompare(b.selectionName);
});
const betsById = new Map<
string,
{
bet: (typeof legs)[0]['bet'];
matchLegs: (typeof legs);
}
>();
for (const leg of legs) {
const key = leg.betId.toString();
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
row.matchLegs.push(leg);
betsById.set(key, row);
}
return {
summary: {
totalBets,
singleBets,
parlayBets,
totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(),
totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(),
statusCounts,
legCount,
},
bySelection,
};
}
const allBets = Array.from(betsById.values())
.map(({ bet, matchLegs }) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: matchLegs[0].bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: matchLegs.length,
selections: matchLegs.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}))
.sort(
(a, b) =>
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
);
async getMatchBetStatsBets(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const totalBets = await this.prisma.bet.count({ where: betWhere });
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
const total = allBets.length;
const start = (page - 1) * pageSize;
const betRows = await this.prisma.bet.findMany({
where: betWhere,
orderBy: { placedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { username: true } },
selections: {
where: { matchId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
});
const items = betRows.map((bet) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: bet.selections.length,
selections: bet.selections.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}));
return {
summary: {
totalBets: betById.size,
singleBets,
parlayBets,
totalStake: totalStake.toString(),
totalPotentialReturn: totalPotential.toString(),
statusCounts,
legCount: legs.length,
},
bySelection,
bets: {
items: allBets.slice(start, start + pageSize),
total,
page,
pageSize,
},
items,
total: totalBets,
page,
pageSize,
};
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const [summaryPart, bets] = await Promise.all([
this.getMatchBetStatsSummary(matchId),
this.getMatchBetStatsBets(matchId, opts),
]);
return {
...summaryPart,
bets,
};
}
@@ -1144,6 +1225,20 @@ export class SettlementService {
},
});
if (match.isOutright && winnerTeamId) {
await this.upsertMatchScoreRecord(
matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId,
},
operatorId,
);
}
return {
batch,
score: scoreInput,
@@ -1170,11 +1265,10 @@ export class SettlementService {
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(
(
await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
)?.winnerTeamId,
);
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const settledBets = await this.prisma.bet.findMany({
where: {
@@ -1188,24 +1282,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of settledBets) {
const oldPayout = new Decimal(bet.actualReturn);