feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档

重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -0,0 +1,152 @@
/**
* 从 zhibo 导出的 world-cup-group-stage-matches.json 生成 seed 用裁剪 JSON 与球队映射 TS。
* 用法: node apps/api/scripts/build-wc2026-seed-json.mjs <源JSON路径>
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(__dirname, '..');
const seedDataDir = path.join(apiRoot, 'src/infrastructure/database/seed-data');
const outrightTeamsPath = path.join(apiRoot, 'src/domains/catalog/wc2026-outright-teams.ts');
const sourcePath = process.argv[2];
if (!sourcePath) {
console.error('用法: node build-wc2026-seed-json.mjs <源JSON路径>');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(path.resolve(sourcePath), 'utf8'));
const outrightSrc = fs.readFileSync(outrightTeamsPath, 'utf8');
const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-CN': '([^']+)', 'en-US': '([^']+)'/g)].map((m) => ({
code: m[1],
zh: m[2],
en: m[3],
}));
function pickNames(names) {
return {
zh: names?.zh ?? null,
en: names?.en ?? null,
zhTw: names?.zhTw ?? null,
vi: names?.vi ?? null,
km: names?.km ?? null,
ms: names?.ms ?? null,
};
}
function pickTeam(team) {
return {
id: team.id,
name: team.name,
names: pickNames(team.names),
image: team.image ?? '',
};
}
function slimMatch(m) {
return {
officialMatchNo: m.officialMatchNo,
stage: m.stage,
groupName: m.groupName,
liveMatchId: m.liveMatchId,
additionMatchId: m.additionMatchId,
channelId: m.channelId,
matchName: m.matchName,
league: { type: m.league.type, en: m.league.en, zh: m.league.zh },
kickoff: {
utcTimeStart: m.kickoff.utcTimeStart,
utcTimeStop: m.kickoff.utcTimeStop,
utcIso: m.kickoff.utcIso,
chinaTime: m.kickoff.chinaTime,
venueTime: m.kickoff.venueTime,
venueTimezone: m.kickoff.venueTimezone,
},
homeTeam: pickTeam(m.homeTeam),
awayTeam: pickTeam(m.awayTeam),
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
venue: {
names: pickNames(m.venue?.names),
city: pickNames(m.venue?.city),
},
sortOrder: m.sortOrder,
isPublished: m.isPublished,
};
}
function resolveCanonicalCode(team) {
if (team.id == null) return null;
const en = (team.name || team.names?.en || '').toLowerCase();
const zh = team.names?.zh || '';
const hit = outrightTeams.find(
(o) =>
o.en.toLowerCase() === en ||
o.zh === zh ||
(o.en === 'Turkey' && en.includes('türkiye')) ||
(o.en === 'Czech' && en === 'czechia') ||
(o.en === 'Bosnia' && en.includes('bosnia')) ||
(o.en === 'Ivory Coast' && en.includes('côte')) ||
(o.en === 'DR Congo' && en.includes('congo')) ||
(o.en === 'Curacao' && en.includes('cura')),
);
return hit?.code ?? null;
}
const matches = (raw.matches || []).map(slimMatch);
const bundle = { count: matches.length, matches };
const teamById = new Map();
for (const m of raw.matches || []) {
for (const t of [m.homeTeam, m.awayTeam]) {
if (t?.id != null) teamById.set(t.id, t);
}
}
const zhiboToCode = {};
const logoByCode = {};
const unmatched = [];
for (const [id, team] of teamById) {
const code = resolveCanonicalCode(team);
if (code) {
zhiboToCode[id] = code;
if (team.image) logoByCode[code] = team.image;
} else {
unmatched.push({ id, name: team.name });
}
}
fs.mkdirSync(seedDataDir, { recursive: true });
const jsonOut = path.join(seedDataDir, 'wc2026-group-stage.json');
fs.writeFileSync(jsonOut, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
const mapLines = Object.entries(zhiboToCode)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([id, code]) => ` ${id}: '${code}',`)
.join('\n');
const logoLines = Object.entries(logoByCode)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
.join('\n');
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
${mapLines}
};
/** zhibo 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
${logoLines}
};
`;
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
fs.writeFileSync(mapOut, mapTs, 'utf8');
console.log(`Wrote ${jsonOut} (${matches.length} matches)`);
console.log(`Wrote ${mapOut} (${Object.keys(zhiboToCode).length} team mappings)`);
if (unmatched.length) {
console.warn('Unmatched teams:', unmatched);
process.exit(1);
}