feat: 世界杯48强夺冠盘、管理端调赔与项目文档

- 固定48强基准数据、同步种子与后台世界杯夺冠页

- 补全 user_preferences 迁移文件;新增启动指南与默认数据说明

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 16:19:36 +08:00
parent 3b739982a1
commit 95abbcb470
17 changed files with 1157 additions and 92 deletions

View File

@@ -0,0 +1,108 @@
import { Injectable } 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 { syncWc2026OutrightMarket } from './wc2026-outright.sync';
const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
@Injectable()
export class OutrightService {
constructor(
private prisma: PrismaService,
private markets: MarketsService,
) {}
async getWc2026ForAdmin() {
const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, {
forceCanonical: false,
});
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 {
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,
};
}),
);
selections.sort((a, b) => a.rank - b.rank);
return {
matchId: matchId.toString(),
marketId: marketId.toString(),
matchStatus: match.status,
marketStatus: market.status,
leagueZh,
leagueEn,
selections,
expectedCount: WC2026_OUTRIGHT_TEAMS.length,
};
}
async applyWc2026Canonical() {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true });
return this.getWc2026ForAdmin();
}
async updateWc2026Odds(
updates: Array<{ selectionId: string; odds: number }>,
operatorId: bigint,
) {
const results = 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(),
}));
}
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 ?? '';
}
}