Files
thebet365/apps/api/src/domains/catalog/matches.service.ts
Mars cc737e2924 feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化
- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持

- 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮

- API 新增联赛列表与子场查询,按 locale 返回队名并修复编译

- 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
2026-06-04 16:25:03 +08:00

1140 lines
36 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { resolveTranslationFallback } from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.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';
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
@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;
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 code = teamCodeFromExport(team);
const translations = translationsFromZhiboNames(team.names, team.name);
let record =
team.id != null
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
: await this.prisma.team.findUnique({ where: { code } });
if (!record) {
record = await this.prisma.team.create({
data: {
code,
externalId: team.id ?? undefined,
logoUrl: team.image || undefined,
},
});
} else {
record = await this.prisma.team.update({
where: { id: record.id },
data: {
logoUrl: team.image || record.logoUrl,
externalId: team.id ?? record.externalId,
},
});
}
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<bigint>();
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,
};
}),
);
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';
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),
]);
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 },
};
}),
);
}
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, awayTeam] = await Promise.all([
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() || '',
}),
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() || '',
}),
]);
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 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 ?? '',
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<unknown>[] = [];
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<string, unknown>, 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<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.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<Record<string, unknown>>) ?? []).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): 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 },
});
}
}