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

@@ -33,6 +33,7 @@ const zh: Record<string, string> = {
'nav.users': '玩家管理',
'nav.agents': '代理管理',
'nav.matches': '赛事管理',
'nav.outright': '世界杯夺冠',
'nav.bets': '注单管理',
'nav.cashback': '返水管理',
'nav.audit': '操作日志',
@@ -181,6 +182,7 @@ const en: Record<string, string> = {
'nav.users': 'Players',
'nav.agents': 'Agents',
'nav.matches': 'Matches',
'nav.outright': 'WC Winner',
'nav.bets': 'Bets',
'nav.cashback': 'Cashback',
'nav.audit': 'Audit Log',
@@ -329,6 +331,7 @@ const ms: Record<string, string> = {
'nav.users': 'Pemain',
'nav.agents': 'Ejen',
'nav.matches': 'Perlawanan',
'nav.outright': 'Juara Piala Dunia',
'nav.bets': 'Pertaruhan',
'nav.cashback': 'Rebat',
'nav.audit': 'Log audit',

View File

@@ -246,6 +246,21 @@ export const adminPagesMs: Record<string, string> = {
'msg.credit_adjusted': 'Kredit dikemas kini',
'msg.credit_adjust_failed': 'Pelarasan gagal',
'msg.outright_no_edit': 'Outright tidak boleh diedit di sini',
'msg.outright_odds_saved': 'Odds juara disimpan',
'msg.load_failed': 'Gagal memuatkan',
'page.outright.title': 'Juara Piala Dunia 48',
'page.outright.desc': '48 pasukan tetap; laraskan odds juara',
'outright.col.rank': 'Kedudukan',
'outright.col.team_zh': 'Pasukan (ZH)',
'outright.col.team_en': 'Pasukan (EN)',
'outright.col.code': 'Kod',
'outright.col.odds': 'Odds juara',
'outright.btn.save_odds': 'Simpan semua odds',
'outright.btn.apply_canonical': 'Guna data jadual asas',
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',
'outright.team_count': '{n} / {total} pasukan',
'outright.err_odds_min': 'Odds mesti lebih 1.00',
'msg.load_matches_failed': 'Gagal memuatkan perlawanan',
'msg.cashback_issued': 'Rebat telah dikeluarkan',
'msg.freeze_confirm_title': '{action} akaun',

View File

@@ -246,6 +246,21 @@ export const adminPagesZh: Record<string, string> = {
'msg.credit_adjusted': '授信已调整',
'msg.credit_adjust_failed': '调整失败',
'msg.outright_no_edit': '冠军盘不支持在此编辑',
'msg.outright_odds_saved': '夺冠赔率已保存',
'msg.load_failed': '加载失败',
'page.outright.title': '世界杯 48 强夺冠',
'page.outright.desc': '固定 48 支球队,可调整夺冠赔率(玩家端「优胜冠军」)',
'outright.col.rank': '排名',
'outright.col.team_zh': '队伍(中文)',
'outright.col.team_en': '队伍(英文)',
'outright.col.code': '代码',
'outright.col.odds': '夺冠赔率',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.apply_canonical': '应用表格基准数据',
'msg.outright_canonical_applied': '已按基准表写入 48 强夺冠赔率',
'outright.team_count': '已配置 {n} / {total} 队',
'outright.err_odds_min': '赔率须大于 1.00',
'msg.load_matches_failed': '加载赛事失败',
'msg.cashback_issued': '返水已发放',
'msg.freeze_confirm_title': '{action}账号',
@@ -502,6 +517,21 @@ export const adminPagesEn: Record<string, string> = {
'msg.credit_adjusted': 'Credit updated',
'msg.credit_adjust_failed': 'Adjustment failed',
'msg.outright_no_edit': 'Outright cannot be edited here',
'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed',
'page.outright.title': 'World Cup 48 — Winner',
'page.outright.desc': 'Fixed 48 teams; adjust winner odds (player Outright tab)',
'outright.col.rank': 'Rank',
'outright.col.team_zh': 'Team (ZH)',
'outright.col.team_en': 'Team (EN)',
'outright.col.code': 'Code',
'outright.col.odds': 'Winner odds',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.apply_canonical': 'Apply baseline table',
'msg.outright_canonical_applied': '48-team winner odds applied from baseline',
'outright.team_count': '{n} / {total} teams',
'outright.err_odds_min': 'Odds must be greater than 1.00',
'msg.load_matches_failed': 'Failed to load matches',
'msg.cashback_issued': 'Cashback issued',
'msg.freeze_confirm_title': '{action} account',

View File

@@ -15,6 +15,7 @@ const adminMenus = computed(() => [
{ path: '/users', label: t('nav.users') },
{ path: '/agents', label: t('nav.agents') },
{ path: '/matches', label: t('nav.matches') },
{ path: '/world-cup-outright', label: t('nav.outright') },
{ path: '/bets', label: t('nav.bets') },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/audit', label: t('nav.audit') },

View File

@@ -26,6 +26,11 @@ const router = createRouter({
component: () => import('../views/Matches.vue'),
meta: { adminOnly: true },
},
{
path: 'world-cup-outright',
component: () => import('../views/WorldCupOutright.vue'),
meta: { adminOnly: true },
},
{
path: 'bets',
component: () => import('../views/Bets.vue'),

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t } = useAdminLocale();
interface SelectionRow {
id: string;
teamCode: string;
rank: number;
teamZh: string;
teamEn: string;
odds: string;
oddsVersion: string;
status: string;
editOdds: number;
}
const loading = ref(false);
const saving = ref(false);
const leagueZh = ref('');
const leagueEn = ref('');
const selections = ref<SelectionRow[]>([]);
const expectedCount = ref(48);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/outrights/wc2026');
const payload = data.data as {
leagueZh: string;
leagueEn: string;
selections: SelectionRow[];
expectedCount: number;
};
leagueZh.value = payload.leagueZh;
leagueEn.value = payload.leagueEn;
expectedCount.value = payload.expectedCount;
selections.value = payload.selections.map((s) => ({
...s,
editOdds: Number(s.odds),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
async function applyCanonical() {
loading.value = true;
try {
await api.post('/admin/outrights/wc2026/apply-canonical');
ElMessage.success(t('msg.outright_canonical_applied'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
loading.value = false;
}
}
async function saveAll() {
const invalid = selections.value.find((s) => !s.editOdds || s.editOdds <= 1);
if (invalid) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
saving.value = true;
try {
await api.put('/admin/outrights/wc2026/odds', {
updates: selections.value.map((s) => ({
selectionId: s.id,
odds: s.editOdds,
})),
});
ElMessage.success(t('msg.outright_odds_saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.outright.title') }}</h2>
<span class="page-desc">{{ t('page.outright.desc') }}</span>
</div>
<el-card class="data-card" shadow="never" v-loading="loading">
<div class="meta-row">
<span>{{ leagueZh }} / {{ leagueEn }}</span>
<span class="meta-count">
{{ t('outright.team_count', { n: selections.length, total: expectedCount }) }}
</span>
</div>
<div class="table-wrap">
<el-table :data="selections" stripe>
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.editOdds"
:min="1.01"
:step="0.05"
:precision="2"
controls-position="right"
size="small"
style="width: 130px"
/>
</template>
</el-table-column>
</el-table>
</div>
<div class="footer-actions">
<el-button @click="load">{{ t('common.reset') }}</el-button>
<el-button :loading="loading" @click="applyCanonical">
{{ t('outright.btn.apply_canonical') }}
</el-button>
<el-button type="primary" :loading="saving" @click="saveAll">
{{ t('outright.btn.save_odds') }}
</el-button>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #888; }
.data-card { border-radius: 12px; }
.meta-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 13px; color: #aaa; }
.meta-count { color: #666; }
.footer-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
</style>

View File

@@ -0,0 +1,3 @@
-- AlterTable: user_preferences 增加手机、邮箱(玩家资料)
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "phone" VARCHAR(32);
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "email" VARCHAR(128);

View File

@@ -1,5 +1,6 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { syncWc2026OutrightMarket } from '../src/domains/catalog/wc2026-outright.sync';
const prisma = new PrismaClient();
@@ -359,81 +360,11 @@ async function seedOutrightDemo() {
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
if (!wc) return;
const placeholder = await upsertTeam('OUT', { 'zh-CN': '冠军盘', 'en-US': 'Outright' });
const outrightOdds: Array<[string, Record<string, string>, number]> = [
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }, 4.95],
['ESP', { 'zh-CN': '西班牙', 'en-US': 'Spain' }, 4.95],
['ENG', { 'zh-CN': '英格兰', 'en-US': 'England' }, 6.3],
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }, 8.55],
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }, 8.55],
['POR', { 'zh-CN': '葡萄牙', 'en-US': 'Portugal' }, 9.0],
['GER', { 'zh-CN': '德国', 'en-US': 'Germany' }, 15.3],
['NED', { 'zh-CN': '荷兰', 'en-US': 'Netherlands' }, 18.9],
['NOR', { 'zh-CN': '挪威', 'en-US': 'Norway' }, 32.4],
['BEL', { 'zh-CN': '比利时', 'en-US': 'Belgium' }, 35.1],
['COL', { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia' }, 45.9],
['JPN', { 'zh-CN': '日本', 'en-US': 'Japan' }, 45.9],
['URU', { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay' }, 63.9],
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }, 63.9],
['MAR', { 'zh-CN': '摩洛哥', 'en-US': 'Morocco' }, 63.9],
['CRO', { 'zh-CN': '克罗地亚', 'en-US': 'Croatia' }, 81.0],
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }, 85.0],
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }, 90.0],
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }, 95.0],
['SEN', { 'zh-CN': '塞内加尔', 'en-US': 'Senegal' }, 100.0],
];
for (const [code, names] of outrightOdds) {
await upsertTeam(code, names);
}
let match = await prisma.match.findFirst({
where: { leagueId: wc.id, isOutright: true },
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
forceCanonical: true,
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: wc.id,
homeTeamId: placeholder.id,
awayTeamId: placeholder.id,
isOutright: true,
startTime: new Date('2027-07-01T00:00:00Z'),
status: 'PUBLISHED',
publishTime: new Date(),
isHot: true,
displayOrder: 0,
},
});
}
const marketExists = await prisma.market.findFirst({
where: { matchId: match.id, marketType: 'OUTRIGHT_WINNER' },
});
if (!marketExists) {
await prisma.market.create({
data: {
matchId: match.id,
marketType: 'OUTRIGHT_WINNER',
period: 'OUTRIGHT',
allowSingle: true,
allowParlay: false,
sortOrder: 1,
status: 'OPEN',
selections: {
create: outrightOdds.map(([code, names, odds], i) => ({
selectionCode: code,
selectionName: names['zh-CN'],
odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
console.log(' Outright demo: World Cup winner market');
const count = await prisma.marketSelection.count({ where: { marketId } });
console.log(` WC2026 outright: match ${matchId}, ${count} selections`);
}
async function seedPlayerDemo() {

View File

@@ -19,6 +19,7 @@ import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { OutrightService } from '../../domains/catalog/outright.service';
import { MarketsService } from '../../domains/odds/markets.service';
import { SettlementService } from '../../domains/settlement/settlement.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
@@ -245,6 +246,20 @@ class UpdateOddsDto {
odds!: number;
}
class OutrightOddsUpdateItemDto {
@IsString()
selectionId!: string;
@IsNumber()
@Min(1.01)
odds!: number;
}
class BatchOutrightOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -263,6 +278,7 @@ export class AdminController {
private agents: AgentsService,
private wallet: WalletService,
private matches: MatchesService,
private outright: OutrightService,
private markets: MarketsService,
private settlement: SettlementService,
private cashback: CashbackService,
@@ -632,6 +648,27 @@ export class AdminController {
return jsonResponse(selection);
}
@Get('outrights/wc2026')
async getWc2026Outright() {
const data = await this.outright.getWc2026ForAdmin();
return jsonResponse(data);
}
@Put('outrights/wc2026/odds')
async updateWc2026OutrightOdds(
@CurrentUser('id') operatorId: bigint,
@Body() dto: BatchOutrightOddsDto,
) {
const data = await this.outright.updateWc2026Odds(dto.updates, operatorId);
return jsonResponse(data);
}
@Post('outrights/wc2026/apply-canonical')
async applyWc2026Canonical() {
const data = await this.outright.applyWc2026Canonical();
return jsonResponse(data);
}
@Post('matches/:id/settlement/score')
async recordScore(
@CurrentUser('id') operatorId: bigint,

View File

@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { MarketsModule } from '../odds/markets.module';
import { MatchesService } from './matches.service';
import { OutrightService } from './outright.service';
@Module({
providers: [MatchesService],
exports: [MatchesService],
imports: [MarketsModule],
providers: [MatchesService, OutrightService],
exports: [MatchesService, OutrightService],
})
export class MatchesModule {}

View File

@@ -14,6 +14,12 @@ import {
toVenueJson,
translationsFromZhiboNames,
} from './zhibo-match.mapper';
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
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));
@Injectable()
export class MatchesService {
@@ -586,22 +592,27 @@ export class MatchesService {
const market = match.markets[0];
if (!market) continue;
const selections = await Promise.all(
market.selections.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,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
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);
results.push({
id: match.id.toString(),

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 ?? '';
}
}

View File

@@ -0,0 +1,63 @@
/**
* 2026 世界杯 48 强固定夺冠盘口(唯一基准数据源)
* 排名 / 中英文名 / 夺冠赔率 — 与运营表格一致;改此处后执行 seed 或后台「应用表格基准数据」
*/
export const WC2026_LEAGUE_CODE = 'WC2026';
export type Wc2026OutrightTeam = {
rank: number;
code: string;
names: { 'zh-CN': string; 'en-US': string };
defaultOdds: number;
};
export const WC2026_OUTRIGHT_TEAMS: Wc2026OutrightTeam[] = [
{ rank: 1, code: 'FRA', names: { 'zh-CN': '法国', 'en-US': 'France' }, defaultOdds: 4.95 },
{ rank: 2, code: 'ESP', names: { 'zh-CN': '西班牙', 'en-US': 'Spain' }, defaultOdds: 4.95 },
{ rank: 3, code: 'ENG', names: { 'zh-CN': '英格兰', 'en-US': 'England' }, defaultOdds: 6.3 },
{ rank: 4, code: 'BRA', names: { 'zh-CN': '巴西', 'en-US': 'Brazil' }, defaultOdds: 8.55 },
{ rank: 5, code: 'ARG', names: { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }, defaultOdds: 8.55 },
{ rank: 6, code: 'POR', names: { 'zh-CN': '葡萄牙', 'en-US': 'Portugal' }, defaultOdds: 9 },
{ rank: 7, code: 'GER', names: { 'zh-CN': '德国', 'en-US': 'Germany' }, defaultOdds: 15.3 },
{ rank: 8, code: 'NED', names: { 'zh-CN': '荷兰', 'en-US': 'Netherlands' }, defaultOdds: 18.9 },
{ rank: 9, code: 'NOR', names: { 'zh-CN': '挪威', 'en-US': 'Norway' }, defaultOdds: 32.4 },
{ rank: 10, code: 'BEL', names: { 'zh-CN': '比利时', 'en-US': 'Belgium' }, defaultOdds: 35.1 },
{ rank: 11, code: 'COL', names: { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia' }, defaultOdds: 45.9 },
{ rank: 12, code: 'JPN', names: { 'zh-CN': '日本', 'en-US': 'Japan' }, defaultOdds: 45.9 },
{ rank: 13, code: 'URU', names: { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay' }, defaultOdds: 63.9 },
{ rank: 14, code: 'USA', names: { 'zh-CN': '美国', 'en-US': 'USA' }, defaultOdds: 63.9 },
{ rank: 15, code: 'MAR', names: { 'zh-CN': '摩洛哥', 'en-US': 'Morocco' }, defaultOdds: 63.9 },
{ rank: 16, code: 'CRO', names: { 'zh-CN': '克罗地亚', 'en-US': 'Croatia' }, defaultOdds: 81 },
{ rank: 17, code: 'MEX', names: { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }, defaultOdds: 85 },
{ rank: 18, code: 'SUI', names: { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }, defaultOdds: 90 },
{ rank: 19, code: 'TUR', names: { 'zh-CN': '土耳其', 'en-US': 'Turkey' }, defaultOdds: 95 },
{ rank: 20, code: 'SEN', names: { 'zh-CN': '塞内加尔', 'en-US': 'Senegal' }, defaultOdds: 100 },
{ rank: 21, code: 'KOR', names: { 'zh-CN': '韩国', 'en-US': 'South Korea' }, defaultOdds: 120 },
{ rank: 22, code: 'AUT', names: { 'zh-CN': '奥地利', 'en-US': 'Austria' }, defaultOdds: 125 },
{ rank: 23, code: 'ECU', names: { 'zh-CN': '厄瓜多尔', 'en-US': 'Ecuador' }, defaultOdds: 130 },
{ rank: 24, code: 'SWE', names: { 'zh-CN': '瑞典', 'en-US': 'Sweden' }, defaultOdds: 140 },
{ rank: 25, code: 'IRN', names: { 'zh-CN': '伊朗', 'en-US': 'Iran' }, defaultOdds: 150 },
{ rank: 26, code: 'GHA', names: { 'zh-CN': '加纳', 'en-US': 'Ghana' }, defaultOdds: 160 },
{ rank: 27, code: 'ALG', names: { 'zh-CN': '阿尔及利亚', 'en-US': 'Algeria' }, defaultOdds: 180 },
{ rank: 28, code: 'BIH', names: { 'zh-CN': '波黑', 'en-US': 'Bosnia' }, defaultOdds: 200 },
{ rank: 29, code: 'EGY', names: { 'zh-CN': '埃及', 'en-US': 'Egypt' }, defaultOdds: 220 },
{ rank: 30, code: 'TUN', names: { 'zh-CN': '突尼斯', 'en-US': 'Tunisia' }, defaultOdds: 250 },
{ rank: 31, code: 'CAN', names: { 'zh-CN': '加拿大', 'en-US': 'Canada' }, defaultOdds: 280 },
{ rank: 32, code: 'PAN', names: { 'zh-CN': '巴拿马', 'en-US': 'Panama' }, defaultOdds: 300 },
{ rank: 33, code: 'AUS', names: { 'zh-CN': '澳大利亚', 'en-US': 'Australia' }, defaultOdds: 350 },
{ rank: 34, code: 'CZE', names: { 'zh-CN': '捷克', 'en-US': 'Czech' }, defaultOdds: 400 },
{ rank: 35, code: 'KSA', names: { 'zh-CN': '沙特阿拉伯', 'en-US': 'Saudi Arabia' }, defaultOdds: 450 },
{ rank: 36, code: 'NZL', names: { 'zh-CN': '新西兰', 'en-US': 'New Zealand' }, defaultOdds: 500 },
{ rank: 37, code: 'COD', names: { 'zh-CN': '刚果(金)', 'en-US': 'DR Congo' }, defaultOdds: 600 },
{ rank: 38, code: 'UZB', names: { 'zh-CN': '乌兹别克斯坦', 'en-US': 'Uzbekistan' }, defaultOdds: 700 },
{ rank: 39, code: 'IRQ', names: { 'zh-CN': '伊拉克', 'en-US': 'Iraq' }, defaultOdds: 750 },
{ rank: 40, code: 'RSA', names: { 'zh-CN': '南非', 'en-US': 'South Africa' }, defaultOdds: 800 },
{ rank: 41, code: 'CIV', names: { 'zh-CN': '科特迪瓦', 'en-US': 'Ivory Coast' }, defaultOdds: 850 },
{ rank: 42, code: 'JOR', names: { 'zh-CN': '约旦', 'en-US': 'Jordan' }, defaultOdds: 900 },
{ rank: 43, code: 'PAR', names: { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }, defaultOdds: 950 },
{ rank: 44, code: 'HAI', names: { 'zh-CN': '海地', 'en-US': 'Haiti' }, defaultOdds: 1000 },
{ rank: 45, code: 'QAT', names: { 'zh-CN': '卡塔尔', 'en-US': 'Qatar' }, defaultOdds: 1200 },
{ rank: 46, code: 'CPV', names: { 'zh-CN': '佛得角', 'en-US': 'Cape Verde' }, defaultOdds: 1500 },
{ rank: 47, code: 'CUW', names: { 'zh-CN': '库拉索', 'en-US': 'Curacao' }, defaultOdds: 2000 },
{ rank: 48, code: 'SCO', names: { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }, defaultOdds: 2500 },
];

View File

@@ -0,0 +1,179 @@
import type { PrismaClient } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import {
WC2026_LEAGUE_CODE,
WC2026_OUTRIGHT_TEAMS,
type Wc2026OutrightTeam,
} from './wc2026-outright-teams';
const PLACEHOLDER_TEAM_CODE = 'OUT';
const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
export type Wc2026OutrightSyncOptions = {
/** true赔率/队名/排序与 wc2026-outright-teams.ts 完全一致,并关闭不在表内的选项 */
forceCanonical?: boolean;
};
function oddsEqual(a: Decimal | number, b: number) {
return Number(a) === b;
}
async function upsertTeamTranslations(
prisma: PrismaClient,
teamId: bigint,
names: Wc2026OutrightTeam['names'],
) {
for (const [locale, value] of Object.entries(names)) {
await 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 },
});
}
}
async function upsertTeam(prisma: PrismaClient, entry: Wc2026OutrightTeam) {
const team = await prisma.team.upsert({
where: { code: entry.code },
create: { code: entry.code },
update: {},
});
await upsertTeamTranslations(prisma, team.id, entry.names);
return team;
}
/** 确保 WC2026 夺冠盘存在forceCanonical 时与基准表完全一致 */
export async function syncWc2026OutrightMarket(
prisma: PrismaClient,
options: Wc2026OutrightSyncOptions = {},
) {
const forceCanonical = options.forceCanonical ?? false;
const league = await prisma.league.findUnique({ where: { code: WC2026_LEAGUE_CODE } });
if (!league) {
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedSportsDemo first`);
}
const placeholder = await upsertTeam(prisma, {
rank: 0,
code: PLACEHOLDER_TEAM_CODE,
names: { 'zh-CN': '冠军盘', 'en-US': 'Outright' },
defaultOdds: 1,
});
for (const entry of WC2026_OUTRIGHT_TEAMS) {
await upsertTeam(prisma, entry);
}
let match = await prisma.match.findFirst({
where: { leagueId: league.id, isOutright: true, deletedAt: null },
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: league.id,
homeTeamId: placeholder.id,
awayTeamId: placeholder.id,
isOutright: true,
matchName: '2026 FIFA World Cup Winner',
startTime: new Date('2027-07-01T00:00:00Z'),
status: 'PUBLISHED',
publishTime: new Date(),
isHot: true,
displayOrder: 0,
},
});
} else if (match.status === 'DRAFT') {
match = await prisma.match.update({
where: { id: match.id },
data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() },
});
}
let market = await prisma.market.findFirst({
where: { matchId: match.id, marketType: 'OUTRIGHT_WINNER' },
include: { selections: true },
});
if (!market) {
market = await prisma.market.create({
data: {
matchId: match.id,
marketType: 'OUTRIGHT_WINNER',
period: 'OUTRIGHT',
allowSingle: true,
allowParlay: false,
sortOrder: 1,
status: 'OPEN',
},
include: { selections: true },
});
}
const existingByCode = new Map(market.selections.map((s) => [s.selectionCode, s]));
for (const entry of WC2026_OUTRIGHT_TEAMS) {
const sortOrder = entry.rank - 1;
const existing = existingByCode.get(entry.code);
if (!existing) {
await prisma.marketSelection.create({
data: {
marketId: market.id,
selectionCode: entry.code,
selectionName: entry.names['zh-CN'],
odds: entry.defaultOdds,
sortOrder,
status: 'OPEN',
},
});
continue;
}
const updateData: {
selectionName: string;
sortOrder: number;
status: string;
odds?: number;
oddsVersion?: bigint;
} = {
selectionName: entry.names['zh-CN'],
sortOrder,
status: 'OPEN',
};
if (forceCanonical && !oddsEqual(existing.odds, entry.defaultOdds)) {
updateData.odds = entry.defaultOdds;
updateData.oddsVersion = existing.oddsVersion + BigInt(1);
}
await prisma.marketSelection.update({
where: { id: existing.id },
data: updateData,
});
}
if (forceCanonical) {
for (const sel of market.selections) {
if (CANONICAL_CODES.has(sel.selectionCode)) continue;
if (sel.selectionCode === PLACEHOLDER_TEAM_CODE) continue;
await prisma.marketSelection.update({
where: { id: sel.id },
data: { status: 'CLOSED' },
});
}
}
return { matchId: match.id, marketId: market.id };
}