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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user