import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { resolveTranslationFallback } from '@thebet365/shared'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../../shared/prisma/prisma.service'; export type MatchBetStatsSummary = { betCount: number; totalStake: string; pendingCount: number; }; import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types'; import { leagueCodeFromExport, resolveInternalStatus, resolveIsHot, resolveStartTime, teamCodeFromExport, toKickoffJson, toVenueJson, translationsFromZhiboNames, } from './zhibo-match.mapper'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; const OUTRIGHT_PLACEHOLDER_CODE = 'OUT'; @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; displayOrder?: number; createdBy?: bigint; status?: string; publishTime?: Date; zhibo?: Partial<{ officialMatchNo: number; stage: string; groupName: string; liveMatchId?: bigint; additionMatchId: bigint | null; channelId: string | null; matchName: string; venueJson: Prisma.InputJsonValue; kickoffJson: Prisma.InputJsonValue; externalStatus: string; }>; }) { const status = data.status ?? 'DRAFT'; return this.prisma.match.create({ data: { leagueId: data.leagueId, homeTeamId: data.homeTeamId, awayTeamId: data.awayTeamId, startTime: data.startTime, isHot: data.isHot ?? false, displayOrder: data.displayOrder ?? 0, createdBy: data.createdBy, status, publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined), officialMatchNo: data.zhibo?.officialMatchNo, stage: data.zhibo?.stage, groupName: data.zhibo?.groupName, liveMatchId: data.zhibo?.liveMatchId, additionMatchId: data.zhibo?.additionMatchId ?? undefined, channelId: data.zhibo?.channelId ?? undefined, matchName: data.zhibo?.matchName, venueJson: data.zhibo?.venueJson, kickoffJson: data.zhibo?.kickoffJson, externalStatus: data.zhibo?.externalStatus, }, }); } private async upsertEntityTranslations( entityType: 'LEAGUE' | 'TEAM', entityId: bigint, translations: Record, ) { for (const [locale, value] of Object.entries(translations)) { await this.prisma.entityTranslation.upsert({ where: { entityType_entityId_locale_fieldName: { entityType, entityId, locale, fieldName: 'name', }, }, create: { entityType, entityId, locale, fieldName: 'name', value }, update: { value }, }); } } async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) { const code = leagueCodeFromExport(league); const record = await this.prisma.league.upsert({ where: { code }, create: { code, sportType: league.type || 'FOOTBALL' }, update: { sportType: league.type || 'FOOTBALL' }, }); await this.upsertEntityTranslations('LEAGUE', record.id, { 'zh-CN': league.zh, 'en-US': league.en, }); return record; } async upsertTeamFromZhiboExport(team: ZhiboTeamExport) { const translations = translationsFromZhiboNames(team.names, team.name); if (team.id != null) { const existing = await this.prisma.team.findFirst({ where: { externalId: team.id }, }); if (existing) { const record = await this.prisma.team.update({ where: { id: existing.id }, data: { logoUrl: team.image || existing.logoUrl, externalId: team.id, }, }); await this.upsertEntityTranslations('TEAM', record.id, translations); return record; } } const code = teamCodeFromExport(team); const record = await this.prisma.team.upsert({ where: { code }, create: { code, externalId: team.id ?? undefined, logoUrl: team.image || undefined, }, update: { logoUrl: team.image || undefined, externalId: team.id ?? undefined, }, }); await this.upsertEntityTranslations('TEAM', record.id, translations); return record; } private async findExistingZhiboMatch( leagueId: bigint, homeTeamId: bigint, awayTeamId: bigint, item: ZhiboMatchExport, ) { if (item.liveMatchId != null) { return this.prisma.match.findUnique({ where: { liveMatchId: BigInt(item.liveMatchId) }, }); } if (item.officialMatchNo != null) { return this.prisma.match.findFirst({ where: { leagueId, homeTeamId, awayTeamId, officialMatchNo: item.officialMatchNo, }, }); } return null; } async createPlatformLeague(data: { leagueEn: string; leagueZh: string; leagueMs?: string; logoUrl?: string; displayOrder?: number; }) { const leagueEn = data.leagueEn.trim(); const leagueZh = data.leagueZh.trim(); if (!leagueEn && !leagueZh) { throw new BadRequestException('请填写赛事名称(中文或英文至少一项)'); } const league = await this.upsertLeagueFromZhiboExport({ type: 'FOOTBALL', en: leagueEn || leagueZh, zh: leagueZh || leagueEn, }); if (data.leagueMs?.trim()) { await this.upsertEntityTranslations('LEAGUE', league.id, { 'ms-MY': data.leagueMs.trim(), }); } const updates: { logoUrl?: string; displayOrder?: number } = {}; if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim(); if (data.displayOrder != null) updates.displayOrder = data.displayOrder; if (Object.keys(updates).length) { await this.prisma.league.update({ where: { id: league.id }, data: updates }); } const [en, zh, ms] = await Promise.all([ this.getTranslationExact('LEAGUE', league.id, 'en-US'), this.getTranslationExact('LEAGUE', league.id, 'zh-CN'), this.getTranslationExact('LEAGUE', league.id, 'ms-MY'), ]); const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } }); return { id: fresh.id.toString(), code: fresh.code, logoUrl: fresh.logoUrl, displayOrder: fresh.displayOrder, leagueEn: en, leagueZh: zh, leagueMs: ms, }; } async listAdminLeagues(opts: { page: number; pageSize: number; keyword?: string; status?: string; }) { const skip = (opts.page - 1) * opts.pageSize; const kw = opts.keyword?.trim(); let idFilter: bigint[] | undefined; if (kw || opts.status) { const ids = new Set(); 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 matchWhere: Prisma.MatchWhereInput = { deletedAt: null, isOutright: false, }; if (opts.status) matchWhere.status = opts.status; if (kw) { matchWhere.OR = [ { matchName: { contains: kw, mode: 'insensitive' } }, { homeTeam: { code: { contains: kw, mode: 'insensitive' } } }, { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, ]; } const matchLeagues = await this.prisma.match.findMany({ where: matchWhere, select: { leagueId: true }, distinct: ['leagueId'], }); for (const m of matchLeagues) ids.add(m.leagueId); idFilter = [...ids]; if (!idFilter.length) { return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize }; } } const where: Prisma.LeagueWhereInput = { deletedAt: null }; if (idFilter) where.id = { in: idFilter }; const [leagues, total] = await Promise.all([ this.prisma.league.findMany({ where, orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }], skip, take: opts.pageSize, }), this.prisma.league.count({ where }), ]); const items = await Promise.all( leagues.map(async (league) => { const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([ this.getTranslationExact('LEAGUE', league.id, 'en-US'), this.getTranslationExact('LEAGUE', league.id, 'zh-CN'), this.getTranslationExact('LEAGUE', league.id, 'ms-MY'), this.prisma.match.count({ where: { leagueId: league.id, deletedAt: null, isOutright: false, ...(opts.status ? { status: opts.status } : {}), }, }), ]); return { id: league.id.toString(), code: league.code, logoUrl: league.logoUrl, displayOrder: league.displayOrder, leagueEn, leagueZh, leagueMs, matchCount, betStats: { betCount: 0, totalStake: '0', pendingCount: 0 }, }; }), ); const leagueIds = leagues.map((l) => l.id); const leagueMatches = await this.prisma.match.findMany({ where: { leagueId: { in: leagueIds }, deletedAt: null, isOutright: false, ...(opts.status ? { status: opts.status } : {}), }, select: { id: true, leagueId: true }, }); const matchStats = await this.betStatsForMatches( leagueMatches.map((m) => m.id), ); const leagueBetRollup = new Map(); for (const lm of leagueMatches) { const lid = lm.leagueId.toString(); const cur = leagueBetRollup.get(lid) ?? { betCount: 0, totalStake: new Decimal(0), pendingCount: 0, }; const ms = matchStats.get(lm.id.toString()); if (ms) { cur.betCount += ms.betCount; cur.totalStake = cur.totalStake.add(ms.totalStake); cur.pendingCount += ms.pendingCount; } leagueBetRollup.set(lid, cur); } for (const item of items) { const roll = leagueBetRollup.get(item.id); if (roll) { item.betStats = { betCount: roll.betCount, totalStake: roll.totalStake.toString(), pendingCount: roll.pendingCount, }; } } return { items, total, page: opts.page, pageSize: opts.pageSize }; } async listAdminLeagueMatches( leagueId: bigint, opts: { status?: string; keyword?: string; locale?: string }, ) { const where: Prisma.MatchWhereInput = { leagueId, deletedAt: null, isOutright: false, }; if (opts.status) where.status = opts.status; const kw = opts.keyword?.trim(); if (kw) { where.OR = [ { matchName: { contains: kw, mode: 'insensitive' } }, { homeTeam: { code: { contains: kw, mode: 'insensitive' } } }, { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, ]; } const items = await this.prisma.match.findMany({ where, include: { homeTeam: true, awayTeam: true }, orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }], }); const locale = opts.locale ?? 'zh-CN'; const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id)); return Promise.all( items.map(async (m) => { const [homeTeamName, awayTeamName] = await Promise.all([ this.getTranslation('TEAM', m.homeTeamId, locale), this.getTranslation('TEAM', m.awayTeamId, locale), ]); const raw = betStatsMap.get(m.id.toString()); const betCount = raw?.betCount ?? 0; const totalStake = raw?.totalStake.toString() ?? '0'; const pendingBets = raw?.pendingCount ?? 0; return { id: m.id.toString(), status: m.status, isOutright: m.isOutright, isHot: m.isHot, displayOrder: m.displayOrder, startTime: m.startTime, matchName: m.matchName, homeTeamName, awayTeamName, homeTeam: { code: m.homeTeam.code }, awayTeam: { code: m.awayTeam.code }, betCount, totalStake, pendingBets, }; }), ); } /** 批量汇总多场关联注单(按 bet 去重计注单数) */ async betStatsForMatches( matchIds: bigint[], ): Promise> { const result = new Map< string, MatchBetStatsSummary & { totalStake: Decimal } >(); if (!matchIds.length) return result; const legs = await this.prisma.betSelection.findMany({ where: { matchId: { in: matchIds } }, select: { matchId: true, betId: true, bet: { select: { stake: true, status: true } }, }, }); const byMatch = new Map< string, Map >(); for (const leg of legs) { if (leg.matchId == null) continue; const mid = leg.matchId.toString(); if (!byMatch.has(mid)) byMatch.set(mid, new Map()); const bets = byMatch.get(mid)!; if (!bets.has(leg.betId.toString())) { bets.set(leg.betId.toString(), { stake: leg.bet.stake, status: leg.bet.status, }); } } for (const id of matchIds) { const mid = id.toString(); const bets = byMatch.get(mid); if (!bets) { result.set(mid, { betCount: 0, totalStake: new Decimal(0), pendingCount: 0, }); continue; } let totalStake = new Decimal(0); let pendingCount = 0; for (const b of bets.values()) { totalStake = totalStake.add(b.stake); if (b.status === 'PENDING') pendingCount += 1; } result.set(mid, { betCount: bets.size, totalStake, pendingCount, }); } return result; } async createPlatformMatch(data: { leagueId?: bigint; leagueEn?: string; leagueZh?: string; leagueMs?: string; homeTeamZh: string; homeTeamEn: string; homeTeamMs?: string; awayTeamZh: string; awayTeamEn: string; awayTeamMs?: string; startTime: Date; isHot?: boolean; displayOrder?: number; matchName?: string; stage?: string; groupName?: string; leagueLogoUrl?: string; homeTeamLogoUrl?: string; awayTeamLogoUrl?: string; createdBy?: bigint; }) { const homeEn = data.homeTeamEn.trim(); const homeZh = data.homeTeamZh.trim(); const homeMs = data.homeTeamMs?.trim() ?? ''; const awayEn = data.awayTeamEn.trim(); const awayZh = data.awayTeamZh.trim(); const awayMs = data.awayTeamMs?.trim() ?? ''; if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) { throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)'); } let league; if (data.leagueId) { league = await this.prisma.league.findFirst({ where: { id: data.leagueId, deletedAt: null }, }); if (!league) throw new NotFoundException('赛事不存在'); } else { const leagueEn = data.leagueEn?.trim() ?? ''; const leagueZh = data.leagueZh?.trim() ?? ''; const leagueMs = data.leagueMs?.trim() ?? ''; if (!leagueEn && !leagueZh && !leagueMs) { throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)'); } league = await this.upsertLeagueFromZhiboExport({ type: 'FOOTBALL', en: leagueEn || leagueZh || leagueMs, zh: leagueZh || leagueEn || leagueMs, }); if (leagueMs) { await this.upsertEntityTranslations('LEAGUE', league.id, { 'ms-MY': leagueMs, }); } if (data.leagueLogoUrl?.trim()) { await this.prisma.league.update({ where: { id: league.id }, data: { logoUrl: data.leagueLogoUrl.trim() }, }); } } 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() || '', }); if (homeTeam.id === awayTeam.id) { throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名'); } const matchName = data.matchName?.trim() || `${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`; return this.createMatch({ leagueId: league.id, homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, startTime: data.startTime, isHot: data.isHot ?? false, displayOrder: data.displayOrder ?? 0, createdBy: data.createdBy, status: 'DRAFT', zhibo: { matchName, stage: data.stage?.trim() || undefined, groupName: data.groupName?.trim() || undefined, }, }); } private async requireAdminMatch(matchId: bigint) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, include: { homeTeam: true, awayTeam: true, league: true }, }); if (!match) throw new NotFoundException('赛事不存在'); return match; } async getAdminMatchDetail(matchId: bigint) { const match = await this.requireAdminMatch(matchId); const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId }, }); const markets = await this.prisma.market.findMany({ where: { matchId }, include: { selections: { orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' }, }); const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] = await Promise.all([ this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'), this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'), this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'), this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'), this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'), this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'), this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'), this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'), this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'), ]); return { id: match.id.toString(), status: match.status, isOutright: match.isOutright, isHot: match.isHot, displayOrder: match.displayOrder, startTime: match.startTime.toISOString(), leagueId: match.leagueId.toString(), leagueCode: match.league.code, leagueEn, leagueZh, leagueMs, leagueLogoUrl: match.league.logoUrl ?? '', homeTeamEn: homeEn, homeTeamZh: homeZh, homeTeamMs: homeMs, homeTeamCode: match.homeTeam.code, homeTeamLogoUrl: match.homeTeam.logoUrl ?? '', awayTeamEn: awayEn, awayTeamZh: awayZh, awayTeamMs: awayMs, awayTeamCode: match.awayTeam.code, awayTeamLogoUrl: match.awayTeam.logoUrl ?? '', matchName: match.matchName ?? '', stage: match.stage ?? '', groupName: match.groupName ?? '', score: scoreRow ? { htHome: scoreRow.htHomeScore ?? 0, htAway: scoreRow.htAwayScore ?? 0, ftHome: scoreRow.ftHomeScore ?? 0, ftAway: scoreRow.ftAwayScore ?? 0, } : null, markets: markets.map((m) => ({ id: m.id.toString(), marketType: m.marketType, period: m.period, lineValue: m.lineValue != null ? Number(m.lineValue) : null, status: m.status, promoLabel: m.promoLabel ?? '', sortOrder: m.sortOrder, selections: m.selections.map((s) => ({ id: s.id.toString(), selectionCode: s.selectionCode, selectionName: s.selectionName, odds: Number(s.odds), status: s.status, sortOrder: s.sortOrder, })), })), }; } async updatePlatformMatch( matchId: bigint, data: { leagueEn: string; leagueZh: string; leagueMs?: string; homeTeamZh: string; homeTeamEn: string; homeTeamMs?: string; awayTeamZh: string; awayTeamEn: string; awayTeamMs?: string; startTime: Date; isHot?: boolean; displayOrder?: number; matchName?: string; stage?: string; groupName?: string; leagueLogoUrl?: string; homeTeamLogoUrl?: string; awayTeamLogoUrl?: string; updatedBy?: bigint; }, ) { const match = await this.requireAdminMatch(matchId); if (match.isOutright) { throw new BadRequestException('冠军盘请通过盘口管理维护'); } if (!['DRAFT', 'PUBLISHED'].includes(match.status)) { throw new BadRequestException('当前状态不可编辑'); } const matchName = data.matchName?.trim() || `${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`; await Promise.all([ this.upsertEntityTranslations('LEAGUE', match.leagueId, { 'zh-CN': data.leagueZh.trim(), 'en-US': data.leagueEn.trim(), 'ms-MY': (data.leagueMs ?? '').trim(), }), this.upsertEntityTranslations('TEAM', match.homeTeamId, { 'zh-CN': data.homeTeamZh.trim(), 'en-US': data.homeTeamEn.trim(), 'ms-MY': (data.homeTeamMs ?? '').trim(), }), this.upsertEntityTranslations('TEAM', match.awayTeamId, { 'zh-CN': data.awayTeamZh.trim(), 'en-US': data.awayTeamEn.trim(), 'ms-MY': (data.awayTeamMs ?? '').trim(), }), ]); const logoUpdates: Promise[] = []; if (data.leagueLogoUrl !== undefined) { logoUpdates.push( this.prisma.league.update({ where: { id: match.leagueId }, data: { logoUrl: data.leagueLogoUrl.trim() || null }, }), ); } if (data.homeTeamLogoUrl !== undefined) { logoUpdates.push( this.prisma.team.update({ where: { id: match.homeTeamId }, data: { logoUrl: data.homeTeamLogoUrl.trim() || null }, }), ); } if (data.awayTeamLogoUrl !== undefined) { logoUpdates.push( this.prisma.team.update({ where: { id: match.awayTeamId }, data: { logoUrl: data.awayTeamLogoUrl.trim() || null }, }), ); } if (logoUpdates.length) await Promise.all(logoUpdates); return this.prisma.match.update({ where: { id: matchId }, data: { startTime: data.startTime, isHot: data.isHot ?? match.isHot, displayOrder: data.displayOrder ?? match.displayOrder, matchName, stage: data.stage !== undefined ? data.stage.trim() || null : match.stage, groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName, updatedBy: data.updatedBy, }, }); } async deleteMatch(matchId: bigint) { const match = await this.requireAdminMatch(matchId); if (match.isOutright) { throw new BadRequestException('冠军盘不可删除'); } if (match.status !== 'DRAFT') { throw new BadRequestException('仅草稿状态可删除'); } const betCount = await this.prisma.betSelection.count({ where: { matchId } }); if (betCount > 0) { throw new BadRequestException('该赛事已有注单关联,无法删除'); } return this.prisma.match.update({ where: { id: matchId }, data: { deletedAt: new Date() }, }); } async createMatchFromZhiboExport( item: ZhiboMatchExport, createdBy?: bigint, opts?: { asDraft?: boolean }, ) { const league = await this.upsertLeagueFromZhiboExport(item.league); const [homeTeam, awayTeam] = await Promise.all([ this.upsertTeamFromZhiboExport(item.homeTeam), this.upsertTeamFromZhiboExport(item.awayTeam), ]); const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item); const startTime = resolveStartTime(item.kickoff); const liveMatchId = item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined; const payload = { leagueId: league.id, homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, startTime, isHot: resolveIsHot(item), displayOrder: item.sortOrder, createdBy, status, publishTime: status === 'PUBLISHED' ? new Date() : undefined, zhibo: { officialMatchNo: item.officialMatchNo, stage: item.stage, groupName: item.groupName, liveMatchId, additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null, channelId: item.channelId, matchName: item.matchName, venueJson: toVenueJson(item.venue), kickoffJson: toKickoffJson(item.kickoff), externalStatus: item.status.state, }, }; const existing = await this.findExistingZhiboMatch( league.id, homeTeam.id, awayTeam.id, item, ); if (existing) { return this.prisma.match.update({ where: { id: existing.id }, data: { leagueId: payload.leagueId, homeTeamId: payload.homeTeamId, awayTeamId: payload.awayTeamId, startTime: payload.startTime, isHot: payload.isHot, displayOrder: payload.displayOrder, status: payload.status, publishTime: existing.publishTime ?? payload.publishTime, officialMatchNo: payload.zhibo.officialMatchNo, stage: payload.zhibo.stage, groupName: payload.zhibo.groupName, liveMatchId: payload.zhibo.liveMatchId ?? undefined, additionMatchId: payload.zhibo.additionMatchId ?? undefined, channelId: payload.zhibo.channelId ?? undefined, matchName: payload.zhibo.matchName, venueJson: payload.zhibo.venueJson, kickoffJson: payload.zhibo.kickoffJson, externalStatus: payload.zhibo.externalStatus, updatedBy: createdBy, }, }); } return this.createMatch(payload); } async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) { if (!bundle.matches?.length) { throw new BadRequestException('matches array is required'); } const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = []; for (const item of bundle.matches) { try { const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true }); results.push({ liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '', id: match.id.toString(), status: match.status, }); } catch (err) { const message = err instanceof Error ? err.message : 'import failed'; results.push({ liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '', id: '', status: 'error', reason: message, }); } } return { total: bundle.matches.length, imported: results.filter((r) => !r.skipped && r.status !== 'error').length, skipped: results.filter((r) => r.skipped).length, failed: results.filter((r) => r.status === 'error').length, results, }; } 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' }, }); } private async getTranslationExact(entityType: string, entityId: bigint, locale: string) { const row = await this.prisma.entityTranslation.findFirst({ where: { entityType, entityId, locale, fieldName: 'name' }, }); return row?.value ?? ''; } 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 resolveTranslationFallback(map, locale); } async enrichMatch(match: Record, locale: string) { const m = match as { id: bigint; leagueId: bigint; homeTeamId: bigint; awayTeamId: bigint; startTime: Date; status?: string; isHot?: boolean; displayOrder?: number; matchName?: string | null; stage?: string | null; groupName?: string | null; homeTeam?: { code: string; logoUrl?: string | null }; awayTeam?: { code: string; logoUrl?: string | null }; league?: { logoUrl?: string | null }; markets?: Array>; }; 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), ]); const base = { id: m.id.toString(), leagueId: m.leagueId.toString(), leagueName, leagueLogoUrl: m.league?.logoUrl ?? null, homeTeamName: homeName, awayTeamName: awayName, homeTeamCode: m.homeTeam?.code ?? '', awayTeamCode: m.awayTeam?.code ?? '', homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null, awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null, startTime: m.startTime.toISOString(), isHot: m.isHot ?? false, displayOrder: m.displayOrder ?? 0, matchName: m.matchName ?? null, stage: m.stage ?? null, groupName: m.groupName ?? null, status: m.status ?? 'PUBLISHED', }; if (m.markets) { return { ...base, markets: m.markets.map((market) => ({ id: (market.id as bigint).toString(), marketType: market.marketType as string, period: market.period as string, lineValue: market.lineValue != null ? Number(market.lineValue) : null, allowParlay: (market.allowParlay as boolean | undefined) ?? true, promoLabel: (market.promoLabel as string | null | undefined) ?? null, selections: ((market.selections as Array>) ?? []).map((s) => ({ id: (s.id as bigint).toString(), selectionCode: s.selectionCode as string, selectionName: s.selectionName as string, odds: Number(s.odds), oddsVersion: (s.oddsVersion as bigint).toString(), })), })), }; } return base; } async listPublished(locale = 'en-US', leagueId?: bigint) { const now = new Date(); const matches = await this.prisma.match.findMany({ where: { status: 'PUBLISHED', isOutright: false, sportType: 'FOOTBALL', deletedAt: null, startTime: { gt: now }, ...(leagueId ? { leagueId } : {}), }, include: { league: true, homeTeam: true, awayTeam: true, markets: { where: { status: 'OPEN' }, include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' }, }, }, orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { 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.findFirst({ where: { id: matchId, deletedAt: null, sportType: 'FOOTBALL', isOutright: false, status: { in: ['PUBLISHED', 'CLOSED'] }, }, include: { league: true, 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') { try { await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false }); } catch { /* 联赛未 seed 时忽略,仍返回已有数据 */ } const matches = await this.prisma.match.findMany({ where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL', deletedAt: null, }, 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 .filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE) .map(async (sel) => { const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode }, }); const teamName = team ? await this.getTranslation('TEAM', team.id, locale) : sel.selectionName; return { id: sel.id.toString(), teamCode: sel.selectionCode, teamName, rank: sel.sortOrder + 1, odds: sel.odds.toString(), oddsVersion: sel.oddsVersion.toString(), }; }), ); if (selections.length === 0) continue; results.push({ id: match.id.toString(), leagueId: match.leagueId.toString(), leagueName, title: `*${leagueName} 冠军`, marketId: market.id.toString(), selections, }); } return results; } private marketLabelKey(marketType: string, locale = 'zh-CN'): string { type LangMap = Record; const labels: Record = { FT_1X2: { 'zh-CN': '全场独赢', 'en-US': 'FT 1X2', 'ms-MY': '1X2 Penuh' }, FT_HANDICAP: { 'zh-CN': '全场让球', 'en-US': 'FT Handicap', 'ms-MY': 'Handicap Penuh' }, FT_OVER_UNDER: { 'zh-CN': '全场大小', 'en-US': 'FT O/U', 'ms-MY': 'Atas/Bawah Penuh' }, FT_ODD_EVEN: { 'zh-CN': '全场单双', 'en-US': 'FT Odd/Even', 'ms-MY': 'Ganjil/Genap Penuh' }, HT_1X2: { 'zh-CN': '半场独赢', 'en-US': 'HT 1X2', 'ms-MY': '1X2 Separuh' }, HT_HANDICAP: { 'zh-CN': '半场让球', 'en-US': 'HT Handicap', 'ms-MY': 'Handicap Separuh' }, HT_OVER_UNDER: { 'zh-CN': '半场大小', 'en-US': 'HT O/U', 'ms-MY': 'Atas/Bawah Separuh' }, OUTRIGHT_WINNER: { 'zh-CN': '冠军', 'en-US': 'Outright', 'ms-MY': 'Juara' }, FT_CORRECT_SCORE: { 'zh-CN': '波胆', 'en-US': 'Correct Score', 'ms-MY': 'Skor Tepat' }, HT_CORRECT_SCORE: { 'zh-CN': '上半场波胆', 'en-US': '1H Correct Score', 'ms-MY': 'Skor Tepat PB1' }, SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' }, }; const entry = labels[marketType]; if (!entry) return marketType; return entry[locale] ?? entry['en-US'] ?? 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, locale), 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 }, }); } }