sync(theme-2): 从 main 同步优胜赛结算态与钱包流水展示优化

- checkout shared/api/admin 自 d3c2114
- player: outright 结算态逻辑、wallet txAmountClass、i18n 新增 key(保留蓝白主题样式)
This commit is contained in:
2026-06-23 13:49:43 +08:00
parent 021d216613
commit 26e2adf786
36 changed files with 577 additions and 127 deletions

View File

@@ -306,7 +306,7 @@ describe('MatchesService listAdminLeagueMatches', () => {
const leagueId = BigInt(1);
let prisma: {
match: { findMany: jest.Mock; count: jest.Mock };
match: { findMany: jest.Mock; findFirst: jest.Mock; count: jest.Mock };
entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock };
};
let matchBetStats: { betStatsForMatches: jest.Mock };
@@ -314,7 +314,11 @@ describe('MatchesService listAdminLeagueMatches', () => {
beforeEach(() => {
prisma = {
match: { findMany: jest.fn(), count: jest.fn() },
match: {
findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn(),
},
entityTranslation: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
@@ -387,3 +391,38 @@ describe('MatchesService listAdminLeagueMatches', () => {
expect(result.items[1].id).toBe('11');
});
});
describe('MatchesService createMatch outright guard', () => {
const leagueId = BigInt(1);
it('rejects fixture creation when outright is settled', async () => {
const prisma = {
match: { create: jest.fn() },
};
const outright = {
assertLeagueAllowsNewFixtures: jest.fn().mockRejectedValue(
Object.assign(new Error('LEAGUE_OUTRIGHT_SETTLED'), {
response: { code: 'LEAGUE_OUTRIGHT_SETTLED' },
}),
),
};
const service = new MatchesService(
prisma as never,
outright as never,
{ betStatsForMatches: jest.fn() } as never,
);
await expect(
service.createMatch({
leagueId,
homeTeamId: BigInt(10),
awayTeamId: BigInt(11),
startTime: new Date('2026-06-01T12:00:00Z'),
}),
).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_OUTRIGHT_SETTLED' }),
});
expect(outright.assertLeagueAllowsNewFixtures).toHaveBeenCalledWith(leagueId);
expect(prisma.match.create).not.toHaveBeenCalled();
});
});

View File

@@ -105,6 +105,7 @@ export class MatchesService {
externalStatus: string;
}>;
}) {
await this.outright.assertLeagueAllowsNewFixtures(data.leagueId);
const status = data.status ?? 'DRAFT';
return this.prisma.match.create({
data: {
@@ -492,8 +493,11 @@ export class MatchesService {
isOutright: true,
deletedAt: null,
},
select: { id: true, leagueId: true },
select: { id: true, leagueId: true, status: true },
});
const outrightStatusByLeague = new Map(
outrightMatches.map((m) => [m.leagueId.toString(), m.status]),
);
const outrightTeamCounts = new Map<string, number>();
if (outrightMatches.length > 0) {
const matchIdToLeagueId = new Map(
@@ -543,6 +547,10 @@ export class MatchesService {
fixtureTeamSets.get(item.id)?.size ?? 0;
(item as { outrightTeamCount?: number }).outrightTeamCount =
outrightTeamCounts.get(item.id) ?? 0;
const outrightStatus = outrightStatusByLeague.get(item.id) ?? null;
(item as { outrightStatus?: string | null }).outrightStatus = outrightStatus;
(item as { isOutrightSettled?: boolean }).isOutrightSettled =
outrightStatus === 'SETTLED';
}
return { items, total, page: opts.page, pageSize: opts.pageSize };
@@ -562,6 +570,16 @@ export class MatchesService {
startTo?: Date;
},
) {
const outrightMatch = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
const leagueMeta = {
outrightStatus: outrightMatch?.status ?? null,
isOutrightSettled: outrightMatch?.status === 'SETTLED',
};
const where: Prisma.MatchWhereInput = {
leagueId,
deletedAt: null,
@@ -648,7 +666,7 @@ export class MatchesService {
const total = filteredItems.length;
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
return { items: paginatedItems, total, page, pageSize };
return { items: paginatedItems, total, page, pageSize, league: leagueMeta };
}
const orderBy =
@@ -698,10 +716,8 @@ export class MatchesService {
};
}),
);
return { items, total, page, pageSize };
return { items, total, page, pageSize, league: leagueMeta };
}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> {

View File

@@ -259,6 +259,18 @@ export class OutrightService {
await this.syncOutrightStatusWithLeague(existing, league);
}
/** 优胜赛(冠军盘)已结算时禁止再新增单场 */
async assertLeagueAllowsNewFixtures(leagueId: bigint) {
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_OUTRIGHT_SETTLED');
}
}
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
return this.prisma.match.count({
@@ -288,7 +300,9 @@ export class OutrightService {
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) return { addedCount: 0, reopenedCount: 0 };
if (!match || match.status === 'SETTLED') {
return { addedCount: 0, reopenedCount: 0 };
}
return this.syncSelectionsFromLeagueFixtures(match.id);
}
@@ -687,18 +701,19 @@ export class OutrightService {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
status: { in: ['PUBLISHED', 'SETTLED'] },
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
},
include: {
score: true,
markets: {
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' },
where: { marketType: OUTRIGHT_MARKET_TYPE },
include: {
selections: {
where: { status: 'OPEN' },
where: { selectionCode: { not: PLACEHOLDER_TEAM_CODE } },
orderBy: { sortOrder: 'asc' },
},
},
@@ -716,10 +731,24 @@ export class OutrightService {
const market = match.markets[0];
if (!market) continue;
const isSettled = match.status === 'SETTLED';
const visibleSelections = isSettled
? market.selections
: market.selections.filter((sel) => sel.status === 'OPEN');
if (!visibleSelections.length) continue;
let winnerTeamCode: string | null = null;
if (match.score?.winnerTeamId) {
const winner = await this.prisma.team.findUnique({
where: { id: match.score.winnerTeamId },
select: { code: true },
});
winnerTeamCode = winner?.code ?? null;
}
const selections = await Promise.all(
market.selections
.filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE)
.map(async (sel) => {
visibleSelections.map(async (sel) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
@@ -743,12 +772,11 @@ export class OutrightService {
logoUrl: team?.logoUrl ?? null,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
isWinner: Boolean(winnerTeamCode && sel.selectionCode === winnerTeamCode),
};
}),
);
if (!selections.length) continue;
const [titleZh, titleEn, titleMs] = await Promise.all([
this.getOutrightTitle(match.id, 'zh-CN'),
this.getOutrightTitle(match.id, 'en-US'),
@@ -764,6 +792,11 @@ export class OutrightService {
match.matchName?.trim() ||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
const bettingOpen =
match.status === 'PUBLISHED' &&
market.status === 'OPEN' &&
market.selections.some((sel) => sel.status === 'OPEN');
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
@@ -771,6 +804,9 @@ export class OutrightService {
leagueName: leagueName || '',
title: title.startsWith('*') ? title : `*${title}`,
marketId: market.id.toString(),
status: match.status,
bettingOpen,
winnerTeamCode,
selectionCount: selections.length,
selections,
});