feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化

管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。

API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:55:56 +08:00
parent efff7c27e6
commit 24fa1b275c
66 changed files with 6289 additions and 1426 deletions

View File

@@ -247,6 +247,14 @@ class CreatePlatformMatchDto {
@IsString()
leagueMs?: string;
@IsOptional()
@IsString()
homeTeamCode?: string;
@IsOptional()
@IsString()
awayTeamCode?: string;
@IsString()
homeTeamEn!: string;
@@ -471,6 +479,11 @@ class AddOutrightSelectionDto {
logoUrl?: string;
}
class AddOutrightSelectionsBatchDto {
@IsArray()
items!: AddOutrightSelectionDto[];
}
class UpdateOutrightSelectionTeamDto {
@IsOptional()
@IsString()
@@ -983,6 +996,15 @@ export class AdminController {
return jsonResponse(result);
}
@Get('leagues/:leagueId/outright')
@RequirePermissions(P.matches, P.reports)
async getLeagueOutright(@Param('leagueId') leagueId: string) {
const data = await this.outright.getOrCreateAndSyncForLeague(
BigInt(leagueId),
);
return jsonResponse(data);
}
@Get('leagues/:leagueId/matches')
@RequirePermissions(P.matches, P.reports)
async listLeagueMatches(
@@ -1096,6 +1118,8 @@ export class AdminController {
leagueEn: dto.leagueEn ?? '',
leagueZh: dto.leagueZh ?? '',
leagueMs: dto.leagueMs,
homeTeamCode: dto.homeTeamCode,
awayTeamCode: dto.awayTeamCode,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
@@ -1319,6 +1343,22 @@ export class AdminController {
return jsonResponse(data);
}
@Post('outrights/:matchId/selections/batch')
@RequirePermissions(P.matches)
async addOutrightSelectionsBatch(
@Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionsBatchDto,
) {
if (!dto.items?.length) {
throw new BadRequestException('At least one team required');
}
const data = await this.outright.addSelectionsBatch(
BigInt(matchId),
dto.items,
);
return jsonResponse(data);
}
@Patch('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches)
async updateOutrightSelectionTeam(

View File

@@ -222,10 +222,28 @@ export class PlayerController {
return jsonResponse({ ...result, items });
}
@Get('bets/stats')
async betStats(@CurrentUser('id') userId: bigint) {
const stats = await this.bets.getUserBetStats(userId);
return jsonResponse(stats);
}
@Get('bets/:betNo')
async betDetail(@CurrentUser('id') userId: bigint, @Param('betNo') betNo: string) {
async betDetail(
@CurrentUser('id') userId: bigint,
@CurrentUser('locale') locale: string,
@Param('betNo') betNo: string,
) {
const bet = await this.bets.getBetByNo(betNo, userId);
return jsonResponse(bet);
if (!bet) return jsonResponse(null);
const [enriched] = await this.matches.enrichBetsForHistory([bet], locale);
return jsonResponse(enriched);
}
@Get('wallet/transactions/stats')
async transactionStats(@CurrentUser('id') userId: bigint) {
const stats = await this.wallet.getTransactionStats(userId);
return jsonResponse(stats);
}
@Get('wallet/transactions')

View File

@@ -247,6 +247,35 @@ export class BetsService {
return { items, total, page, pageSize };
}
async getUserBetStats(userId: bigint) {
const [byStatus, aggregates] = await Promise.all([
this.prisma.bet.groupBy({
by: ['status'],
where: { userId },
_count: { id: true },
_sum: { stake: true, actualReturn: true, potentialReturn: true },
}),
this.prisma.bet.aggregate({
where: { userId },
_sum: { stake: true, actualReturn: true, potentialReturn: true },
_count: { id: true },
}),
]);
return {
total: aggregates._count.id,
totalStake: aggregates._sum.stake?.toString() ?? '0',
totalReturn: aggregates._sum.actualReturn?.toString() ?? '0',
totalPotentialReturn: aggregates._sum.potentialReturn?.toString() ?? '0',
byStatus: byStatus.map((g) => ({
status: g.status,
count: g._count.id,
totalStake: g._sum.stake?.toString() ?? '0',
totalReturn: g._sum.actualReturn?.toString() ?? '0',
})),
};
}
async getBetByNo(betNo: string, userId?: bigint) {
return this.prisma.bet.findFirst({
where: { betNo, ...(userId ? { userId } : {}) },

View File

@@ -262,31 +262,28 @@ export class MatchesService {
const kw = opts.keyword?.trim();
let idFilter: bigint[] | undefined;
if (kw || opts.status) {
// 状态仅用于单场计数/展开列表筛选,不隐藏无该状态单场的联赛(含新建空联赛)
if (kw) {
const ids = new Set<bigint>();
if (kw) {
const trRows = await this.prisma.entityTranslation.findMany({
where: {
entityType: 'LEAGUE',
fieldName: 'name',
value: { contains: kw, mode: 'insensitive' },
},
select: { entityId: true },
});
for (const r of trRows) ids.add(r.entityId);
}
const trRows = await this.prisma.entityTranslation.findMany({
where: {
entityType: 'LEAGUE',
fieldName: 'name',
value: { contains: kw, mode: 'insensitive' },
},
select: { entityId: true },
});
for (const r of trRows) ids.add(r.entityId);
const matchWhere: Prisma.MatchWhereInput = {
deletedAt: null,
isOutright: false,
};
if (opts.status) matchWhere.status = opts.status;
if (kw) {
matchWhere.OR = [
OR: [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
],
};
if (opts.status) matchWhere.status = opts.status;
const matchLeagues = await this.prisma.match.findMany({
where: matchWhere,
select: { leagueId: true },
@@ -351,6 +348,27 @@ export class MatchesService {
},
select: { id: true, leagueId: true },
});
const fixtureTeamRows = await this.prisma.match.findMany({
where: {
leagueId: { in: leagueIds },
deletedAt: null,
isOutright: false,
},
select: {
leagueId: true,
homeTeam: { select: { id: true, code: true } },
awayTeam: { select: { id: true, code: true } },
},
});
const fixtureTeamSets = new Map<string, Set<string>>();
for (const row of fixtureTeamRows) {
const lid = row.leagueId.toString();
if (!fixtureTeamSets.has(lid)) fixtureTeamSets.set(lid, new Set());
const set = fixtureTeamSets.get(lid)!;
for (const team of [row.homeTeam, row.awayTeam]) {
if (team.code !== 'OUT') set.add(team.id.toString());
}
}
const matchStats = await this.betStatsForMatches(
leagueMatches.map((m) => m.id),
);
@@ -373,6 +391,50 @@ export class MatchesService {
}
leagueBetRollup.set(lid, cur);
}
const outrightMatches = await this.prisma.match.findMany({
where: {
leagueId: { in: leagueIds },
isOutright: true,
deletedAt: null,
},
select: { id: true, leagueId: true },
});
const outrightTeamCounts = new Map<string, number>();
if (outrightMatches.length > 0) {
const matchIdToLeagueId = new Map(
outrightMatches.map((m) => [m.id.toString(), m.leagueId.toString()]),
);
const outrightMatchIds = outrightMatches.map((m) => m.id);
const markets = await this.prisma.market.findMany({
where: {
matchId: { in: outrightMatchIds },
marketType: 'OUTRIGHT_WINNER',
},
select: { id: true, matchId: true },
});
if (markets.length > 0) {
const marketIdToMatchId = new Map(
markets.map((m) => [m.id.toString(), m.matchId.toString()]),
);
const selectionCounts = await this.prisma.marketSelection.groupBy({
by: ['marketId'],
where: {
marketId: { in: markets.map((m) => m.id) },
status: 'OPEN',
selectionCode: { not: 'OUT' },
},
_count: { id: true },
});
for (const row of selectionCounts) {
const matchId = marketIdToMatchId.get(row.marketId.toString());
const leagueId = matchId ? matchIdToLeagueId.get(matchId) : undefined;
if (leagueId) {
outrightTeamCounts.set(leagueId, row._count.id);
}
}
}
}
for (const item of items) {
const roll = leagueBetRollup.get(item.id);
if (roll) {
@@ -382,6 +444,10 @@ export class MatchesService {
pendingCount: roll.pendingCount,
};
}
(item as { fixtureTeamCount?: number }).fixtureTeamCount =
fixtureTeamSets.get(item.id)?.size ?? 0;
(item as { outrightTeamCount?: number }).outrightTeamCount =
outrightTeamCounts.get(item.id) ?? 0;
}
return { items, total, page: opts.page, pageSize: opts.pageSize };
@@ -501,11 +567,37 @@ export class MatchesService {
return result;
}
private async upsertTeamByCode(data: {
code: string;
teamZh: string;
teamEn: string;
teamMs?: string;
logoUrl?: string;
}) {
const code = data.code.trim().toUpperCase();
if (!code) throw new BadRequestException('请填写球队代码');
const logoUrl = data.logoUrl?.trim() || undefined;
const team = await this.prisma.team.upsert({
where: { code },
create: { code, logoUrl },
update: logoUrl ? { logoUrl } : {},
});
const translations: Record<string, string> = {
'zh-CN': data.teamZh.trim() || data.teamEn.trim(),
'en-US': data.teamEn.trim() || data.teamZh.trim(),
};
if (data.teamMs?.trim()) translations['ms-MY'] = data.teamMs.trim();
await this.upsertEntityTranslations('TEAM', team.id, translations);
return team;
}
async createPlatformMatch(data: {
leagueId?: bigint;
leagueEn?: string;
leagueZh?: string;
leagueMs?: string;
homeTeamCode?: string;
awayTeamCode?: string;
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
@@ -563,32 +655,56 @@ export class MatchesService {
});
}
}
const homeTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh || homeMs,
names: {
zh: homeZh || null,
en: homeEn || null,
zhTw: '',
vi: null,
km: null,
ms: homeMs || null,
},
image: data.homeTeamLogoUrl?.trim() || '',
});
const awayTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh || awayMs,
names: {
zh: awayZh || null,
en: awayEn || null,
zhTw: '',
vi: null,
km: null,
ms: awayMs || null,
},
image: data.awayTeamLogoUrl?.trim() || '',
});
const homeCode = data.homeTeamCode?.trim().toUpperCase();
const awayCode = data.awayTeamCode?.trim().toUpperCase();
let homeTeam;
let awayTeam;
if (homeCode && awayCode) {
if (homeCode === awayCode) {
throw new BadRequestException('主客队不能为同一支球队,请选择不同的队伍');
}
homeTeam = await this.upsertTeamByCode({
code: homeCode,
teamZh: homeZh,
teamEn: homeEn,
teamMs: homeMs,
logoUrl: data.homeTeamLogoUrl,
});
awayTeam = await this.upsertTeamByCode({
code: awayCode,
teamZh: awayZh,
teamEn: awayEn,
teamMs: awayMs,
logoUrl: data.awayTeamLogoUrl,
});
} else {
homeTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh || homeMs,
names: {
zh: homeZh || null,
en: homeEn || null,
zhTw: '',
vi: null,
km: null,
ms: homeMs || null,
},
image: data.homeTeamLogoUrl?.trim() || '',
});
awayTeam = await this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh || awayMs,
names: {
zh: awayZh || null,
en: awayEn || null,
zhTw: '',
vi: null,
km: null,
ms: awayMs || null,
},
image: data.awayTeamLogoUrl?.trim() || '',
});
}
if (homeTeam.id === awayTeam.id) {
throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
@@ -1199,13 +1315,18 @@ export class MatchesService {
matchIds.length > 0
? await this.prisma.match.findMany({
where: { id: { in: matchIds } },
include: { homeTeam: true, awayTeam: true },
include: { homeTeam: true, awayTeam: true, score: true },
})
: [];
const matchMeta = new Map<
string,
{ leagueName: string; matchTitle: string; isOutright: boolean }
{
leagueName: string;
matchTitle: string;
isOutright: boolean;
score: { ht: string | null; ft: string | null } | null;
}
>();
for (const m of matches) {
@@ -1214,10 +1335,20 @@ export class MatchesService {
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
const s = m.score;
const ftScore =
s?.ftHomeScore != null && s?.ftAwayScore != null
? `${s.ftHomeScore}-${s.ftAwayScore}`
: null;
const htScore =
s?.htHomeScore != null && s?.htAwayScore != null
? `${s.htHomeScore}-${s.htAwayScore}`
: null;
matchMeta.set(m.id.toString(), {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
});
}
@@ -1237,9 +1368,12 @@ export class MatchesService {
resultStatus: sel.resultStatus,
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
leagueName: m?.leagueName ?? '',
score: m?.score ?? null,
};
});
const firstScore = meta?.score ?? legs[0]?.score ?? null;
return {
betNo: bet.betNo,
betType: bet.betType,
@@ -1261,6 +1395,7 @@ export class MatchesService {
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
legs,
isParlay,
matchScore: isParlay ? null : firstScore,
};
});
}

View File

@@ -168,6 +168,11 @@ export class OutrightService {
this.getOutrightTitle(match.id, 'ms-MY'),
]);
const addableFixtureTeams = await this.listAddableFixtureTeams(
match.leagueId,
fullMarket.id,
);
return {
id: match.id.toString(),
leagueId: match.leagueId.toString(),
@@ -187,9 +192,146 @@ export class OutrightService {
playerVisible: visibility.playerVisible,
playerHiddenReason: visibility.playerHiddenReason,
selections,
addableFixtureTeams,
};
}
private async listAddableFixtureTeams(leagueId: bigint, marketId: bigint) {
const teams = await this.collectFixtureTeamsForLeague(leagueId);
const openCodes = new Set(
(
await this.prisma.marketSelection.findMany({
where: {
marketId,
status: 'OPEN',
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
select: { selectionCode: true },
})
).map((s) => s.selectionCode),
);
const result: Array<{
teamCode: string;
teamZh: string;
teamEn: string;
logoUrl: string | null;
}> = [];
for (const team of teams) {
if (openCodes.has(team.code)) continue;
const [teamZh, teamEn] = await Promise.all([
this.getTranslation('TEAM', team.id, 'zh-CN'),
this.getTranslation('TEAM', team.id, 'en-US'),
]);
result.push({
teamCode: team.code,
teamZh: teamZh || team.code,
teamEn: teamEn || team.code,
logoUrl: team.logoUrl ?? null,
});
}
return result;
}
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
async getOrCreateAndSyncForLeague(leagueId: bigint) {
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 new NotFoundException('League not found');
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', leagueId, 'en-US'),
this.getTranslation('LEAGUE', leagueId, 'ms-MY'),
]);
await this.createForAdmin({
leagueId,
titleZh: leagueZh || league.code,
titleEn: leagueEn || league.code,
titleMs: leagueMs || undefined,
status: 'DRAFT',
});
match = await this.prisma.match.findFirstOrThrow({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
}
return this.syncSelectionsFromLeagueFixtures(match.id);
}
async syncSelectionsFromLeagueFixtures(matchId: bigint) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const teams = await this.collectFixtureTeamsForLeague(match.leagueId);
const fixtureCodes = new Set(teams.map((t) => t.code));
const existing = await this.prisma.marketSelection.findMany({
where: {
marketId: market.id,
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
});
// 仅当球队重新出现在单场赛程时,恢复曾被关闭的选项;不因「暂无单场」而自动关闭
for (const sel of existing) {
if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') {
await this.prisma.marketSelection.update({
where: { id: sel.id },
data: { status: 'OPEN' },
});
}
}
const existingCodes = new Set(existing.map((s) => s.selectionCode));
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
for (const team of teams) {
if (existingCodes.has(team.code)) continue;
const [teamZh, teamEn] = await Promise.all([
this.getTranslation('TEAM', team.id, 'zh-CN'),
this.getTranslation('TEAM', team.id, 'en-US'),
]);
sortOrder += 1;
await this.prisma.marketSelection.create({
data: {
marketId: market.id,
selectionCode: team.code,
selectionName: teamZh || teamEn || team.code,
odds: 10,
sortOrder,
status: 'OPEN',
},
});
}
return this.getForAdmin(matchId);
}
private async collectFixtureTeamsForLeague(leagueId: bigint) {
const matches = await this.prisma.match.findMany({
where: { leagueId, isOutright: false, deletedAt: null },
select: { homeTeamId: true, awayTeamId: true },
});
const teamIds = [
...new Set(matches.flatMap((m) => [m.homeTeamId, m.awayTeamId])),
];
if (teamIds.length === 0) return [];
return this.prisma.team.findMany({
where: {
id: { in: teamIds },
code: { not: PLACEHOLDER_TEAM_CODE },
},
orderBy: { code: 'asc' },
});
}
async createForAdmin(data: {
leagueId: bigint;
titleZh: string;
@@ -325,6 +467,17 @@ export class OutrightService {
where: { marketId: market.id, selectionCode: code },
});
if (existing) {
if (existing.status === 'CLOSED') {
await this.prisma.marketSelection.update({
where: { id: existing.id },
data: {
status: 'OPEN',
odds: data.odds,
selectionName: data.teamZh.trim() || data.teamEn,
},
});
return this.getForAdmin(matchId);
}
throw new BadRequestException('Selection already exists for this team code');
}
@@ -347,6 +500,38 @@ export class OutrightService {
return this.getForAdmin(matchId);
}
async addSelectionsBatch(
matchId: bigint,
items: Array<{
teamCode: string;
teamZh: string;
teamEn: string;
odds: number;
logoUrl?: string;
}>,
) {
if (!items.length) {
throw new BadRequestException('At least one team required');
}
let added = 0;
let skipped = 0;
for (const item of items) {
try {
await this.addSelection(matchId, item);
added += 1;
} catch (e) {
const msg = e instanceof Error ? e.message : '';
if (msg.includes('already exists')) {
skipped += 1;
continue;
}
throw e;
}
}
const data = await this.getForAdmin(matchId);
return { ...data, batchResult: { added, skipped } };
}
async updateSelectionTeam(
matchId: bigint,
selectionId: bigint,

View File

@@ -272,4 +272,30 @@ export class WalletService {
]);
return { items, total, page, pageSize };
}
async getTransactionStats(userId: bigint) {
const [aggregates, byType] = await Promise.all([
this.prisma.walletTransaction.aggregate({
where: { userId },
_sum: { amount: true },
_count: { id: true },
}),
this.prisma.walletTransaction.groupBy({
by: ['transactionType'],
where: { userId },
_count: { id: true },
_sum: { amount: true },
}),
]);
return {
total: aggregates._count.id,
netAmount: aggregates._sum.amount?.toString() ?? '0',
byType: byType.map((g) => ({
transactionType: g.transactionType,
count: g._count.id,
totalAmount: g._sum.amount?.toString() ?? '0',
})),
};
}
}