Files
thebet365/apps/api/src/domains/catalog/matches.service.ts
Mars b5dca1bfb1 feat(player): 完善 H5 投注端与 API 演示数据
- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:18:11 +08:00

339 lines
9.8 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class MatchesService {
constructor(private prisma: PrismaService) {}
async createLeague(code: string, translations: Record<string, string>) {
const league = await this.prisma.league.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'LEAGUE',
entityId: league.id,
locale,
fieldName: 'name',
value,
},
});
}
return league;
}
async createTeam(code: string, translations: Record<string, string>) {
const team = await this.prisma.team.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
value,
},
});
}
return team;
}
async createMatch(data: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
createdBy?: bigint;
}) {
return this.prisma.match.create({
data: {
leagueId: data.leagueId,
homeTeamId: data.homeTeamId,
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
createdBy: data.createdBy,
status: 'DRAFT',
},
});
}
async publishMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', publishTime: new Date() },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
}
async cancelMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CANCELLED' },
});
}
async getTranslation(entityType: string, entityId: bigint, locale: string) {
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType, entityId },
});
const map = Object.fromEntries(
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
);
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
}
async enrichMatch(match: Record<string, unknown>, locale: string) {
const m = match as {
id: bigint;
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
homeTeam?: { code: string };
awayTeam?: { code: string };
markets?: unknown[];
};
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
return {
...match,
id: m.id.toString(),
leagueId: m.leagueId.toString(),
leagueName,
homeTeamName: homeName,
awayTeamName: awayName,
homeTeamCode: m.homeTeam?.code ?? '',
awayTeamCode: m.awayTeam?.code ?? '',
};
}
async listPublished(locale = 'en-US', leagueId?: bigint) {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
isOutright: false,
...(leagueId ? { leagueId } : {}),
},
include: {
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
},
},
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
});
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
}
async getMatchDetail(matchId: bigint, locale = 'en-US') {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
score: true,
},
});
if (!match) throw new NotFoundException('Match not found');
return this.enrichMatch(match, locale);
}
async listOutrights(locale = 'en-US') {
const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true },
include: {
markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
include: {
selections: {
where: { status: 'OPEN' },
orderBy: { sortOrder: 'asc' },
},
},
},
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
});
const results = [];
for (const match of matches) {
const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
const market = match.markets[0];
if (!market) continue;
const selections = await Promise.all(
market.selections.map(async (sel) => {
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
const teamName = team
? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode,
teamName,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
leagueName,
title: `*${leagueName} 冠军`,
marketId: market.id.toString(),
selections,
});
}
return results;
}
private marketLabelKey(marketType: string): string {
const keys: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
HT_HANDICAP: '半场让球',
HT_OVER_UNDER: '半场大小',
OUTRIGHT_WINNER: '冠军',
FT_CORRECT_SCORE: '波胆',
HT_CORRECT_SCORE: '上半场波胆',
SH_CORRECT_SCORE: '下半场波胆',
};
return keys[marketType] ?? marketType;
}
async enrichBetsForHistory(
bets: Array<{
betNo: string;
betType: string;
stake: unknown;
totalOdds: unknown;
potentialReturn: unknown;
actualReturn: unknown;
status: string;
placedAt: Date;
selections: Array<{
matchId: bigint | null;
marketType: string;
selectionNameSnapshot: string;
odds: unknown;
resultStatus?: string | null;
}>;
}>,
locale: string,
) {
const matchIds = [
...new Set(
bets.flatMap((b) =>
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
),
),
];
const matches =
matchIds.length > 0
? await this.prisma.match.findMany({
where: { id: { in: matchIds } },
include: { homeTeam: true, awayTeam: true },
})
: [];
const matchMeta = new Map<
string,
{ leagueName: string; matchTitle: string; isOutright: boolean }
>();
for (const m of matches) {
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
matchMeta.set(m.id.toString(), {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
});
}
return bets.map((bet) => {
const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString();
const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined;
const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1;
const legs = bet.selections.map((sel) => {
const mid = sel.matchId?.toString();
const m = mid ? matchMeta.get(mid) : undefined;
return {
marketType: sel.marketType,
marketLabel: this.marketLabelKey(sel.marketType),
selectionName: sel.selectionNameSnapshot,
odds: sel.odds,
resultStatus: sel.resultStatus,
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
leagueName: m?.leagueName ?? '',
};
});
return {
betNo: bet.betNo,
betType: bet.betType,
stake: bet.stake,
totalOdds: bet.totalOdds,
potentialReturn: bet.potentialReturn,
actualReturn: bet.actualReturn,
status: bet.status,
placedAt: bet.placedAt,
leagueName: isParlay
? 'Parlay'
: meta?.leagueName ?? legs[0]?.leagueName ?? '',
legCount: bet.selections.length,
matchTitle: isParlay
? ''
: meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo,
pickLabel: isParlay
? ''
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
legs,
isParlay,
};
});
}
@Cron(CronExpression.EVERY_MINUTE)
async autoCloseMatches() {
const now = new Date();
await this.prisma.match.updateMany({
where: {
status: 'PUBLISHED',
isOutright: false,
startTime: { lte: now },
},
data: { status: 'CLOSED', closeTime: now },
});
}
}