重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
981 lines
29 KiB
TypeScript
981 lines
29 KiB
TypeScript
import {
|
|
Injectable,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
|
import { MarketsService } from '../odds/markets.service';
|
|
import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
|
|
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
|
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
|
|
|
const PLACEHOLDER_TEAM_CODE = 'OUT';
|
|
const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER';
|
|
|
|
@Injectable()
|
|
export class OutrightService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private markets: MarketsService,
|
|
) {}
|
|
|
|
async listForAdmin() {
|
|
const matches = await this.prisma.match.findMany({
|
|
where: { isOutright: true, sportType: 'FOOTBALL', deletedAt: null },
|
|
include: {
|
|
league: true,
|
|
markets: {
|
|
where: { marketType: OUTRIGHT_MARKET_TYPE },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
_count: {
|
|
select: {
|
|
selections: {
|
|
where: {
|
|
status: { not: 'CLOSED' },
|
|
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
selections: {
|
|
where: {
|
|
status: 'OPEN',
|
|
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
|
},
|
|
select: { id: true },
|
|
take: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
|
|
});
|
|
|
|
return Promise.all(
|
|
matches.map(async (m) => {
|
|
const league = m.league;
|
|
const leagueCode = league.code;
|
|
const [leagueZh, leagueEn] = await Promise.all([
|
|
this.getTranslation('LEAGUE', league.id, 'zh-CN'),
|
|
this.getTranslation('LEAGUE', league.id, 'en-US'),
|
|
]);
|
|
const market = m.markets[0];
|
|
const openCount = market?.selections.length ?? 0;
|
|
const selectionCount = market?._count.selections ?? 0;
|
|
const visibility = this.playerVisibilityByCounts(
|
|
m.status,
|
|
market,
|
|
openCount,
|
|
league.isActive,
|
|
);
|
|
const [titleZh, titleEn, titleMs] = await Promise.all([
|
|
this.getOutrightTitle(m.id, 'zh-CN'),
|
|
this.getOutrightTitle(m.id, 'en-US'),
|
|
this.getOutrightTitle(m.id, 'ms-MY'),
|
|
]);
|
|
return {
|
|
id: m.id.toString(),
|
|
leagueId: league.id.toString(),
|
|
leagueCode,
|
|
leagueZh,
|
|
leagueEn,
|
|
matchName: m.matchName ?? '',
|
|
titleZh: titleZh || m.matchName || '',
|
|
titleEn: titleEn || m.matchName || '',
|
|
titleMs,
|
|
status: m.status,
|
|
selectionCount,
|
|
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
|
|
playerVisible: visibility.playerVisible,
|
|
playerHiddenReason: visibility.playerHiddenReason,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
async listLeagueOptions() {
|
|
const leagues = await this.prisma.league.findMany({
|
|
where: { isActive: true, deletedAt: null, sportType: 'FOOTBALL' },
|
|
orderBy: { displayOrder: 'asc' },
|
|
});
|
|
return Promise.all(
|
|
leagues.map(async (l) => ({
|
|
id: l.id.toString(),
|
|
code: l.code,
|
|
nameZh: await this.getTranslation('LEAGUE', l.id, 'zh-CN'),
|
|
nameEn: await this.getTranslation('LEAGUE', l.id, 'en-US'),
|
|
})),
|
|
);
|
|
}
|
|
|
|
async getForAdmin(matchId: bigint) {
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(match.id);
|
|
const league = await this.prisma.league.findUniqueOrThrow({
|
|
where: { id: match.leagueId },
|
|
});
|
|
|
|
const [leagueZh, leagueEn] = await Promise.all([
|
|
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
|
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
|
]);
|
|
|
|
const fullMarket = await this.prisma.market.findUniqueOrThrow({
|
|
where: { id: market.id },
|
|
include: {
|
|
selections: { orderBy: { sortOrder: 'asc' } },
|
|
},
|
|
});
|
|
|
|
const selections = await Promise.all(
|
|
fullMarket.selections
|
|
.filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE)
|
|
.map(async (sel, index) => {
|
|
const team = await this.prisma.team.findUnique({
|
|
where: { code: sel.selectionCode },
|
|
});
|
|
const teamZh = team
|
|
? await this.getTranslation('TEAM', team.id, 'zh-CN')
|
|
: sel.selectionName;
|
|
const teamEn = team
|
|
? await this.getTranslation('TEAM', team.id, 'en-US')
|
|
: sel.selectionName;
|
|
return {
|
|
id: sel.id.toString(),
|
|
teamId: team?.id.toString() ?? null,
|
|
teamCode: sel.selectionCode,
|
|
rank: sel.sortOrder + 1 || index + 1,
|
|
teamZh: teamZh || sel.selectionName,
|
|
teamEn: teamEn || sel.selectionName,
|
|
logoUrl: team?.logoUrl ?? null,
|
|
odds: sel.odds.toString(),
|
|
oddsVersion: sel.oddsVersion.toString(),
|
|
status: sel.status,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const visibility = this.playerVisibility(
|
|
match.status,
|
|
fullMarket,
|
|
fullMarket.selections.filter(
|
|
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
|
|
),
|
|
league.isActive,
|
|
);
|
|
|
|
const unsettledFixtureCount = await this.countUnsettledLeagueFixtures(
|
|
match.leagueId,
|
|
);
|
|
|
|
const [titleZh, titleEn, titleMs] = await Promise.all([
|
|
this.getOutrightTitle(match.id, 'zh-CN'),
|
|
this.getOutrightTitle(match.id, 'en-US'),
|
|
this.getOutrightTitle(match.id, 'ms-MY'),
|
|
]);
|
|
|
|
return {
|
|
id: match.id.toString(),
|
|
leagueId: match.leagueId.toString(),
|
|
leagueCode: league.code,
|
|
leagueZh,
|
|
leagueEn,
|
|
matchName: match.matchName ?? '',
|
|
titleZh: titleZh || match.matchName || '',
|
|
titleEn: titleEn || match.matchName || '',
|
|
titleMs,
|
|
status: match.status,
|
|
leagueIsPublished: league.isActive,
|
|
unsettledFixtureCount,
|
|
marketId: fullMarket.id.toString(),
|
|
marketStatus: fullMarket.status,
|
|
canImportCanonical: league.code === WC2026_LEAGUE_CODE,
|
|
expectedCanonicalCount:
|
|
league.code === WC2026_LEAGUE_CODE ? WC2026_OUTRIGHT_TEAMS.length : null,
|
|
playerVisible: visibility.playerVisible,
|
|
playerHiddenReason: visibility.playerHiddenReason,
|
|
selections,
|
|
};
|
|
}
|
|
|
|
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
|
|
async getOrCreateAndSyncForLeague(leagueId: bigint) {
|
|
const league = await this.prisma.league.findUnique({
|
|
where: { id: leagueId },
|
|
});
|
|
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
|
|
|
let match = await this.prisma.match.findFirst({
|
|
where: { leagueId, isOutright: true, deletedAt: null },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
if (!match) {
|
|
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
|
|
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
|
|
this.getTranslation('LEAGUE', leagueId, 'en-US'),
|
|
this.getTranslation('LEAGUE', leagueId, 'ms-MY'),
|
|
]);
|
|
await this.createForAdmin({
|
|
leagueId,
|
|
titleZh: leagueZh || league.code,
|
|
titleEn: leagueEn || league.code,
|
|
titleMs: leagueMs || undefined,
|
|
status: league.isActive ? 'PUBLISHED' : 'DRAFT',
|
|
});
|
|
match = await this.prisma.match.findFirstOrThrow({
|
|
where: { leagueId, isOutright: true, deletedAt: null },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
} else {
|
|
await this.syncOutrightStatusWithLeague(match, league);
|
|
match = await this.prisma.match.findFirstOrThrow({
|
|
where: { id: match.id },
|
|
});
|
|
}
|
|
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
|
|
const data = await this.getForAdmin(match.id);
|
|
return {
|
|
...data,
|
|
fixtureSyncAdded: sync.addedCount,
|
|
fixtureSyncReopened: sync.reopenedCount,
|
|
};
|
|
}
|
|
|
|
/** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */
|
|
async syncWithLeaguePublished(leagueId: bigint) {
|
|
const league = await this.prisma.league.findUnique({
|
|
where: { id: leagueId },
|
|
});
|
|
if (!league?.isActive) return;
|
|
|
|
const existing = await this.prisma.match.findFirst({
|
|
where: { leagueId, isOutright: true, deletedAt: null },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
if (!existing) {
|
|
await this.getOrCreateAndSyncForLeague(leagueId);
|
|
return;
|
|
}
|
|
await this.syncOutrightStatusWithLeague(existing, league);
|
|
}
|
|
|
|
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
|
|
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
|
|
return this.prisma.match.count({
|
|
where: {
|
|
leagueId,
|
|
isOutright: false,
|
|
deletedAt: null,
|
|
status: { notIn: ['SETTLED', 'CANCELLED'] },
|
|
},
|
|
});
|
|
}
|
|
|
|
private async syncOutrightStatusWithLeague(
|
|
match: { id: bigint; status: string },
|
|
league: { isActive: boolean },
|
|
) {
|
|
if (!league.isActive || match.status !== 'DRAFT') return;
|
|
await this.prisma.match.update({
|
|
where: { id: match.id },
|
|
data: { status: 'PUBLISHED', publishTime: new Date() },
|
|
});
|
|
}
|
|
|
|
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
|
|
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
|
|
const match = await this.prisma.match.findFirst({
|
|
where: { leagueId, isOutright: true, deletedAt: null },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
if (!match) return { addedCount: 0, reopenedCount: 0 };
|
|
return this.syncSelectionsFromLeagueFixtures(match.id);
|
|
}
|
|
|
|
async syncSelectionsFromLeagueFixtures(matchId: bigint) {
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(match.id);
|
|
const teams = await this.collectFixtureTeamsForLeague(match.leagueId);
|
|
const fixtureCodes = new Set(teams.map((t) => t.code));
|
|
|
|
const existing = await this.prisma.marketSelection.findMany({
|
|
where: {
|
|
marketId: market.id,
|
|
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
|
},
|
|
});
|
|
|
|
const existingCodes = new Set(existing.map((s) => s.selectionCode));
|
|
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
|
|
let addedCount = 0;
|
|
let reopenedCount = 0;
|
|
|
|
for (const sel of existing) {
|
|
if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') {
|
|
await this.prisma.marketSelection.update({
|
|
where: { id: sel.id },
|
|
data: { status: 'OPEN' },
|
|
});
|
|
reopenedCount += 1;
|
|
}
|
|
}
|
|
|
|
for (const team of teams) {
|
|
if (existingCodes.has(team.code)) continue;
|
|
const [teamZh, teamEn] = await Promise.all([
|
|
this.getTranslation('TEAM', team.id, 'zh-CN'),
|
|
this.getTranslation('TEAM', team.id, 'en-US'),
|
|
]);
|
|
sortOrder += 1;
|
|
await this.prisma.marketSelection.create({
|
|
data: {
|
|
marketId: market.id,
|
|
selectionCode: team.code,
|
|
selectionName: teamZh || teamEn || team.code,
|
|
odds: 10,
|
|
sortOrder,
|
|
status: 'OPEN',
|
|
},
|
|
});
|
|
addedCount += 1;
|
|
}
|
|
|
|
return { addedCount, reopenedCount };
|
|
}
|
|
|
|
private async collectFixtureTeamsForLeague(leagueId: bigint) {
|
|
const matches = await this.prisma.match.findMany({
|
|
where: { leagueId, isOutright: false, deletedAt: null },
|
|
select: { homeTeamId: true, awayTeamId: true },
|
|
});
|
|
const teamIds = [
|
|
...new Set(matches.flatMap((m) => [m.homeTeamId, m.awayTeamId])),
|
|
];
|
|
if (teamIds.length === 0) return [];
|
|
return this.prisma.team.findMany({
|
|
where: {
|
|
id: { in: teamIds },
|
|
code: { not: PLACEHOLDER_TEAM_CODE },
|
|
},
|
|
orderBy: { code: 'asc' },
|
|
});
|
|
}
|
|
|
|
async createForAdmin(data: {
|
|
leagueId: bigint;
|
|
titleZh: string;
|
|
titleEn: string;
|
|
titleMs?: string;
|
|
status?: string;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
startTime?: Date;
|
|
}) {
|
|
const league = await this.prisma.league.findUnique({
|
|
where: { id: data.leagueId },
|
|
});
|
|
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
|
|
|
|
const placeholder = await this.ensurePlaceholderTeam();
|
|
const status = data.status ?? 'PUBLISHED';
|
|
const matchName = this.resolveOutrightMatchName(data);
|
|
const match = await this.prisma.match.create({
|
|
data: {
|
|
leagueId: data.leagueId,
|
|
homeTeamId: placeholder.id,
|
|
awayTeamId: placeholder.id,
|
|
isOutright: true,
|
|
matchName,
|
|
startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'),
|
|
status,
|
|
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
|
isHot: data.isHot ?? false,
|
|
displayOrder: data.displayOrder ?? 0,
|
|
},
|
|
});
|
|
|
|
await this.upsertOutrightTitles(match.id, {
|
|
zh: data.titleZh,
|
|
en: data.titleEn,
|
|
ms: data.titleMs,
|
|
});
|
|
await this.ensureOutrightMarket(match.id);
|
|
return this.getForAdmin(match.id);
|
|
}
|
|
|
|
async updateForAdmin(
|
|
matchId: bigint,
|
|
data: {
|
|
status?: string;
|
|
matchName?: string;
|
|
isHot?: boolean;
|
|
displayOrder?: number;
|
|
titleZh?: string;
|
|
titleEn?: string;
|
|
titleMs?: string;
|
|
},
|
|
) {
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const status = data.status ?? match.status;
|
|
|
|
let matchName = data.matchName?.trim();
|
|
const titlesTouched =
|
|
data.titleZh !== undefined ||
|
|
data.titleEn !== undefined ||
|
|
data.titleMs !== undefined;
|
|
if (titlesTouched) {
|
|
const [curZh, curEn, curMs] = await Promise.all([
|
|
this.getOutrightTitle(matchId, 'zh-CN'),
|
|
this.getOutrightTitle(matchId, 'en-US'),
|
|
this.getOutrightTitle(matchId, 'ms-MY'),
|
|
]);
|
|
const zh = data.titleZh !== undefined ? data.titleZh : curZh;
|
|
const en = data.titleEn !== undefined ? data.titleEn : curEn;
|
|
const ms = data.titleMs !== undefined ? data.titleMs : curMs;
|
|
await this.upsertOutrightTitles(matchId, { zh, en, ms });
|
|
matchName = this.resolveOutrightMatchName({ titleZh: zh, titleEn: en, titleMs: ms });
|
|
}
|
|
|
|
await this.prisma.match.update({
|
|
where: { id: matchId },
|
|
data: {
|
|
status,
|
|
matchName: matchName !== undefined ? matchName : undefined,
|
|
isHot: data.isHot,
|
|
displayOrder: data.displayOrder,
|
|
publishTime:
|
|
status === 'PUBLISHED' && !match.publishTime
|
|
? new Date()
|
|
: match.publishTime,
|
|
},
|
|
});
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async addSelection(
|
|
matchId: bigint,
|
|
data: {
|
|
teamCode: string;
|
|
teamZh: string;
|
|
teamEn: string;
|
|
odds: number;
|
|
logoUrl?: string;
|
|
},
|
|
) {
|
|
if (!data.teamCode?.trim()) {
|
|
throw appBadRequest('TEAM_CODE_REQUIRED');
|
|
}
|
|
if (data.odds <= 1) {
|
|
throw appBadRequest('ODDS_MIN');
|
|
}
|
|
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(match.id);
|
|
const code = data.teamCode.trim().toUpperCase();
|
|
|
|
const logoUrl =
|
|
data.logoUrl === undefined
|
|
? undefined
|
|
: data.logoUrl.trim()
|
|
? data.logoUrl.trim()
|
|
: null;
|
|
const team = await this.prisma.team.upsert({
|
|
where: { code },
|
|
create: {
|
|
code,
|
|
...(logoUrl !== undefined ? { logoUrl } : {}),
|
|
},
|
|
update: logoUrl !== undefined ? { logoUrl } : {},
|
|
});
|
|
await this.upsertTeamTranslations(team.id, {
|
|
'zh-CN': data.teamZh.trim() || data.teamEn,
|
|
'en-US': data.teamEn.trim() || data.teamZh,
|
|
});
|
|
|
|
const existing = await this.prisma.marketSelection.findFirst({
|
|
where: { marketId: market.id, selectionCode: code },
|
|
});
|
|
if (existing) {
|
|
if (existing.status === 'CLOSED') {
|
|
await this.prisma.marketSelection.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
status: 'OPEN',
|
|
odds: data.odds,
|
|
selectionName: data.teamZh.trim() || data.teamEn,
|
|
},
|
|
});
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
throw appBadRequest('OUTRIGHT_SELECTION_EXISTS');
|
|
}
|
|
|
|
const maxSort = await this.prisma.marketSelection.aggregate({
|
|
where: { marketId: market.id },
|
|
_max: { sortOrder: true },
|
|
});
|
|
|
|
await this.prisma.marketSelection.create({
|
|
data: {
|
|
marketId: market.id,
|
|
selectionCode: code,
|
|
selectionName: data.teamZh.trim() || data.teamEn,
|
|
odds: data.odds,
|
|
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
|
status: 'OPEN',
|
|
},
|
|
});
|
|
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async addSelectionsBatch(
|
|
matchId: bigint,
|
|
items: Array<{
|
|
teamCode: string;
|
|
teamZh: string;
|
|
teamEn: string;
|
|
odds: number;
|
|
logoUrl?: string;
|
|
}>,
|
|
) {
|
|
if (!items.length) {
|
|
throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED');
|
|
}
|
|
let added = 0;
|
|
let skipped = 0;
|
|
for (const item of items) {
|
|
try {
|
|
await this.addSelection(matchId, item);
|
|
added += 1;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : '';
|
|
if (msg.includes('already exists')) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
const data = await this.getForAdmin(matchId);
|
|
return { ...data, batchResult: { added, skipped } };
|
|
}
|
|
|
|
async updateSelectionTeam(
|
|
matchId: bigint,
|
|
selectionId: bigint,
|
|
data: {
|
|
teamCode?: string;
|
|
teamZh?: string;
|
|
teamEn?: string;
|
|
logoUrl?: string | null;
|
|
},
|
|
) {
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(match.id);
|
|
const sel = await this.prisma.marketSelection.findFirst({
|
|
where: { id: selectionId, marketId: market.id },
|
|
});
|
|
if (!sel) throw appNotFound('SELECTION_NOT_FOUND');
|
|
|
|
const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode;
|
|
if (nextCode !== sel.selectionCode) {
|
|
const dup = await this.prisma.marketSelection.findFirst({
|
|
where: {
|
|
marketId: market.id,
|
|
selectionCode: nextCode,
|
|
id: { not: selectionId },
|
|
},
|
|
});
|
|
if (dup) {
|
|
throw appBadRequest('OUTRIGHT_SELECTION_EXISTS');
|
|
}
|
|
await this.prisma.marketSelection.update({
|
|
where: { id: selectionId },
|
|
data: {
|
|
selectionCode: nextCode,
|
|
selectionName:
|
|
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
|
},
|
|
});
|
|
} else if (data.teamZh?.trim() || data.teamEn?.trim()) {
|
|
await this.prisma.marketSelection.update({
|
|
where: { id: selectionId },
|
|
data: {
|
|
selectionName:
|
|
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
|
},
|
|
});
|
|
}
|
|
|
|
const team = await this.prisma.team.upsert({
|
|
where: { code: nextCode },
|
|
create: { code: nextCode },
|
|
update: {},
|
|
});
|
|
|
|
if (data.teamZh !== undefined || data.teamEn !== undefined) {
|
|
await this.upsertTeamTranslations(team.id, {
|
|
'zh-CN': data.teamZh?.trim() || data.teamEn?.trim() || nextCode,
|
|
'en-US': data.teamEn?.trim() || data.teamZh?.trim() || nextCode,
|
|
});
|
|
}
|
|
|
|
if (data.logoUrl !== undefined) {
|
|
const logoUrl = data.logoUrl?.trim() ? data.logoUrl.trim() : null;
|
|
await this.prisma.team.update({
|
|
where: { id: team.id },
|
|
data: { logoUrl },
|
|
});
|
|
}
|
|
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async closeSelection(matchId: bigint, selectionId: bigint) {
|
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(match.id);
|
|
const sel = await this.prisma.marketSelection.findFirst({
|
|
where: { id: selectionId, marketId: market.id },
|
|
});
|
|
if (!sel) throw appNotFound('SELECTION_NOT_FOUND');
|
|
await this.prisma.marketSelection.update({
|
|
where: { id: selectionId },
|
|
data: { status: 'CLOSED' },
|
|
});
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async batchUpdateOdds(
|
|
matchId: bigint,
|
|
updates: Array<{ selectionId: string; odds: number }>,
|
|
operatorId: bigint,
|
|
) {
|
|
await this.getOutrightMatchOrThrow(matchId);
|
|
const market = await this.ensureOutrightMarket(matchId);
|
|
const allowed = new Set(
|
|
(
|
|
await this.prisma.marketSelection.findMany({
|
|
where: { marketId: market.id },
|
|
select: { id: true },
|
|
})
|
|
).map((s) => s.id.toString()),
|
|
);
|
|
|
|
for (const u of updates) {
|
|
if (!allowed.has(u.selectionId)) {
|
|
throw appBadRequest('OUTRIGHT_SELECTION_INVALID');
|
|
}
|
|
}
|
|
|
|
await this.markets.batchUpdateOdds(
|
|
updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })),
|
|
operatorId,
|
|
);
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async importWc2026Canonical() {
|
|
const { matchId } = await syncWc2026OutrightMarket(this.prisma, {
|
|
forceCanonical: true,
|
|
});
|
|
return this.getForAdmin(matchId);
|
|
}
|
|
|
|
async listForPlayer(locale: string) {
|
|
await this.trySyncWc2026();
|
|
|
|
const matches = await this.prisma.match.findMany({
|
|
where: {
|
|
status: 'PUBLISHED',
|
|
isOutright: true,
|
|
sportType: 'FOOTBALL',
|
|
deletedAt: null,
|
|
league: { isActive: true, deletedAt: null },
|
|
},
|
|
include: {
|
|
markets: {
|
|
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' },
|
|
include: {
|
|
selections: {
|
|
where: { status: 'OPEN' },
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
|
|
});
|
|
|
|
const results = [];
|
|
for (const match of matches) {
|
|
const league = await this.prisma.league.findUniqueOrThrow({
|
|
where: { id: match.leagueId },
|
|
});
|
|
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 !== PLACEHOLDER_TEAM_CODE)
|
|
.map(async (sel) => {
|
|
const team = await this.prisma.team.findUnique({
|
|
where: { code: sel.selectionCode },
|
|
});
|
|
const translated = team
|
|
? await this.getTranslation('TEAM', team.id, locale)
|
|
: '';
|
|
const teamZh = team
|
|
? await this.getTranslation('TEAM', team.id, 'zh-CN')
|
|
: sel.selectionName;
|
|
const teamEn = team
|
|
? await this.getTranslation('TEAM', team.id, 'en-US')
|
|
: sel.selectionName;
|
|
const teamName =
|
|
translated ||
|
|
(locale.startsWith('zh') ? teamZh : teamEn) ||
|
|
sel.selectionName;
|
|
return {
|
|
id: sel.id.toString(),
|
|
teamCode: sel.selectionCode,
|
|
teamName,
|
|
logoUrl: team?.logoUrl ?? null,
|
|
odds: sel.odds.toString(),
|
|
oddsVersion: sel.oddsVersion.toString(),
|
|
};
|
|
}),
|
|
);
|
|
|
|
if (!selections.length) continue;
|
|
|
|
const [titleZh, titleEn, titleMs] = await Promise.all([
|
|
this.getOutrightTitle(match.id, 'zh-CN'),
|
|
this.getOutrightTitle(match.id, 'en-US'),
|
|
this.getOutrightTitle(match.id, 'ms-MY'),
|
|
]);
|
|
const localizedTitle = this.pickOutrightTitleForLocale(locale, {
|
|
zh: titleZh,
|
|
en: titleEn,
|
|
ms: titleMs,
|
|
});
|
|
const title =
|
|
localizedTitle ||
|
|
match.matchName?.trim() ||
|
|
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
|
|
|
|
results.push({
|
|
id: match.id.toString(),
|
|
leagueId: match.leagueId.toString(),
|
|
leagueCode: league.code,
|
|
leagueName: leagueName || '',
|
|
title: title.startsWith('*') ? title : `*${title}`,
|
|
marketId: market.id.toString(),
|
|
selectionCount: selections.length,
|
|
selections,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/** @deprecated 使用 listForPlayer */
|
|
async getWc2026ForPlayer(locale: string) {
|
|
return this.listForPlayer(locale);
|
|
}
|
|
|
|
private async trySyncWc2026() {
|
|
try {
|
|
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
|
|
} catch {
|
|
/* 联赛未 seed 时忽略 */
|
|
}
|
|
}
|
|
|
|
private async getOutrightMatchOrThrow(matchId: bigint) {
|
|
const match = await this.prisma.match.findFirst({
|
|
where: { id: matchId, isOutright: true, deletedAt: null },
|
|
});
|
|
if (!match) throw appNotFound('OUTRIGHT_EVENT_NOT_FOUND');
|
|
return match;
|
|
}
|
|
|
|
private async ensureOutrightMarket(matchId: bigint) {
|
|
let market = await this.prisma.market.findFirst({
|
|
where: { matchId, marketType: OUTRIGHT_MARKET_TYPE },
|
|
});
|
|
if (!market) {
|
|
market = await this.prisma.market.create({
|
|
data: {
|
|
matchId,
|
|
marketType: OUTRIGHT_MARKET_TYPE,
|
|
period: 'OUTRIGHT',
|
|
allowSingle: true,
|
|
allowParlay: false,
|
|
sortOrder: 1,
|
|
status: 'OPEN',
|
|
},
|
|
});
|
|
}
|
|
return market;
|
|
}
|
|
|
|
private async ensurePlaceholderTeam() {
|
|
const existing = await this.prisma.team.findUnique({
|
|
where: { code: PLACEHOLDER_TEAM_CODE },
|
|
});
|
|
if (existing) return existing;
|
|
return this.prisma.team.create({
|
|
data: { code: PLACEHOLDER_TEAM_CODE },
|
|
});
|
|
}
|
|
|
|
private async upsertTeamTranslations(
|
|
teamId: bigint,
|
|
names: Record<string, string>,
|
|
) {
|
|
for (const [locale, value] of Object.entries(names)) {
|
|
if (!value) continue;
|
|
await this.prisma.entityTranslation.upsert({
|
|
where: {
|
|
entityType_entityId_locale_fieldName: {
|
|
entityType: 'TEAM',
|
|
entityId: teamId,
|
|
locale,
|
|
fieldName: 'name',
|
|
},
|
|
},
|
|
create: {
|
|
entityType: 'TEAM',
|
|
entityId: teamId,
|
|
locale,
|
|
fieldName: 'name',
|
|
value,
|
|
},
|
|
update: { value },
|
|
});
|
|
}
|
|
}
|
|
|
|
private playerVisibility(
|
|
matchStatus: string,
|
|
market: { status: string } | null | undefined,
|
|
selections: Array<{ selectionCode: string; status: string }>,
|
|
leagueIsActive = true,
|
|
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
|
const openCount = selections.filter(
|
|
(s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE,
|
|
).length;
|
|
return this.playerVisibilityByCounts(
|
|
matchStatus,
|
|
market,
|
|
openCount,
|
|
leagueIsActive,
|
|
);
|
|
}
|
|
|
|
private playerVisibilityByCounts(
|
|
matchStatus: string,
|
|
market: { status: string } | null | undefined,
|
|
openSelectionCount: number,
|
|
leagueIsActive = true,
|
|
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
|
if (!leagueIsActive) {
|
|
return { playerVisible: false, playerHiddenReason: 'LEAGUE_INACTIVE' };
|
|
}
|
|
if (matchStatus !== 'PUBLISHED') {
|
|
return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' };
|
|
}
|
|
if (!market || market.status !== 'OPEN') {
|
|
return { playerVisible: false, playerHiddenReason: 'MARKET_CLOSED' };
|
|
}
|
|
if (openSelectionCount === 0) {
|
|
return { playerVisible: false, playerHiddenReason: 'NO_SELECTIONS' };
|
|
}
|
|
return { playerVisible: true, playerHiddenReason: null };
|
|
}
|
|
|
|
private async getTranslation(
|
|
entityType: string,
|
|
entityId: bigint,
|
|
locale: string,
|
|
): Promise<string> {
|
|
const row = await this.prisma.entityTranslation.findFirst({
|
|
where: { entityType, entityId, locale, fieldName: 'name' },
|
|
});
|
|
return row?.value ?? '';
|
|
}
|
|
|
|
private async getOutrightTitle(matchId: bigint, locale: string): Promise<string> {
|
|
const row = await this.prisma.entityTranslation.findFirst({
|
|
where: {
|
|
entityType: 'MATCH',
|
|
entityId: matchId,
|
|
locale,
|
|
fieldName: 'title',
|
|
},
|
|
});
|
|
return row?.value?.trim() ?? '';
|
|
}
|
|
|
|
private resolveOutrightMatchName(data: {
|
|
titleZh?: string;
|
|
titleEn?: string;
|
|
titleMs?: string;
|
|
}): string {
|
|
return (
|
|
data.titleEn?.trim() ||
|
|
data.titleZh?.trim() ||
|
|
data.titleMs?.trim() ||
|
|
'Outright'
|
|
);
|
|
}
|
|
|
|
private pickOutrightTitleForLocale(
|
|
locale: string,
|
|
titles: { zh: string; en: string; ms: string },
|
|
): string {
|
|
if (locale === 'ms-MY' || locale.startsWith('ms')) {
|
|
return titles.ms || titles.en || titles.zh;
|
|
}
|
|
if (locale.startsWith('zh')) {
|
|
return titles.zh || titles.en || titles.ms;
|
|
}
|
|
return titles.en || titles.zh || titles.ms;
|
|
}
|
|
|
|
private async upsertOutrightTitles(
|
|
matchId: bigint,
|
|
titles: { zh?: string; en?: string; ms?: string },
|
|
) {
|
|
const entries: Array<[string, string | undefined]> = [
|
|
['zh-CN', titles.zh],
|
|
['en-US', titles.en],
|
|
['ms-MY', titles.ms],
|
|
];
|
|
for (const [locale, raw] of entries) {
|
|
if (raw === undefined) continue;
|
|
const value = raw.trim();
|
|
if (!value) continue;
|
|
await this.prisma.entityTranslation.upsert({
|
|
where: {
|
|
entityType_entityId_locale_fieldName: {
|
|
entityType: 'MATCH',
|
|
entityId: matchId,
|
|
locale,
|
|
fieldName: 'title',
|
|
},
|
|
},
|
|
create: {
|
|
entityType: 'MATCH',
|
|
entityId: matchId,
|
|
locale,
|
|
fieldName: 'title',
|
|
value,
|
|
},
|
|
update: { value },
|
|
});
|
|
}
|
|
}
|
|
}
|