feat(admin+api): 代理停用默认、结算加固与冒烟配置探针

- 代理层级默认授信比例与停用冻结/禁登全局默认

- 结算预览去重、比分校验、串关当场判负与市场类型校验

- 站内信 Banner/公告自动通知开关;开发环境动态 API 端口

- 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
2026-06-23 11:08:41 +08:00
parent fa06fee64c
commit ce84226219
47 changed files with 9284 additions and 118 deletions

View File

@@ -18,6 +18,7 @@ import {
resolveSelectionCode,
templateScoresForMarket,
} from './domain/settlement-helpers';
import { isSettlementSupportedMarketType } from '@thebet365/shared';
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
const STAT_MARKET_REQUIREMENTS = {
@@ -189,6 +190,95 @@ export class SettlementService {
}
}
private assertScoreConsistency(score: ScoreInput, isOutright: boolean) {
if (isOutright) return;
if (
score.htHome < 0 ||
score.htAway < 0 ||
score.ftHome < 0 ||
score.ftAway < 0 ||
score.htHome > score.ftHome ||
score.htAway > score.ftAway
) {
throw appBadRequest('SETTLEMENT_SCORE_INVALID');
}
}
private assertSupportedMarketTypes(
bets: Array<{ selections: Array<{ marketType: string }> }>,
) {
for (const bet of bets) {
for (const sel of bet.selections) {
if (!isSettlementSupportedMarketType(sel.marketType)) {
throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', {
marketType: sel.marketType,
});
}
}
}
}
private assertOutrightWinnerForBets(
bets: Array<{ selections: Array<{ marketType: string }> }>,
winnerTeamCode: string | null,
isOutright: boolean,
) {
const hasOutrightLeg =
isOutright ||
bets.some((bet) =>
bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'),
);
if (hasOutrightLeg && !winnerTeamCode) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
}
}
private requireBetUpdate(updated: { count: number }, betNo: string) {
if (updated.count !== 1) {
throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo });
}
}
private async assertLatestPreviewBatch(
batchId: bigint,
matchId: bigint,
tx?: TxClient,
) {
const client: PrismaClientLike = tx ?? this.prisma;
const latest = await client.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW', isResettle: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (latest && latest.id !== batchId) {
throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() });
}
}
private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) {
const client: PrismaClientLike = tx ?? this.prisma;
await client.settlementBatch.updateMany({
where: { matchId, status: 'PREVIEW', isResettle: false },
data: { status: 'CANCELLED' },
});
}
private parlayBetStatusFromResult(
parlayResult: ReturnType<typeof calculateParlayPayout>,
): 'LOST' | 'PUSH' | 'WON' {
if (parlayResult.betResult === 'LOST') return 'LOST';
if (parlayResult.betResult === 'PUSH') return 'PUSH';
return 'WON';
}
private walletResultFromParlayBetResult(
betResult: 'WON' | 'LOST' | 'PUSH',
): 'WIN' | 'LOSE' | 'PUSH' {
if (betResult === 'LOST') return 'LOSE';
if (betResult === 'PUSH') return 'PUSH';
return 'WIN';
}
async recordScore(
matchId: bigint,
htHome: number,
@@ -226,6 +316,13 @@ export class SettlementService {
}
}
if (!match.isOutright) {
this.assertScoreConsistency(
{ htHome, htAway, ftHome, ftAway },
match.isOutright,
);
}
const stats = this.statsInputFromSource(statsInput);
await this.prisma.matchScore.upsert({
where: { matchId },
@@ -312,7 +409,19 @@ export class SettlementService {
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreSource.htHome,
htAway: scoreSource.htAway,
ftHome: scoreSource.ftHome,
ftAway: scoreSource.ftAway,
},
match.isOutright,
);
}
const computation = await this.computePreviewComputation(matchId, scoreSource);
await this.cancelStalePreviewBatches(matchId);
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
@@ -733,6 +842,8 @@ export class SettlementService {
statsInput,
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false);
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
@@ -758,6 +869,7 @@ export class SettlementService {
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
if (result === 'LOSE') lostOnThisMatch += 1;
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
@@ -776,6 +888,7 @@ export class SettlementService {
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
if (parlay.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -809,6 +922,7 @@ export class SettlementService {
selectionCodes,
);
if (preview.kind === 'SETTLED') {
if (preview.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -865,11 +979,18 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
if (legResult === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
result: legResult,
});
} else if (sel.resultStatus) {
if (sel.resultStatus === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
@@ -966,6 +1087,13 @@ export class SettlementService {
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(
pendingBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx);
let settledCount = 0;
await this.upsertMatchScoreRecord(
@@ -1001,7 +1129,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await tx.betSelection.update({
where: { id: sel.id },
@@ -1045,12 +1173,7 @@ export class SettlementService {
});
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -1060,7 +1183,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -1068,12 +1191,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1116,12 +1234,7 @@ export class SettlementService {
result: s.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -1131,7 +1244,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -1139,12 +1252,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1386,7 +1494,7 @@ export class SettlementService {
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
if (bet.betType === 'SINGLE') {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
@@ -1401,6 +1509,26 @@ export class SettlementService {
};
}
if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
for (const sel of bet.selections) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
legUpdates.set(sel.id.toString(), result);
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
@@ -1423,14 +1551,11 @@ export class SettlementService {
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
return { payout: parlayResult.payout, betStatus, legUpdates };
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
async previewResettlement(
@@ -1448,6 +1573,18 @@ export class SettlementService {
throw appBadRequest('RESETTLE_SETTLED_ONLY');
}
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
},
match.isOutright,
);
}
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
@@ -1467,6 +1604,8 @@ export class SettlementService {
statsInput,
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright);
const items: Array<{
betId: bigint;
betNo: string;
@@ -1612,6 +1751,12 @@ export class SettlementService {
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(
settledBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
let affectedCount = 0;
await this.upsertMatchScoreRecord(
@@ -1715,6 +1860,7 @@ export class SettlementService {
options: { cancelMatch?: boolean } = {},
) {
const agentIds = new Set<bigint>();
const voidBatchNo = `void:${matchId}`;
const voidedCount = await this.prisma.$transaction(async (tx) => {
if (options.cancelMatch) {
@@ -1726,21 +1872,82 @@ export class SettlementService {
const bets = await tx.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
let count = 0;
for (const bet of bets) {
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1;
await this.funds.voidBet({
if (isSingleOneLeg) {
const sel = bet.selections[0];
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
await this.funds.voidBet({
userId: bet.userId,
stake: bet.stake,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);
count += 1;
continue;
}
const legsOnMatch = bet.selections.filter(
(sel) => sel.matchId?.toString() === matchId.toString(),
);
if (!legsOnMatch.length) continue;
for (const sel of legsOnMatch) {
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
}
const updatedLegs = await tx.betSelection.findMany({
where: { betId: bet.id },
orderBy: { sortOrder: 'asc' },
});
if (!updatedLegs.every((sel) => sel.resultStatus != null)) {
continue;
}
const legResults = updatedLegs.map((sel) => ({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout: parlayResult.payout,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
batchNo: voidBatchNo,
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);