feat(admin,player,api): 优胜冠军通用管理与界面精简
管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -260,6 +260,52 @@ class BatchOutrightOddsDto {
|
||||
updates!: OutrightOddsUpdateItemDto[];
|
||||
}
|
||||
|
||||
class CreateOutrightDto {
|
||||
@IsString()
|
||||
leagueId!: string;
|
||||
|
||||
@IsString()
|
||||
titleZh!: string;
|
||||
|
||||
@IsString()
|
||||
titleEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
class UpdateOutrightDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
isHot?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
class AddOutrightSelectionDto {
|
||||
@IsString()
|
||||
teamCode!: string;
|
||||
|
||||
@IsString()
|
||||
teamZh!: string;
|
||||
|
||||
@IsString()
|
||||
teamEn!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds!: number;
|
||||
}
|
||||
|
||||
class CashbackPreviewDto {
|
||||
@IsString()
|
||||
periodStart!: string;
|
||||
@@ -648,24 +694,111 @@ export class AdminController {
|
||||
return jsonResponse(selection);
|
||||
}
|
||||
|
||||
@Get('outrights/wc2026')
|
||||
async getWc2026Outright() {
|
||||
const data = await this.outright.getWc2026ForAdmin();
|
||||
@Get('outrights')
|
||||
async listOutrights() {
|
||||
const data = await this.outright.listForAdmin();
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Get('outrights/leagues')
|
||||
async listOutrightLeagues() {
|
||||
const data = await this.outright.listLeagueOptions();
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights')
|
||||
async createOutright(@Body() dto: CreateOutrightDto) {
|
||||
const data = await this.outright.createForAdmin({
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
titleZh: dto.titleZh,
|
||||
titleEn: dto.titleEn,
|
||||
status: dto.status,
|
||||
});
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights/import/wc2026')
|
||||
async importWc2026Outright() {
|
||||
const data = await this.outright.importWc2026Canonical();
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
@Get('outrights/wc2026')
|
||||
async getWc2026OutrightLegacy() {
|
||||
const list = await this.outright.listForAdmin();
|
||||
const wc = list.find((e) => e.leagueCode === 'WC2026');
|
||||
if (!wc) throw new BadRequestException('WC2026 outright not found — run import');
|
||||
return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id)));
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
@Put('outrights/wc2026/odds')
|
||||
async updateWc2026OutrightOdds(
|
||||
async updateWc2026OutrightOddsLegacy(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: BatchOutrightOddsDto,
|
||||
) {
|
||||
const data = await this.outright.updateWc2026Odds(dto.updates, operatorId);
|
||||
const list = await this.outright.listForAdmin();
|
||||
const wc = list.find((e) => e.leagueCode === 'WC2026');
|
||||
if (!wc) throw new BadRequestException('WC2026 outright not found');
|
||||
return jsonResponse(
|
||||
await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId),
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
@Post('outrights/wc2026/apply-canonical')
|
||||
async applyWc2026CanonicalLegacy() {
|
||||
return jsonResponse(await this.outright.importWc2026Canonical());
|
||||
}
|
||||
|
||||
@Get('outrights/:matchId')
|
||||
async getOutright(@Param('matchId') matchId: string) {
|
||||
const data = await this.outright.getForAdmin(BigInt(matchId));
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights/wc2026/apply-canonical')
|
||||
async applyWc2026Canonical() {
|
||||
const data = await this.outright.applyWc2026Canonical();
|
||||
@Put('outrights/:matchId')
|
||||
async updateOutright(
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: UpdateOutrightDto,
|
||||
) {
|
||||
const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Put('outrights/:matchId/odds')
|
||||
async updateOutrightOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: BatchOutrightOddsDto,
|
||||
) {
|
||||
const data = await this.outright.batchUpdateOdds(
|
||||
BigInt(matchId),
|
||||
dto.updates,
|
||||
operatorId,
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('outrights/:matchId/selections')
|
||||
async addOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@Body() dto: AddOutrightSelectionDto,
|
||||
) {
|
||||
const data = await this.outright.addSelection(BigInt(matchId), dto);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Delete('outrights/:matchId/selections/:selectionId')
|
||||
async removeOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@Param('selectionId') selectionId: string,
|
||||
) {
|
||||
const data = await this.outright.closeSelection(
|
||||
BigInt(matchId),
|
||||
BigInt(selectionId),
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { jsonResponse } from '../../shared/common/filters';
|
||||
import { UsersService } from '../../domains/identity/users.service';
|
||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||
import { MatchesService } from '../../domains/catalog/matches.service';
|
||||
import { OutrightService } from '../../domains/catalog/outright.service';
|
||||
import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
|
||||
@@ -82,6 +83,7 @@ export class PlayerController {
|
||||
private users: UsersService,
|
||||
private wallet: WalletService,
|
||||
private matches: MatchesService,
|
||||
private outright: OutrightService,
|
||||
private bets: BetsService,
|
||||
private content: ContentService,
|
||||
private cashback: CashbackService,
|
||||
@@ -134,7 +136,7 @@ export class PlayerController {
|
||||
|
||||
@Get('outrights')
|
||||
async listOutrights(@CurrentUser('locale') locale: string) {
|
||||
const items = await this.matches.listOutrights(locale);
|
||||
const items = await this.outright.listForPlayer(locale);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
toVenueJson,
|
||||
translationsFromZhiboNames,
|
||||
} from './zhibo-match.mapper';
|
||||
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
|
||||
const WC2026_OUTRIGHT_RANK = new Map(
|
||||
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t.rank]),
|
||||
);
|
||||
const WC2026_OUTRIGHT_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
@@ -570,8 +567,19 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
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' },
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
isOutright: true,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
markets: {
|
||||
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
|
||||
@@ -592,27 +600,28 @@ export class MatchesService {
|
||||
const market = match.markets[0];
|
||||
if (!market) continue;
|
||||
|
||||
const selections = (
|
||||
await Promise.all(
|
||||
market.selections
|
||||
.filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode))
|
||||
.map(async (sel) => {
|
||||
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
|
||||
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
|
||||
const teamName = team
|
||||
? await this.getTranslation('TEAM', team.id, locale)
|
||||
: sel.selectionName;
|
||||
return {
|
||||
id: sel.id.toString(),
|
||||
teamCode,
|
||||
teamName,
|
||||
rank: WC2026_OUTRIGHT_RANK.get(teamCode) ?? 999,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
};
|
||||
}),
|
||||
)
|
||||
).sort((a, b) => a.rank - b.rank);
|
||||
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(),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { MarketsService } from '../odds/markets.service';
|
||||
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
|
||||
import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
|
||||
const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
|
||||
const PLACEHOLDER_TEAM_CODE = 'OUT';
|
||||
const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER';
|
||||
|
||||
@Injectable()
|
||||
export class OutrightService {
|
||||
@@ -13,86 +18,518 @@ export class OutrightService {
|
||||
private markets: MarketsService,
|
||||
) {}
|
||||
|
||||
async getWc2026ForAdmin() {
|
||||
const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, {
|
||||
forceCanonical: false,
|
||||
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' }],
|
||||
});
|
||||
|
||||
const match = await this.prisma.match.findUniqueOrThrow({ where: { id: matchId } });
|
||||
const [leagueZh, leagueEn, market] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
||||
this.prisma.market.findUniqueOrThrow({
|
||||
where: { id: marketId },
|
||||
include: {
|
||||
selections: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const teamByCode = new Map(
|
||||
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t]),
|
||||
);
|
||||
|
||||
const canonicalSelections = market.selections.filter((sel) =>
|
||||
CANONICAL_CODES.has(sel.selectionCode),
|
||||
);
|
||||
|
||||
const selections = await Promise.all(
|
||||
canonicalSelections.map(async (sel) => {
|
||||
const meta = teamByCode.get(sel.selectionCode);
|
||||
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 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,
|
||||
);
|
||||
return {
|
||||
id: sel.id.toString(),
|
||||
teamCode: sel.selectionCode,
|
||||
rank: meta?.rank ?? sel.sortOrder + 1,
|
||||
teamZh,
|
||||
teamEn,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
status: sel.status,
|
||||
id: m.id.toString(),
|
||||
leagueId: league.id.toString(),
|
||||
leagueCode,
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: m.matchName ?? '',
|
||||
status: m.status,
|
||||
selectionCount,
|
||||
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
|
||||
playerVisible: visibility.playerVisible,
|
||||
playerHiddenReason: visibility.playerHiddenReason,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
selections.sort((a, b) => a.rank - b.rank);
|
||||
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(),
|
||||
teamCode: sel.selectionCode,
|
||||
rank: sel.sortOrder + 1 || index + 1,
|
||||
teamZh: teamZh || sel.selectionName,
|
||||
teamEn: teamEn || sel.selectionName,
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
matchId: matchId.toString(),
|
||||
marketId: marketId.toString(),
|
||||
matchStatus: match.status,
|
||||
marketStatus: market.status,
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
leagueCode: league.code,
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: match.matchName ?? '',
|
||||
status: match.status,
|
||||
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,
|
||||
expectedCount: WC2026_OUTRIGHT_TEAMS.length,
|
||||
};
|
||||
}
|
||||
|
||||
async applyWc2026Canonical() {
|
||||
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true });
|
||||
return this.getWc2026ForAdmin();
|
||||
async createForAdmin(data: {
|
||||
leagueId: bigint;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
startTime?: Date;
|
||||
}) {
|
||||
const league = await this.prisma.league.findUnique({
|
||||
where: { id: data.leagueId },
|
||||
});
|
||||
if (!league) throw new NotFoundException('League not found');
|
||||
|
||||
const placeholder = await this.ensurePlaceholderTeam();
|
||||
const status = data.status ?? 'PUBLISHED';
|
||||
const match = await this.prisma.match.create({
|
||||
data: {
|
||||
leagueId: data.leagueId,
|
||||
homeTeamId: placeholder.id,
|
||||
awayTeamId: placeholder.id,
|
||||
isOutright: true,
|
||||
matchName: data.titleEn || data.titleZh,
|
||||
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.ensureOutrightMarket(match.id);
|
||||
return this.getForAdmin(match.id);
|
||||
}
|
||||
|
||||
async updateWc2026Odds(
|
||||
async updateForAdmin(
|
||||
matchId: bigint,
|
||||
data: {
|
||||
status?: string;
|
||||
matchName?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
titleZh?: string;
|
||||
titleEn?: string;
|
||||
},
|
||||
) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const status = data.status ?? match.status;
|
||||
await this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: {
|
||||
status,
|
||||
matchName: data.matchName,
|
||||
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;
|
||||
},
|
||||
) {
|
||||
if (!data.teamCode?.trim()) {
|
||||
throw new BadRequestException('Team code required');
|
||||
}
|
||||
if (data.odds <= 1) {
|
||||
throw new BadRequestException('Odds must be greater than 1');
|
||||
}
|
||||
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const code = data.teamCode.trim().toUpperCase();
|
||||
|
||||
const team = await this.prisma.team.upsert({
|
||||
where: { code },
|
||||
create: { code },
|
||||
update: {},
|
||||
});
|
||||
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) {
|
||||
throw new BadRequestException('Selection already exists for this team code');
|
||||
}
|
||||
|
||||
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 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 new NotFoundException('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,
|
||||
) {
|
||||
const results = await this.markets.batchUpdateOdds(
|
||||
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 new BadRequestException('Invalid selection for this outright event');
|
||||
}
|
||||
}
|
||||
|
||||
await this.markets.batchUpdateOdds(
|
||||
updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })),
|
||||
operatorId,
|
||||
);
|
||||
return results.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
odds: r.odds.toString(),
|
||||
oddsVersion: r.oddsVersion.toString(),
|
||||
}));
|
||||
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,
|
||||
},
|
||||
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,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!selections.length) continue;
|
||||
|
||||
const title =
|
||||
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 new NotFoundException('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 }>,
|
||||
): { 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);
|
||||
}
|
||||
|
||||
private playerVisibilityByCounts(
|
||||
matchStatus: string,
|
||||
market: { status: string } | null | undefined,
|
||||
openSelectionCount: number,
|
||||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user