Files
thebet365/apps/api/src/domains/catalog/matches.service.ts

1831 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@nestjs/common';
import {
defaultMarketName,
defaultSelectionName,
isPreMatchKickoff,
isSettlementSupportedMarketType,
PARLAY_MARKET_TYPES,
resolveMarketText,
resolveTranslationFallback,
sanitizeLocalizedText,
} 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';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
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';
import { OutrightService } from './outright.service';
export type { MatchBetStatsSummary } from './match-bet-stats.service';
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
export type ListPublishedOptions = {
/** 响应中是否包含 markets列表页默认 false仅摘要 */
includeMarkets?: boolean;
/** 仅返回串关玩法 markets需 includeMarkets=true */
parlayMarketsOnly?: boolean;
};
@Injectable()
export class MatchesService {
constructor(
private prisma: PrismaService,
private outright: OutrightService,
private matchBetStats: MatchBetStatsService,
) {}
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;
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<string, string>,
) {
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;
isActive?: boolean;
}) {
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
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; isActive: boolean } = {
isActive: data.isActive ?? false,
};
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
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,
isPublished: fresh.isActive,
leagueEn: en,
leagueZh: zh,
leagueMs: ms,
};
}
async updatePlatformLeague(
leagueId: bigint,
data: {
leagueEn: string;
leagueZh: string;
leagueMs?: string;
logoUrl?: string;
displayOrder?: number;
isActive?: boolean;
},
) {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
await this.upsertEntityTranslations('LEAGUE', leagueId, {
'zh-CN': leagueZh,
'en-US': leagueEn,
'ms-MY': (data.leagueMs ?? '').trim(),
});
const updates: { logoUrl?: string | null; displayOrder?: number; isActive?: boolean } = {};
if (data.logoUrl !== undefined) {
updates.logoUrl = data.logoUrl.trim() || null;
}
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (data.isActive !== undefined) {
if (league.isActive && data.isActive === false) {
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
}
}
updates.isActive = data.isActive;
}
if (Object.keys(updates).length) {
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
}
if (data.isActive === true) {
await this.outright.syncWithLeaguePublished(leagueId);
}
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
this.getTranslationExact('LEAGUE', leagueId, 'ms-MY'),
]);
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: leagueId } });
return {
id: fresh.id.toString(),
code: fresh.code,
logoUrl: fresh.logoUrl,
displayOrder: fresh.displayOrder,
isPublished: fresh.isActive,
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) {
const ids = new Set<bigint>();
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,
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 },
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,
isPublished: league.isActive,
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 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),
);
const leagueBetRollup = new Map<
string,
{ betCount: number; totalStake: Decimal; pendingCount: number }
>();
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(new Decimal(ms.totalStake));
cur.pendingCount += ms.pendingCount;
}
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) {
item.betStats = {
betCount: roll.betCount,
totalStake: roll.totalStake.toString(),
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 };
}
async listAdminLeagueMatches(
leagueId: bigint,
opts: {
status?: string;
keyword?: string;
locale?: string;
page?: number;
pageSize?: number;
hasBets?: string;
orderBy?: 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 page = Math.max(1, opts.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
if (opts.hasBets === 'true' || opts.orderBy === 'betCount' || opts.orderBy === 'totalStake') {
const allRows = await this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
});
const locale = opts.locale ?? 'zh-CN';
const betStatsMap = await this.betStatsForMatches(allRows.map((m) => m.id));
const itemsWithStats = await Promise.all(
allRows.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 ?? '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,
};
}),
);
let filteredItems = itemsWithStats;
if (opts.hasBets === 'true') {
filteredItems = itemsWithStats.filter((item) => item.betCount > 0);
}
if (opts.orderBy === 'betCount') {
filteredItems.sort((a, b) => b.betCount - a.betCount);
} else if (opts.orderBy === 'totalStake') {
filteredItems.sort((a, b) => {
const stakeA = parseFloat(a.totalStake);
const stakeB = parseFloat(b.totalStake);
return stakeB - stakeA;
});
} else {
filteredItems.sort((a, b) => {
if (a.displayOrder !== b.displayOrder) {
return a.displayOrder - b.displayOrder;
}
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
});
}
const total = filteredItems.length;
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
return { items: paginatedItems, total, page, pageSize };
}
const [total, rows] = await Promise.all([
this.prisma.match.count({ where }),
this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const locale = opts.locale ?? 'zh-CN';
const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
const items = await Promise.all(
rows.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 ?? '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,
};
}),
);
return { items, total, page, pageSize };
}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> {
return this.matchBetStats.betStatsForMatches(matchIds);
}
private async upsertTeamByCode(data: {
code: string;
teamZh: string;
teamEn: string;
teamMs?: string;
logoUrl?: string;
}) {
const code = data.code.trim().toUpperCase();
if (!code) throw appBadRequest('TEAM_CODE_REQUIRED');
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;
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 appBadRequest('TEAMS_NAME_REQUIRED');
}
let league;
if (data.leagueId) {
league = await this.prisma.league.findFirst({
where: { id: data.leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
} else {
const leagueEn = data.leagueEn?.trim() ?? '';
const leagueZh = data.leagueZh?.trim() ?? '';
const leagueMs = data.leagueMs?.trim() ?? '';
if (!leagueEn && !leagueZh && !leagueMs) {
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
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 homeCode = data.homeTeamCode?.trim().toUpperCase();
const awayCode = data.awayTeamCode?.trim().toUpperCase();
let homeTeam;
let awayTeam;
if (homeCode && awayCode) {
if (homeCode === awayCode) {
throw appBadRequest('TEAMS_SAME');
}
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 appBadRequest('TEAMS_SAME');
}
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 appNotFound('MATCH_NOT_FOUND');
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,
homeCorners: scoreRow.homeCorners ?? null,
awayCorners: scoreRow.awayCorners ?? null,
homeYellowCards: scoreRow.homeYellowCards ?? null,
awayYellowCards: scoreRow.awayYellowCards ?? null,
homeRedCards: scoreRow.homeRedCards ?? null,
awayRedCards: scoreRow.awayRedCards ?? null,
homeCards: scoreRow.homeCards ?? null,
awayCards: scoreRow.awayCards ?? null,
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
}
: null,
markets: markets.map((m) => ({
id: m.id.toString(),
marketType: m.marketType,
marketKey: m.marketKey ?? m.marketType,
lineKey: m.lineKey ?? null,
period: m.period,
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
paramsJson: m.paramsJson ?? null,
status: m.status,
allowSingle: m.allowSingle,
allowParlay: m.allowParlay,
showOnPlayer: m.showOnPlayer,
promoLabel: m.promoLabel ?? '',
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
nameI18n: sanitizeLocalizedText(m.nameI18n),
sortOrder: m.sortOrder,
selections: m.selections.map((s) => ({
id: s.id.toString(),
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: sanitizeLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
};
}
async updatePlatformMatch(
matchId: bigint,
data: {
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
awayTeamZh: string;
awayTeamEn: string;
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
groupName?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
updatedBy?: bigint;
},
) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
}
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
throw appBadRequest('MATCH_NOT_EDITABLE');
}
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('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<unknown>[] = [];
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 appBadRequest('OUTRIGHT_DELETE_FORBIDDEN');
}
if (match.status !== 'DRAFT') {
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
if (betCount > 0) {
throw appBadRequest('MATCH_HAS_BETS');
}
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 appBadRequest('MATCHES_ARRAY_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 unpublishMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
}
const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
if (!allowed.includes(match.status)) {
throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
}
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'DRAFT', closeTime: null },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
}
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
const reopenable =
match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', closeTime: null },
});
}
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
const reopenable =
match.status === 'CLOSED' ||
(match.status === 'PENDING_SETTLEMENT' && !scoreRow);
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
const effectiveStart = startTime ?? match.startTime;
if (!isPreMatchKickoff(effectiveStart)) {
throw appBadRequest('MATCH_REOPEN_KICKOFF_REQUIRED');
}
return this.prisma.match.update({
where: { id: matchId },
data: {
status: 'PUBLISHED',
closeTime: null,
...(startTime ? { startTime } : {}),
},
});
}
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<string, unknown>,
locale: string,
options?: { omitMarkets?: boolean },
) {
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 };
score?: {
htHomeScore: number | null;
htAwayScore: number | null;
ftHomeScore: number | null;
ftAwayScore: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
markets?: Array<Record<string, 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),
]);
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.status === 'SETTLED' ? false : (m.isHot ?? false),
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,
groupName: m.groupName ?? null,
status: m.status ?? 'PUBLISHED',
score: m.score
? {
htHome: m.score.htHomeScore ?? null,
htAway: m.score.htAwayScore ?? null,
ftHome: m.score.ftHomeScore ?? null,
ftAway: m.score.ftAwayScore ?? null,
homeCorners: m.score.homeCorners ?? null,
awayCorners: m.score.awayCorners ?? null,
homeYellowCards: m.score.homeYellowCards ?? null,
awayYellowCards: m.score.awayYellowCards ?? null,
homeRedCards: m.score.homeRedCards ?? null,
awayRedCards: m.score.awayRedCards ?? null,
homeCards: m.score.homeCards ?? null,
awayCards: m.score.awayCards ?? null,
}
: null,
bettingOpen: this.isMatchBettingOpen({
status: m.status,
startTime: m.startTime,
markets: (m.markets ?? []) as Array<{
status: string;
selections: Array<{ status: string }>;
}>,
}),
matchPhase: this.resolvePlayerMatchPhase(m.status ?? 'PUBLISHED', {
status: m.status,
startTime: m.startTime,
markets: (m.markets ?? []) as Array<{
status: string;
selections: Array<{ status: string }>;
}>,
}),
};
if (m.markets && !options?.omitMarkets) {
return {
...base,
markets: m.markets
.filter(
(market) =>
((market.showOnPlayer as boolean | undefined) ?? true) &&
isSettlementSupportedMarketType(market.marketType as string),
)
.map((market) => ({
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
lineKey: (market.lineKey as string | null | undefined) ?? null,
period: market.period as string,
status: (market.status as string) ?? 'OPEN',
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
marketDisplayName:
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
(market.marketType as string),
promoLabel:
resolveMarketText(
market.promoLabelI18n,
locale,
(market.promoLabel as string | null | undefined) ?? '',
) || null,
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
id: (s.id as bigint).toString(),
selectionCode: s.selectionCode as string,
selectionName: s.selectionName as string,
selectionDisplayName:
resolveMarketText(
s.nameI18n,
locale,
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
(s.selectionName as string),
) || (s.selectionName as string),
status: (s.status as string) ?? 'OPEN',
odds: Number(s.odds),
oddsVersion: (s.oddsVersion as bigint).toString(),
})),
})),
};
}
return base;
}
private resolvePlayerMatchPhaseFromStatus(
status: string,
startTime: Date,
): 'open' | 'closed_pending' | 'settled' {
if (status === 'SETTLED') return 'settled';
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
if (status === 'PUBLISHED' && !isPreMatchKickoff(startTime)) return 'closed_pending';
return 'open';
}
private resolvePlayerMatchPhase(
status: string,
m: {
status?: string;
startTime: Date;
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
},
): 'open' | 'closed_pending' | 'settled' {
if (status === 'SETTLED') return 'settled';
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
if (status === 'PUBLISHED' && !this.isMatchBettingOpen(m)) return 'closed_pending';
return 'open';
}
private isMatchBettingOpen(m: {
status?: string;
startTime: Date;
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
}): boolean {
if (m.status !== 'PUBLISHED') return false;
if (!isPreMatchKickoff(m.startTime)) return false;
return (m.markets ?? []).some(
(mk) => mk.status === 'OPEN' && mk.selections.some((s) => s.status === 'OPEN'),
);
}
private playerMarketInclude = {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
orderBy: { sortOrder: 'asc' as const },
},
},
orderBy: { sortOrder: 'asc' as const },
};
/** 仅 status 字段,用于列表页计算 bettingOpen不返回给客户端 */
private playerMarketStatusInclude = {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
select: {
status: true,
selections: { select: { status: true } },
},
};
async listPublished(
locale = 'en-US',
leagueId?: bigint,
options?: ListPublishedOptions,
) {
const includeMarkets = options?.includeMarkets ?? true;
const parlayMarketsOnly = options?.parlayMarketsOnly ?? false;
const marketWhere = {
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
showOnPlayer: true,
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
};
const marketsRelation = includeMarkets
? {
where: marketWhere,
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
orderBy: { sortOrder: 'asc' as const },
},
},
orderBy: { sortOrder: 'asc' as const },
}
: this.playerMarketStatusInclude;
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
...(leagueId ? { leagueId } : {}),
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
score: true,
markets: marketsRelation,
},
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
});
const enriched = await Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
);
if (!includeMarkets || !parlayMarketsOnly) return enriched;
return enriched.filter(
(m) =>
Array.isArray((m as { markets?: unknown[] }).markets) &&
(m as { markets: unknown[] }).markets.length > 0,
);
}
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot */
async listUpcomingPublished(
locale = 'en-US',
options?: { limit?: number; days?: number },
) {
const limit = options?.limit ?? 50;
const days = options?.days ?? 3;
const now = new Date();
const end = new Date(now);
end.setDate(end.getDate() + days);
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
startTime: { gte: now, lte: end },
league: { isActive: true, deletedAt: null },
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
score: true,
markets: this.playerMarketStatusInclude,
},
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: limit,
});
return Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
);
}
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', 'PENDING_SETTLEMENT', 'SETTLED'] },
league: { isActive: true, deletedAt: null },
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
markets: this.playerMarketInclude,
score: true,
},
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return this.enrichMatch(match, locale);
}
async getSelectionsOdds(ids: bigint[]) {
const selections = await this.prisma.marketSelection.findMany({
where: { id: { in: ids } },
include: { market: { include: { match: true } } },
});
return selections.map((sel) => ({
id: sel.id.toString(),
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
status: sel.status,
marketStatus: sel.market.status,
marketShowOnPlayer: sel.market.showOnPlayer,
matchStatus: sel.market.match.status,
matchId: sel.market.match.id.toString(),
}));
}
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<string, string>;
const labels: Record<string, LangMap> = {
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 defaultMarketName(marketType, locale);
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
}
async enrichBetsForHistory(
bets: Array<{
betNo: string;
betType: string;
stake: unknown;
totalOdds: unknown;
potentialReturn: unknown;
actualReturn: unknown;
status: string;
placedAt: Date;
isCashbacked?: boolean;
selections: Array<{
matchId: bigint | null;
marketType: string;
marketNameSnapshot?: string | null;
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, score: true },
})
: [];
const matchMeta = new Map<
string,
{
leagueName: string;
matchTitle: string;
isOutright: boolean;
matchPhase: 'open' | 'closed_pending' | 'settled';
score: { ht: string | null; ft: string | null } | null;
}
>();
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),
]);
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,
matchPhase: this.resolvePlayerMatchPhaseFromStatus(m.status, m.startTime),
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
});
}
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: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
selectionName: sel.selectionNameSnapshot,
odds: sel.odds,
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,
stake: bet.stake,
totalOdds: bet.totalOdds,
potentialReturn: bet.potentialReturn,
actualReturn: bet.actualReturn,
status: bet.status,
placedAt: bet.placedAt,
isCashbacked: bet.isCashbacked ?? false,
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,
matchScore: isParlay ? null : firstScore,
matchPhase: isParlay ? null : meta?.matchPhase ?? null,
};
});
}
@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 },
});
}
}