- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑 - 固定顶栏、公告与底栏,仅内容区滚动 - 底部导航与站点 favicon 使用 logo,登录页精简 - API 种子、冠军盘与历史注单增强 Co-authored-by: Cursor <cursoragent@cursor.com>
339 lines
9.8 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|