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) { 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) { 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, 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 = { 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 }, }); } }