From e7e938f2617a02a3fa6a0cd71db4feed8fb4ccb1 Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Fri, 12 Jun 2026 18:17:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WC2026=20=E8=B5=9B=E4=BA=8B=20seed?= =?UTF-8?q?=E3=80=81=E7=94=9F=E4=BA=A7=E4=B8=8A=E7=BA=BF=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E8=84=9A=E6=9C=AC=E4=B8=8E=E7=9B=AE=E5=BD=95=E5=BD=92?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor --- .env.docker.example | 5 +- apps/admin/package.json | 4 + apps/admin/scripts/report-chunk-sizes.mjs | 32 + apps/admin/scripts/split-i18n.mjs | 51 + apps/admin/src/App.vue | 35 +- .../src/components/AdminPlayerRowActions.vue | 10 + .../src/components/InviteHistoryPanel.vue | 1 - .../src/components/LeagueArchiveDialog.vue | 166 + .../src/components/MatchArchiveDialog.vue | 182 + .../src/components/dashboard/EChartPanel.vue | 12 +- apps/admin/src/composables/useAdminLocale.ts | 4 +- apps/admin/src/i18n/admin-messages.ts | 30 +- apps/admin/src/i18n/admin-pages-ms.ts | 43 +- apps/admin/src/i18n/admin-pages.ts | 102 +- apps/admin/src/i18n/bundles/en-US.ts | 3 + apps/admin/src/i18n/bundles/ms-MY.ts | 3 + apps/admin/src/i18n/bundles/zh-CN.ts | 3 + apps/admin/src/i18n/index.ts | 24 +- apps/admin/src/i18n/locale-loader.ts | 32 + apps/admin/src/i18n/pages/en.ts | 1009 ++++ apps/admin/src/i18n/pages/zh.ts | 1017 ++++ apps/admin/src/main.ts | 9 +- apps/admin/src/utils/matchesListState.ts | 23 +- apps/admin/src/utils/session-hydrate.ts | 13 + .../src/views/AgentCreditTransactions.vue | 2 +- apps/admin/src/views/AgentManager.vue | 149 +- apps/admin/src/views/Audit.vue | 2 +- apps/admin/src/views/Contents.vue | 65 +- apps/admin/src/views/DepositOrders.vue | 104 +- apps/admin/src/views/Matches.vue | 70 +- apps/admin/src/views/PaymentMethods.vue | 18 +- apps/admin/src/views/Settlement.vue | 209 +- apps/admin/src/views/SmokeTests.vue | 2 +- apps/admin/src/views/agent/Players.vue | 127 +- apps/admin/src/views/match-form.ts | 7 + .../src/views/matches/LeagueMatchesPanel.vue | 127 +- .../views/matches/LeagueOutrightOddsPanel.vue | 156 +- .../src/views/matches/LeagueOutrightPanel.vue | 36 +- .../src/views/matches/MatchEventEditor.vue | 5 + apps/admin/vite.config.ts | 46 +- apps/api/nest-cli.json | 8 +- apps/api/package.json | 2 + .../migration.sql | 2 + apps/api/prisma/schema.prisma | 1 + apps/api/scripts/build-wc2026-seed-json.mjs | 152 + .../applications/admin/admin.controller.ts | 178 +- .../agent/agent-portal.controller.ts | 43 +- apps/api/src/domains/agent/agents.service.ts | 76 +- apps/api/src/domains/betting/bets.service.ts | 8 + .../catalog/catalog-archive.service.spec.ts | 174 + .../catalog/catalog-archive.service.ts | 302 + .../api/src/domains/catalog/matches.module.ts | 5 +- .../domains/catalog/matches.service.spec.ts | 115 + .../src/domains/catalog/matches.service.ts | 105 +- .../src/domains/catalog/outright.service.ts | 79 +- .../domains/catalog/wc2026-outright.sync.ts | 10 +- .../src/domains/deposit/deposit.service.ts | 147 +- apps/api/src/domains/identity/auth.service.ts | 4 + apps/api/src/domains/identity/jwt.strategy.ts | 15 +- .../domains/identity/sms/chuanglan/client.ts | 21 +- .../domains/identity/sms/chuanglan/config.ts | 4 +- .../api/src/domains/identity/users.service.ts | 33 + apps/api/src/domains/ledger/wallet.service.ts | 14 +- .../operations/content/content.service.ts | 8 +- .../settlement/settlement.service.spec.ts | 242 + .../domains/settlement/settlement.service.ts | 360 +- .../infrastructure/database/database-init.ts | 34 + .../database/database-reset.service.ts | 25 +- .../database/reset-and-seed-cli.ts | 59 + .../src/infrastructure/database/run-seed.ts | 633 +- .../infrastructure/database/seed-catalog.ts | 350 ++ .../src/infrastructure/database/seed-cli.ts | 5 +- .../database/seed-data/index.ts | 10 + .../seed-data/wc2026-group-stage.json | 5189 +++++++++++++++++ .../seed-data/wc2026-zhibo-team-map.ts | 103 + .../database/seed-demo-markets.ts | 165 + apps/api/src/shared/common/app-error.ts | 4 + apps/api/src/shared/common/filters.ts | 6 +- .../src/shared/uploads/delete-upload-file.ts | 18 + apps/player/src/api/index.ts | 35 +- apps/player/src/components/TeamEmblem.vue | 6 + apps/player/src/i18n/en-US.ts | 7 +- apps/player/src/i18n/ms-MY.ts | 7 +- apps/player/src/i18n/zh-CN.ts | 7 +- apps/player/src/views/MatchDetailView.vue | 11 +- apps/player/src/views/RechargeView.vue | 50 +- apps/player/src/views/RegisterView.vue | 79 +- docker-compose.prod.yml | 15 +- docs/Docker部署指南.md | 9 +- package.json | 2 + packages/shared/src/api-errors.ts | 78 +- pnpm-lock.yaml | 207 +- scripts/prod-init-db.ps1 | 65 + scripts/prod-init-db.sh | 83 + 94 files changed, 12332 insertions(+), 976 deletions(-) create mode 100644 apps/admin/scripts/report-chunk-sizes.mjs create mode 100644 apps/admin/scripts/split-i18n.mjs create mode 100644 apps/admin/src/components/LeagueArchiveDialog.vue create mode 100644 apps/admin/src/components/MatchArchiveDialog.vue create mode 100644 apps/admin/src/i18n/bundles/en-US.ts create mode 100644 apps/admin/src/i18n/bundles/ms-MY.ts create mode 100644 apps/admin/src/i18n/bundles/zh-CN.ts create mode 100644 apps/admin/src/i18n/locale-loader.ts create mode 100644 apps/admin/src/i18n/pages/en.ts create mode 100644 apps/admin/src/i18n/pages/zh.ts create mode 100644 apps/api/prisma/migrations/20260612140000_match_correct_score_enabled/migration.sql create mode 100644 apps/api/scripts/build-wc2026-seed-json.mjs create mode 100644 apps/api/src/domains/catalog/catalog-archive.service.spec.ts create mode 100644 apps/api/src/domains/catalog/catalog-archive.service.ts create mode 100644 apps/api/src/domains/catalog/matches.service.spec.ts create mode 100644 apps/api/src/domains/settlement/settlement.service.spec.ts create mode 100644 apps/api/src/infrastructure/database/database-init.ts create mode 100644 apps/api/src/infrastructure/database/reset-and-seed-cli.ts create mode 100644 apps/api/src/infrastructure/database/seed-catalog.ts create mode 100644 apps/api/src/infrastructure/database/seed-data/index.ts create mode 100644 apps/api/src/infrastructure/database/seed-data/wc2026-group-stage.json create mode 100644 apps/api/src/infrastructure/database/seed-data/wc2026-zhibo-team-map.ts create mode 100644 apps/api/src/infrastructure/database/seed-demo-markets.ts create mode 100644 apps/api/src/shared/uploads/delete-upload-file.ts create mode 100644 scripts/prod-init-db.ps1 create mode 100644 scripts/prod-init-db.sh diff --git a/.env.docker.example b/.env.docker.example index abddddb..650fb99 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -10,8 +10,11 @@ JWT_PLAYER_EXPIRES=24h JWT_ADMIN_EXPIRES=2h JWT_AGENT_EXPIRES=8h -# 首次部署写入演示账号与默认数据,完成后改为 false +# 首次部署写入默认数据(生产环境仅 admin + WC2026 赛事;本地开发含 agent1/player1 演示账号) +# 灌完数据后改为 false 并重启 api SEED_DATABASE=true +# 可选:覆盖 admin 初始密码(仅 seed/重置时生效) +# ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere # 对外端口(宝塔/Nginx 反代时可改回 80/443 或保留以下端口) API_PORT=3000 diff --git a/apps/admin/package.json b/apps/admin/package.json index 951ebbd..266cd96 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -7,9 +7,12 @@ "scripts": { "dev": "vite --port 5174 --host", "build": "vue-tsc -b && vite build", + "build:analyze": "vue-tsc -b && vite build --mode analyze && node scripts/report-chunk-sizes.mjs", + "build:report": "node scripts/report-chunk-sizes.mjs", "preview": "vite preview" }, "dependencies": { + "@thebet365/shared": "workspace:*", "axios": "^1.7.9", "echarts": "^6.1.0", "element-plus": "^2.9.3", @@ -20,6 +23,7 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", + "rollup-plugin-visualizer": "^7.0.1", "typescript": "^5.7.3", "vite": "^6.0.11", "vue-tsc": "^2.2.0" diff --git a/apps/admin/scripts/report-chunk-sizes.mjs b/apps/admin/scripts/report-chunk-sizes.mjs new file mode 100644 index 0000000..9a37816 --- /dev/null +++ b/apps/admin/scripts/report-chunk-sizes.mjs @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { gzipSync } from 'zlib'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(__dirname, '../dist/assets'); + +if (!fs.existsSync(dist)) { + console.error('dist/assets not found — run pnpm build first'); + process.exit(1); +} + +const files = fs + .readdirSync(dist) + .filter((f) => f.endsWith('.js')) + .map((f) => { + const buf = fs.readFileSync(path.join(dist, f)); + return { + file: f, + raw: buf.length, + gzip: gzipSync(buf).length, + }; + }) + .sort((a, b) => b.gzip - a.gzip); + +console.log('Admin bundle chunk sizes (gzip):'); +for (const row of files) { + console.log(`${(row.gzip / 1024).toFixed(1)} KB gzip ${(row.raw / 1024).toFixed(1)} KB raw ${row.file}`); +} +const totalGzip = files.reduce((s, r) => s + r.gzip, 0); +console.log(`\nTotal JS (all chunks): ${(totalGzip / 1024).toFixed(1)} KB gzip`); diff --git a/apps/admin/scripts/split-i18n.mjs b/apps/admin/scripts/split-i18n.mjs new file mode 100644 index 0000000..c786ce4 --- /dev/null +++ b/apps/admin/scripts/split-i18n.mjs @@ -0,0 +1,51 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '../src/i18n'); +const am = fs.readFileSync(path.join(root, 'admin-messages.ts'), 'utf8'); +const ap = fs.readFileSync(path.join(root, 'admin-pages.ts'), 'utf8'); + +const zhStart = am.indexOf('const zh:'); +const enStart = am.indexOf('const en:'); +const msStart = am.indexOf('const ms:'); +const exportStart = am.indexOf('export const adminMessages'); +const header = am.slice(0, zhStart); +const zhBody = am.slice(zhStart, enStart).replace(/^const zh/, 'const messages'); +const enBody = am.slice(enStart, msStart).replace(/^const en/, 'const messages'); +const msBody = am.slice(msStart, exportStart).replace(/^const ms/, 'const messages'); + +const apZhEnd = ap.indexOf('export const adminPagesEn'); +function toDefaultExport(block, constName) { + const body = block + .replace(`export const ${constName}`, 'const adminPages') + .trimEnd(); + return `${body}\n\nexport default adminPages;\n`; +} +const apZh = toDefaultExport(ap.slice(0, apZhEnd), 'adminPagesZh'); +const apEn = toDefaultExport(ap.slice(apZhEnd), 'adminPagesEn'); + +fs.mkdirSync(path.join(root, 'bundles'), { recursive: true }); +fs.mkdirSync(path.join(root, 'pages'), { recursive: true }); +fs.writeFileSync(path.join(root, 'pages/zh.ts'), `${apZh.trim()}\n`); +fs.writeFileSync(path.join(root, 'pages/en.ts'), `${apEn.trim()}\n`); + +const bundles = [ + ['zh-CN', zhBody, "import adminPages from '../pages/zh';"], + ['en-US', enBody, "import adminPages from '../pages/en';"], + ['ms-MY', msBody, "import adminPages from '../admin-pages-ms';"], +]; + +for (const [loc, body, pagesImport] of bundles) { + const content = `${pagesImport}\n${body.replace(/\.\.\.adminPages\w+/g, '...adminPages')}\nexport default { ...messages, ...adminPages };\n`; + fs.writeFileSync(path.join(root, 'bundles', `${loc}.ts`), content); +} + +const typesOnly = header + .replace(/import \{ adminPagesEn, adminPagesZh \} from '\.\/admin-pages';\r?\n/, '') + .replace(/import \{ adminPagesMs \} from '\.\/admin-pages-ms';\r?\n/, '') + + `export const adminMessages: Record> = {\n 'zh-CN': {},\n 'en-US': {},\n 'ms-MY': {},\n};\n`; + +fs.writeFileSync(path.join(root, 'admin-messages.ts'), typesOnly); +console.log('split i18n ok'); diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index 2ed3a46..3b187dd 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -1,23 +1,38 @@ diff --git a/apps/admin/src/components/AdminPlayerRowActions.vue b/apps/admin/src/components/AdminPlayerRowActions.vue index 2a573b6..c571be9 100644 --- a/apps/admin/src/components/AdminPlayerRowActions.vue +++ b/apps/admin/src/components/AdminPlayerRowActions.vue @@ -27,6 +27,7 @@ const emit = defineEmits<{ deposit: []; withdraw: []; freeze: []; + delete: []; }>(); const { t } = useAdminLocale(); @@ -58,6 +59,9 @@ const { t } = useAdminLocale(); {{ t('common.unfreeze') }} + + {{ t('common.delete') }} + @@ -79,4 +86,7 @@ const { t } = useAdminLocale(); :deep(.action-warning) { color: var(--el-color-warning); } +:deep(.action-danger) { + color: var(--el-color-danger); +} diff --git a/apps/admin/src/components/InviteHistoryPanel.vue b/apps/admin/src/components/InviteHistoryPanel.vue index b2decd4..18911f0 100644 --- a/apps/admin/src/components/InviteHistoryPanel.vue +++ b/apps/admin/src/components/InviteHistoryPanel.vue @@ -287,7 +287,6 @@ watch(
+import { ref, watch, computed } from 'vue'; +import { ElMessage } from 'element-plus'; +import api from '../api'; +import { useAdminLocale } from '../composables/useAdminLocale'; + +type LeagueBlockingMatch = { + id: string; + status: string; + isOutright: boolean; + title: string; + pendingCount: number; +}; + +type LeagueArchivePreview = { + leagueId: string; + canArchive: boolean; + blockingMatches: LeagueBlockingMatch[]; + totalPendingBets: number; +}; + +const props = defineProps<{ + modelValue: boolean; + leagueId: string; + leagueName?: string; +}>(); + +const emit = defineEmits<{ + 'update:modelValue': [value: boolean]; + archived: []; +}>(); + +const { t } = useAdminLocale(); +const loading = ref(false); +const submitting = ref(false); +const preview = ref(null); + +const displayName = computed(() => props.leagueName || props.leagueId); + +watch( + () => [props.modelValue, props.leagueId] as const, + ([open]) => { + if (open && props.leagueId) void loadPreview(); + }, +); + +function close() { + emit('update:modelValue', false); +} + +async function loadPreview() { + loading.value = true; + preview.value = null; + try { + const { data } = await api.get(`/admin/leagues/${props.leagueId}/archive-preview`); + preview.value = data.data as LeagueArchivePreview; + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? t('msg.load_failed')); + close(); + } finally { + loading.value = false; + } +} + +async function confirmArchive() { + if (!preview.value?.canArchive) return; + submitting.value = true; + try { + await api.post(`/admin/leagues/${props.leagueId}/archive`); + ElMessage.success(t('archive.league_done')); + close(); + emit('archived'); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed')); + } finally { + submitting.value = false; + } +} + +function matchTypeLabel(row: LeagueBlockingMatch) { + return row.isOutright ? t('nav.outrights') : t('match.col.matchup'); +} + + + + + diff --git a/apps/admin/src/components/MatchArchiveDialog.vue b/apps/admin/src/components/MatchArchiveDialog.vue new file mode 100644 index 0000000..0414e3f --- /dev/null +++ b/apps/admin/src/components/MatchArchiveDialog.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/apps/admin/src/components/dashboard/EChartPanel.vue b/apps/admin/src/components/dashboard/EChartPanel.vue index 15282d5..5d5594e 100644 --- a/apps/admin/src/components/dashboard/EChartPanel.vue +++ b/apps/admin/src/components/dashboard/EChartPanel.vue @@ -1,6 +1,10 @@ @@ -609,6 +657,13 @@ function isLeagueExpanded(id: string) { {{ t('user.btn.create') }} + +
@@ -780,4 +835,5 @@ function isLeagueExpanded(id: string) { :deep(.logo-url-field) { width: 100%; } + diff --git a/apps/admin/src/views/PaymentMethods.vue b/apps/admin/src/views/PaymentMethods.vue index 2f2678a..e29610f 100644 --- a/apps/admin/src/views/PaymentMethods.vue +++ b/apps/admin/src/views/PaymentMethods.vue @@ -17,7 +17,6 @@ interface PaymentMethod { displayName: string | null; sortOrder: number; isActive: boolean; - showOnPlayer: boolean; createdAt: string; translations?: { displayName?: Record; @@ -42,7 +41,6 @@ const form = ref({ displayName: '', sortOrder: 0, isActive: true, - showOnPlayer: true, translations: { displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' }, bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' }, @@ -76,7 +74,6 @@ function openCreate() { displayName: '', sortOrder: 0, isActive: true, - showOnPlayer: true, translations: { displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' }, bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' }, @@ -98,7 +95,6 @@ function openEdit(row: PaymentMethod) { displayName: row.displayName ?? '', sortOrder: row.sortOrder, isActive: row.isActive, - showOnPlayer: row.showOnPlayer, translations: { displayName: { 'zh-CN': t.displayName?.['zh-CN'] ?? '', @@ -153,9 +149,9 @@ async function handleDelete(row: PaymentMethod) { } catch { /* */ } } -async function toggleField(row: PaymentMethod, field: 'isActive' | 'showOnPlayer') { +async function toggleActive(row: PaymentMethod) { try { - await api.put(`/admin/payment-methods/${row.id}`, { [field]: !row[field] }); + await api.put(`/admin/payment-methods/${row.id}`, { isActive: !row.isActive }); await fetchList(); } catch { /* */ } } @@ -205,7 +201,6 @@ onMounted(fetchList); {{ t('deposit.details') }} {{ t('deposit.sort') }} {{ t('deposit.active') }} - {{ t('deposit.show_player') }} {{ t('common.actions') }} @@ -227,17 +222,11 @@ onMounted(fetchList); {{ row.isActive ? 'ON' : 'OFF' }} - - {{ row.showOnPlayer ? 'ON' : 'OFF' }} - - - @@ -321,7 +310,6 @@ onMounted(fetchList);
-
diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index 8c13d97..68e5666 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -1,10 +1,13 @@ {{ t('match.outright.setup') }} @@ -161,6 +188,13 @@ watch(

{{ t('outright.expand_no_teams') }}

+ diff --git a/apps/admin/src/views/matches/MatchEventEditor.vue b/apps/admin/src/views/matches/MatchEventEditor.vue index 7574dad..cf02575 100644 --- a/apps/admin/src/views/matches/MatchEventEditor.vue +++ b/apps/admin/src/views/matches/MatchEventEditor.vue @@ -234,6 +234,11 @@ async function saveMeta() { + + + + + diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index cd7ab58..b9c2dd3 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -1,14 +1,25 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import { resolve } from 'path'; +import { visualizer } from 'rollup-plugin-visualizer'; -export default defineConfig({ - plugins: [vue()], +export default defineConfig(({ mode }) => { + const analyze = process.env.ANALYZE === '1' || mode === 'analyze'; + + return { + plugins: [ + vue(), + analyze && + visualizer({ + filename: 'dist/stats.html', + gzipSize: true, + brotliSize: true, + open: false, + }), + ].filter(Boolean), resolve: { - // 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失) extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'], alias: { - // shared 的 dist 为 CommonJS,Vite 无法按命名导出加载;直连源码 '@thebet365/shared': resolve(__dirname, '../../packages/shared/src/index.ts'), }, dedupe: ['echarts', 'vue-echarts', 'vue'], @@ -16,6 +27,32 @@ export default defineConfig({ optimizeDeps: { include: ['echarts', 'vue-echarts'], }, + build: { + chunkSizeWarningLimit: 600, + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + if (id.includes('/src/i18n/bundles/')) { + const m = id.match(/bundles\/(zh-CN|en-US|ms-MY)/); + if (m) return `i18n-${m[1]}`; + } + if (id.includes('/src/i18n/pages/')) return 'i18n-pages'; + if (id.includes('echarts-setup') || id.includes('vue-echarts') || id.includes('node_modules/echarts')) { + return 'echarts'; + } + return undefined; + } + if (id.includes('element-plus')) return 'element-plus'; + if (id.includes('echarts')) return 'echarts'; + if (id.includes('vue-i18n')) return 'vue-i18n'; + if (id.includes('vue-router')) return 'vue-router'; + if (id.includes('vue/') || id.includes('vue/dist')) return 'vue'; + return undefined; + }, + }, + }, + }, publicDir: resolve(__dirname, '../../packages/shared/public'), server: { port: 5174, @@ -24,4 +61,5 @@ export default defineConfig({ '/uploads': { target: 'http://localhost:3000', changeOrigin: true }, }, }, +}; }); diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index 113593f..a6c074c 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -4,6 +4,12 @@ "sourceRoot": "src", "compilerOptions": { "deleteOutDir": false, - "tsConfigPath": "tsconfig.build.json" + "tsConfigPath": "tsconfig.build.json", + "assets": [ + { + "include": "infrastructure/database/seed-data/**/*.json", + "outDir": "dist" + } + ] } } diff --git a/apps/api/package.json b/apps/api/package.json index 6097aca..12f7f86 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,8 @@ "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy && prisma generate", "db:seed": "ts-node prisma/seed.ts", + "db:reset": "ts-node src/infrastructure/database/reset-and-seed-cli.ts --yes --production", + "db:reset:dev": "ts-node src/infrastructure/database/reset-and-seed-cli.ts --yes --dev", "db:studio": "prisma studio" }, "dependencies": { diff --git a/apps/api/prisma/migrations/20260612140000_match_correct_score_enabled/migration.sql b/apps/api/prisma/migrations/20260612140000_match_correct_score_enabled/migration.sql new file mode 100644 index 0000000..644df54 --- /dev/null +++ b/apps/api/prisma/migrations/20260612140000_match_correct_score_enabled/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "correct_score_enabled" BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index dc14269..c12b0fc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -318,6 +318,7 @@ model Match { venueJson Json? @map("venue_json") kickoffJson Json? @map("kickoff_json") externalStatus String? @map("external_status") @db.VarChar(32) + correctScoreEnabled Boolean @default(true) @map("correct_score_enabled") createdBy BigInt? @map("created_by") updatedBy BigInt? @map("updated_by") createdAt DateTime @default(now()) @map("created_at") diff --git a/apps/api/scripts/build-wc2026-seed-json.mjs b/apps/api/scripts/build-wc2026-seed-json.mjs new file mode 100644 index 0000000..3e87ec9 --- /dev/null +++ b/apps/api/scripts/build-wc2026-seed-json.mjs @@ -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 = { +${mapLines} +}; + +/** zhibo 球队 logo(seed 时写入 teams.logo_url) */ +export const WC2026_TEAM_LOGO_BY_CODE: Record = { +${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); +} diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 20622fe..d2deb04 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -27,6 +27,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 { CatalogArchiveService } from '../../domains/catalog/catalog-archive.service'; import { OutrightService } from '../../domains/catalog/outright.service'; import { MarketsService } from '../../domains/odds/markets.service'; import { SettlementService } from '../../domains/settlement/settlement.service'; @@ -501,6 +502,10 @@ class CreatePlatformMatchDto { @IsOptional() @IsString() awayTeamLogoUrl?: string; + + @IsOptional() + @IsBoolean() + correctScoreEnabled?: boolean; } class UpdatePlatformMatchDto { @@ -554,6 +559,10 @@ class UpdatePlatformMatchDto { @IsOptional() @IsString() awayTeamLogoUrl?: string; + + @IsOptional() + @IsBoolean() + correctScoreEnabled?: boolean; } class ReopenMatchDto { @@ -562,6 +571,16 @@ class ReopenMatchDto { startTime?: string; } +class ArchiveMatchDto { + @IsOptional() + @IsBoolean() + force?: boolean; + + @IsOptional() + @IsBoolean() + refundPendingBets?: boolean; +} + class BatchMatchOddsDto { @IsArray() updates!: OutrightOddsUpdateItemDto[]; @@ -924,6 +943,7 @@ export class AdminController { private agents: AgentsService, private wallet: WalletService, private matches: MatchesService, + private catalogArchive: CatalogArchiveService, private outright: OutrightService, private markets: MarketsService, private settlement: SettlementService, @@ -948,6 +968,31 @@ export class AdminController { return jsonResponse(overview); } + @Get('users/page-init') + @RequirePermissions(P.agentsView) + async getUsersPageInit() { + const [ + playerSettings, + bettingLimits, + hierarchySettings, + platformDirect, + agentLevelCounts, + ] = await Promise.all([ + this.systemConfig.getPlayerAccountSettings(), + this.bettingLimits.getLimits(), + this.systemConfig.getAgentHierarchySettings(), + this.systemConfig.getPlatformDirectCashbackSettings(), + this.agents.countAgentsByLevel(), + ]); + return jsonResponse({ + playerSettings, + bettingLimits, + hierarchySettings, + platformDirect, + agentLevelCounts, + }); + } + @Get('users/settings/account') @RequirePermissions(P.settings) async getPlayerAccountSettings() { @@ -1126,6 +1171,23 @@ export class AdminController { return jsonResponse(detail); } + @Delete('users/:id') + @RequirePermissions(P.usersCreate) + async deletePlayer( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + ) { + await this.users.softDeletePlayer(BigInt(id)); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'DELETE_PLAYER', + module: 'USERS', + targetId: id, + }); + return jsonResponse({ deleted: true }); + } + @Post('users') @RequirePermissions(P.usersCreate) async createPlayer( @@ -1184,9 +1246,20 @@ export class AdminController { @Get('agents/options') @RequirePermissions(P.agentsView) - async listAgentOptions() { + async listAgentOptions( + @Query('keyword') keyword?: string, + @Query('limit') limit?: string, + ) { + const take = Math.min(100, Math.max(1, parseInt(limit ?? '50', 10) || 50)); + const kw = keyword?.trim(); const agents = await this.prisma.user.findMany({ - where: { userType: 'AGENT', deletedAt: null }, + where: { + userType: 'AGENT', + deletedAt: null, + ...(kw + ? { username: { contains: kw, mode: 'insensitive' as const } } + : {}), + }, select: { id: true, username: true, @@ -1194,6 +1267,7 @@ export class AdminController { parent: { select: { username: true } }, }, orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }], + take, }); return jsonResponse( agents.map((a) => ({ @@ -1464,6 +1538,20 @@ export class AdminController { return jsonResponse(league); } + @Get('leagues/:leagueId/archive-preview') + @RequirePermissions(P.matches) + async getLeagueArchivePreview(@Param('leagueId') leagueId: string) { + const preview = await this.catalogArchive.getLeagueArchivePreview(BigInt(leagueId)); + return jsonResponse(preview); + } + + @Post('leagues/:leagueId/archive') + @RequirePermissions(P.matches) + async archiveLeague(@Param('leagueId') leagueId: string) { + const result = await this.catalogArchive.archiveLeague(BigInt(leagueId)); + return jsonResponse(result); + } + @Get('leagues') @RequirePermissions(P.matches, P.reports) async listLeagues( @@ -1499,13 +1587,17 @@ export class AdminController { @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('locale') locale?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, ) { - const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), { + const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), { status: status || undefined, keyword: keyword || undefined, locale: locale || undefined, + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20, }); - return jsonResponse({ items }); + return jsonResponse(result); } @Post('teams') @@ -1581,6 +1673,7 @@ export class AdminController { groupName: dto.groupName, homeTeamLogoUrl: dto.homeTeamLogoUrl, awayTeamLogoUrl: dto.awayTeamLogoUrl, + correctScoreEnabled: dto.correctScoreEnabled, updatedBy: operatorId, }); await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId); @@ -1594,6 +1687,28 @@ export class AdminController { return jsonResponse({ deleted: true }); } + @Get('matches/:id/archive-preview') + @RequirePermissions(P.matches) + async getMatchArchivePreview(@Param('id') id: string) { + const preview = await this.catalogArchive.getMatchArchivePreview(BigInt(id)); + return jsonResponse(preview); + } + + @Post('matches/:id/archive') + @RequirePermissions(P.matches) + async archiveMatch(@Param('id') id: string, @Body() dto: ArchiveMatchDto) { + const matchId = BigInt(id); + const result = await this.catalogArchive.archiveMatch(matchId, { + force: dto.force === true, + }); + let voidedCount = 0; + if (dto.refundPendingBets) { + const voided = await this.settlement.voidMatchBets(matchId); + voidedCount = voided.voidedCount; + } + return jsonResponse({ ...result, voidedCount }); + } + @Post('matches') @RequirePermissions(P.matches) async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) { @@ -1612,6 +1727,7 @@ export class AdminController { awayTeamMs: dto.awayTeamMs, startTime: new Date(dto.startTime), isHot: dto.isHot, + correctScoreEnabled: dto.correctScoreEnabled, displayOrder: dto.displayOrder, matchName: dto.matchName, stage: dto.stage, @@ -1642,6 +1758,13 @@ export class AdminController { return jsonResponse(match); } + @Post('matches/:id/unpublish') + @RequirePermissions(P.matches) + async unpublishMatch(@Param('id') id: string) { + const match = await this.matches.unpublishMatch(BigInt(id)); + return jsonResponse(match); + } + @Post('matches/:id/close') @RequirePermissions(P.matches) async closeMatch(@Param('id') id: string) { @@ -1880,6 +2003,27 @@ export class AdminController { return jsonResponse(data); } + @Get('matches/:id/settlement/summary') + @RequirePermissions(P.settlement, P.reports) + async getMatchSettlementSummary(@Param('id') id: string) { + const data = await this.settlement.getMatchBetStatsSummary(BigInt(id)); + return jsonResponse(data); + } + + @Get('matches/:id/settlement/bets') + @RequirePermissions(P.settlement, P.reports) + async getMatchSettlementBets( + @Param('id') id: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const data = await this.settlement.getMatchBetStatsBets(BigInt(id), { + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10, + }); + return jsonResponse(data); + } + @Get('matches/:id/settlement/stats') @RequirePermissions(P.settlement, P.reports) async getMatchSettlementStats( @@ -2495,4 +2639,30 @@ export class AdminController { ); return jsonResponse(result); } + + @Post('deposit-orders/:id/reopen') + @RequirePermissions(P.depositReview) + async reopenDepositOrder( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + ) { + const result = await this.depositService.reopenDepositOrderForReview( + BigInt(id), + operatorId, + ); + return jsonResponse(result); + } + + @Delete('deposit-orders/:id') + @RequirePermissions(P.depositReview) + async deleteDepositOrder( + @CurrentUser('id') operatorId: bigint, + @Param('id') id: string, + ) { + const result = await this.depositService.deleteDepositOrder( + BigInt(id), + operatorId, + ); + return jsonResponse(result); + } } diff --git a/apps/api/src/applications/agent/agent-portal.controller.ts b/apps/api/src/applications/agent/agent-portal.controller.ts index f1c688d..4dc7f47 100644 --- a/apps/api/src/applications/agent/agent-portal.controller.ts +++ b/apps/api/src/applications/agent/agent-portal.controller.ts @@ -3,6 +3,7 @@ import { Get, Post, Put, + Delete, Body, Param, Query, @@ -176,9 +177,18 @@ export class AgentPortalController { } @Get('players') - async listPlayers(@CurrentUser('id') agentId: bigint) { - const players = await this.agents.getDirectPlayers(agentId); - return jsonResponse(players); + async listPlayers( + @CurrentUser('id') agentId: bigint, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const result = await this.agents.getDirectPlayers(agentId, { + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize + ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) + : 100, + }); + return jsonResponse(result.items); } @Get('players/scoped') @@ -261,6 +271,15 @@ export class AgentPortalController { return jsonResponse(detail); } + @Delete('players/:id') + async deletePlayer( + @CurrentUser('id') agentId: bigint, + @Param('id') playerId: string, + ) { + await this.agents.deleteDirectPlayer(agentId, BigInt(playerId)); + return jsonResponse({ deleted: true }); + } + @Get('agents') async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) { const maxLevel = await this.agents.getMaxAgentLevel(); @@ -401,13 +420,23 @@ export class AgentPortalController { @CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Param('id') subAgentId: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, ) { if (!(await this.canManageSubAgents(level))) { - return jsonResponse([]); + return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 }); } - await this.agents.assertDescendantAgent(agentId, BigInt(subAgentId)); - const players = await this.agents.getPortalAgentDirectPlayers(agentId, BigInt(subAgentId)); - return jsonResponse(players); + const result = await this.agents.getPortalAgentDirectPlayers( + agentId, + BigInt(subAgentId), + { + page: page ? Math.max(1, parseInt(page, 10) || 1) : 1, + pageSize: pageSize + ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) + : 20, + }, + ); + return jsonResponse(result); } @Post('agents/:id/credit') diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index afc00ab..199b0f9 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -806,6 +806,34 @@ export class AgentsService { return this.getDirectPlayerDetail(agentId, playerId); } + async deleteDirectPlayer(agentId: bigint, playerId: bigint) { + await this.requireDirectPlayer(agentId, playerId); + + const betCount = await this.prisma.bet.count({ + where: { + userId: playerId, + status: 'PENDING', + }, + }); + if (betCount > 0) { + throw appBadRequest('PLAYER_HAS_PENDING_BETS'); + } + + const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } }); + if (wallet) { + const available = new Decimal(wallet.availableBalance); + const frozen = new Decimal(wallet.frozenBalance); + if (available.gt(0) || frozen.gt(0)) { + throw appBadRequest('PLAYER_HAS_BALANCE'); + } + } + + return this.prisma.user.update({ + where: { id: playerId }, + data: { deletedAt: new Date(), status: 'SUSPENDED' }, + }); + } + async listAgentsAdmin(params?: { page?: number; pageSize?: number; @@ -1696,9 +1724,18 @@ export class AgentsService { return user; } - async getPortalAgentDirectPlayers(rootAgentId: bigint, targetAgentId: bigint) { + async getPortalAgentDirectPlayers( + rootAgentId: bigint, + targetAgentId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { await this.assertDescendantAgent(rootAgentId, targetAgentId); - const players = await this.getDirectPlayers(targetAgentId); + const page = Math.max(1, opts?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20)); + const { items: players, total } = await this.getDirectPlayers(targetAgentId, { + page, + pageSize, + }); const profile = await this.prisma.agentProfile.findUnique({ where: { userId: targetAgentId }, select: { @@ -1714,7 +1751,7 @@ export class AgentsService { players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })), parentCashbackMap, ); - return players.map((p) => ({ + const mapped = players.map((p) => ({ ...p, parentAgentId: targetKey, parentAgentUsername, @@ -1722,18 +1759,30 @@ export class AgentsService { inChain: true, isDirect: targetKey === rootKey, })); + return { items: mapped, total, page, pageSize }; } - async getDirectPlayers(agentId: bigint) { - const rows = await this.prisma.user.findMany({ - where: { parentId: agentId, userType: 'PLAYER', deletedAt: null }, - include: { - wallet: true, - usedInvite: { select: { code: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); - return rows.map((u) => ({ + async getDirectPlayers( + agentId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { + const where = { parentId: agentId, userType: 'PLAYER' as const, deletedAt: null }; + const page = Math.max(1, opts?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20)); + const [total, rows] = await Promise.all([ + this.prisma.user.count({ where }), + this.prisma.user.findMany({ + where, + include: { + wallet: true, + usedInvite: { select: { code: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + const items = rows.map((u) => ({ id: u.id.toString(), username: u.username, status: u.status, @@ -1746,6 +1795,7 @@ export class AgentsService { } : undefined, })); + return { items, total, page, pageSize }; } async getChildAgents(agentId: bigint) { diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index b12202d..0b31c49 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -68,6 +68,14 @@ export class BetsService { if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) { throw appBadRequest('PRE_MATCH_ONLY'); } + // Block correct-score bets when the match has the CS toggle turned off + const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']; + if ( + CS_MARKET_TYPES.includes(selection.market.marketType) && + !(selection.market.match.correctScoreEnabled ?? true) + ) { + throw appBadRequest('CORRECT_SCORE_DISABLED'); + } if (selection.oddsVersion !== oddsVersion) { throw appBadRequest('ODDS_CHANGED'); } diff --git a/apps/api/src/domains/catalog/catalog-archive.service.spec.ts b/apps/api/src/domains/catalog/catalog-archive.service.spec.ts new file mode 100644 index 0000000..d1e9be7 --- /dev/null +++ b/apps/api/src/domains/catalog/catalog-archive.service.spec.ts @@ -0,0 +1,174 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { CatalogArchiveService } from './catalog-archive.service'; + +describe('CatalogArchiveService', () => { + const matchId = BigInt(10); + const leagueId = BigInt(1); + + let prisma: { + match: { findFirst: jest.Mock; update: jest.Mock; findMany: jest.Mock; updateMany: jest.Mock }; + league: { findFirst: jest.Mock; update: jest.Mock }; + bet: { findMany: jest.Mock }; + settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock }; + market: { updateMany: jest.Mock }; + marketSelection: { updateMany: jest.Mock }; + entityTranslation: { findFirst: jest.Mock }; + $transaction: jest.Mock; + }; + let matches: { betStatsForMatches: jest.Mock }; + let service: CatalogArchiveService; + + beforeEach(() => { + prisma = { + match: { + findFirst: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + updateMany: jest.fn(), + }, + league: { findFirst: jest.fn(), update: jest.fn() }, + bet: { findMany: jest.fn().mockResolvedValue([]) }, + settlementBatch: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]) }, + market: { updateMany: jest.fn() }, + marketSelection: { updateMany: jest.fn() }, + entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) }, + $transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise) => fn(prisma)), + }; + matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) }; + service = new CatalogArchiveService(prisma as never, matches as never); + }); + + const baseMatch = { + id: matchId, + status: 'PUBLISHED', + isOutright: false, + matchName: null, + homeTeamId: BigInt(2), + awayTeamId: BigInt(3), + homeTeam: { code: 'A' }, + awayTeam: { code: 'B' }, + league: { id: leagueId }, + }; + + it('preview flags pending bets and unsettled match', async () => { + prisma.match.findFirst.mockResolvedValue(baseMatch); + prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(50) }, { stake: new Decimal(25) }]); + + const preview = await service.getMatchArchivePreview(matchId); + + expect(preview.pendingBetCount).toBe(2); + expect(preview.pendingStake).toBe('75'); + expect(preview.requiresForce).toBe(true); + expect(preview.warnings).toEqual(expect.arrayContaining(['PENDING_BETS', 'UNSETTLED_MATCH'])); + }); + + it('archive without force throws ARCHIVE_BLOCKED when warnings exist', async () => { + prisma.match.findFirst.mockResolvedValue(baseMatch); + prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(10) }]); + + await expect(service.archiveMatch(matchId, { force: false })).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'ARCHIVE_BLOCKED' }), + }); + }); + + it('archive with force soft-deletes and cancels match', async () => { + prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' }); + prisma.match.update.mockResolvedValue({}); + + const result = await service.archiveMatch(matchId, { force: true }); + + expect(result.matchId).toBe(matchId.toString()); + expect(prisma.marketSelection.updateMany).toHaveBeenCalled(); + expect(prisma.market.updateMany).toHaveBeenCalled(); + expect(prisma.match.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: matchId }, + data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }), + }), + ); + }); + + it('league preview blocks when child match is not terminal', async () => { + prisma.league.findFirst.mockResolvedValue({ id: leagueId }); + prisma.match.findMany.mockResolvedValue([ + { + id: matchId, + status: 'CLOSED', + isOutright: false, + matchName: null, + homeTeamId: BigInt(2), + awayTeamId: BigInt(3), + homeTeam: { code: 'A' }, + awayTeam: { code: 'B' }, + }, + ]); + matches.betStatsForMatches.mockResolvedValue( + new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]), + ); + + const preview = await service.getLeagueArchivePreview(leagueId); + + expect(preview.canArchive).toBe(false); + expect(preview.blockingMatches).toHaveLength(1); + expect(preview.blockingMatches[0].status).toBe('CLOSED'); + }); + + it('league archive cascades when all children are settled', async () => { + prisma.league.findFirst.mockResolvedValue({ id: leagueId }); + prisma.match.findMany + .mockResolvedValueOnce([ + { + id: matchId, + status: 'SETTLED', + isOutright: false, + matchName: null, + homeTeamId: BigInt(2), + awayTeamId: BigInt(3), + homeTeam: { code: 'A' }, + awayTeam: { code: 'B' }, + }, + ]) + .mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]); + matches.betStatsForMatches.mockResolvedValue( + new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]), + ); + + const result = await service.archiveLeague(leagueId); + + expect(result.leagueId).toBe(leagueId.toString()); + expect(prisma.match.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: { in: [matchId] } }, + data: { deletedAt: expect.any(Date) }, + }), + ); + expect(prisma.league.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { deletedAt: expect.any(Date), isActive: false }, + }), + ); + }); + + it('league archive throws LEAGUE_ARCHIVE_NOT_READY when blocked', async () => { + prisma.league.findFirst.mockResolvedValue({ id: leagueId }); + prisma.match.findMany.mockResolvedValue([ + { + id: matchId, + status: 'PUBLISHED', + isOutright: false, + matchName: null, + homeTeamId: BigInt(2), + awayTeamId: BigInt(3), + homeTeam: { code: 'A' }, + awayTeam: { code: 'B' }, + }, + ]); + matches.betStatsForMatches.mockResolvedValue( + new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]), + ); + + await expect(service.archiveLeague(leagueId)).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'LEAGUE_ARCHIVE_NOT_READY' }), + }); + }); +}); diff --git a/apps/api/src/domains/catalog/catalog-archive.service.ts b/apps/api/src/domains/catalog/catalog-archive.service.ts new file mode 100644 index 0000000..f56e227 --- /dev/null +++ b/apps/api/src/domains/catalog/catalog-archive.service.ts @@ -0,0 +1,302 @@ +import { Injectable } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error'; +import { MatchesService } from './matches.service'; + +const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']); + +export type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH'; + +export type MatchArchivePreview = { + matchId: string; + matchStatus: string; + isOutright: boolean; + title: string; + pendingBetCount: number; + pendingStake: string; + hasPreviewSettlementBatch: boolean; + requiresForce: boolean; + warnings: MatchArchiveWarning[]; +}; + +export type LeagueBlockingMatch = { + id: string; + status: string; + isOutright: boolean; + title: string; + pendingCount: number; +}; + +export type LeagueArchivePreview = { + leagueId: string; + canArchive: boolean; + blockingMatches: LeagueBlockingMatch[]; + totalPendingBets: number; +}; + +@Injectable() +export class CatalogArchiveService { + constructor( + private prisma: PrismaService, + private matches: MatchesService, + ) {} + + async getMatchArchivePreview(matchId: bigint): Promise { + const match = await this.requireActiveMatch(matchId); + const [pending, hasPreviewBatch] = await Promise.all([ + this.pendingBetSummary(matchId), + this.hasPreviewBatch(matchId), + ]); + const warnings = this.buildMatchWarnings(match.status, pending.pendingBetCount, hasPreviewBatch); + const requiresForce = warnings.length > 0; + const title = await this.matchTitle(match); + + return { + matchId: match.id.toString(), + matchStatus: match.status, + isOutright: match.isOutright, + title, + pendingBetCount: pending.pendingBetCount, + pendingStake: pending.pendingStake, + hasPreviewSettlementBatch: hasPreviewBatch, + requiresForce, + warnings, + }; + } + + async archiveMatch(matchId: bigint, opts: { force: boolean }) { + const match = await this.requireActiveMatch(matchId); + if (match.status === 'DRAFT') { + throw appBadRequest('MATCH_DELETE_DRAFT_ONLY'); + } + if (match.status === 'SETTLED') { + throw appBadRequest('ARCHIVE_BLOCKED'); + } + const preview = await this.getMatchArchivePreview(matchId); + if (preview.requiresForce && !opts.force) { + throw appConflict('ARCHIVE_BLOCKED', preview); + } + + const now = new Date(); + await this.prisma.$transaction(async (tx) => { + await tx.marketSelection.updateMany({ + where: { market: { matchId } }, + data: { status: 'CLOSED' }, + }); + await tx.market.updateMany({ + where: { matchId }, + data: { status: 'CLOSED' }, + }); + await tx.match.update({ + where: { id: matchId }, + data: { + deletedAt: now, + status: + match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED', + }, + }); + }); + + return { matchId: matchId.toString(), archivedAt: now.toISOString() }; + } + + async getLeagueArchivePreview(leagueId: bigint): Promise { + const league = await this.prisma.league.findFirst({ + where: { id: leagueId, deletedAt: null }, + }); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); + + const matches = await this.prisma.match.findMany({ + where: { leagueId, deletedAt: null }, + include: { homeTeam: true, awayTeam: true }, + orderBy: [{ isOutright: 'desc' }, { id: 'asc' }], + }); + + const matchIds = matches.map((m) => m.id); + const stats = await this.matches.betStatsForMatches(matchIds); + const previewBatches = matchIds.length + ? await this.prisma.settlementBatch.findMany({ + where: { matchId: { in: matchIds }, status: 'PREVIEW' }, + select: { matchId: true }, + }) + : []; + const previewBatchMatchIds = new Set(previewBatches.map((b) => b.matchId?.toString())); + + const blockingMatches: LeagueBlockingMatch[] = []; + let totalPendingBets = 0; + + for (const match of matches) { + const mid = match.id.toString(); + const stat = stats.get(mid) ?? { betCount: 0, totalStake: '0', pendingCount: 0 }; + totalPendingBets += stat.pendingCount; + + const hasPreview = previewBatchMatchIds.has(mid); + const blocks = this.isLeagueMatchBlocking(match.status, stat.betCount, stat.pendingCount, hasPreview); + if (blocks) { + blockingMatches.push({ + id: mid, + status: match.status, + isOutright: match.isOutright, + title: await this.matchTitle(match), + pendingCount: stat.pendingCount, + }); + } + } + + if (totalPendingBets > 0 && !blockingMatches.length) { + // Pending bets exist but each match might be terminal — still block league archive + for (const match of matches) { + const mid = match.id.toString(); + const stat = stats.get(mid)!; + if (stat.pendingCount > 0) { + blockingMatches.push({ + id: mid, + status: match.status, + isOutright: match.isOutright, + title: await this.matchTitle(match), + pendingCount: stat.pendingCount, + }); + } + } + } + + const canArchive = blockingMatches.length === 0 && totalPendingBets === 0; + + return { + leagueId: leagueId.toString(), + canArchive, + blockingMatches, + totalPendingBets, + }; + } + + async archiveLeague(leagueId: bigint) { + const league = await this.prisma.league.findFirst({ + where: { id: leagueId, deletedAt: null }, + }); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); + + const preview = await this.getLeagueArchivePreview(leagueId); + if (!preview.canArchive) { + throw appConflict('LEAGUE_ARCHIVE_NOT_READY', preview); + } + + const now = new Date(); + await this.prisma.$transaction(async (tx) => { + const matches = await tx.match.findMany({ + where: { leagueId, deletedAt: null }, + select: { id: true, status: true }, + }); + const matchIds = matches.map((m) => m.id); + if (matchIds.length) { + await tx.marketSelection.updateMany({ + where: { market: { matchId: { in: matchIds } } }, + data: { status: 'CLOSED' }, + }); + await tx.market.updateMany({ + where: { matchId: { in: matchIds } }, + data: { status: 'CLOSED' }, + }); + await tx.match.updateMany({ + where: { id: { in: matchIds } }, + data: { deletedAt: now }, + }); + } + await tx.league.update({ + where: { id: leagueId }, + data: { deletedAt: now, isActive: false }, + }); + }); + + return { leagueId: leagueId.toString(), archivedAt: now.toISOString() }; + } + + private async requireActiveMatch(matchId: bigint) { + const match = await this.prisma.match.findFirst({ + where: { id: matchId, deletedAt: null }, + include: { homeTeam: true, awayTeam: true, league: true }, + }); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); + return match; + } + + private async pendingBetSummary(matchId: bigint) { + const bets = await this.prisma.bet.findMany({ + where: { status: 'PENDING', selections: { some: { matchId } } }, + select: { stake: true }, + }); + let pendingStake = new Decimal(0); + for (const bet of bets) { + pendingStake = pendingStake.add(bet.stake); + } + return { + pendingBetCount: bets.length, + pendingStake: pendingStake.toString(), + }; + } + + private async hasPreviewBatch(matchId: bigint) { + const batch = await this.prisma.settlementBatch.findFirst({ + where: { matchId, status: 'PREVIEW' }, + select: { id: true }, + }); + return batch != null; + } + + private buildMatchWarnings( + status: string, + pendingBetCount: number, + hasPreviewBatch: boolean, + ): MatchArchiveWarning[] { + const warnings: MatchArchiveWarning[] = []; + if (pendingBetCount > 0) warnings.push('PENDING_BETS'); + if (!TERMINAL_MATCH_STATUSES.has(status) && status !== 'DRAFT') { + warnings.push('UNSETTLED_MATCH'); + } + if (hasPreviewBatch) warnings.push('PREVIEW_BATCH'); + return warnings; + } + + private isLeagueMatchBlocking( + status: string, + betCount: number, + pendingCount: number, + hasPreviewBatch: boolean, + ): boolean { + if (pendingCount > 0) return true; + if (hasPreviewBatch) return true; + if (TERMINAL_MATCH_STATUSES.has(status)) return false; + if (status === 'DRAFT' && betCount === 0) return false; + return true; + } + + private async matchTitle(match: { + id: bigint; + isOutright: boolean; + matchName: string | null; + homeTeamId: bigint; + awayTeamId: bigint; + homeTeam?: { code: string } | null; + awayTeam?: { code: string } | null; + }) { + if (match.isOutright) { + const name = match.matchName?.trim(); + if (name) return name; + return `Outright #${match.id}`; + } + const [home, away] = await Promise.all([ + this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'), + this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'), + ]); + if (home && away) return `${home} vs ${away}`; + return `${match.homeTeam?.code ?? '?'} vs ${match.awayTeam?.code ?? '?'}`; + } + + private async getTranslationExact(entityType: string, entityId: bigint, locale: string) { + const row = await this.prisma.entityTranslation.findFirst({ + where: { entityType, entityId, locale, fieldName: 'name' }, + }); + return row?.value ?? ''; + } +} diff --git a/apps/api/src/domains/catalog/matches.module.ts b/apps/api/src/domains/catalog/matches.module.ts index fe326cd..2716dac 100644 --- a/apps/api/src/domains/catalog/matches.module.ts +++ b/apps/api/src/domains/catalog/matches.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { MarketsModule } from '../odds/markets.module'; +import { CatalogArchiveService } from './catalog-archive.service'; import { MatchesService } from './matches.service'; import { OutrightService } from './outright.service'; @Module({ imports: [MarketsModule], - providers: [MatchesService, OutrightService], - exports: [MatchesService, OutrightService], + providers: [MatchesService, OutrightService, CatalogArchiveService], + exports: [MatchesService, OutrightService, CatalogArchiveService], }) export class MatchesModule {} diff --git a/apps/api/src/domains/catalog/matches.service.spec.ts b/apps/api/src/domains/catalog/matches.service.spec.ts new file mode 100644 index 0000000..1dcfe9c --- /dev/null +++ b/apps/api/src/domains/catalog/matches.service.spec.ts @@ -0,0 +1,115 @@ +import { MatchesService } from './matches.service'; + +describe('MatchesService publish/unpublish', () => { + const leagueId = BigInt(1); + const matchId = BigInt(10); + + let prisma: { + league: { findFirst: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock }; + match: { findFirst: jest.Mock; update: jest.Mock }; + entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock }; + settlementBatch: { deleteMany: jest.Mock }; + }; + let outright: { syncWithLeaguePublished: jest.Mock }; + let service: MatchesService; + + beforeEach(() => { + prisma = { + league: { + findFirst: jest.fn(), + findUniqueOrThrow: jest.fn(), + update: jest.fn(), + }, + match: { + findFirst: jest.fn(), + update: jest.fn(), + }, + entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) }, + settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + }; + outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) }; + service = new MatchesService(prisma as never, outright as never); + }); + + describe('updatePlatformLeague unpublish', () => { + const baseLeague = { id: leagueId, code: 'EPL', isActive: true, logoUrl: null, displayOrder: 0 }; + + beforeEach(() => { + prisma.league.findFirst.mockResolvedValue(baseLeague); + prisma.league.update.mockResolvedValue({}); + prisma.league.findUniqueOrThrow.mockResolvedValue({ ...baseLeague, isActive: false }); + }); + + it('rejects unpublish when outright is settled', async () => { + prisma.match.findFirst.mockResolvedValue({ status: 'SETTLED' }); + + await expect( + service.updatePlatformLeague(leagueId, { + leagueEn: 'EPL', + leagueZh: '英超', + isActive: false, + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'LEAGUE_UNPUBLISH_SETTLED' }), + }); + expect(prisma.league.update).not.toHaveBeenCalled(); + }); + + it('allows unpublish when outright is not settled', async () => { + prisma.match.findFirst.mockResolvedValue({ status: 'PUBLISHED' }); + + const result = await service.updatePlatformLeague(leagueId, { + leagueEn: 'EPL', + leagueZh: '英超', + isActive: false, + }); + + expect(prisma.league.update).toHaveBeenCalledWith({ + where: { id: leagueId }, + data: { isActive: false }, + }); + expect(result.isPublished).toBe(false); + }); + }); + + describe('unpublishMatch', () => { + const baseMatch = { + id: matchId, + status: 'PUBLISHED', + isOutright: false, + deletedAt: null, + }; + + beforeEach(() => { + prisma.match.findFirst.mockResolvedValue(baseMatch); + prisma.match.update.mockResolvedValue({ ...baseMatch, status: 'DRAFT' }); + }); + + it('unpublishes published fixture to draft', async () => { + await service.unpublishMatch(matchId); + + expect(prisma.match.update).toHaveBeenCalledWith({ + where: { id: matchId }, + data: { status: 'DRAFT', closeTime: null }, + }); + }); + + it('rejects unpublish when settled', async () => { + prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' }); + + await expect(service.unpublishMatch(matchId)).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'MATCH_UNPUBLISH_FORBIDDEN' }), + }); + }); + + it('clears preview settlement batch when pending settlement', async () => { + prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'PENDING_SETTLEMENT' }); + + await service.unpublishMatch(matchId); + + expect(prisma.settlementBatch.deleteMany).toHaveBeenCalledWith({ + where: { matchId, status: 'PREVIEW' }, + }); + }); + }); +}); diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index ae3b69e..0ae79c8 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -23,6 +23,7 @@ import { translationsFromZhiboNames, } from './zhibo-match.mapper'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; +import { OutrightService } from './outright.service'; const OUTRIGHT_PLACEHOLDER_CODE = 'OUT'; @@ -45,7 +46,10 @@ export type ListPublishedOptions = { @Injectable() export class MatchesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private outright: OutrightService, + ) {} async createLeague(code: string, translations: Record) { const league = await this.prisma.league.create({ data: { code } }); @@ -85,6 +89,7 @@ export class MatchesService { awayTeamId: bigint; startTime: Date; isHot?: boolean; + correctScoreEnabled?: boolean; displayOrder?: number; createdBy?: bigint; status?: string; @@ -110,6 +115,7 @@ export class MatchesService { awayTeamId: data.awayTeamId, startTime: data.startTime, isHot: data.isHot ?? false, + correctScoreEnabled: data.correctScoreEnabled ?? true, displayOrder: data.displayOrder ?? 0, createdBy: data.createdBy, status, @@ -307,7 +313,13 @@ export class MatchesService { if (data.displayOrder != null) updates.displayOrder = data.displayOrder; if (data.isActive !== undefined) { if (league.isActive && data.isActive === false) { - throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN'); + const outright = await this.prisma.match.findFirst({ + where: { leagueId, isOutright: true, deletedAt: null }, + select: { status: true }, + }); + if (outright?.status === 'SETTLED') { + throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED'); + } } updates.isActive = data.isActive; } @@ -315,6 +327,10 @@ export class MatchesService { await this.prisma.league.update({ where: { id: leagueId }, data: updates }); } + if (data.isActive === true) { + await this.outright.syncWithLeaguePublished(leagueId); + } + const [en, zh, ms] = await Promise.all([ this.getTranslationExact('LEAGUE', leagueId, 'en-US'), this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'), @@ -537,7 +553,13 @@ export class MatchesService { async listAdminLeagueMatches( leagueId: bigint, - opts: { status?: string; keyword?: string; locale?: string }, + opts: { + status?: string; + keyword?: string; + locale?: string; + page?: number; + pageSize?: number; + }, ) { const where: Prisma.MatchWhereInput = { leagueId, @@ -553,15 +575,22 @@ export class MatchesService { { awayTeam: { code: { contains: kw, mode: 'insensitive' } } }, ]; } - const items = await this.prisma.match.findMany({ - where, - include: { homeTeam: true, awayTeam: true }, - orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }], - }); + const page = Math.max(1, opts.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20)); + const [total, rows] = await Promise.all([ + this.prisma.match.count({ where }), + this.prisma.match.findMany({ + where, + include: { homeTeam: true, awayTeam: true }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); const locale = opts.locale ?? 'zh-CN'; - const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id)); - return Promise.all( - items.map(async (m) => { + const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id)); + const items = await Promise.all( + rows.map(async (m) => { const [homeTeamName, awayTeamName] = await Promise.all([ this.getTranslation('TEAM', m.homeTeamId, locale), this.getTranslation('TEAM', m.awayTeamId, locale), @@ -588,6 +617,7 @@ export class MatchesService { }; }), ); + return { items, total, page, pageSize }; } /** 批量汇总多场关联注单(按 bet 去重计注单数) */ @@ -688,6 +718,7 @@ export class MatchesService { awayTeamMs?: string; startTime: Date; isHot?: boolean; + correctScoreEnabled?: boolean; displayOrder?: number; matchName?: string; stage?: string; @@ -802,6 +833,7 @@ export class MatchesService { awayTeamId: awayTeam.id, startTime: data.startTime, isHot: data.isHot ?? false, + correctScoreEnabled: data.correctScoreEnabled ?? true, displayOrder: data.displayOrder ?? 0, createdBy: data.createdBy, status: 'DRAFT', @@ -849,6 +881,7 @@ export class MatchesService { status: match.status, isOutright: match.isOutright, isHot: match.isHot, + correctScoreEnabled: match.correctScoreEnabled, displayOrder: match.displayOrder, startTime: match.startTime.toISOString(), leagueId: match.leagueId.toString(), @@ -876,6 +909,7 @@ export class MatchesService { htAway: scoreRow.htAwayScore ?? 0, ftHome: scoreRow.ftHomeScore ?? 0, ftAway: scoreRow.ftAwayScore ?? 0, + winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null, } : null, markets: markets.map((m) => ({ @@ -915,6 +949,7 @@ export class MatchesService { groupName?: string; homeTeamLogoUrl?: string; awayTeamLogoUrl?: string; + correctScoreEnabled?: boolean; updatedBy?: bigint; }, ) { @@ -971,6 +1006,7 @@ export class MatchesService { matchName, stage: data.stage !== undefined ? data.stage.trim() || null : match.stage, groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName, + correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled, updatedBy: data.updatedBy, }, }); @@ -1111,6 +1147,26 @@ export class MatchesService { }); } + async unpublishMatch(matchId: bigint) { + const match = await this.requireAdminMatch(matchId); + if (match.isOutright) { + throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS'); + } + const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT']; + if (!allowed.includes(match.status)) { + throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN'); + } + if (match.status === 'PENDING_SETTLEMENT') { + await this.prisma.settlementBatch.deleteMany({ + where: { matchId, status: 'PREVIEW' }, + }); + } + return this.prisma.match.update({ + where: { id: matchId }, + data: { status: 'DRAFT', closeTime: null }, + }); + } + async closeMatch(matchId: bigint) { return this.prisma.match.update({ where: { id: matchId }, @@ -1120,7 +1176,24 @@ export class MatchesService { async reopenMatch(matchId: bigint, startTime?: Date) { const match = await this.requireAdminMatch(matchId); - if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS'); + + if (match.isOutright) { + const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } }); + if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE'); + if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE'); + const reopenable = + match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT'; + if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE'); + if (match.status === 'PENDING_SETTLEMENT') { + await this.prisma.settlementBatch.deleteMany({ + where: { matchId, status: 'PREVIEW' }, + }); + } + return this.prisma.match.update({ + where: { id: matchId }, + data: { status: 'PUBLISHED', closeTime: null }, + }); + } const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } }); if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE'); @@ -1188,6 +1261,7 @@ export class MatchesService { startTime: Date; status?: string; isHot?: boolean; + correctScoreEnabled?: boolean; displayOrder?: number; matchName?: string | null; stage?: string | null; @@ -1221,6 +1295,7 @@ export class MatchesService { awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null, startTime: m.startTime.toISOString(), isHot: m.isHot ?? false, + correctScoreEnabled: m.correctScoreEnabled ?? true, displayOrder: m.displayOrder ?? 0, matchName: m.matchName ?? null, stage: m.stage ?? null, @@ -1252,9 +1327,13 @@ export class MatchesService { }), }; if (m.markets && !options?.omitMarkets) { + const csEnabled = m.correctScoreEnabled ?? true; + const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']; return { ...base, - markets: m.markets.map((market) => ({ + markets: m.markets + .filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string)) + .map((market) => ({ id: (market.id as bigint).toString(), marketType: market.marketType as string, period: market.period as string, diff --git a/apps/api/src/domains/catalog/outright.service.ts b/apps/api/src/domains/catalog/outright.service.ts index dafb466..7f8fd16 100644 --- a/apps/api/src/domains/catalog/outright.service.ts +++ b/apps/api/src/domains/catalog/outright.service.ts @@ -66,6 +66,7 @@ export class OutrightService { m.status, market, openCount, + league.isActive, ); const [titleZh, titleEn, titleMs] = await Promise.all([ this.getOutrightTitle(m.id, 'zh-CN'), @@ -141,6 +142,7 @@ export class OutrightService { : 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, @@ -159,6 +161,11 @@ export class OutrightService { 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([ @@ -178,6 +185,8 @@ export class OutrightService { 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, @@ -191,15 +200,16 @@ export class OutrightService { /** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */ 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 league = await this.prisma.league.findUnique({ - where: { id: leagueId }, - }); - if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const [leagueZh, leagueEn, leagueMs] = await Promise.all([ this.getTranslation('LEAGUE', leagueId, 'zh-CN'), this.getTranslation('LEAGUE', leagueId, 'en-US'), @@ -210,12 +220,17 @@ export class OutrightService { titleZh: leagueZh || league.code, titleEn: leagueEn || league.code, titleMs: leagueMs || undefined, - status: 'DRAFT', + 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); @@ -226,6 +241,47 @@ export class OutrightService { }; } + /** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */ + 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 { + 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({ @@ -635,6 +691,7 @@ export class OutrightService { isOutright: true, sportType: 'FOOTBALL', deletedAt: null, + league: { isActive: true, deletedAt: null }, }, include: { markets: { @@ -804,18 +861,28 @@ export class OutrightService { 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); + 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' }; } diff --git a/apps/api/src/domains/catalog/wc2026-outright.sync.ts b/apps/api/src/domains/catalog/wc2026-outright.sync.ts index f11752d..1b7352e 100644 --- a/apps/api/src/domains/catalog/wc2026-outright.sync.ts +++ b/apps/api/src/domains/catalog/wc2026-outright.sync.ts @@ -64,7 +64,7 @@ export async function syncWc2026OutrightMarket( 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`); + throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedCatalog first`); } const placeholder = await upsertTeam(prisma, { @@ -96,10 +96,14 @@ export async function syncWc2026OutrightMarket( displayOrder: 0, }, }); - } else if (match.status === 'DRAFT') { + } else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') { match = await prisma.match.update({ where: { id: match.id }, - data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() }, + data: { + status: 'PUBLISHED', + publishTime: match.publishTime ?? new Date(), + closeTime: null, + }, }); } diff --git a/apps/api/src/domains/deposit/deposit.service.ts b/apps/api/src/domains/deposit/deposit.service.ts index 5f25070..d713e9a 100644 --- a/apps/api/src/domains/deposit/deposit.service.ts +++ b/apps/api/src/domains/deposit/deposit.service.ts @@ -5,6 +5,7 @@ import { resolveTranslationFallback } from '@thebet365/shared'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; import { appBadRequest } from '../../shared/common/app-error'; +import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file'; function generateOrderNo(): string { const ts = Date.now().toString(36).toUpperCase(); @@ -12,6 +13,9 @@ function generateOrderNo(): string { return `DEP${ts}${rand}`; } +/** 已通过充值订单允许撤回的时间窗口 */ +const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000; + @Injectable() export class DepositService { constructor( @@ -21,6 +25,20 @@ export class DepositService { // ============ Payment Methods (Admin CRUD) ============ + /** isActive 与 showOnPlayer 合并为同一开关,DB 两列保持同步以兼容旧数据。 */ + private normalizePaymentMethodActive(data: { + isActive?: boolean; + showOnPlayer?: boolean; + }): { isActive?: boolean; showOnPlayer?: boolean } { + if (data.isActive !== undefined) { + return { isActive: data.isActive, showOnPlayer: data.isActive }; + } + if (data.showOnPlayer !== undefined) { + return { isActive: data.showOnPlayer, showOnPlayer: data.showOnPlayer }; + } + return {}; + } + async createPaymentMethod(data: { methodType: string; bankName?: string; @@ -38,6 +56,7 @@ export class DepositService { bankName?: Record; }; }) { + const active = data.isActive ?? data.showOnPlayer ?? true; const method = await this.prisma.paymentMethod.create({ data: { methodType: data.methodType, @@ -48,8 +67,8 @@ export class DepositService { qrCodeUrl: data.qrCodeUrl, displayName: data.displayName, sortOrder: data.sortOrder ?? 0, - isActive: data.isActive ?? true, - showOnPlayer: data.showOnPlayer ?? true, + isActive: active, + showOnPlayer: active, createdBy: data.createdBy, }, }); @@ -78,9 +97,10 @@ export class DepositService { }, ) { const { translations, ...rest } = data; + const activePatch = this.normalizePaymentMethodActive(rest); const method = await this.prisma.paymentMethod.update({ where: { id }, - data: rest, + data: { ...rest, ...activePatch }, }); if (translations) { await this.upsertPaymentMethodTranslations(id, translations); @@ -128,7 +148,6 @@ export class DepositService { async listPlayerPaymentMethods(methodType?: string, locale?: string) { const where: Prisma.PaymentMethodWhereInput = { isActive: true, - showOnPlayer: true, }; if (methodType) { where.methodType = methodType; @@ -438,4 +457,124 @@ export class DepositService { return { success: true }; } + + private async reverseApprovedDepositCredit( + order: { + playerId: bigint; + orderNo: string; + approvedAmount: Decimal | null; + amount: Decimal; + }, + operatorId: bigint, + remark: string, + ) { + const credit = order.approvedAmount ?? order.amount; + await this.wallet.withdraw( + order.playerId, + credit, + operatorId, + remark, + order.orderNo, + 'PLAYER_DEPOSIT_REVERSAL', + ); + } + + /** 已拒绝:恢复待审核;已通过(5 分钟内):作废期间待结算注单并扣回入账 */ + async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) { + const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } }); + if (!order) throw appBadRequest('ORDER_NOT_FOUND'); + if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING'); + + if (order.status === 'REJECTED') { + await this.prisma.depositOrder.update({ + where: { id: orderId }, + data: { + status: 'PENDING', + approvedAmount: null, + reviewerId: null, + reviewedAt: null, + rejectReason: null, + remark: null, + }, + }); + return { success: true }; + } + + if (order.status !== 'APPROVED') { + throw appBadRequest('ORDER_NOT_APPROVED'); + } + + if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) { + throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED'); + } + + const reviewedAt = order.reviewedAt; + + return this.prisma.$transaction(async (tx) => { + const betsAfterReview = await tx.bet.findMany({ + where: { + userId: order.playerId, + placedAt: { gte: reviewedAt }, + status: { not: 'VOID' }, + }, + }); + + const settled = betsAfterReview.filter((b) => b.status !== 'PENDING'); + if (settled.length > 0) { + throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS'); + } + + for (const bet of betsAfterReview) { + await this.wallet.settleBet( + bet.userId, + bet.stake, + bet.stake, + bet.betNo, + 'VOID', + tx, + ); + await tx.bet.update({ + where: { id: bet.id }, + data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() }, + }); + } + + const credit = order.approvedAmount ?? order.amount; + await this.wallet.withdraw( + order.playerId, + credit, + operatorId, + `Revoke approved deposit ${order.orderNo}`, + order.orderNo, + 'PLAYER_DEPOSIT_REVERSAL', + tx, + ); + + await tx.depositOrder.update({ + where: { id: orderId }, + data: { + status: 'PENDING', + approvedAmount: null, + reviewerId: null, + reviewedAt: null, + rejectReason: null, + remark: null, + }, + }); + + return { success: true, voidedBets: betsAfterReview.length }; + }); + } + + /** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */ + async deleteDepositOrder(orderId: bigint, _operatorId: bigint) { + const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } }); + if (!order) throw appBadRequest('ORDER_NOT_FOUND'); + + const screenshotUrl = order.screenshotUrl; + await this.prisma.depositOrder.delete({ where: { id: orderId } }); + await deleteUploadFileByUrl(screenshotUrl); + + return { success: true }; + } } diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 0bfb0ca..43b9588 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -119,6 +119,10 @@ export class AuthService { throw appForbidden('AGENT_ACCOUNT_SUSPENDED'); } + if (portal === 'player' && user.status === 'SUSPENDED') { + throw appForbidden('ACCOUNT_SUSPENDED'); + } + if (portal === 'player' && user.parentId) { const parentAgent = await this.prisma.user.findUnique({ where: { id: user.parentId }, diff --git a/apps/api/src/domains/identity/jwt.strategy.ts b/apps/api/src/domains/identity/jwt.strategy.ts index 697ebb9..408b819 100644 --- a/apps/api/src/domains/identity/jwt.strategy.ts +++ b/apps/api/src/domains/identity/jwt.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { appUnauthorized } from '../../shared/common/app-error'; +import { appForbidden, appUnauthorized } from '../../shared/common/app-error'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @@ -36,9 +36,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }, }, }); - if (!user || user.status !== 'ACTIVE') { + if (!user) { throw appUnauthorized('INVALID_CREDENTIALS'); } + if (user.status === 'DISABLED') { + throw appForbidden('ACCOUNT_DISABLED'); + } + if (user.status === 'SUSPENDED') { + throw appForbidden( + user.userType === 'AGENT' ? 'AGENT_ACCOUNT_SUSPENDED' : 'ACCOUNT_SUSPENDED', + ); + } + if (user.status !== 'ACTIVE') { + throw appForbidden('ACCOUNT_DISABLED'); + } const permissions = user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? []; const roleCode = user.adminRole?.role?.code ?? payload.role; diff --git a/apps/api/src/domains/identity/sms/chuanglan/client.ts b/apps/api/src/domains/identity/sms/chuanglan/client.ts index dae9796..bd20ff4 100644 --- a/apps/api/src/domains/identity/sms/chuanglan/client.ts +++ b/apps/api/src/domains/identity/sms/chuanglan/client.ts @@ -8,17 +8,34 @@ import { maskPhoneForLog, shortSessionId } from '../sms-log.util'; @Injectable() export class ChuanglanClient { private readonly logger = new Logger(ChuanglanClient.name); - private readonly cfg; + private readonly cfg: ReturnType; constructor(config: ConfigService) { this.cfg = loadChuanglanConfig(config); + if (!this.cfg) { + this.logger.warn( + 'Chuanglan SMS not configured (missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD); SMS send will fail until credentials are set', + ); + } } async sendSms(mobile: string, msg: string, uid?: string): Promise { - const nonce = String(Date.now()); const maskedMobile = maskPhoneForLog(mobile); const session = uid ? shortSessionId(uid) : 'n/a'; + if (!this.cfg) { + this.logger.error( + `Chuanglan not configured mobile=${maskedMobile} session=${session}`, + ); + return { + success: false, + code: 'NOT_CONFIGURED', + message: 'Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD', + }; + } + + const nonce = String(Date.now()); + this.logger.log(`Chuanglan request mobile=${maskedMobile} session=${session}`); const body: Record = { diff --git a/apps/api/src/domains/identity/sms/chuanglan/config.ts b/apps/api/src/domains/identity/sms/chuanglan/config.ts index f6b9a2f..a22511e 100644 --- a/apps/api/src/domains/identity/sms/chuanglan/config.ts +++ b/apps/api/src/domains/identity/sms/chuanglan/config.ts @@ -15,11 +15,11 @@ export interface SmsBusinessConfig { debugLogCode: boolean; } -export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig { +export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig | null { const account = config.get('CHUANGLAN_ACCOUNT'); const password = config.get('CHUANGLAN_PASSWORD'); if (!account || !password) { - throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD'); + return null; } return { account, diff --git a/apps/api/src/domains/identity/users.service.ts b/apps/api/src/domains/identity/users.service.ts index 52b06b8..71dcd36 100644 --- a/apps/api/src/domains/identity/users.service.ts +++ b/apps/api/src/domains/identity/users.service.ts @@ -482,4 +482,37 @@ export class UsersService { }); } } + + async softDeletePlayer(playerId: bigint) { + const user = await this.prisma.user.findFirst({ + where: { id: playerId, deletedAt: null }, + }); + if (!user) throw appNotFound('USER_NOT_FOUND'); + if (user.userType !== 'PLAYER') { + throw appBadRequest('NOT_PLAYER'); + } + // Block deletion when the player has any unresolved bets + const betCount = await this.prisma.bet.count({ + where: { + userId: playerId, + status: 'PENDING', + }, + }); + if (betCount > 0) { + throw appBadRequest('PLAYER_HAS_PENDING_BETS'); + } + // Block deletion when wallet still has balance + const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } }); + if (wallet) { + const available = new Decimal(wallet.availableBalance); + const frozen = new Decimal(wallet.frozenBalance); + if (available.gt(0) || frozen.gt(0)) { + throw appBadRequest('PLAYER_HAS_BALANCE'); + } + } + return this.prisma.user.update({ + where: { id: playerId }, + data: { deletedAt: new Date(), status: 'SUSPENDED' }, + }); + } } diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index 8a022e5..d42fc91 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -84,17 +84,18 @@ export class WalletService { remark?: string, referenceId?: string, transactionType = 'MANUAL_WITHDRAW', + tx?: TxClient, ) { const amt = new Decimal(amount); if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE'); - return this.prisma.$transaction(async (tx) => { - const w = await this.lockWallet(tx, userId); + const run = async (client: TxClient) => { + const w = await this.lockWallet(client, userId); const balanceBefore = new Decimal(w.available_balance); if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE'); const balanceAfter = balanceBefore.sub(amt); - await tx.wallet.update({ + await client.wallet.update({ where: { id: w.id }, data: { availableBalance: balanceAfter, @@ -102,7 +103,7 @@ export class WalletService { }, }); - await tx.walletTransaction.create({ + await client.walletTransaction.create({ data: { transactionId: generateTransactionId(), userId, @@ -121,7 +122,10 @@ export class WalletService { }); return { balanceAfter }; - }); + }; + + if (tx) return run(tx); + return this.prisma.$transaction(run); } async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) { diff --git a/apps/api/src/domains/operations/content/content.service.ts b/apps/api/src/domains/operations/content/content.service.ts index eb15a9c..d12dadf 100644 --- a/apps/api/src/domains/operations/content/content.service.ts +++ b/apps/api/src/domains/operations/content/content.service.ts @@ -229,6 +229,7 @@ export class ContentService { async listActive(contentType: string, locale: string) { const now = new Date(); + const type = this.assertContentType(contentType); const items = await this.prisma.content.findMany({ where: { contentType, @@ -237,7 +238,10 @@ export class ContentService { AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }], }, include: { translations: true }, - orderBy: { sortOrder: 'asc' }, + orderBy: + type === 'BANNER' + ? [{ createdAt: 'desc' }, { id: 'desc' }] + : [{ sortOrder: 'asc' }, { id: 'asc' }], }); return items @@ -277,7 +281,7 @@ export class ContentService { this.prisma.content.findMany({ where, include: { translations: true }, - orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), diff --git a/apps/api/src/domains/settlement/settlement.service.spec.ts b/apps/api/src/domains/settlement/settlement.service.spec.ts new file mode 100644 index 0000000..d63721c --- /dev/null +++ b/apps/api/src/domains/settlement/settlement.service.spec.ts @@ -0,0 +1,242 @@ +import { SettlementService } from './settlement.service'; +import { Decimal } from '@prisma/client/runtime/library'; + +describe('SettlementService outright winner flow', () => { + const matchId = BigInt(100); + const operatorId = BigInt(1); + const winnerTeamId = BigInt(10); + const batchId = BigInt(500); + const winningBetId = BigInt(1001); + const losingBetId = BigInt(1002); + const winningSelId = BigInt(201); + const losingSelId = BigInt(202); + + const outrightMatch = { + id: matchId, + isOutright: true, + status: 'CLOSED', + deletedAt: null, + }; + + const winnerTeam = { id: winnerTeamId, code: 'BRA' }; + + const winningBet = { + id: winningBetId, + betNo: 'BET-WIN', + betType: 'SINGLE', + status: 'PENDING', + stake: new Decimal(100), + agentId: null, + userId: BigInt(50), + user: { id: BigInt(50) }, + selections: [ + { + id: BigInt(301), + matchId, + marketType: 'OUTRIGHT_WINNER', + selectionId: winningSelId, + selectionNameSnapshot: '巴西', + handicapLine: null, + totalLine: null, + odds: new Decimal(3), + resultStatus: null, + sortOrder: 0, + }, + ], + }; + + const losingBet = { + id: losingBetId, + betNo: 'BET-LOSE', + betType: 'SINGLE', + status: 'PENDING', + stake: new Decimal(50), + agentId: null, + userId: BigInt(51), + user: { id: BigInt(51) }, + selections: [ + { + id: BigInt(302), + matchId, + marketType: 'OUTRIGHT_WINNER', + selectionId: losingSelId, + selectionNameSnapshot: '阿根廷', + handicapLine: null, + totalLine: null, + odds: new Decimal(5), + resultStatus: null, + sortOrder: 0, + }, + ], + }; + + let matchScoreUpsert: jest.Mock; + let matchFindFirst: jest.Mock; + let matchUpdate: jest.Mock; + let teamFindUnique: jest.Mock; + let marketSelectionFindFirst: jest.Mock; + let marketSelectionFindMany: jest.Mock; + let matchScoreFindUnique: jest.Mock; + let settlementBatchCreate: jest.Mock; + let settlementBatchFindUnique: jest.Mock; + let betFindMany: jest.Mock; + let transaction: jest.Mock; + let wallet: { settleBet: jest.Mock }; + let agents: Record; + let service: SettlementService; + + beforeEach(() => { + matchScoreUpsert = jest.fn().mockResolvedValue({}); + matchFindFirst = jest.fn().mockResolvedValue(outrightMatch); + matchUpdate = jest.fn().mockResolvedValue({}); + teamFindUnique = jest.fn().mockResolvedValue(winnerTeam); + marketSelectionFindFirst = jest + .fn() + .mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' }); + marketSelectionFindMany = jest.fn().mockResolvedValue([ + { id: winningSelId, selectionCode: 'BRA' }, + { id: losingSelId, selectionCode: 'ARG' }, + ]); + matchScoreFindUnique = jest.fn(); + settlementBatchCreate = jest.fn().mockResolvedValue({ + id: batchId, + matchId, + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 0, + ftAwayScore: 0, + status: 'PREVIEW', + }); + settlementBatchFindUnique = jest.fn(); + betFindMany = jest.fn(); + transaction = jest.fn(async (fn: (client: unknown) => Promise) => + fn({ + matchScore: { upsert: matchScoreUpsert }, + bet: { update: jest.fn().mockResolvedValue({}) }, + betSelection: { update: jest.fn().mockResolvedValue({}) }, + settlementItem: { create: jest.fn().mockResolvedValue({}) }, + settlementBatch: { update: jest.fn().mockResolvedValue({}) }, + match: { update: jest.fn().mockResolvedValue({}) }, + }), + ); + + const prisma = { + match: { findFirst: matchFindFirst, update: matchUpdate }, + team: { findUnique: teamFindUnique }, + marketSelection: { + findFirst: marketSelectionFindFirst, + findMany: marketSelectionFindMany, + }, + matchScore: { + findUnique: matchScoreFindUnique, + upsert: matchScoreUpsert, + }, + settlementBatch: { + create: settlementBatchCreate, + findUnique: settlementBatchFindUnique, + update: jest.fn().mockResolvedValue({}), + }, + bet: { findMany: betFindMany }, + $transaction: transaction, + }; + + wallet = { settleBet: jest.fn().mockResolvedValue(undefined) }; + agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) }; + + service = new SettlementService(prisma as never, wallet as never, agents as never); + }); + + it('previewSettlement persists winnerTeamId and previews WIN/LOSE', async () => { + betFindMany.mockResolvedValue([winningBet, losingBet]); + + const preview = await service.previewSettlement(matchId, operatorId, { + winnerTeamId, + }); + + expect(matchScoreUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { matchId }, + create: expect.objectContaining({ winnerTeamId }), + update: expect.objectContaining({ winnerTeamId }), + }), + ); + expect(preview.winnerTeamCode).toBe('BRA'); + expect(preview.items.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ betNo: 'BET-WIN', result: 'WIN', payout: '300' }), + expect.objectContaining({ betNo: 'BET-LOSE', result: 'LOSE', payout: '0' }), + ]), + ); + }); + + it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => { + const txBetUpdate = jest.fn().mockResolvedValue({}); + transaction.mockImplementation(async (fn: (client: unknown) => Promise) => { + await fn({ + matchScore: { upsert: matchScoreUpsert }, + bet: { update: txBetUpdate }, + betSelection: { update: jest.fn().mockResolvedValue({}) }, + settlementItem: { create: jest.fn().mockResolvedValue({}) }, + settlementBatch: { update: jest.fn().mockResolvedValue({}) }, + match: { update: jest.fn().mockResolvedValue({}) }, + }); + }); + settlementBatchFindUnique.mockResolvedValue({ + id: batchId, + matchId, + status: 'PREVIEW', + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 0, + ftAwayScore: 0, + match: { ...outrightMatch, status: 'PENDING_SETTLEMENT' }, + }); + matchScoreFindUnique.mockResolvedValue({ + matchId, + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 0, + ftAwayScore: 0, + winnerTeamId, + }); + betFindMany.mockResolvedValue([winningBet, losingBet]); + + const result = await service.confirmSettlement(batchId, operatorId); + + expect(matchScoreUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ winnerTeamId }), + }), + ); + expect(wallet.settleBet).toHaveBeenCalledTimes(2); + expect(wallet.settleBet.mock.calls[0]).toEqual([ + winningBet.userId, + expect.anything(), + expect.anything(), + 'BET-WIN', + 'WIN', + expect.anything(), + ]); + expect(wallet.settleBet.mock.calls[1]).toEqual([ + losingBet.userId, + expect.anything(), + expect.anything(), + 'BET-LOSE', + 'LOSE', + expect.anything(), + ]); + expect(txBetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: winningBetId }, + data: expect.objectContaining({ status: 'WON' }), + }), + ); + expect(txBetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: losingBetId }, + data: expect.objectContaining({ status: 'LOST' }), + }), + ); + expect(result).toEqual({ success: true, batchId: batchId.toString() }); + }); +}); diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index d312575..85e91f4 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -46,6 +46,24 @@ export class SettlementService { return team?.code ?? null; } + private async assertOutrightLeagueFixturesSettled(match: { + leagueId: bigint; + isOutright: boolean; + }) { + if (!match.isOutright) return; + const unsettled = await this.prisma.match.count({ + where: { + leagueId: match.leagueId, + isOutright: false, + deletedAt: null, + status: { notIn: ['SETTLED', 'CANCELLED'] }, + }, + }); + if (unsettled > 0) { + throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED'); + } + } + private buildSettleInput( sel: BetSelectionLeg, selectionCode: string, @@ -107,6 +125,10 @@ export class SettlementService { if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); + if (match.isOutright) { + await this.assertOutrightLeagueFixturesSettled(match); + } + if (match.isOutright) { if (!winnerTeamId) { throw appBadRequest('SETTLEMENT_WINNER_REQUIRED'); @@ -172,6 +194,10 @@ export class SettlementService { if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); + if (match.isOutright) { + await this.assertOutrightLeagueFixturesSettled(match); + } + const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts); const computation = await this.computePreviewComputation(matchId, scoreSource); const batch = await this.prisma.settlementBatch.create({ @@ -190,6 +216,10 @@ export class SettlementService { }, }); + if (match.isOutright && scoreSource.winnerTeamId) { + await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId); + } + if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') { await this.prisma.match.update({ where: { id: matchId }, @@ -306,6 +336,41 @@ export class SettlementService { }; } + private async upsertMatchScoreRecord( + matchId: bigint, + scoreSource: { + htHome: number; + htAway: number; + ftHome: number; + ftAway: number; + winnerTeamId?: bigint | null; + }, + operatorId: bigint, + tx?: Parameters[0]>[0], + ) { + const client = tx ?? this.prisma; + await client.matchScore.upsert({ + where: { matchId }, + create: { + matchId, + htHomeScore: scoreSource.htHome, + htAwayScore: scoreSource.htAway, + ftHomeScore: scoreSource.ftHome, + ftAwayScore: scoreSource.ftAway, + winnerTeamId: scoreSource.winnerTeamId ?? null, + recordedBy: operatorId, + }, + update: { + htHomeScore: scoreSource.htHome, + htAwayScore: scoreSource.htAway, + ftHomeScore: scoreSource.ftHome, + ftAwayScore: scoreSource.ftAway, + winnerTeamId: scoreSource.winnerTeamId ?? null, + recordedBy: operatorId, + }, + }); + } + private async resolvePreviewScoreSource( matchId: bigint, isOutright: boolean, @@ -601,6 +666,10 @@ export class SettlementService { throw appBadRequest('MATCH_NOT_SETTLEABLE'); } + if (batch.match.isOutright) { + await this.assertOutrightLeagueFixturesSettled(batch.match); + } + const scoreInput: ScoreInput = { htHome: batch.htHomeScore ?? 0, htAway: batch.htAwayScore ?? 0, @@ -624,25 +693,18 @@ export class SettlementService { const agentIds = new Set(); await this.prisma.$transaction(async (tx) => { - await tx.matchScore.upsert({ - where: { matchId: batch.matchId }, - create: { - matchId: batch.matchId, - htHomeScore: scoreInput.htHome, - htAwayScore: scoreInput.htAway, - ftHomeScore: scoreInput.ftHome, - ftAwayScore: scoreInput.ftAway, + await this.upsertMatchScoreRecord( + batch.matchId, + { + htHome: scoreInput.htHome, + htAway: scoreInput.htAway, + ftHome: scoreInput.ftHome, + ftAway: scoreInput.ftAway, winnerTeamId: existingScore?.winnerTeamId ?? null, - recordedBy: operatorId, }, - update: { - htHomeScore: scoreInput.htHome, - htAwayScore: scoreInput.htAway, - ftHomeScore: scoreInput.ftHome, - ftAwayScore: scoreInput.ftAway, - recordedBy: operatorId, - }, - }); + operatorId, + tx, + ); for (const bet of pendingBets) { if (bet.betType === 'SINGLE' && bet.selections.length === 1) { @@ -833,56 +895,58 @@ export class SettlementService { return { success: true, batchId: batchId.toString() }; } - async getMatchBetStats( - matchId: bigint, - opts?: { page?: number; pageSize?: number }, - ) { + private async assertMatchReadyForBetStats(matchId: bigint) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); + return match; + } - const legs = await this.prisma.betSelection.findMany({ - where: { matchId }, - include: { - bet: { - select: { - id: true, - betNo: true, - betType: true, - stake: true, - status: true, - settlementStatus: true, - potentialReturn: true, - actualReturn: true, - placedAt: true, - user: { select: { username: true } }, - }, + async getMatchBetStatsSummary(matchId: bigint) { + await this.assertMatchReadyForBetStats(matchId); + const betWhere = { selections: { some: { matchId } } }; + + const [ + legCount, + totalBets, + singleBets, + parlayBets, + stakeAgg, + statusGroups, + legsForSelection, + ] = await Promise.all([ + this.prisma.betSelection.count({ where: { matchId } }), + this.prisma.bet.count({ where: betWhere }), + this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }), + this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }), + this.prisma.bet.aggregate({ + where: betWhere, + _sum: { stake: true, potentialReturn: true }, + }), + this.prisma.bet.groupBy({ + by: ['status'], + where: betWhere, + _count: { _all: true }, + }), + this.prisma.betSelection.findMany({ + where: { matchId }, + select: { + marketId: true, + selectionId: true, + marketType: true, + period: true, + selectionNameSnapshot: true, + bet: { select: { betType: true, stake: true } }, }, - }, - orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }], - }); + orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }], + }), + ]); - const betById = new Map(); - for (const leg of legs) { - betById.set(leg.betId.toString(), leg.bet); - } - - let totalStake = new Decimal(0); - let totalPotential = new Decimal(0); - let singleBets = 0; - let parlayBets = 0; const statusCounts: Record = {}; - - for (const bet of betById.values()) { - totalStake = totalStake.add(bet.stake); - if (bet.potentialReturn) { - totalPotential = totalPotential.add(bet.potentialReturn); - } - if (bet.betType === 'SINGLE') singleBets += 1; - else if (bet.betType === 'PARLAY') parlayBets += 1; - statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1; + for (const row of statusGroups) { + statusCounts[row.status] = row._count._all; } type SelAgg = { @@ -896,7 +960,7 @@ export class SettlementService { }; const selMap = new Map(); - for (const leg of legs) { + for (const leg of legsForSelection) { const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`; let row = selMap.get(key); if (!row) { @@ -935,67 +999,84 @@ export class SettlementService { return a.selectionName.localeCompare(b.selectionName); }); - const betsById = new Map< - string, - { - bet: (typeof legs)[0]['bet']; - matchLegs: (typeof legs); - } - >(); - for (const leg of legs) { - const key = leg.betId.toString(); - const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] }; - row.matchLegs.push(leg); - betsById.set(key, row); - } + return { + summary: { + totalBets, + singleBets, + parlayBets, + totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(), + totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(), + statusCounts, + legCount, + }, + bySelection, + }; + } - const allBets = Array.from(betsById.values()) - .map(({ bet, matchLegs }) => ({ - id: bet.id.toString(), - betNo: bet.betNo, - username: matchLegs[0].bet.user.username, - betType: bet.betType, - status: bet.status, - settlementStatus: bet.settlementStatus, - stake: bet.stake.toString(), - potentialReturn: bet.potentialReturn?.toString() ?? null, - actualReturn: bet.actualReturn.toString(), - placedAt: bet.placedAt.toISOString(), - legCountOnMatch: matchLegs.length, - selections: matchLegs.map((leg) => ({ - marketType: leg.marketType, - period: leg.period, - selectionName: leg.selectionNameSnapshot, - odds: leg.odds.toString(), - })), - })) - .sort( - (a, b) => - new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(), - ); + async getMatchBetStatsBets( + matchId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { + await this.assertMatchReadyForBetStats(matchId); + const betWhere = { selections: { some: { matchId } } }; + const totalBets = await this.prisma.bet.count({ where: betWhere }); const page = Math.max(1, opts?.page ?? 1); const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10)); - const total = allBets.length; - const start = (page - 1) * pageSize; + + const betRows = await this.prisma.bet.findMany({ + where: betWhere, + orderBy: { placedAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + user: { select: { username: true } }, + selections: { + where: { matchId }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + }, + }, + }); + + const items = betRows.map((bet) => ({ + id: bet.id.toString(), + betNo: bet.betNo, + username: bet.user.username, + betType: bet.betType, + status: bet.status, + settlementStatus: bet.settlementStatus, + stake: bet.stake.toString(), + potentialReturn: bet.potentialReturn?.toString() ?? null, + actualReturn: bet.actualReturn.toString(), + placedAt: bet.placedAt.toISOString(), + legCountOnMatch: bet.selections.length, + selections: bet.selections.map((leg) => ({ + marketType: leg.marketType, + period: leg.period, + selectionName: leg.selectionNameSnapshot, + odds: leg.odds.toString(), + })), + })); return { - summary: { - totalBets: betById.size, - singleBets, - parlayBets, - totalStake: totalStake.toString(), - totalPotentialReturn: totalPotential.toString(), - statusCounts, - legCount: legs.length, - }, - bySelection, - bets: { - items: allBets.slice(start, start + pageSize), - total, - page, - pageSize, - }, + items, + total: totalBets, + page, + pageSize, + }; + } + + async getMatchBetStats( + matchId: bigint, + opts?: { page?: number; pageSize?: number }, + ) { + const [summaryPart, bets] = await Promise.all([ + this.getMatchBetStatsSummary(matchId), + this.getMatchBetStatsBets(matchId, opts), + ]); + return { + ...summaryPart, + bets, }; } @@ -1144,6 +1225,20 @@ export class SettlementService { }, }); + if (match.isOutright && winnerTeamId) { + await this.upsertMatchScoreRecord( + matchId, + { + htHome: scoreInput.htHome, + htAway: scoreInput.htAway, + ftHome: scoreInput.ftHome, + ftAway: scoreInput.ftAway, + winnerTeamId, + }, + operatorId, + ); + } + return { batch, score: scoreInput, @@ -1170,11 +1265,10 @@ export class SettlementService { ftHome: batch.ftHomeScore ?? 0, ftAway: batch.ftAwayScore ?? 0, }; - const winnerTeamCode = await this.resolveWinnerTeamCode( - ( - await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } }) - )?.winnerTeamId, - ); + const existingScore = await this.prisma.matchScore.findUnique({ + where: { matchId: batch.matchId }, + }); + const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null); const settledBets = await this.prisma.bet.findMany({ where: { @@ -1188,24 +1282,18 @@ export class SettlementService { const agentIds = new Set(); await this.prisma.$transaction(async (tx) => { - await tx.matchScore.upsert({ - where: { matchId: batch.matchId }, - create: { - matchId: batch.matchId, - htHomeScore: scoreInput.htHome, - htAwayScore: scoreInput.htAway, - ftHomeScore: scoreInput.ftHome, - ftAwayScore: scoreInput.ftAway, - recordedBy: operatorId, + await this.upsertMatchScoreRecord( + batch.matchId, + { + htHome: scoreInput.htHome, + htAway: scoreInput.htAway, + ftHome: scoreInput.ftHome, + ftAway: scoreInput.ftAway, + winnerTeamId: existingScore?.winnerTeamId ?? null, }, - update: { - htHomeScore: scoreInput.htHome, - htAwayScore: scoreInput.htAway, - ftHomeScore: scoreInput.ftHome, - ftAwayScore: scoreInput.ftAway, - recordedBy: operatorId, - }, - }); + operatorId, + tx, + ); for (const bet of settledBets) { const oldPayout = new Decimal(bet.actualReturn); diff --git a/apps/api/src/infrastructure/database/database-init.ts b/apps/api/src/infrastructure/database/database-init.ts new file mode 100644 index 0000000..90730b4 --- /dev/null +++ b/apps/api/src/infrastructure/database/database-init.ts @@ -0,0 +1,34 @@ +import type { PrismaClient } from '@prisma/client'; +import { resolveSeedAccounts, resolveSeedMode, runSeed, type RunSeedOptions, type SeedMode } from './run-seed'; + +export type ResetSeedOptions = RunSeedOptions; + +/** 清空 public 下除 _prisma_migrations 外的全部表(含用户、充值、注单、赛事等) */ +export async function truncateApplicationTables(prisma: PrismaClient): Promise { + const rows = await prisma.$queryRaw>` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename <> '_prisma_migrations' + `; + + const tableNames = rows.map((r) => `"${r.tablename}"`); + if (tableNames.length === 0) return; + + await prisma.$executeRawUnsafe( + `TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`, + ); +} + +function resolveResetSeedMode(options?: ResetSeedOptions): SeedMode { + if (options?.mode) return options.mode; + return resolveSeedMode(); +} + +/** 全量初始化:清表 + seed。production 模式仅保留 admin + WC2026 赛事目录。 */ +export async function resetAndSeedDatabase(prisma: PrismaClient, options?: ResetSeedOptions) { + const mode = resolveResetSeedMode(options); + await truncateApplicationTables(prisma); + await runSeed(prisma, { mode }); + return { seedMode: mode, demoAccounts: resolveSeedAccounts(mode) }; +} diff --git a/apps/api/src/infrastructure/database/database-reset.service.ts b/apps/api/src/infrastructure/database/database-reset.service.ts index f84945b..9baabc9 100644 --- a/apps/api/src/infrastructure/database/database-reset.service.ts +++ b/apps/api/src/infrastructure/database/database-reset.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { appForbidden } from '../../shared/common/app-error'; import { PrismaService } from '../../shared/prisma/prisma.service'; -import { DEMO_ACCOUNTS, runSeed } from './run-seed'; +import { resetAndSeedDatabase } from './database-init'; @Injectable() export class DatabaseResetService { @@ -17,25 +17,8 @@ export class DatabaseResetService { throw appForbidden('DB_RESET_FORBIDDEN'); } - await this.truncateApplicationTables(); - await runSeed(this.prisma); - - return { demoAccounts: DEMO_ACCOUNTS }; - } - - private async truncateApplicationTables() { - const rows = await this.prisma.$queryRaw>` - SELECT tablename - FROM pg_tables - WHERE schemaname = 'public' - AND tablename <> '_prisma_migrations' - `; - - const tableNames = rows.map((r) => `"${r.tablename}"`); - if (tableNames.length === 0) return; - - await this.prisma.$executeRawUnsafe( - `TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`, - ); + return resetAndSeedDatabase(this.prisma, { + mode: process.env.NODE_ENV === 'production' ? 'production' : 'dev', + }); } } diff --git a/apps/api/src/infrastructure/database/reset-and-seed-cli.ts b/apps/api/src/infrastructure/database/reset-and-seed-cli.ts new file mode 100644 index 0000000..2e45524 --- /dev/null +++ b/apps/api/src/infrastructure/database/reset-and-seed-cli.ts @@ -0,0 +1,59 @@ +import { PrismaClient } from '@prisma/client'; +import { resetAndSeedDatabase } from './database-init'; +import { resolveSeedMode } from './run-seed'; + +function assertInitAllowed() { + if (process.argv.includes('--yes')) { + process.env.INIT_DATABASE_CONFIRM = 'YES'; + } + + if (process.argv.includes('--dev')) { + process.env.SEED_MODE = 'dev'; + } + + if (process.argv.includes('--production')) { + process.env.SEED_MODE = 'production'; + } + + if (process.env.INIT_DATABASE_CONFIRM !== 'YES') { + console.error( + '[init-db] 拒绝执行:须设置 INIT_DATABASE_CONFIRM=YES 或传入 --yes 以确认清空全部业务数据。', + ); + console.error('[init-db] 生产上线: pnpm db:reset:prod'); + console.error('[init-db] 本地演示: pnpm db:reset:dev'); + process.exit(1); + } + + const isProd = process.env.NODE_ENV === 'production'; + if (isProd && process.env.ALLOW_DB_RESET !== 'true') { + console.error( + '[init-db] 生产环境须同时设置 ALLOW_DB_RESET=true(与管理端「重置数据库」相同策略)。', + ); + process.exit(1); + } +} + +async function main() { + assertInitAllowed(); + + const mode = resolveSeedMode(); + const prisma = new PrismaClient(); + try { + console.log(`[init-db] 正在清空全部业务表并重新 seed(mode=${mode})…`); + const result = await resetAndSeedDatabase(prisma, { mode }); + console.log('[init-db] 完成。保留账号:'); + for (const line of result.demoAccounts) { + console.log(` - ${line}`); + } + if (result.seedMode === 'production') { + console.log('[init-db] 已写入 WC2026 赛事示例数据(无代理/玩家/充值/注单)。'); + } + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/src/infrastructure/database/run-seed.ts b/apps/api/src/infrastructure/database/run-seed.ts index e5637b0..ee27d3c 100644 --- a/apps/api/src/infrastructure/database/run-seed.ts +++ b/apps/api/src/infrastructure/database/run-seed.ts @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; -import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync'; +import { seedCatalog } from './seed-catalog'; import { ensureUserInviteCode } from '../../shared/common/invite-code.util'; export const DEMO_ACCOUNTS = [ @@ -9,505 +9,30 @@ export const DEMO_ACCOUNTS = [ 'player1 / Player@123', ] as const; +export const PRODUCTION_SEED_ACCOUNTS = [ + 'admin / Admin@123', +] as const; + +export type SeedMode = 'dev' | 'production'; + +export type RunSeedOptions = { + mode?: SeedMode; +}; + +export function resolveSeedMode(options?: RunSeedOptions): SeedMode { + if (options?.mode) return options.mode; + if (process.env.SEED_MODE === 'dev') return 'dev'; + if (process.env.SEED_MODE === 'production') return 'production'; + return process.env.NODE_ENV === 'production' ? 'production' : 'dev'; +} + +export function resolveSeedAccounts(mode: SeedMode): readonly string[] { + return mode === 'production' ? PRODUCTION_SEED_ACCOUNTS : DEMO_ACCOUNTS; +} + let prisma: PrismaClient; -/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */ -async function seedDemoMarkets(matchId: bigint) { - const configs: Array<{ - marketType: string; - period: string; - lineValue?: number; - sortOrder: number; - selections: Array<{ code: string; name: string; odds: number }>; - }> = [ - { - marketType: 'FT_1X2', - period: 'FT', - sortOrder: 1, - selections: [ - { code: 'HOME', name: '主胜', odds: 2.5 }, - { code: 'DRAW', name: '和', odds: 3.2 }, - { code: 'AWAY', name: '客胜', odds: 2.8 }, - ], - }, - { - marketType: 'FT_HANDICAP', - period: 'FT', - lineValue: -0.5, - sortOrder: 2, - selections: [ - { code: 'HOME', name: '主 -0.5', odds: 1.9 }, - { code: 'AWAY', name: '客 +0.5', odds: 1.9 }, - ], - }, - { - marketType: 'FT_OVER_UNDER', - period: 'FT', - lineValue: 2.5, - sortOrder: 3, - selections: [ - { code: 'OVER', name: '大 2.5', odds: 1.85 }, - { code: 'UNDER', name: '小 2.5', odds: 1.95 }, - ], - }, - { - marketType: 'FT_ODD_EVEN', - period: 'FT', - sortOrder: 4, - selections: [ - { code: 'ODD', name: '单', odds: 1.9 }, - { code: 'EVEN', name: '双', odds: 1.9 }, - ], - }, - { - marketType: 'HT_1X2', - period: 'HT', - sortOrder: 5, - selections: [ - { code: 'HOME', name: '半场主', odds: 3.0 }, - { code: 'DRAW', name: '半场和', odds: 2.0 }, - { code: 'AWAY', name: '半场客', odds: 3.5 }, - ], - }, - { - marketType: 'HT_HANDICAP', - period: 'HT', - lineValue: -0.5, - sortOrder: 6, - selections: [ - { code: 'HOME', name: '半场主 -0.5', odds: 1.9 }, - { code: 'AWAY', name: '半场客 +0.5', odds: 1.9 }, - ], - }, - { - marketType: 'HT_OVER_UNDER', - period: 'HT', - lineValue: 1.5, - sortOrder: 7, - selections: [ - { code: 'OVER', name: '半场大 1.5', odds: 2.0 }, - { code: 'UNDER', name: '半场小 1.5', odds: 1.75 }, - ], - }, - { - marketType: 'FT_CORRECT_SCORE', - period: 'FT', - sortOrder: 8, - selections: [ - { code: 'SCORE_1_0', name: '1-0', odds: 4.86 }, - { code: 'SCORE_2_0', name: '2-0', odds: 5.22 }, - { code: 'SCORE_2_1', name: '2-1', odds: 7.92 }, - { code: 'SCORE_3_0', name: '3-0', odds: 8.28 }, - { code: 'SCORE_0_0', name: '0-0', odds: 8.64 }, - { code: 'SCORE_1_1', name: '1-1', odds: 7.47 }, - { code: 'SCORE_2_2', name: '2-2', odds: 24.3 }, - { code: 'SCORE_3_3', name: '3-3', odds: 175.5 }, - { code: 'SCORE_0_1', name: '0-1', odds: 14.4 }, - { code: 'SCORE_0_2', name: '0-2', odds: 45.9 }, - { code: 'SCORE_1_2', name: '1-2', odds: 23.4 }, - { code: 'SCORE_0_3', name: '0-3', odds: 207 }, - ], - }, - { - marketType: 'HT_CORRECT_SCORE', - period: 'HT', - sortOrder: 9, - selections: [ - { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, - { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, - { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, - { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, - { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, - { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, - ], - }, - { - marketType: 'SH_CORRECT_SCORE', - period: 'SH', - sortOrder: 10, - selections: [ - { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, - { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, - { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, - { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, - { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, - { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, - ], - }, - ]; - - for (const cfg of configs) { - const exists = await prisma.market.findFirst({ - where: { matchId, marketType: cfg.marketType }, - include: { _count: { select: { selections: true } } }, - }); - if (exists) { - const needRefresh = - cfg.marketType.includes('CORRECT_SCORE') && - exists._count.selections < cfg.selections.length; - if (needRefresh) { - await prisma.market.delete({ where: { id: exists.id } }); - } else { - continue; - } - } - await prisma.market.create({ - data: { - matchId, - marketType: cfg.marketType, - period: cfg.period, - lineValue: cfg.lineValue, - allowSingle: true, - allowParlay: true, - sortOrder: cfg.sortOrder, - status: 'OPEN', - selections: { - create: cfg.selections.map((s, i) => ({ - selectionCode: s.code, - selectionName: s.name, - odds: s.odds, - sortOrder: i, - status: 'OPEN', - })), - }, - }, - }); - } -} - -async function upsertLeagueName(leagueId: bigint, names: Record) { - for (const [locale, value] of Object.entries(names)) { - await prisma.entityTranslation.upsert({ - where: { - entityType_entityId_locale_fieldName: { - entityType: 'LEAGUE', - entityId: leagueId, - locale, - fieldName: 'name', - }, - }, - create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value }, - update: { value }, - }); - } -} - -async function upsertTeam( - code: string, - names: Record, -) { - const team = await prisma.team.upsert({ - where: { code }, - create: { code }, - update: {}, - }); - for (const [locale, value] of Object.entries(names)) { - await prisma.entityTranslation.upsert({ - where: { - entityType_entityId_locale_fieldName: { - entityType: 'TEAM', - entityId: team.id, - locale, - fieldName: 'name', - }, - }, - create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value }, - update: { value }, - }); - } - return team; -} - -async function ensurePublishedMatch(opts: { - leagueId: bigint; - homeTeamId: bigint; - awayTeamId: bigint; - startTime: Date; - isHot?: boolean; - displayOrder?: number; -}) { - let match = await prisma.match.findFirst({ - where: { - leagueId: opts.leagueId, - homeTeamId: opts.homeTeamId, - awayTeamId: opts.awayTeamId, - status: 'PUBLISHED', - }, - }); - if (!match) { - match = await prisma.match.create({ - data: { - leagueId: opts.leagueId, - homeTeamId: opts.homeTeamId, - awayTeamId: opts.awayTeamId, - startTime: opts.startTime, - status: 'PUBLISHED', - isHot: opts.isHot ?? false, - displayOrder: opts.displayOrder ?? 0, - publishTime: new Date(), - }, - }); - } else { - match = await prisma.match.update({ - where: { id: match.id }, - data: { - startTime: opts.startTime, - isHot: opts.isHot ?? match.isHot, - displayOrder: opts.displayOrder ?? match.displayOrder, - }, - }); - } - await seedDemoMarkets(match.id); - return match; -} - -function hoursFromNow(hours: number) { - return new Date(Date.now() + hours * 3600 * 1000); -} - -async function seedSportsDemo() { - const epl = await prisma.league.upsert({ - where: { code: 'EPL' }, - create: { code: 'EPL' }, - update: {}, - }); - await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' }); - - const wc = await prisma.league.upsert({ - where: { code: 'WC2026' }, - create: { code: 'WC2026' }, - update: {}, - }); - await upsertLeagueName(wc.id, { - 'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)', - 'en-US': '2026 FIFA World Cup', - }); - - const teams: Array<[string, Record]> = [ - ['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }], - ['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }], - ['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }], - ['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }], - ['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }], - ['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }], - ['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }], - ['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }], - ['USA', { 'zh-CN': '美国', 'en-US': 'USA' }], - ['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }], - ['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }], - ['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }], - ['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }], - ['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }], - ['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }], - ['FRA', { 'zh-CN': '法国', 'en-US': 'France' }], - ]; - - const teamMap = new Map(); - for (const [code, names] of teams) { - teamMap.set(code, await upsertTeam(code, names)); - } - - const get = (code: string) => { - const t = teamMap.get(code); - if (!t) throw new Error(`Team ${code} missing`); - return t; - }; - - // 英超:明日开赛 → 早盘 - await ensurePublishedMatch({ - leagueId: epl.id, - homeTeamId: get('MUN').id, - awayTeamId: get('CHE').id, - startTime: hoursFromNow(26), - isHot: true, - displayOrder: 1, - }); - - // 英超:今晚开赛 → 今日 - await ensurePublishedMatch({ - leagueId: epl.id, - homeTeamId: get('CHE').id, - awayTeamId: get('MUN').id, - startTime: hoursFromNow(8), - isHot: false, - displayOrder: 2, - }); - - const wcFixtures: Array<{ - home: string; - away: string; - start: Date; - hot?: boolean; - order: number; - }> = [ - { home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 }, - { home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 }, - { home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 }, - { home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 }, - { home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 }, - { home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 }, - { home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 }, - ]; - - for (const f of wcFixtures) { - await ensurePublishedMatch({ - leagueId: wc.id, - homeTeamId: get(f.home).id, - awayTeamId: get(f.away).id, - startTime: f.start, - isHot: f.hot, - displayOrder: f.order, - }); - } - - console.log(` Sports demo: ${wcFixtures.length + 2} published matches`); -} - -async function seedOutrightDemo() { - const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } }); - if (!wc) return; - - const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, { - forceCanonical: true, - }); - const count = await prisma.marketSelection.count({ where: { marketId } }); - console.log(` WC2026 outright: match ${matchId}, ${count} selections`); -} - -async function seedPlayerDemo() { - const player = await prisma.user.findUnique({ - where: { username: 'player1' }, - include: { wallet: true }, - }); - if (!player?.wallet) return; - - await prisma.wallet.update({ - where: { id: player.wallet.id }, - data: { availableBalance: 88888.88 }, - }); - - await prisma.walletTransaction.upsert({ - where: { transactionId: 'DEMO-DEP-001' }, - create: { - transactionId: 'DEMO-DEP-001', - userId: player.id, - walletId: player.wallet.id, - transactionType: 'DEPOSIT', - amount: 50000, - balanceBefore: 1000, - balanceAfter: 51000, - frozenBefore: 0, - frozenAfter: 0, - remark: '演示充值', - }, - update: {}, - }); - - await prisma.walletTransaction.upsert({ - where: { transactionId: 'DEMO-DEP-002' }, - create: { - transactionId: 'DEMO-DEP-002', - userId: player.id, - walletId: player.wallet.id, - transactionType: 'DEPOSIT', - amount: 37888.88, - balanceBefore: 51000, - balanceAfter: 88888.88, - frozenBefore: 0, - frozenAfter: 0, - remark: '演示充值(二笔)', - }, - update: {}, - }); - - const sampleSel = await prisma.marketSelection.findFirst({ - where: { - status: 'OPEN', - market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } }, - }, - include: { market: { include: { match: true } } }, - }); - - if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) { - const odds = Number(sampleSel.odds); - const stake = 200; - await prisma.bet.create({ - data: { - betNo: 'DEMO-BET-001', - userId: player.id, - agentId: player.parentId, - betType: 'SINGLE', - stake, - totalOdds: odds, - potentialReturn: stake * odds, - status: 'PENDING', - requestId: 'seed-demo-bet-001', - selections: { - create: { - matchId: sampleSel.market.matchId, - marketId: sampleSel.marketId, - selectionId: sampleSel.id, - marketType: sampleSel.market.marketType, - period: sampleSel.market.period, - selectionNameSnapshot: sampleSel.selectionName, - odds: sampleSel.odds, - oddsVersion: sampleSel.oddsVersion, - }, - }, - }, - }); - } - - const settledSel = await prisma.marketSelection.findFirst({ - where: { - market: { marketType: 'FT_1X2' }, - selectionCode: 'DRAW', - }, - include: { market: true }, - }); - - if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) { - const odds = Number(settledSel.odds); - const stake = 50; - await prisma.bet.create({ - data: { - betNo: 'DEMO-BET-002', - userId: player.id, - agentId: player.parentId, - betType: 'SINGLE', - stake, - totalOdds: odds, - potentialReturn: stake * odds, - actualReturn: stake * odds, - status: 'WON', - settlementStatus: 'SETTLED', - settledAt: new Date(Date.now() - 86400000), - requestId: 'seed-demo-bet-002', - selections: { - create: { - matchId: settledSel.market.matchId, - marketId: settledSel.marketId, - selectionId: settledSel.id, - marketType: settledSel.market.marketType, - period: settledSel.market.period, - selectionNameSnapshot: settledSel.selectionName, - odds: settledSel.odds, - oddsVersion: settledSel.oddsVersion, - resultStatus: 'WIN', - effectiveOdds: settledSel.odds, - }, - }, - }, - }); - } - - console.log(' Player demo: wallet + transactions + sample bets'); -} - -export async function runSeed(client: PrismaClient) { - prisma = client; - console.log('Seeding database...'); - +async function seedRolesAndConfig() { const superAdminRole = await prisma.role.upsert({ where: { code: 'SUPER_ADMIN' }, create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' }, @@ -579,10 +104,12 @@ export async function runSeed(client: PrismaClient) { }); } - const hash = await bcrypt.hash('Admin@123', 10); - const agentHash = await bcrypt.hash('Agent@123', 10); - const playerHash = await bcrypt.hash('Player@123', 10); + return superAdminRole; +} +async function seedAdminUser(superAdminRole: { id: bigint }) { + const adminPassword = process.env.ADMIN_INITIAL_PASSWORD ?? 'Admin@123'; + const hash = await bcrypt.hash(adminPassword, 10); await prisma.user.upsert({ where: { username: 'admin' }, create: { @@ -593,6 +120,11 @@ export async function runSeed(client: PrismaClient) { }, update: {}, }); +} + +async function seedDevDemoUsers() { + const agentHash = await bcrypt.hash('Agent@123', 10); + const playerHash = await bcrypt.hash('Player@123', 10); const agent1 = await prisma.user.upsert({ where: { username: 'agent1' }, @@ -651,7 +183,70 @@ export async function runSeed(client: PrismaClient) { }, update: {}, }); +} +async function seedPlayerDemo() { + const player = await prisma.user.findUnique({ + where: { username: 'player1' }, + include: { wallet: true }, + }); + if (!player?.wallet) return; + + await prisma.wallet.update({ + where: { id: player.wallet.id }, + data: { availableBalance: 88888.88 }, + }); + + await prisma.walletTransaction.upsert({ + where: { transactionId: 'DEMO-DEP-001' }, + create: { + transactionId: 'DEMO-DEP-001', + userId: player.id, + walletId: player.wallet.id, + transactionType: 'DEPOSIT', + amount: 50000, + balanceBefore: 1000, + balanceAfter: 51000, + frozenBefore: 0, + frozenAfter: 0, + remark: '演示充值', + }, + update: {}, + }); + + await prisma.walletTransaction.upsert({ + where: { transactionId: 'DEMO-DEP-002' }, + create: { + transactionId: 'DEMO-DEP-002', + userId: player.id, + walletId: player.wallet.id, + transactionType: 'DEPOSIT', + amount: 37888.88, + balanceBefore: 51000, + balanceAfter: 88888.88, + frozenBefore: 0, + frozenAfter: 0, + remark: '演示充值(二笔)', + }, + update: {}, + }); + + const legacyDemoBets = await prisma.bet.findMany({ + where: { betNo: { in: ['DEMO-BET-001', 'DEMO-BET-002'] } }, + select: { id: true }, + }); + if (legacyDemoBets.length > 0) { + const betIds = legacyDemoBets.map((b) => b.id); + await prisma.settlementItem.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.cashbackBet.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.bet.deleteMany({ where: { id: { in: betIds } } }); + } + + console.log(' Player demo: wallet + transactions (no catalog demo bets)'); +} + +async function seedI18nMessages() { const messages = [ { key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' }, { key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' }, @@ -668,11 +263,9 @@ export async function runSeed(client: PrismaClient) { }); } } +} - await seedSportsDemo(); - await seedOutrightDemo(); - await seedPlayerDemo(); - +async function seedDefaultSiteContent() { await prisma.content.create({ data: { contentType: 'BANNER', @@ -746,7 +339,9 @@ export async function runSeed(client: PrismaClient) { }, }, }).catch(() => {}); +} +async function ensureStaffInviteCodes() { const staffWithoutInvite = await prisma.user.findMany({ where: { userType: { in: ['ADMIN', 'AGENT'] }, inviteCode: null }, select: { id: true }, @@ -754,6 +349,30 @@ export async function runSeed(client: PrismaClient) { for (const row of staffWithoutInvite) { await ensureUserInviteCode(prisma, row.id); } - - console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`); +} + +export async function runSeed(client: PrismaClient, options?: RunSeedOptions) { + prisma = client; + const mode = resolveSeedMode(options); + console.log(`Seeding database (mode=${mode})...`); + + const superAdminRole = await seedRolesAndConfig(); + await seedAdminUser(superAdminRole); + + if (mode === 'dev') { + await seedDevDemoUsers(); + } + + await seedI18nMessages(); + await seedCatalog(prisma); + + if (mode === 'dev') { + await seedPlayerDemo(); + } + + await seedDefaultSiteContent(); + await ensureStaffInviteCodes(); + + const accounts = resolveSeedAccounts(mode); + console.log(`Seed completed! ${accounts.join(', ')}`); } diff --git a/apps/api/src/infrastructure/database/seed-catalog.ts b/apps/api/src/infrastructure/database/seed-catalog.ts new file mode 100644 index 0000000..a8e9a8b --- /dev/null +++ b/apps/api/src/infrastructure/database/seed-catalog.ts @@ -0,0 +1,350 @@ +import type { PrismaClient } from '@prisma/client'; +import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync'; +import { + WC2026_LEAGUE_CODE, + WC2026_OUTRIGHT_TEAMS, +} from '../../domains/catalog/wc2026-outright-teams'; +import type { ZhiboMatchExport, ZhiboTeamExport } from '../../domains/catalog/zhibo-match.types'; +import { + leagueCodeFromExport, + resolveInternalStatus, + resolveIsHot, + resolveStartTime, + teamCodeFromExport, + toKickoffJson, + toVenueJson, + translationsFromZhiboNames, +} from '../../domains/catalog/zhibo-match.mapper'; +import { + WC2026_GROUP_STAGE_BUNDLE, + WC2026_TEAM_LOGO_BY_CODE, + WC2026_ZIBO_ID_TO_CODE, +} from './seed-data'; +import { seedDemoMarkets } from './seed-demo-markets'; + +export { seedDemoMarkets }; + +/** 旧版演示联赛(已从 seed 移除,增量 seed 时软删其赛事) */ +const LEGACY_DEMO_LEAGUE_CODES = ['EPL'] as const; + +const CATALOG_PUBLISHABLE_STATUSES = ['DRAFT', 'PUBLISHED'] as const; + +function getSeedLiveMatchIds(): bigint[] { + return (WC2026_GROUP_STAGE_BUNDLE.matches ?? []) + .filter((m) => m.liveMatchId != null) + .map((m) => BigInt(m.liveMatchId!)); +} + +async function softDeleteLegacyDemoLeagueMatches(prisma: PrismaClient) { + const leagues = await prisma.league.findMany({ + where: { code: { in: [...LEGACY_DEMO_LEAGUE_CODES] } }, + select: { id: true }, + }); + if (leagues.length === 0) return; + + const leagueIds = leagues.map((l) => l.id); + await prisma.match.updateMany({ + where: { leagueId: { in: leagueIds }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + await prisma.league.updateMany({ + where: { id: { in: leagueIds } }, + data: { isActive: false }, + }); +} + +async function softDeleteStaleWc2026GroupMatches(prisma: PrismaClient, leagueId: bigint) { + const seedIds = getSeedLiveMatchIds(); + await prisma.match.updateMany({ + where: { + leagueId, + isOutright: false, + deletedAt: null, + OR: [{ liveMatchId: null }, { liveMatchId: { notIn: seedIds } }], + }, + data: { deletedAt: new Date() }, + }); +} + +async function purgeMatchCatalogBettingData(prisma: PrismaClient, matchIds: bigint[]) { + if (matchIds.length === 0) return; + + const selections = await prisma.betSelection.findMany({ + where: { matchId: { in: matchIds } }, + select: { betId: true }, + }); + const betIds = [...new Set(selections.map((s) => s.betId))]; + + if (betIds.length > 0) { + await prisma.settlementItem.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.cashbackBet.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } }); + await prisma.bet.deleteMany({ where: { id: { in: betIds } } }); + } + + await prisma.settlementBatch.deleteMany({ where: { matchId: { in: matchIds } } }); + await prisma.matchScore.deleteMany({ where: { matchId: { in: matchIds } } }); +} + +/** 增量 seed:清理 legacy 赛事、移除 WC2026 下注/结算/比分,保证仅保留 seed 包内小组赛 */ +async function resetWc2026CatalogState(prisma: PrismaClient, leagueId: bigint) { + await softDeleteLegacyDemoLeagueMatches(prisma); + await softDeleteStaleWc2026GroupMatches(prisma, leagueId); + + const activeMatches = await prisma.match.findMany({ + where: { leagueId, deletedAt: null }, + select: { id: true }, + }); + await purgeMatchCatalogBettingData( + prisma, + activeMatches.map((m) => m.id), + ); +} + +async function normalizeWc2026OutrightMatch(prisma: PrismaClient, leagueId: bigint) { + const outright = await prisma.match.findFirst({ + where: { leagueId, isOutright: true, deletedAt: null }, + }); + if (!outright) return; + + if (!CATALOG_PUBLISHABLE_STATUSES.includes(outright.status as (typeof CATALOG_PUBLISHABLE_STATUSES)[number])) { + await prisma.match.update({ + where: { id: outright.id }, + data: { + status: 'PUBLISHED', + publishTime: outright.publishTime ?? new Date(), + closeTime: null, + }, + }); + } + await prisma.matchScore.deleteMany({ where: { matchId: outright.id } }); +} + +/** 清除 seed 后仍残留的已结算/已封盘等非发布态(小组赛 upsert 已写入 DRAFT/PUBLISHED) */ +async function ensureWc2026PublishableStatuses(prisma: PrismaClient, leagueId: bigint) { + await prisma.match.updateMany({ + where: { + leagueId, + deletedAt: null, + status: { notIn: [...CATALOG_PUBLISHABLE_STATUSES] }, + }, + data: { + status: 'PUBLISHED', + closeTime: null, + publishTime: new Date(), + }, + }); +} + +async function upsertEntityTranslations( + prisma: PrismaClient, + entityType: 'LEAGUE' | 'TEAM', + entityId: bigint, + translations: Record, +) { + for (const [locale, value] of Object.entries(translations)) { + await prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType, + entityId, + locale, + fieldName: 'name', + }, + }, + create: { entityType, entityId, locale, fieldName: 'name', value }, + update: { value }, + }); + } +} + +export async function seedWc2026League(prisma: PrismaClient) { + const league = await prisma.league.upsert({ + where: { code: WC2026_LEAGUE_CODE }, + create: { code: WC2026_LEAGUE_CODE, sportType: 'FOOTBALL', isActive: true }, + update: { sportType: 'FOOTBALL', isActive: true }, + }); + await upsertEntityTranslations(prisma, 'LEAGUE', league.id, { + 'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)', + 'en-US': '2026 FIFA World Cup', + }); + return league; +} + +export async function seedWc2026Teams(prisma: PrismaClient) { + for (const entry of WC2026_OUTRIGHT_TEAMS) { + const logoUrl = WC2026_TEAM_LOGO_BY_CODE[entry.code] ?? null; + const externalIdEntry = Object.entries(WC2026_ZIBO_ID_TO_CODE).find(([, code]) => code === entry.code); + const externalId = externalIdEntry ? Number(externalIdEntry[0]) : undefined; + const team = await prisma.team.upsert({ + where: { code: entry.code }, + create: { + code: entry.code, + externalId, + logoUrl, + }, + update: { + externalId: externalId ?? undefined, + logoUrl: logoUrl ?? undefined, + }, + }); + await upsertEntityTranslations(prisma, 'TEAM', team.id, entry.names); + } +} + +async function upsertWc2026TeamFromZhibo(prisma: PrismaClient, team: ZhiboTeamExport) { + const canonicalCode = + team.id != null ? WC2026_ZIBO_ID_TO_CODE[team.id] : undefined; + const outrightEntry = canonicalCode + ? WC2026_OUTRIGHT_TEAMS.find((t) => t.code === canonicalCode) + : undefined; + const code = canonicalCode ?? teamCodeFromExport(team); + const zhiboTranslations = translationsFromZhiboNames(team.names, team.name); + const translations = outrightEntry + ? { ...outrightEntry.names, ...zhiboTranslations } + : zhiboTranslations; + const logoUrl = + (canonicalCode && WC2026_TEAM_LOGO_BY_CODE[canonicalCode]) || + team.image || + undefined; + + const record = await prisma.team.upsert({ + where: { code }, + create: { + code, + externalId: team.id ?? undefined, + logoUrl, + }, + update: { + externalId: team.id ?? undefined, + logoUrl: logoUrl ?? undefined, + }, + }); + await upsertEntityTranslations(prisma, 'TEAM', record.id, translations); + return record; +} + +async function findExistingZhiboMatch( + prisma: PrismaClient, + leagueId: bigint, + homeTeamId: bigint, + awayTeamId: bigint, + item: ZhiboMatchExport, +) { + if (item.liveMatchId != null) { + return prisma.match.findUnique({ + where: { liveMatchId: BigInt(item.liveMatchId) }, + }); + } + if (item.officialMatchNo != null) { + return prisma.match.findFirst({ + where: { + leagueId, + homeTeamId, + awayTeamId, + officialMatchNo: item.officialMatchNo, + }, + }); + } + return null; +} + +async function upsertWc2026GroupMatch(prisma: PrismaClient, item: ZhiboMatchExport, leagueId: bigint) { + const [homeTeam, awayTeam] = await Promise.all([ + upsertWc2026TeamFromZhibo(prisma, item.homeTeam), + upsertWc2026TeamFromZhibo(prisma, item.awayTeam), + ]); + + const status = resolveInternalStatus(item); + const startTime = resolveStartTime(item.kickoff); + const liveMatchId = item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined; + const matchData = { + leagueId, + homeTeamId: homeTeam.id, + awayTeamId: awayTeam.id, + startTime, + isHot: resolveIsHot(item), + displayOrder: item.sortOrder, + status, + publishTime: status === 'PUBLISHED' ? new Date() : undefined, + officialMatchNo: item.officialMatchNo, + stage: item.stage, + groupName: item.groupName, + liveMatchId, + additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null, + channelId: item.channelId, + matchName: item.matchName, + venueJson: toVenueJson(item.venue), + kickoffJson: toKickoffJson(item.kickoff), + externalStatus: item.status.state, + }; + + const existing = await findExistingZhiboMatch( + prisma, + leagueId, + homeTeam.id, + awayTeam.id, + item, + ); + + const match = existing + ? await prisma.match.update({ + where: { id: existing.id }, + data: { + ...matchData, + deletedAt: null, + closeTime: null, + publishTime: status === 'PUBLISHED' ? (existing.publishTime ?? matchData.publishTime ?? new Date()) : null, + }, + }) + : await prisma.match.create({ data: matchData }); + + await prisma.matchScore.deleteMany({ where: { matchId: match.id } }); + await seedDemoMarkets(prisma, match.id); + return match; +} + +export async function seedWc2026GroupMatches(prisma: PrismaClient, leagueId: bigint) { + const matches = WC2026_GROUP_STAGE_BUNDLE.matches ?? []; + let created = 0; + let updated = 0; + + for (const item of matches) { + const code = leagueCodeFromExport(item.league); + if (code !== WC2026_LEAGUE_CODE) { + throw new Error(`Unexpected league in seed bundle: ${item.league.en}`); + } + const before = item.liveMatchId != null + ? await prisma.match.findUnique({ where: { liveMatchId: BigInt(item.liveMatchId) } }) + : null; + await upsertWc2026GroupMatch(prisma, item, leagueId); + if (before) updated += 1; + else created += 1; + } + + return { total: matches.length, created, updated }; +} + +export async function seedWc2026Outright(prisma: PrismaClient) { + const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, { + forceCanonical: true, + }); + const count = await prisma.marketSelection.count({ where: { marketId } }); + return { matchId, marketId, selectionCount: count }; +} + +export async function seedCatalog(prisma: PrismaClient) { + const league = await seedWc2026League(prisma); + await seedWc2026Teams(prisma); + await resetWc2026CatalogState(prisma, league.id); + const groupResult = await seedWc2026GroupMatches(prisma, league.id); + const outrightResult = await seedWc2026Outright(prisma); + await normalizeWc2026OutrightMatch(prisma, league.id); + await ensureWc2026PublishableStatuses(prisma, league.id); + + console.log( + ` WC2026 catalog: ${groupResult.total} group matches (${groupResult.created} new, ${groupResult.updated} updated), outright ${outrightResult.selectionCount} selections`, + ); + + return { league, groupResult, outrightResult }; +} diff --git a/apps/api/src/infrastructure/database/seed-cli.ts b/apps/api/src/infrastructure/database/seed-cli.ts index a6595f4..330bca6 100644 --- a/apps/api/src/infrastructure/database/seed-cli.ts +++ b/apps/api/src/infrastructure/database/seed-cli.ts @@ -1,9 +1,10 @@ import { PrismaClient } from '@prisma/client'; -import { runSeed } from './run-seed'; +import { resolveSeedMode, runSeed } from './run-seed'; const prisma = new PrismaClient(); +const mode = resolveSeedMode(); -runSeed(prisma) +runSeed(prisma, { mode }) .catch((err) => { console.error(err); process.exit(1); diff --git a/apps/api/src/infrastructure/database/seed-data/index.ts b/apps/api/src/infrastructure/database/seed-data/index.ts new file mode 100644 index 0000000..214b9ea --- /dev/null +++ b/apps/api/src/infrastructure/database/seed-data/index.ts @@ -0,0 +1,10 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ZhiboMatchesBundleExport } from '../../../domains/catalog/zhibo-match.types'; + +export { WC2026_ZIBO_ID_TO_CODE, WC2026_TEAM_LOGO_BY_CODE } from './wc2026-zhibo-team-map'; + +const bundlePath = path.join(__dirname, 'wc2026-group-stage.json'); +export const WC2026_GROUP_STAGE_BUNDLE = JSON.parse( + fs.readFileSync(bundlePath, 'utf8'), +) as ZhiboMatchesBundleExport; diff --git a/apps/api/src/infrastructure/database/seed-data/wc2026-group-stage.json b/apps/api/src/infrastructure/database/seed-data/wc2026-group-stage.json new file mode 100644 index 0000000..91427b7 --- /dev/null +++ b/apps/api/src/infrastructure/database/seed-data/wc2026-group-stage.json @@ -0,0 +1,5189 @@ +{ + "count": 72, + "matches": [ + { + "officialMatchNo": 1, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148965, + "additionMatchId": 20260001, + "channelId": null, + "matchName": "Mexico - South Africa", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781204400, + "utcTimeStop": 1781211600, + "utcIso": "2026-06-11T19:00:00Z", + "chinaTime": "2026-06-12 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-11 13:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11188, + "name": "Mexico", + "names": { + "zh": "墨西哥", + "en": "Mexico", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png" + }, + "awayTeam": { + "id": 11173, + "name": "South Africa", + "names": { + "zh": "南非", + "en": "South Africa", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Azteca", + "en": "Estadio Azteca", + "zhTw": null, + "vi": "Estadio Azteca", + "km": "Estadio Azteca", + "ms": "Estadio Azteca" + }, + "city": { + "zh": "墨西哥城", + "en": "Mexico City", + "zhTw": null, + "vi": "Mexico City", + "km": "Mexico City", + "ms": "Mexico City" + } + }, + "sortOrder": 1, + "isPublished": true + }, + { + "officialMatchNo": 2, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148822, + "additionMatchId": 20260002, + "channelId": null, + "matchName": "South Korea - Czechia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781229600, + "utcTimeStop": 1781236800, + "utcIso": "2026-06-12T02:00:00Z", + "chinaTime": "2026-06-12 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-11 20:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11261, + "name": "South Korea", + "names": { + "zh": "韩国", + "en": "South Korea", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png" + }, + "awayTeam": { + "id": 50467, + "name": "Czechia", + "names": { + "zh": "捷克", + "en": "Czechia", + "zhTw": "捷克", + "vi": "Séc", + "km": "ឆែក", + "ms": "Czechia" + }, + "image": "https://flagcdn.com/cz.svg" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Akron", + "en": "Estadio Akron", + "zhTw": null, + "vi": "Estadio Akron", + "km": "Estadio Akron", + "ms": "Estadio Akron" + }, + "city": { + "zh": "瓜达拉哈拉", + "en": "Guadalajara", + "zhTw": null, + "vi": "Guadalajara", + "km": "Guadalajara", + "ms": "Guadalajara" + } + }, + "sortOrder": 2, + "isPublished": true + }, + { + "officialMatchNo": 25, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148794, + "additionMatchId": 20260025, + "channelId": null, + "matchName": "Czechia - South Africa", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781798400, + "utcTimeStop": 1781805600, + "utcIso": "2026-06-18T16:00:00Z", + "chinaTime": "2026-06-19 00:00:00 Asia/Shanghai", + "venueTime": "2026-06-18 12:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 50467, + "name": "Czechia", + "names": { + "zh": "捷克", + "en": "Czechia", + "zhTw": "捷克", + "vi": "Séc", + "km": "ឆែក", + "ms": "Czechia" + }, + "image": "https://flagcdn.com/cz.svg" + }, + "awayTeam": { + "id": 11173, + "name": "South Africa", + "names": { + "zh": "南非", + "en": "South Africa", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Mercedes-Benz Stadium", + "en": "Mercedes-Benz Stadium", + "zhTw": null, + "vi": "Mercedes-Benz Stadium", + "km": "Mercedes-Benz Stadium", + "ms": "Mercedes-Benz Stadium" + }, + "city": { + "zh": "亚特兰大", + "en": "Atlanta", + "zhTw": null, + "vi": "Atlanta", + "km": "Atlanta", + "ms": "Atlanta" + } + }, + "sortOrder": 25, + "isPublished": true + }, + { + "officialMatchNo": 28, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148739, + "additionMatchId": 20260028, + "channelId": null, + "matchName": "Mexico - South Korea", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781830800, + "utcTimeStop": 1781838000, + "utcIso": "2026-06-19T01:00:00Z", + "chinaTime": "2026-06-19 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-18 19:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11188, + "name": "Mexico", + "names": { + "zh": "墨西哥", + "en": "Mexico", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png" + }, + "awayTeam": { + "id": 11261, + "name": "South Korea", + "names": { + "zh": "韩国", + "en": "South Korea", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Akron", + "en": "Estadio Akron", + "zhTw": null, + "vi": "Estadio Akron", + "km": "Estadio Akron", + "ms": "Estadio Akron" + }, + "city": { + "zh": "瓜达拉哈拉", + "en": "Guadalajara", + "zhTw": null, + "vi": "Guadalajara", + "km": "Guadalajara", + "ms": "Guadalajara" + } + }, + "sortOrder": 28, + "isPublished": true + }, + { + "officialMatchNo": 49, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148795, + "additionMatchId": 20260049, + "channelId": null, + "matchName": "Czechia - Mexico", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782349200, + "utcTimeStop": 1782356400, + "utcIso": "2026-06-25T01:00:00Z", + "chinaTime": "2026-06-25 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 19:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 50467, + "name": "Czechia", + "names": { + "zh": "捷克", + "en": "Czechia", + "zhTw": "捷克", + "vi": "Séc", + "km": "ឆែក", + "ms": "Czechia" + }, + "image": "https://flagcdn.com/cz.svg" + }, + "awayTeam": { + "id": 11188, + "name": "Mexico", + "names": { + "zh": "墨西哥", + "en": "Mexico", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Azteca", + "en": "Estadio Azteca", + "zhTw": null, + "vi": "Estadio Azteca", + "km": "Estadio Azteca", + "ms": "Estadio Azteca" + }, + "city": { + "zh": "墨西哥城", + "en": "Mexico City", + "zhTw": null, + "vi": "Mexico City", + "km": "Mexico City", + "ms": "Mexico City" + } + }, + "sortOrder": 49, + "isPublished": true + }, + { + "officialMatchNo": 50, + "stage": "group", + "groupName": "A", + "liveMatchId": 20148740, + "additionMatchId": 20260050, + "channelId": null, + "matchName": "South Africa - South Korea", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782349200, + "utcTimeStop": 1782356400, + "utcIso": "2026-06-25T01:00:00Z", + "chinaTime": "2026-06-25 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 19:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11173, + "name": "South Africa", + "names": { + "zh": "南非", + "en": "South Africa", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png" + }, + "awayTeam": { + "id": 11261, + "name": "South Korea", + "names": { + "zh": "韩国", + "en": "South Korea", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio BBVA", + "en": "Estadio BBVA", + "zhTw": null, + "vi": "Estadio BBVA", + "km": "Estadio BBVA", + "ms": "Estadio BBVA" + }, + "city": { + "zh": "蒙特雷", + "en": "Monterrey", + "zhTw": null, + "vi": "Monterrey", + "km": "Monterrey", + "ms": "Monterrey" + } + }, + "sortOrder": 50, + "isPublished": true + }, + { + "officialMatchNo": 3, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148741, + "additionMatchId": 20260003, + "channelId": null, + "matchName": "Canada - Bosnia and Herzegovina", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781290800, + "utcTimeStop": 1781298000, + "utcIso": "2026-06-12T19:00:00Z", + "chinaTime": "2026-06-13 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-12 15:00:00 America/Toronto", + "venueTimezone": "America/Toronto" + }, + "homeTeam": { + "id": 11166, + "name": "Canada", + "names": { + "zh": "加拿大", + "en": "Canada", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png" + }, + "awayTeam": { + "id": 11153, + "name": "Bosnia and Herzegovina", + "names": { + "zh": "波黑", + "en": "Bosnia and Herzegovina", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BMO Field", + "en": "BMO Field", + "zhTw": null, + "vi": "BMO Field", + "km": "BMO Field", + "ms": "BMO Field" + }, + "city": { + "zh": "多伦多", + "en": "Toronto", + "zhTw": null, + "vi": "Toronto", + "km": "Toronto", + "ms": "Toronto" + } + }, + "sortOrder": 3, + "isPublished": true + }, + { + "officialMatchNo": 5, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148742, + "additionMatchId": 20260005, + "channelId": null, + "matchName": "Qatar - Switzerland", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781377200, + "utcTimeStop": 1781384400, + "utcIso": "2026-06-13T19:00:00Z", + "chinaTime": "2026-06-14 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-13 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11267, + "name": "Qatar", + "names": { + "zh": "卡塔尔", + "en": "Qatar", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png" + }, + "awayTeam": { + "id": 11036, + "name": "Switzerland", + "names": { + "zh": "瑞士", + "en": "Switzerland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Levi's Stadium", + "en": "Levi's Stadium", + "zhTw": null, + "vi": "Levi's Stadium", + "km": "Levi's Stadium", + "ms": "Levi's Stadium" + }, + "city": { + "zh": "San Francisco Bay Area", + "en": "San Francisco Bay Area", + "zhTw": null, + "vi": "San Francisco Bay Area", + "km": "San Francisco Bay Area", + "ms": "San Francisco Bay Area" + } + }, + "sortOrder": 5, + "isPublished": true + }, + { + "officialMatchNo": 26, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148743, + "additionMatchId": 20260026, + "channelId": null, + "matchName": "Switzerland - Bosnia and Herzegovina", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781809200, + "utcTimeStop": 1781816400, + "utcIso": "2026-06-18T19:00:00Z", + "chinaTime": "2026-06-19 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-18 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11036, + "name": "Switzerland", + "names": { + "zh": "瑞士", + "en": "Switzerland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png" + }, + "awayTeam": { + "id": 11153, + "name": "Bosnia and Herzegovina", + "names": { + "zh": "波黑", + "en": "Bosnia and Herzegovina", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "SoFi Stadium", + "en": "SoFi Stadium", + "zhTw": null, + "vi": "SoFi Stadium", + "km": "SoFi Stadium", + "ms": "SoFi Stadium" + }, + "city": { + "zh": "洛杉矶", + "en": "Los Angeles", + "zhTw": null, + "vi": "Los Angeles", + "km": "Los Angeles", + "ms": "Los Angeles" + } + }, + "sortOrder": 26, + "isPublished": true + }, + { + "officialMatchNo": 27, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148966, + "additionMatchId": 20260027, + "channelId": null, + "matchName": "Canada - Qatar", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781820000, + "utcTimeStop": 1781827200, + "utcIso": "2026-06-18T22:00:00Z", + "chinaTime": "2026-06-19 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-18 15:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11166, + "name": "Canada", + "names": { + "zh": "加拿大", + "en": "Canada", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png" + }, + "awayTeam": { + "id": 11267, + "name": "Qatar", + "names": { + "zh": "卡塔尔", + "en": "Qatar", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BC Place", + "en": "BC Place", + "zhTw": null, + "vi": "BC Place", + "km": "BC Place", + "ms": "BC Place" + }, + "city": { + "zh": "温哥华", + "en": "Vancouver", + "zhTw": null, + "vi": "Vancouver", + "km": "Vancouver", + "ms": "Vancouver" + } + }, + "sortOrder": 27, + "isPublished": true + }, + { + "officialMatchNo": 51, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148745, + "additionMatchId": 20260051, + "channelId": null, + "matchName": "Switzerland - Canada", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782327600, + "utcTimeStop": 1782334800, + "utcIso": "2026-06-24T19:00:00Z", + "chinaTime": "2026-06-25 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11036, + "name": "Switzerland", + "names": { + "zh": "瑞士", + "en": "Switzerland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png" + }, + "awayTeam": { + "id": 11166, + "name": "Canada", + "names": { + "zh": "加拿大", + "en": "Canada", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BC Place", + "en": "BC Place", + "zhTw": null, + "vi": "BC Place", + "km": "BC Place", + "ms": "BC Place" + }, + "city": { + "zh": "温哥华", + "en": "Vancouver", + "zhTw": null, + "vi": "Vancouver", + "km": "Vancouver", + "ms": "Vancouver" + } + }, + "sortOrder": 51, + "isPublished": true + }, + { + "officialMatchNo": 52, + "stage": "group", + "groupName": "B", + "liveMatchId": 20148746, + "additionMatchId": null, + "channelId": null, + "matchName": "Bosnia and Herzegovina - Qatar", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782327600, + "utcTimeStop": 1782334800, + "utcIso": "2026-06-24T19:00:00Z", + "chinaTime": "2026-06-25 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": null, + "name": "Bosnia and Herzegovina", + "names": { + "zh": null, + "en": null, + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "" + }, + "awayTeam": { + "id": null, + "name": "Qatar", + "names": { + "zh": null, + "en": null, + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "" + }, + "status": { + "state": "off", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lumen Field", + "en": "Lumen Field", + "zhTw": null, + "vi": "Lumen Field", + "km": "Lumen Field", + "ms": "Lumen Field" + }, + "city": { + "zh": "西雅图", + "en": "Seattle", + "zhTw": null, + "vi": "Seattle", + "km": "Seattle", + "ms": "Seattle" + } + }, + "sortOrder": 52, + "isPublished": true + }, + { + "officialMatchNo": 6, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148747, + "additionMatchId": 20260006, + "channelId": null, + "matchName": "Brazil - Morocco", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781388000, + "utcTimeStop": 1781395200, + "utcIso": "2026-06-13T22:00:00Z", + "chinaTime": "2026-06-14 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-13 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11150, + "name": "Brazil", + "names": { + "zh": "巴西", + "en": "Brazil", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png" + }, + "awayTeam": { + "id": 11182, + "name": "Morocco", + "names": { + "zh": "摩洛哥", + "en": "Morocco", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "MetLife Stadium", + "en": "MetLife Stadium", + "zhTw": null, + "vi": "MetLife Stadium", + "km": "MetLife Stadium", + "ms": "MetLife Stadium" + }, + "city": { + "zh": "纽约/新泽西", + "en": "New York / New Jersey", + "zhTw": null, + "vi": "New York / New Jersey", + "km": "New York / New Jersey", + "ms": "New York / New Jersey" + } + }, + "sortOrder": 6, + "isPublished": true + }, + { + "officialMatchNo": 7, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148748, + "additionMatchId": 20260007, + "channelId": null, + "matchName": "Haiti - Scotland", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781398800, + "utcTimeStop": 1781406000, + "utcIso": "2026-06-14T01:00:00Z", + "chinaTime": "2026-06-14 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-13 21:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11270, + "name": "Haiti", + "names": { + "zh": "海地", + "en": "Haiti", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png" + }, + "awayTeam": { + "id": 11030, + "name": "Scotland", + "names": { + "zh": "苏格兰", + "en": "Scotland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Gillette Stadium", + "en": "Gillette Stadium", + "zhTw": null, + "vi": "Gillette Stadium", + "km": "Gillette Stadium", + "ms": "Gillette Stadium" + }, + "city": { + "zh": "波士顿", + "en": "Boston", + "zhTw": null, + "vi": "Boston", + "km": "Boston", + "ms": "Boston" + } + }, + "sortOrder": 7, + "isPublished": true + }, + { + "officialMatchNo": 29, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148749, + "additionMatchId": 20260029, + "channelId": null, + "matchName": "Scotland - Morocco", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781906400, + "utcTimeStop": 1781913600, + "utcIso": "2026-06-19T22:00:00Z", + "chinaTime": "2026-06-20 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-19 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11030, + "name": "Scotland", + "names": { + "zh": "苏格兰", + "en": "Scotland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png" + }, + "awayTeam": { + "id": 11182, + "name": "Morocco", + "names": { + "zh": "摩洛哥", + "en": "Morocco", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Gillette Stadium", + "en": "Gillette Stadium", + "zhTw": null, + "vi": "Gillette Stadium", + "km": "Gillette Stadium", + "ms": "Gillette Stadium" + }, + "city": { + "zh": "波士顿", + "en": "Boston", + "zhTw": null, + "vi": "Boston", + "km": "Boston", + "ms": "Boston" + } + }, + "sortOrder": 29, + "isPublished": true + }, + { + "officialMatchNo": 31, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148750, + "additionMatchId": 20260031, + "channelId": null, + "matchName": "Brazil - Haiti", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781917200, + "utcTimeStop": 1781924400, + "utcIso": "2026-06-20T01:00:00Z", + "chinaTime": "2026-06-20 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-19 21:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11150, + "name": "Brazil", + "names": { + "zh": "巴西", + "en": "Brazil", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png" + }, + "awayTeam": { + "id": 11270, + "name": "Haiti", + "names": { + "zh": "海地", + "en": "Haiti", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lincoln Financial Field", + "en": "Lincoln Financial Field", + "zhTw": null, + "vi": "Lincoln Financial Field", + "km": "Lincoln Financial Field", + "ms": "Lincoln Financial Field" + }, + "city": { + "zh": "费城", + "en": "Philadelphia", + "zhTw": null, + "vi": "Philadelphia", + "km": "Philadelphia", + "ms": "Philadelphia" + } + }, + "sortOrder": 31, + "isPublished": true + }, + { + "officialMatchNo": 53, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148751, + "additionMatchId": 20260053, + "channelId": null, + "matchName": "Scotland - Brazil", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782338400, + "utcTimeStop": 1782345600, + "utcIso": "2026-06-24T22:00:00Z", + "chinaTime": "2026-06-25 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11030, + "name": "Scotland", + "names": { + "zh": "苏格兰", + "en": "Scotland", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png" + }, + "awayTeam": { + "id": 11150, + "name": "Brazil", + "names": { + "zh": "巴西", + "en": "Brazil", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Hard Rock Stadium", + "en": "Hard Rock Stadium", + "zhTw": null, + "vi": "Hard Rock Stadium", + "km": "Hard Rock Stadium", + "ms": "Hard Rock Stadium" + }, + "city": { + "zh": "迈阿密", + "en": "Miami", + "zhTw": null, + "vi": "Miami", + "km": "Miami", + "ms": "Miami" + } + }, + "sortOrder": 53, + "isPublished": true + }, + { + "officialMatchNo": 54, + "stage": "group", + "groupName": "C", + "liveMatchId": 20148752, + "additionMatchId": 20260054, + "channelId": null, + "matchName": "Morocco - Haiti", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782338400, + "utcTimeStop": 1782345600, + "utcIso": "2026-06-24T22:00:00Z", + "chinaTime": "2026-06-25 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-24 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11182, + "name": "Morocco", + "names": { + "zh": "摩洛哥", + "en": "Morocco", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png" + }, + "awayTeam": { + "id": 11270, + "name": "Haiti", + "names": { + "zh": "海地", + "en": "Haiti", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Mercedes-Benz Stadium", + "en": "Mercedes-Benz Stadium", + "zhTw": null, + "vi": "Mercedes-Benz Stadium", + "km": "Mercedes-Benz Stadium", + "ms": "Mercedes-Benz Stadium" + }, + "city": { + "zh": "亚特兰大", + "en": "Atlanta", + "zhTw": null, + "vi": "Atlanta", + "km": "Atlanta", + "ms": "Atlanta" + } + }, + "sortOrder": 54, + "isPublished": true + }, + { + "officialMatchNo": 4, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148753, + "additionMatchId": 20260004, + "channelId": null, + "matchName": "USA - Paraguay", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781312400, + "utcTimeStop": 1781319600, + "utcIso": "2026-06-13T01:00:00Z", + "chinaTime": "2026-06-13 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-12 18:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11168, + "name": "USA", + "names": { + "zh": "美国", + "en": "USA", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png" + }, + "awayTeam": { + "id": 11148, + "name": "Paraguay", + "names": { + "zh": "巴拉圭", + "en": "Paraguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "SoFi Stadium", + "en": "SoFi Stadium", + "zhTw": null, + "vi": "SoFi Stadium", + "km": "SoFi Stadium", + "ms": "SoFi Stadium" + }, + "city": { + "zh": "洛杉矶", + "en": "Los Angeles", + "zhTw": null, + "vi": "Los Angeles", + "km": "Los Angeles", + "ms": "Los Angeles" + } + }, + "sortOrder": 4, + "isPublished": true + }, + { + "officialMatchNo": 8, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148968, + "additionMatchId": 20260008, + "channelId": null, + "matchName": "Australia - Türkiye", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781323200, + "utcTimeStop": 1781330400, + "utcIso": "2026-06-13T04:00:00Z", + "chinaTime": "2026-06-13 12:00:00 Asia/Shanghai", + "venueTime": "2026-06-12 21:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11273, + "name": "Australia", + "names": { + "zh": "澳大利亚", + "en": "Australia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png" + }, + "awayTeam": { + "id": 50468, + "name": "Türkiye", + "names": { + "zh": "土耳其", + "en": "Türkiye", + "zhTw": "土耳其", + "vi": "Thổ Nhĩ Kỳ", + "km": "តួកគី", + "ms": "Turki" + }, + "image": "https://flagcdn.com/tr.svg" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BC Place", + "en": "BC Place", + "zhTw": null, + "vi": "BC Place", + "km": "BC Place", + "ms": "BC Place" + }, + "city": { + "zh": "温哥华", + "en": "Vancouver", + "zhTw": null, + "vi": "Vancouver", + "km": "Vancouver", + "ms": "Vancouver" + } + }, + "sortOrder": 8, + "isPublished": true + }, + { + "officialMatchNo": 30, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148754, + "additionMatchId": 20260030, + "channelId": null, + "matchName": "USA - Australia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781895600, + "utcTimeStop": 1781902800, + "utcIso": "2026-06-19T19:00:00Z", + "chinaTime": "2026-06-20 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-19 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11168, + "name": "USA", + "names": { + "zh": "美国", + "en": "USA", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png" + }, + "awayTeam": { + "id": 11273, + "name": "Australia", + "names": { + "zh": "澳大利亚", + "en": "Australia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lumen Field", + "en": "Lumen Field", + "zhTw": null, + "vi": "Lumen Field", + "km": "Lumen Field", + "ms": "Lumen Field" + }, + "city": { + "zh": "西雅图", + "en": "Seattle", + "zhTw": null, + "vi": "Seattle", + "km": "Seattle", + "ms": "Seattle" + } + }, + "sortOrder": 30, + "isPublished": true + }, + { + "officialMatchNo": 32, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148790, + "additionMatchId": 20260032, + "channelId": null, + "matchName": "Türkiye - Paraguay", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781928000, + "utcTimeStop": 1781935200, + "utcIso": "2026-06-20T04:00:00Z", + "chinaTime": "2026-06-20 12:00:00 Asia/Shanghai", + "venueTime": "2026-06-19 21:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 50468, + "name": "Türkiye", + "names": { + "zh": "土耳其", + "en": "Türkiye", + "zhTw": "土耳其", + "vi": "Thổ Nhĩ Kỳ", + "km": "តួកគី", + "ms": "Turki" + }, + "image": "https://flagcdn.com/tr.svg" + }, + "awayTeam": { + "id": 11148, + "name": "Paraguay", + "names": { + "zh": "巴拉圭", + "en": "Paraguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Levi's Stadium", + "en": "Levi's Stadium", + "zhTw": null, + "vi": "Levi's Stadium", + "km": "Levi's Stadium", + "ms": "Levi's Stadium" + }, + "city": { + "zh": "San Francisco Bay Area", + "en": "San Francisco Bay Area", + "zhTw": null, + "vi": "San Francisco Bay Area", + "km": "San Francisco Bay Area", + "ms": "San Francisco Bay Area" + } + }, + "sortOrder": 32, + "isPublished": true + }, + { + "officialMatchNo": 55, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148791, + "additionMatchId": 20260055, + "channelId": null, + "matchName": "Türkiye - USA", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782439200, + "utcTimeStop": 1782446400, + "utcIso": "2026-06-26T02:00:00Z", + "chinaTime": "2026-06-26 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 19:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 50468, + "name": "Türkiye", + "names": { + "zh": "土耳其", + "en": "Türkiye", + "zhTw": "土耳其", + "vi": "Thổ Nhĩ Kỳ", + "km": "តួកគី", + "ms": "Turki" + }, + "image": "https://flagcdn.com/tr.svg" + }, + "awayTeam": { + "id": 11168, + "name": "USA", + "names": { + "zh": "美国", + "en": "USA", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "SoFi Stadium", + "en": "SoFi Stadium", + "zhTw": null, + "vi": "SoFi Stadium", + "km": "SoFi Stadium", + "ms": "SoFi Stadium" + }, + "city": { + "zh": "洛杉矶", + "en": "Los Angeles", + "zhTw": null, + "vi": "Los Angeles", + "km": "Los Angeles", + "ms": "Los Angeles" + } + }, + "sortOrder": 55, + "isPublished": true + }, + { + "officialMatchNo": 56, + "stage": "group", + "groupName": "D", + "liveMatchId": 20148755, + "additionMatchId": 20260056, + "channelId": null, + "matchName": "Paraguay - Australia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782439200, + "utcTimeStop": 1782446400, + "utcIso": "2026-06-26T02:00:00Z", + "chinaTime": "2026-06-26 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 19:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11148, + "name": "Paraguay", + "names": { + "zh": "巴拉圭", + "en": "Paraguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png" + }, + "awayTeam": { + "id": 11273, + "name": "Australia", + "names": { + "zh": "澳大利亚", + "en": "Australia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Levi's Stadium", + "en": "Levi's Stadium", + "zhTw": null, + "vi": "Levi's Stadium", + "km": "Levi's Stadium", + "ms": "Levi's Stadium" + }, + "city": { + "zh": "San Francisco Bay Area", + "en": "San Francisco Bay Area", + "zhTw": null, + "vi": "San Francisco Bay Area", + "km": "San Francisco Bay Area", + "ms": "San Francisco Bay Area" + } + }, + "sortOrder": 56, + "isPublished": true + }, + { + "officialMatchNo": 9, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148816, + "additionMatchId": 20260009, + "channelId": null, + "matchName": "Germany - Curaçao", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781456400, + "utcTimeStop": 1781463600, + "utcIso": "2026-06-14T17:00:00Z", + "chinaTime": "2026-06-15 01:00:00 Asia/Shanghai", + "venueTime": "2026-06-14 12:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11038, + "name": "Germany", + "names": { + "zh": "德国", + "en": "Germany", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png" + }, + "awayTeam": { + "id": 19979, + "name": "Curaçao", + "names": { + "zh": "库拉索", + "en": "Curacao", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "NRG Stadium", + "en": "NRG Stadium", + "zhTw": null, + "vi": "NRG Stadium", + "km": "NRG Stadium", + "ms": "NRG Stadium" + }, + "city": { + "zh": "休斯敦", + "en": "Houston", + "zhTw": null, + "vi": "Houston", + "km": "Houston", + "ms": "Houston" + } + }, + "sortOrder": 9, + "isPublished": true + }, + { + "officialMatchNo": 11, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148798, + "additionMatchId": 20260011, + "channelId": null, + "matchName": "Côte d'Ivoire - Ecuador", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781478000, + "utcTimeStop": 1781485200, + "utcIso": "2026-06-14T23:00:00Z", + "chinaTime": "2026-06-15 07:00:00 Asia/Shanghai", + "venueTime": "2026-06-14 19:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 50469, + "name": "Côte d'Ivoire", + "names": { + "zh": "科特迪瓦", + "en": "Côte d'Ivoire", + "zhTw": "科特迪瓦", + "vi": "Bờ Biển Ngà", + "km": "កូតឌីវ័រ", + "ms": "Cote dIvoire" + }, + "image": "https://flagcdn.com/ci.svg" + }, + "awayTeam": { + "id": 11151, + "name": "Ecuador", + "names": { + "zh": "厄瓜多尔", + "en": "Ecuador", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lincoln Financial Field", + "en": "Lincoln Financial Field", + "zhTw": null, + "vi": "Lincoln Financial Field", + "km": "Lincoln Financial Field", + "ms": "Lincoln Financial Field" + }, + "city": { + "zh": "费城", + "en": "Philadelphia", + "zhTw": null, + "vi": "Philadelphia", + "km": "Philadelphia", + "ms": "Philadelphia" + } + }, + "sortOrder": 11, + "isPublished": true + }, + { + "officialMatchNo": 34, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148969, + "additionMatchId": 20260034, + "channelId": null, + "matchName": "Germany - Côte d'Ivoire", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781985600, + "utcTimeStop": 1781992800, + "utcIso": "2026-06-20T20:00:00Z", + "chinaTime": "2026-06-21 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-20 16:00:00 America/Toronto", + "venueTimezone": "America/Toronto" + }, + "homeTeam": { + "id": 11038, + "name": "Germany", + "names": { + "zh": "德国", + "en": "Germany", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png" + }, + "awayTeam": { + "id": 50469, + "name": "Côte d'Ivoire", + "names": { + "zh": "科特迪瓦", + "en": "Côte d'Ivoire", + "zhTw": "科特迪瓦", + "vi": "Bờ Biển Ngà", + "km": "កូតឌីវ័រ", + "ms": "Cote dIvoire" + }, + "image": "https://flagcdn.com/ci.svg" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BMO Field", + "en": "BMO Field", + "zhTw": null, + "vi": "BMO Field", + "km": "BMO Field", + "ms": "BMO Field" + }, + "city": { + "zh": "多伦多", + "en": "Toronto", + "zhTw": null, + "vi": "Toronto", + "km": "Toronto", + "ms": "Toronto" + } + }, + "sortOrder": 34, + "isPublished": true + }, + { + "officialMatchNo": 35, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148817, + "additionMatchId": 20260035, + "channelId": null, + "matchName": "Ecuador - Curaçao", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782000000, + "utcTimeStop": 1782007200, + "utcIso": "2026-06-21T00:00:00Z", + "chinaTime": "2026-06-21 08:00:00 Asia/Shanghai", + "venueTime": "2026-06-20 19:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11151, + "name": "Ecuador", + "names": { + "zh": "厄瓜多尔", + "en": "Ecuador", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png" + }, + "awayTeam": { + "id": 19979, + "name": "Curaçao", + "names": { + "zh": "库拉索", + "en": "Curacao", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png" + }, + "status": { + "state": "off", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Arrowhead Stadium", + "en": "Arrowhead Stadium", + "zhTw": null, + "vi": "Arrowhead Stadium", + "km": "Arrowhead Stadium", + "ms": "Arrowhead Stadium" + }, + "city": { + "zh": "堪萨斯城", + "en": "Kansas City", + "zhTw": null, + "vi": "Kansas City", + "km": "Kansas City", + "ms": "Kansas City" + } + }, + "sortOrder": 35, + "isPublished": true + }, + { + "officialMatchNo": 57, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148756, + "additionMatchId": 20260057, + "channelId": null, + "matchName": "Ecuador - Germany", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782417600, + "utcTimeStop": 1782424800, + "utcIso": "2026-06-25T20:00:00Z", + "chinaTime": "2026-06-26 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 16:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11151, + "name": "Ecuador", + "names": { + "zh": "厄瓜多尔", + "en": "Ecuador", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png" + }, + "awayTeam": { + "id": 11038, + "name": "Germany", + "names": { + "zh": "德国", + "en": "Germany", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "MetLife Stadium", + "en": "MetLife Stadium", + "zhTw": null, + "vi": "MetLife Stadium", + "km": "MetLife Stadium", + "ms": "MetLife Stadium" + }, + "city": { + "zh": "纽约/新泽西", + "en": "New York / New Jersey", + "zhTw": null, + "vi": "New York / New Jersey", + "km": "New York / New Jersey", + "ms": "New York / New Jersey" + } + }, + "sortOrder": 57, + "isPublished": true + }, + { + "officialMatchNo": 58, + "stage": "group", + "groupName": "E", + "liveMatchId": 20148841, + "additionMatchId": 20260058, + "channelId": null, + "matchName": "Curaçao - Côte d'Ivoire", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782417600, + "utcTimeStop": 1782424800, + "utcIso": "2026-06-25T20:00:00Z", + "chinaTime": "2026-06-26 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 16:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 19979, + "name": "Curaçao", + "names": { + "zh": "库拉索", + "en": "Curacao", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png" + }, + "awayTeam": { + "id": 50469, + "name": "Côte d'Ivoire", + "names": { + "zh": "科特迪瓦", + "en": "Côte d'Ivoire", + "zhTw": "科特迪瓦", + "vi": "Bờ Biển Ngà", + "km": "កូតឌីវ័រ", + "ms": "Cote dIvoire" + }, + "image": "https://flagcdn.com/ci.svg" + }, + "status": { + "state": "off", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lincoln Financial Field", + "en": "Lincoln Financial Field", + "zhTw": null, + "vi": "Lincoln Financial Field", + "km": "Lincoln Financial Field", + "ms": "Lincoln Financial Field" + }, + "city": { + "zh": "费城", + "en": "Philadelphia", + "zhTw": null, + "vi": "Philadelphia", + "km": "Philadelphia", + "ms": "Philadelphia" + } + }, + "sortOrder": 58, + "isPublished": true + }, + { + "officialMatchNo": 10, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148757, + "additionMatchId": 20260010, + "channelId": null, + "matchName": "Netherlands - Japan", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781467200, + "utcTimeStop": 1781474400, + "utcIso": "2026-06-14T20:00:00Z", + "chinaTime": "2026-06-15 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-14 15:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11034, + "name": "Netherlands", + "names": { + "zh": "荷兰", + "en": "Netherlands", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png" + }, + "awayTeam": { + "id": 11266, + "name": "Japan", + "names": { + "zh": "日本", + "en": "Japan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "AT&T Stadium", + "en": "AT&T Stadium", + "zhTw": null, + "vi": "AT&T Stadium", + "km": "AT&T Stadium", + "ms": "AT&T Stadium" + }, + "city": { + "zh": "达拉斯", + "en": "Dallas", + "zhTw": null, + "vi": "Dallas", + "km": "Dallas", + "ms": "Dallas" + } + }, + "sortOrder": 10, + "isPublished": true + }, + { + "officialMatchNo": 12, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148758, + "additionMatchId": 20260012, + "channelId": null, + "matchName": "Sweden - Tunisia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781488800, + "utcTimeStop": 1781496000, + "utcIso": "2026-06-15T02:00:00Z", + "chinaTime": "2026-06-15 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-14 20:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11032, + "name": "Sweden", + "names": { + "zh": "瑞典", + "en": "Sweden", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png" + }, + "awayTeam": { + "id": 11190, + "name": "Tunisia", + "names": { + "zh": "突尼斯", + "en": "Tunisia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio BBVA", + "en": "Estadio BBVA", + "zhTw": null, + "vi": "Estadio BBVA", + "km": "Estadio BBVA", + "ms": "Estadio BBVA" + }, + "city": { + "zh": "蒙特雷", + "en": "Monterrey", + "zhTw": null, + "vi": "Monterrey", + "km": "Monterrey", + "ms": "Monterrey" + } + }, + "sortOrder": 12, + "isPublished": true + }, + { + "officialMatchNo": 33, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148759, + "additionMatchId": 20260033, + "channelId": null, + "matchName": "Netherlands - Sweden", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781974800, + "utcTimeStop": 1781982000, + "utcIso": "2026-06-20T17:00:00Z", + "chinaTime": "2026-06-21 01:00:00 Asia/Shanghai", + "venueTime": "2026-06-20 12:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11034, + "name": "Netherlands", + "names": { + "zh": "荷兰", + "en": "Netherlands", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png" + }, + "awayTeam": { + "id": 11032, + "name": "Sweden", + "names": { + "zh": "瑞典", + "en": "Sweden", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "NRG Stadium", + "en": "NRG Stadium", + "zhTw": null, + "vi": "NRG Stadium", + "km": "NRG Stadium", + "ms": "NRG Stadium" + }, + "city": { + "zh": "休斯敦", + "en": "Houston", + "zhTw": null, + "vi": "Houston", + "km": "Houston", + "ms": "Houston" + } + }, + "sortOrder": 33, + "isPublished": true + }, + { + "officialMatchNo": 36, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148760, + "additionMatchId": 20260036, + "channelId": null, + "matchName": "Tunisia - Japan", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782014400, + "utcTimeStop": 1782021600, + "utcIso": "2026-06-21T04:00:00Z", + "chinaTime": "2026-06-21 12:00:00 Asia/Shanghai", + "venueTime": "2026-06-20 22:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11190, + "name": "Tunisia", + "names": { + "zh": "突尼斯", + "en": "Tunisia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png" + }, + "awayTeam": { + "id": 11266, + "name": "Japan", + "names": { + "zh": "日本", + "en": "Japan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio BBVA", + "en": "Estadio BBVA", + "zhTw": null, + "vi": "Estadio BBVA", + "km": "Estadio BBVA", + "ms": "Estadio BBVA" + }, + "city": { + "zh": "蒙特雷", + "en": "Monterrey", + "zhTw": null, + "vi": "Monterrey", + "km": "Monterrey", + "ms": "Monterrey" + } + }, + "sortOrder": 36, + "isPublished": true + }, + { + "officialMatchNo": 59, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148761, + "additionMatchId": 20260059, + "channelId": null, + "matchName": "Japan - Sweden", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782428400, + "utcTimeStop": 1782435600, + "utcIso": "2026-06-25T23:00:00Z", + "chinaTime": "2026-06-26 07:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 18:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11266, + "name": "Japan", + "names": { + "zh": "日本", + "en": "Japan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png" + }, + "awayTeam": { + "id": 11032, + "name": "Sweden", + "names": { + "zh": "瑞典", + "en": "Sweden", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "AT&T Stadium", + "en": "AT&T Stadium", + "zhTw": null, + "vi": "AT&T Stadium", + "km": "AT&T Stadium", + "ms": "AT&T Stadium" + }, + "city": { + "zh": "达拉斯", + "en": "Dallas", + "zhTw": null, + "vi": "Dallas", + "km": "Dallas", + "ms": "Dallas" + } + }, + "sortOrder": 59, + "isPublished": true + }, + { + "officialMatchNo": 60, + "stage": "group", + "groupName": "F", + "liveMatchId": 20148762, + "additionMatchId": 20260060, + "channelId": null, + "matchName": "Tunisia - Netherlands", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782428400, + "utcTimeStop": 1782435600, + "utcIso": "2026-06-25T23:00:00Z", + "chinaTime": "2026-06-26 07:00:00 Asia/Shanghai", + "venueTime": "2026-06-25 18:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11190, + "name": "Tunisia", + "names": { + "zh": "突尼斯", + "en": "Tunisia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png" + }, + "awayTeam": { + "id": 11034, + "name": "Netherlands", + "names": { + "zh": "荷兰", + "en": "Netherlands", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Arrowhead Stadium", + "en": "Arrowhead Stadium", + "zhTw": null, + "vi": "Arrowhead Stadium", + "km": "Arrowhead Stadium", + "ms": "Arrowhead Stadium" + }, + "city": { + "zh": "堪萨斯城", + "en": "Kansas City", + "zhTw": null, + "vi": "Kansas City", + "km": "Kansas City", + "ms": "Kansas City" + } + }, + "sortOrder": 60, + "isPublished": true + }, + { + "officialMatchNo": 14, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148763, + "additionMatchId": 20260014, + "channelId": null, + "matchName": "Belgium - Egypt", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781550000, + "utcTimeStop": 1781557200, + "utcIso": "2026-06-15T19:00:00Z", + "chinaTime": "2026-06-16 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-15 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11033, + "name": "Belgium", + "names": { + "zh": "比利时", + "en": "Belgium", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png" + }, + "awayTeam": { + "id": 11109, + "name": "Egypt", + "names": { + "zh": "埃及", + "en": "Egypt", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lumen Field", + "en": "Lumen Field", + "zhTw": null, + "vi": "Lumen Field", + "km": "Lumen Field", + "ms": "Lumen Field" + }, + "city": { + "zh": "西雅图", + "en": "Seattle", + "zhTw": null, + "vi": "Seattle", + "km": "Seattle", + "ms": "Seattle" + } + }, + "sortOrder": 14, + "isPublished": true + }, + { + "officialMatchNo": 16, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148823, + "additionMatchId": 20260016, + "channelId": null, + "matchName": "Iran - New Zealand", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781571600, + "utcTimeStop": 1781578800, + "utcIso": "2026-06-16T01:00:00Z", + "chinaTime": "2026-06-16 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-15 18:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11154, + "name": "Iran", + "names": { + "zh": "伊朗", + "en": "Iran", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png" + }, + "awayTeam": { + "id": 12310, + "name": "New Zealand", + "names": { + "zh": "新西兰", + "en": "New Zealand", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "SoFi Stadium", + "en": "SoFi Stadium", + "zhTw": null, + "vi": "SoFi Stadium", + "km": "SoFi Stadium", + "ms": "SoFi Stadium" + }, + "city": { + "zh": "洛杉矶", + "en": "Los Angeles", + "zhTw": null, + "vi": "Los Angeles", + "km": "Los Angeles", + "ms": "Los Angeles" + } + }, + "sortOrder": 16, + "isPublished": true + }, + { + "officialMatchNo": 38, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148764, + "additionMatchId": 20260038, + "channelId": null, + "matchName": "Belgium - Iran", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782068400, + "utcTimeStop": 1782075600, + "utcIso": "2026-06-21T19:00:00Z", + "chinaTime": "2026-06-22 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-21 12:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11033, + "name": "Belgium", + "names": { + "zh": "比利时", + "en": "Belgium", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png" + }, + "awayTeam": { + "id": 11154, + "name": "Iran", + "names": { + "zh": "伊朗", + "en": "Iran", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "SoFi Stadium", + "en": "SoFi Stadium", + "zhTw": null, + "vi": "SoFi Stadium", + "km": "SoFi Stadium", + "ms": "SoFi Stadium" + }, + "city": { + "zh": "洛杉矶", + "en": "Los Angeles", + "zhTw": null, + "vi": "Los Angeles", + "km": "Los Angeles", + "ms": "Los Angeles" + } + }, + "sortOrder": 38, + "isPublished": true + }, + { + "officialMatchNo": 40, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148796, + "additionMatchId": 20260040, + "channelId": null, + "matchName": "New Zealand - Egypt", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782090000, + "utcTimeStop": 1782097200, + "utcIso": "2026-06-22T01:00:00Z", + "chinaTime": "2026-06-22 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-21 18:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 12310, + "name": "New Zealand", + "names": { + "zh": "新西兰", + "en": "New Zealand", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png" + }, + "awayTeam": { + "id": 11109, + "name": "Egypt", + "names": { + "zh": "埃及", + "en": "Egypt", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BC Place", + "en": "BC Place", + "zhTw": null, + "vi": "BC Place", + "km": "BC Place", + "ms": "BC Place" + }, + "city": { + "zh": "温哥华", + "en": "Vancouver", + "zhTw": null, + "vi": "Vancouver", + "km": "Vancouver", + "ms": "Vancouver" + } + }, + "sortOrder": 40, + "isPublished": true + }, + { + "officialMatchNo": 61, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148765, + "additionMatchId": 20260061, + "channelId": null, + "matchName": "Egypt - Iran", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782529200, + "utcTimeStop": 1782536400, + "utcIso": "2026-06-27T03:00:00Z", + "chinaTime": "2026-06-27 11:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 20:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11109, + "name": "Egypt", + "names": { + "zh": "埃及", + "en": "Egypt", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png" + }, + "awayTeam": { + "id": 11154, + "name": "Iran", + "names": { + "zh": "伊朗", + "en": "Iran", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lumen Field", + "en": "Lumen Field", + "zhTw": null, + "vi": "Lumen Field", + "km": "Lumen Field", + "ms": "Lumen Field" + }, + "city": { + "zh": "西雅图", + "en": "Seattle", + "zhTw": null, + "vi": "Seattle", + "km": "Seattle", + "ms": "Seattle" + } + }, + "sortOrder": 61, + "isPublished": true + }, + { + "officialMatchNo": 62, + "stage": "group", + "groupName": "G", + "liveMatchId": 20148797, + "additionMatchId": 20260062, + "channelId": null, + "matchName": "New Zealand - Belgium", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782529200, + "utcTimeStop": 1782536400, + "utcIso": "2026-06-27T03:00:00Z", + "chinaTime": "2026-06-27 11:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 20:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 12310, + "name": "New Zealand", + "names": { + "zh": "新西兰", + "en": "New Zealand", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png" + }, + "awayTeam": { + "id": 11033, + "name": "Belgium", + "names": { + "zh": "比利时", + "en": "Belgium", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BC Place", + "en": "BC Place", + "zhTw": null, + "vi": "BC Place", + "km": "BC Place", + "ms": "BC Place" + }, + "city": { + "zh": "温哥华", + "en": "Vancouver", + "zhTw": null, + "vi": "Vancouver", + "km": "Vancouver", + "ms": "Vancouver" + } + }, + "sortOrder": 62, + "isPublished": true + }, + { + "officialMatchNo": 13, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148766, + "additionMatchId": 20260013, + "channelId": null, + "matchName": "Spain - Cape Verde", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781539200, + "utcTimeStop": 1781546400, + "utcIso": "2026-06-15T16:00:00Z", + "chinaTime": "2026-06-16 00:00:00 Asia/Shanghai", + "venueTime": "2026-06-15 12:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11144, + "name": "Spain", + "names": { + "zh": "西班牙", + "en": "Spain", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png" + }, + "awayTeam": { + "id": 11161, + "name": "Cape Verde", + "names": { + "zh": "佛得角", + "en": "Cape Verde", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Mercedes-Benz Stadium", + "en": "Mercedes-Benz Stadium", + "zhTw": null, + "vi": "Mercedes-Benz Stadium", + "km": "Mercedes-Benz Stadium", + "ms": "Mercedes-Benz Stadium" + }, + "city": { + "zh": "亚特兰大", + "en": "Atlanta", + "zhTw": null, + "vi": "Atlanta", + "km": "Atlanta", + "ms": "Atlanta" + } + }, + "sortOrder": 13, + "isPublished": true + }, + { + "officialMatchNo": 15, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148767, + "additionMatchId": 20260015, + "channelId": null, + "matchName": "Saudi Arabia - Uruguay", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781560800, + "utcTimeStop": 1781568000, + "utcIso": "2026-06-15T22:00:00Z", + "chinaTime": "2026-06-16 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-15 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11254, + "name": "Saudi Arabia", + "names": { + "zh": "沙特阿拉伯", + "en": "Saudi Arabia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png" + }, + "awayTeam": { + "id": 11139, + "name": "Uruguay", + "names": { + "zh": "乌拉圭", + "en": "Uruguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Hard Rock Stadium", + "en": "Hard Rock Stadium", + "zhTw": null, + "vi": "Hard Rock Stadium", + "km": "Hard Rock Stadium", + "ms": "Hard Rock Stadium" + }, + "city": { + "zh": "迈阿密", + "en": "Miami", + "zhTw": null, + "vi": "Miami", + "km": "Miami", + "ms": "Miami" + } + }, + "sortOrder": 15, + "isPublished": true + }, + { + "officialMatchNo": 37, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148768, + "additionMatchId": 20260037, + "channelId": null, + "matchName": "Spain - Saudi Arabia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782057600, + "utcTimeStop": 1782064800, + "utcIso": "2026-06-21T16:00:00Z", + "chinaTime": "2026-06-22 00:00:00 Asia/Shanghai", + "venueTime": "2026-06-21 12:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11144, + "name": "Spain", + "names": { + "zh": "西班牙", + "en": "Spain", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png" + }, + "awayTeam": { + "id": 11254, + "name": "Saudi Arabia", + "names": { + "zh": "沙特阿拉伯", + "en": "Saudi Arabia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Mercedes-Benz Stadium", + "en": "Mercedes-Benz Stadium", + "zhTw": null, + "vi": "Mercedes-Benz Stadium", + "km": "Mercedes-Benz Stadium", + "ms": "Mercedes-Benz Stadium" + }, + "city": { + "zh": "亚特兰大", + "en": "Atlanta", + "zhTw": null, + "vi": "Atlanta", + "km": "Atlanta", + "ms": "Atlanta" + } + }, + "sortOrder": 37, + "isPublished": true + }, + { + "officialMatchNo": 39, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148769, + "additionMatchId": 20260039, + "channelId": null, + "matchName": "Uruguay - Cape Verde", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782079200, + "utcTimeStop": 1782086400, + "utcIso": "2026-06-21T22:00:00Z", + "chinaTime": "2026-06-22 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-21 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11139, + "name": "Uruguay", + "names": { + "zh": "乌拉圭", + "en": "Uruguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png" + }, + "awayTeam": { + "id": 11161, + "name": "Cape Verde", + "names": { + "zh": "佛得角", + "en": "Cape Verde", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Hard Rock Stadium", + "en": "Hard Rock Stadium", + "zhTw": null, + "vi": "Hard Rock Stadium", + "km": "Hard Rock Stadium", + "ms": "Hard Rock Stadium" + }, + "city": { + "zh": "迈阿密", + "en": "Miami", + "zhTw": null, + "vi": "Miami", + "km": "Miami", + "ms": "Miami" + } + }, + "sortOrder": 39, + "isPublished": true + }, + { + "officialMatchNo": 63, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148770, + "additionMatchId": 20260063, + "channelId": null, + "matchName": "Cape Verde - Saudi Arabia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782518400, + "utcTimeStop": 1782525600, + "utcIso": "2026-06-27T00:00:00Z", + "chinaTime": "2026-06-27 08:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 19:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11161, + "name": "Cape Verde", + "names": { + "zh": "佛得角", + "en": "Cape Verde", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png" + }, + "awayTeam": { + "id": 11254, + "name": "Saudi Arabia", + "names": { + "zh": "沙特阿拉伯", + "en": "Saudi Arabia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "NRG Stadium", + "en": "NRG Stadium", + "zhTw": null, + "vi": "NRG Stadium", + "km": "NRG Stadium", + "ms": "NRG Stadium" + }, + "city": { + "zh": "休斯敦", + "en": "Houston", + "zhTw": null, + "vi": "Houston", + "km": "Houston", + "ms": "Houston" + } + }, + "sortOrder": 63, + "isPublished": true + }, + { + "officialMatchNo": 64, + "stage": "group", + "groupName": "H", + "liveMatchId": 20148771, + "additionMatchId": 20260064, + "channelId": null, + "matchName": "Uruguay - Spain", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782518400, + "utcTimeStop": 1782525600, + "utcIso": "2026-06-27T00:00:00Z", + "chinaTime": "2026-06-27 08:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 18:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11139, + "name": "Uruguay", + "names": { + "zh": "乌拉圭", + "en": "Uruguay", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png" + }, + "awayTeam": { + "id": 11144, + "name": "Spain", + "names": { + "zh": "西班牙", + "en": "Spain", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Akron", + "en": "Estadio Akron", + "zhTw": null, + "vi": "Estadio Akron", + "km": "Estadio Akron", + "ms": "Estadio Akron" + }, + "city": { + "zh": "瓜达拉哈拉", + "en": "Guadalajara", + "zhTw": null, + "vi": "Guadalajara", + "km": "Guadalajara", + "ms": "Guadalajara" + } + }, + "sortOrder": 64, + "isPublished": true + }, + { + "officialMatchNo": 17, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148772, + "additionMatchId": 20260017, + "channelId": null, + "matchName": "France - Senegal", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781636400, + "utcTimeStop": 1781643600, + "utcIso": "2026-06-16T19:00:00Z", + "chinaTime": "2026-06-17 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-16 15:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11037, + "name": "France", + "names": { + "zh": "法国", + "en": "France", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png" + }, + "awayTeam": { + "id": 11184, + "name": "Senegal", + "names": { + "zh": "塞内加尔", + "en": "Senegal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "MetLife Stadium", + "en": "MetLife Stadium", + "zhTw": null, + "vi": "MetLife Stadium", + "km": "MetLife Stadium", + "ms": "MetLife Stadium" + }, + "city": { + "zh": "纽约/新泽西", + "en": "New York / New Jersey", + "zhTw": null, + "vi": "New York / New Jersey", + "km": "New York / New Jersey", + "ms": "New York / New Jersey" + } + }, + "sortOrder": 17, + "isPublished": true + }, + { + "officialMatchNo": 18, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148773, + "additionMatchId": 20260018, + "channelId": null, + "matchName": "Iraq - Norway", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781647200, + "utcTimeStop": 1781654400, + "utcIso": "2026-06-16T22:00:00Z", + "chinaTime": "2026-06-17 06:00:00 Asia/Shanghai", + "venueTime": "2026-06-16 18:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11238, + "name": "Iraq", + "names": { + "zh": "伊拉克", + "en": "Iraq", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png" + }, + "awayTeam": { + "id": 11029, + "name": "Norway", + "names": { + "zh": "挪威", + "en": "Norway", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Gillette Stadium", + "en": "Gillette Stadium", + "zhTw": null, + "vi": "Gillette Stadium", + "km": "Gillette Stadium", + "ms": "Gillette Stadium" + }, + "city": { + "zh": "波士顿", + "en": "Boston", + "zhTw": null, + "vi": "Boston", + "km": "Boston", + "ms": "Boston" + } + }, + "sortOrder": 18, + "isPublished": true + }, + { + "officialMatchNo": 41, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148774, + "additionMatchId": 20260041, + "channelId": null, + "matchName": "France - Iraq", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782162000, + "utcTimeStop": 1782169200, + "utcIso": "2026-06-22T21:00:00Z", + "chinaTime": "2026-06-23 05:00:00 Asia/Shanghai", + "venueTime": "2026-06-22 17:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11037, + "name": "France", + "names": { + "zh": "法国", + "en": "France", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png" + }, + "awayTeam": { + "id": 11238, + "name": "Iraq", + "names": { + "zh": "伊拉克", + "en": "Iraq", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lincoln Financial Field", + "en": "Lincoln Financial Field", + "zhTw": null, + "vi": "Lincoln Financial Field", + "km": "Lincoln Financial Field", + "ms": "Lincoln Financial Field" + }, + "city": { + "zh": "费城", + "en": "Philadelphia", + "zhTw": null, + "vi": "Philadelphia", + "km": "Philadelphia", + "ms": "Philadelphia" + } + }, + "sortOrder": 41, + "isPublished": true + }, + { + "officialMatchNo": 42, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148775, + "additionMatchId": 20260042, + "channelId": null, + "matchName": "Norway - Senegal", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782172800, + "utcTimeStop": 1782180000, + "utcIso": "2026-06-23T00:00:00Z", + "chinaTime": "2026-06-23 08:00:00 Asia/Shanghai", + "venueTime": "2026-06-22 20:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11029, + "name": "Norway", + "names": { + "zh": "挪威", + "en": "Norway", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png" + }, + "awayTeam": { + "id": 11184, + "name": "Senegal", + "names": { + "zh": "塞内加尔", + "en": "Senegal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "MetLife Stadium", + "en": "MetLife Stadium", + "zhTw": null, + "vi": "MetLife Stadium", + "km": "MetLife Stadium", + "ms": "MetLife Stadium" + }, + "city": { + "zh": "纽约/新泽西", + "en": "New York / New Jersey", + "zhTw": null, + "vi": "New York / New Jersey", + "km": "New York / New Jersey", + "ms": "New York / New Jersey" + } + }, + "sortOrder": 42, + "isPublished": true + }, + { + "officialMatchNo": 65, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148776, + "additionMatchId": 20260065, + "channelId": null, + "matchName": "Norway - France", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782500400, + "utcTimeStop": 1782507600, + "utcIso": "2026-06-26T19:00:00Z", + "chinaTime": "2026-06-27 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 15:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11029, + "name": "Norway", + "names": { + "zh": "挪威", + "en": "Norway", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png" + }, + "awayTeam": { + "id": 11037, + "name": "France", + "names": { + "zh": "法国", + "en": "France", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Gillette Stadium", + "en": "Gillette Stadium", + "zhTw": null, + "vi": "Gillette Stadium", + "km": "Gillette Stadium", + "ms": "Gillette Stadium" + }, + "city": { + "zh": "波士顿", + "en": "Boston", + "zhTw": null, + "vi": "Boston", + "km": "Boston", + "ms": "Boston" + } + }, + "sortOrder": 65, + "isPublished": true + }, + { + "officialMatchNo": 66, + "stage": "group", + "groupName": "I", + "liveMatchId": 20148777, + "additionMatchId": 20260066, + "channelId": null, + "matchName": "Senegal - Iraq", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782500400, + "utcTimeStop": 1782507600, + "utcIso": "2026-06-26T19:00:00Z", + "chinaTime": "2026-06-27 03:00:00 Asia/Shanghai", + "venueTime": "2026-06-26 15:00:00 America/Toronto", + "venueTimezone": "America/Toronto" + }, + "homeTeam": { + "id": 11184, + "name": "Senegal", + "names": { + "zh": "塞内加尔", + "en": "Senegal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png" + }, + "awayTeam": { + "id": 11238, + "name": "Iraq", + "names": { + "zh": "伊拉克", + "en": "Iraq", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BMO Field", + "en": "BMO Field", + "zhTw": null, + "vi": "BMO Field", + "km": "BMO Field", + "ms": "BMO Field" + }, + "city": { + "zh": "多伦多", + "en": "Toronto", + "zhTw": null, + "vi": "Toronto", + "km": "Toronto", + "ms": "Toronto" + } + }, + "sortOrder": 66, + "isPublished": true + }, + { + "officialMatchNo": 19, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148818, + "additionMatchId": 20260019, + "channelId": null, + "matchName": "Argentina - Algeria", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781658000, + "utcTimeStop": 1781665200, + "utcIso": "2026-06-17T01:00:00Z", + "chinaTime": "2026-06-17 09:00:00 Asia/Shanghai", + "venueTime": "2026-06-16 20:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11138, + "name": "Argentina", + "names": { + "zh": "阿根廷", + "en": "Argentina", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png" + }, + "awayTeam": { + "id": 20195, + "name": "Algeria", + "names": { + "zh": "阿尔及利亚", + "en": "Algeria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Arrowhead Stadium", + "en": "Arrowhead Stadium", + "zhTw": null, + "vi": "Arrowhead Stadium", + "km": "Arrowhead Stadium", + "ms": "Arrowhead Stadium" + }, + "city": { + "zh": "堪萨斯城", + "en": "Kansas City", + "zhTw": null, + "vi": "Kansas City", + "km": "Kansas City", + "ms": "Kansas City" + } + }, + "sortOrder": 19, + "isPublished": true + }, + { + "officialMatchNo": 20, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148778, + "additionMatchId": 20260020, + "channelId": null, + "matchName": "Austria - Jordan", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781668800, + "utcTimeStop": 1781676000, + "utcIso": "2026-06-17T04:00:00Z", + "chinaTime": "2026-06-17 12:00:00 Asia/Shanghai", + "venueTime": "2026-06-16 21:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11035, + "name": "Austria", + "names": { + "zh": "奥地利", + "en": "Austria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png" + }, + "awayTeam": { + "id": 11244, + "name": "Jordan", + "names": { + "zh": "约旦", + "en": "Jordan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Levi's Stadium", + "en": "Levi's Stadium", + "zhTw": null, + "vi": "Levi's Stadium", + "km": "Levi's Stadium", + "ms": "Levi's Stadium" + }, + "city": { + "zh": "San Francisco Bay Area", + "en": "San Francisco Bay Area", + "zhTw": null, + "vi": "San Francisco Bay Area", + "km": "San Francisco Bay Area", + "ms": "San Francisco Bay Area" + } + }, + "sortOrder": 20, + "isPublished": true + }, + { + "officialMatchNo": 43, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148779, + "additionMatchId": 20260043, + "channelId": null, + "matchName": "Argentina - Austria", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782147600, + "utcTimeStop": 1782154800, + "utcIso": "2026-06-22T17:00:00Z", + "chinaTime": "2026-06-23 01:00:00 Asia/Shanghai", + "venueTime": "2026-06-22 12:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11138, + "name": "Argentina", + "names": { + "zh": "阿根廷", + "en": "Argentina", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png" + }, + "awayTeam": { + "id": 11035, + "name": "Austria", + "names": { + "zh": "奥地利", + "en": "Austria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "AT&T Stadium", + "en": "AT&T Stadium", + "zhTw": null, + "vi": "AT&T Stadium", + "km": "AT&T Stadium", + "ms": "AT&T Stadium" + }, + "city": { + "zh": "达拉斯", + "en": "Dallas", + "zhTw": null, + "vi": "Dallas", + "km": "Dallas", + "ms": "Dallas" + } + }, + "sortOrder": 43, + "isPublished": true + }, + { + "officialMatchNo": 44, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148819, + "additionMatchId": 20260044, + "channelId": null, + "matchName": "Jordan - Algeria", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782183600, + "utcTimeStop": 1782190800, + "utcIso": "2026-06-23T03:00:00Z", + "chinaTime": "2026-06-23 11:00:00 Asia/Shanghai", + "venueTime": "2026-06-22 20:00:00 America/Los_Angeles", + "venueTimezone": "America/Los_Angeles" + }, + "homeTeam": { + "id": 11244, + "name": "Jordan", + "names": { + "zh": "约旦", + "en": "Jordan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png" + }, + "awayTeam": { + "id": 20195, + "name": "Algeria", + "names": { + "zh": "阿尔及利亚", + "en": "Algeria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Levi's Stadium", + "en": "Levi's Stadium", + "zhTw": null, + "vi": "Levi's Stadium", + "km": "Levi's Stadium", + "ms": "Levi's Stadium" + }, + "city": { + "zh": "San Francisco Bay Area", + "en": "San Francisco Bay Area", + "zhTw": null, + "vi": "San Francisco Bay Area", + "km": "San Francisco Bay Area", + "ms": "San Francisco Bay Area" + } + }, + "sortOrder": 44, + "isPublished": true + }, + { + "officialMatchNo": 67, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148792, + "additionMatchId": 20260067, + "channelId": null, + "matchName": "Algeria - Austria", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782612000, + "utcTimeStop": 1782619200, + "utcIso": "2026-06-28T02:00:00Z", + "chinaTime": "2026-06-28 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-27 21:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 20195, + "name": "Algeria", + "names": { + "zh": "阿尔及利亚", + "en": "Algeria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png" + }, + "awayTeam": { + "id": 11035, + "name": "Austria", + "names": { + "zh": "奥地利", + "en": "Austria", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Arrowhead Stadium", + "en": "Arrowhead Stadium", + "zhTw": null, + "vi": "Arrowhead Stadium", + "km": "Arrowhead Stadium", + "ms": "Arrowhead Stadium" + }, + "city": { + "zh": "堪萨斯城", + "en": "Kansas City", + "zhTw": null, + "vi": "Kansas City", + "km": "Kansas City", + "ms": "Kansas City" + } + }, + "sortOrder": 67, + "isPublished": true + }, + { + "officialMatchNo": 68, + "stage": "group", + "groupName": "J", + "liveMatchId": 20148780, + "additionMatchId": 20260068, + "channelId": null, + "matchName": "Jordan - Argentina", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782612000, + "utcTimeStop": 1782619200, + "utcIso": "2026-06-28T02:00:00Z", + "chinaTime": "2026-06-28 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-27 21:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11244, + "name": "Jordan", + "names": { + "zh": "约旦", + "en": "Jordan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png" + }, + "awayTeam": { + "id": 11138, + "name": "Argentina", + "names": { + "zh": "阿根廷", + "en": "Argentina", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "AT&T Stadium", + "en": "AT&T Stadium", + "zhTw": null, + "vi": "AT&T Stadium", + "km": "AT&T Stadium", + "ms": "AT&T Stadium" + }, + "city": { + "zh": "达拉斯", + "en": "Dallas", + "zhTw": null, + "vi": "Dallas", + "km": "Dallas", + "ms": "Dallas" + } + }, + "sortOrder": 68, + "isPublished": true + }, + { + "officialMatchNo": 21, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148820, + "additionMatchId": 20260021, + "channelId": null, + "matchName": "Portugal - DR Congo", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781715600, + "utcTimeStop": 1781722800, + "utcIso": "2026-06-17T17:00:00Z", + "chinaTime": "2026-06-18 01:00:00 Asia/Shanghai", + "venueTime": "2026-06-17 12:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11137, + "name": "Portugal", + "names": { + "zh": "葡萄牙", + "en": "Portugal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png" + }, + "awayTeam": { + "id": 50470, + "name": "DR Congo", + "names": { + "zh": "刚果民主共和国", + "en": "DR Congo", + "zhTw": "刚果民主共和国", + "vi": "CHDC Congo", + "km": "DR Congo", + "ms": "DR Congo" + }, + "image": "https://flagcdn.com/cd.svg" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "NRG Stadium", + "en": "NRG Stadium", + "zhTw": null, + "vi": "NRG Stadium", + "km": "NRG Stadium", + "ms": "NRG Stadium" + }, + "city": { + "zh": "休斯敦", + "en": "Houston", + "zhTw": null, + "vi": "Houston", + "km": "Houston", + "ms": "Houston" + } + }, + "sortOrder": 21, + "isPublished": true + }, + { + "officialMatchNo": 24, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148781, + "additionMatchId": 20260024, + "channelId": null, + "matchName": "Uzbekistan - Colombia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781748000, + "utcTimeStop": 1781755200, + "utcIso": "2026-06-18T02:00:00Z", + "chinaTime": "2026-06-18 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-17 20:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11239, + "name": "Uzbekistan", + "names": { + "zh": "乌兹别克斯坦", + "en": "Uzbekistan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png" + }, + "awayTeam": { + "id": 11147, + "name": "Colombia", + "names": { + "zh": "哥伦比亚", + "en": "Colombia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Azteca", + "en": "Estadio Azteca", + "zhTw": null, + "vi": "Estadio Azteca", + "km": "Estadio Azteca", + "ms": "Estadio Azteca" + }, + "city": { + "zh": "墨西哥城", + "en": "Mexico City", + "zhTw": null, + "vi": "Mexico City", + "km": "Mexico City", + "ms": "Mexico City" + } + }, + "sortOrder": 24, + "isPublished": true + }, + { + "officialMatchNo": 45, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148782, + "additionMatchId": 20260045, + "channelId": null, + "matchName": "Portugal - Uzbekistan", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782234000, + "utcTimeStop": 1782241200, + "utcIso": "2026-06-23T17:00:00Z", + "chinaTime": "2026-06-24 01:00:00 Asia/Shanghai", + "venueTime": "2026-06-23 12:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11137, + "name": "Portugal", + "names": { + "zh": "葡萄牙", + "en": "Portugal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png" + }, + "awayTeam": { + "id": 11239, + "name": "Uzbekistan", + "names": { + "zh": "乌兹别克斯坦", + "en": "Uzbekistan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "NRG Stadium", + "en": "NRG Stadium", + "zhTw": null, + "vi": "NRG Stadium", + "km": "NRG Stadium", + "ms": "NRG Stadium" + }, + "city": { + "zh": "休斯敦", + "en": "Houston", + "zhTw": null, + "vi": "Houston", + "km": "Houston", + "ms": "Houston" + } + }, + "sortOrder": 45, + "isPublished": true + }, + { + "officialMatchNo": 48, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148821, + "additionMatchId": 20260048, + "channelId": null, + "matchName": "Colombia - DR Congo", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782266400, + "utcTimeStop": 1782273600, + "utcIso": "2026-06-24T02:00:00Z", + "chinaTime": "2026-06-24 10:00:00 Asia/Shanghai", + "venueTime": "2026-06-23 20:00:00 America/Mexico_City", + "venueTimezone": "America/Mexico_City" + }, + "homeTeam": { + "id": 11147, + "name": "Colombia", + "names": { + "zh": "哥伦比亚", + "en": "Colombia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png" + }, + "awayTeam": { + "id": 50470, + "name": "DR Congo", + "names": { + "zh": "刚果民主共和国", + "en": "DR Congo", + "zhTw": "刚果民主共和国", + "vi": "CHDC Congo", + "km": "DR Congo", + "ms": "DR Congo" + }, + "image": "https://flagcdn.com/cd.svg" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Estadio Akron", + "en": "Estadio Akron", + "zhTw": null, + "vi": "Estadio Akron", + "km": "Estadio Akron", + "ms": "Estadio Akron" + }, + "city": { + "zh": "瓜达拉哈拉", + "en": "Guadalajara", + "zhTw": null, + "vi": "Guadalajara", + "km": "Guadalajara", + "ms": "Guadalajara" + } + }, + "sortOrder": 48, + "isPublished": true + }, + { + "officialMatchNo": 69, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148783, + "additionMatchId": 20260069, + "channelId": null, + "matchName": "Colombia - Portugal", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782603000, + "utcTimeStop": 1782610200, + "utcIso": "2026-06-27T23:30:00Z", + "chinaTime": "2026-06-28 07:30:00 Asia/Shanghai", + "venueTime": "2026-06-27 19:30:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11147, + "name": "Colombia", + "names": { + "zh": "哥伦比亚", + "en": "Colombia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png" + }, + "awayTeam": { + "id": 11137, + "name": "Portugal", + "names": { + "zh": "葡萄牙", + "en": "Portugal", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Hard Rock Stadium", + "en": "Hard Rock Stadium", + "zhTw": null, + "vi": "Hard Rock Stadium", + "km": "Hard Rock Stadium", + "ms": "Hard Rock Stadium" + }, + "city": { + "zh": "迈阿密", + "en": "Miami", + "zhTw": null, + "vi": "Miami", + "km": "Miami", + "ms": "Miami" + } + }, + "sortOrder": 69, + "isPublished": true + }, + { + "officialMatchNo": 70, + "stage": "group", + "groupName": "K", + "liveMatchId": 20148793, + "additionMatchId": 20260070, + "channelId": null, + "matchName": "DR Congo - Uzbekistan", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782603000, + "utcTimeStop": 1782610200, + "utcIso": "2026-06-27T23:30:00Z", + "chinaTime": "2026-06-28 07:30:00 Asia/Shanghai", + "venueTime": "2026-06-27 19:30:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 50470, + "name": "DR Congo", + "names": { + "zh": "刚果民主共和国", + "en": "DR Congo", + "zhTw": "刚果民主共和国", + "vi": "CHDC Congo", + "km": "DR Congo", + "ms": "DR Congo" + }, + "image": "https://flagcdn.com/cd.svg" + }, + "awayTeam": { + "id": 11239, + "name": "Uzbekistan", + "names": { + "zh": "乌兹别克斯坦", + "en": "Uzbekistan", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Mercedes-Benz Stadium", + "en": "Mercedes-Benz Stadium", + "zhTw": null, + "vi": "Mercedes-Benz Stadium", + "km": "Mercedes-Benz Stadium", + "ms": "Mercedes-Benz Stadium" + }, + "city": { + "zh": "亚特兰大", + "en": "Atlanta", + "zhTw": null, + "vi": "Atlanta", + "km": "Atlanta", + "ms": "Atlanta" + } + }, + "sortOrder": 70, + "isPublished": true + }, + { + "officialMatchNo": 22, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148784, + "additionMatchId": 20260022, + "channelId": null, + "matchName": "England - Croatia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781726400, + "utcTimeStop": 1781733600, + "utcIso": "2026-06-17T20:00:00Z", + "chinaTime": "2026-06-18 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-17 15:00:00 America/Chicago", + "venueTimezone": "America/Chicago" + }, + "homeTeam": { + "id": 11116, + "name": "England", + "names": { + "zh": "英格兰", + "en": "England", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png" + }, + "awayTeam": { + "id": 11140, + "name": "Croatia", + "names": { + "zh": "克罗地亚", + "en": "Croatia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "AT&T Stadium", + "en": "AT&T Stadium", + "zhTw": null, + "vi": "AT&T Stadium", + "km": "AT&T Stadium", + "ms": "AT&T Stadium" + }, + "city": { + "zh": "达拉斯", + "en": "Dallas", + "zhTw": null, + "vi": "Dallas", + "km": "Dallas", + "ms": "Dallas" + } + }, + "sortOrder": 22, + "isPublished": true + }, + { + "officialMatchNo": 23, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148967, + "additionMatchId": 20260023, + "channelId": null, + "matchName": "Ghana - Panama", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1781737200, + "utcTimeStop": 1781744400, + "utcIso": "2026-06-17T23:00:00Z", + "chinaTime": "2026-06-18 07:00:00 Asia/Shanghai", + "venueTime": "2026-06-17 19:00:00 America/Toronto", + "venueTimezone": "America/Toronto" + }, + "homeTeam": { + "id": 11179, + "name": "Ghana", + "names": { + "zh": "加纳", + "en": "Ghana", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png" + }, + "awayTeam": { + "id": 11169, + "name": "Panama", + "names": { + "zh": "巴拿马", + "en": "Panama", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BMO Field", + "en": "BMO Field", + "zhTw": null, + "vi": "BMO Field", + "km": "BMO Field", + "ms": "BMO Field" + }, + "city": { + "zh": "多伦多", + "en": "Toronto", + "zhTw": null, + "vi": "Toronto", + "km": "Toronto", + "ms": "Toronto" + } + }, + "sortOrder": 23, + "isPublished": true + }, + { + "officialMatchNo": 46, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148786, + "additionMatchId": 20260046, + "channelId": null, + "matchName": "England - Ghana", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782244800, + "utcTimeStop": 1782252000, + "utcIso": "2026-06-23T20:00:00Z", + "chinaTime": "2026-06-24 04:00:00 Asia/Shanghai", + "venueTime": "2026-06-23 16:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11116, + "name": "England", + "names": { + "zh": "英格兰", + "en": "England", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png" + }, + "awayTeam": { + "id": 11179, + "name": "Ghana", + "names": { + "zh": "加纳", + "en": "Ghana", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Gillette Stadium", + "en": "Gillette Stadium", + "zhTw": null, + "vi": "Gillette Stadium", + "km": "Gillette Stadium", + "ms": "Gillette Stadium" + }, + "city": { + "zh": "波士顿", + "en": "Boston", + "zhTw": null, + "vi": "Boston", + "km": "Boston", + "ms": "Boston" + } + }, + "sortOrder": 46, + "isPublished": true + }, + { + "officialMatchNo": 47, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148787, + "additionMatchId": 20260047, + "channelId": null, + "matchName": "Panama - Croatia", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782255600, + "utcTimeStop": 1782262800, + "utcIso": "2026-06-23T23:00:00Z", + "chinaTime": "2026-06-24 07:00:00 Asia/Shanghai", + "venueTime": "2026-06-23 19:00:00 America/Toronto", + "venueTimezone": "America/Toronto" + }, + "homeTeam": { + "id": 11169, + "name": "Panama", + "names": { + "zh": "巴拿马", + "en": "Panama", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png" + }, + "awayTeam": { + "id": 11140, + "name": "Croatia", + "names": { + "zh": "克罗地亚", + "en": "Croatia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "BMO Field", + "en": "BMO Field", + "zhTw": null, + "vi": "BMO Field", + "km": "BMO Field", + "ms": "BMO Field" + }, + "city": { + "zh": "多伦多", + "en": "Toronto", + "zhTw": null, + "vi": "Toronto", + "km": "Toronto", + "ms": "Toronto" + } + }, + "sortOrder": 47, + "isPublished": true + }, + { + "officialMatchNo": 71, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148788, + "additionMatchId": 20260071, + "channelId": null, + "matchName": "Panama - England", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782594000, + "utcTimeStop": 1782601200, + "utcIso": "2026-06-27T21:00:00Z", + "chinaTime": "2026-06-28 05:00:00 Asia/Shanghai", + "venueTime": "2026-06-27 17:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11169, + "name": "Panama", + "names": { + "zh": "巴拿马", + "en": "Panama", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png" + }, + "awayTeam": { + "id": 11116, + "name": "England", + "names": { + "zh": "英格兰", + "en": "England", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "MetLife Stadium", + "en": "MetLife Stadium", + "zhTw": null, + "vi": "MetLife Stadium", + "km": "MetLife Stadium", + "ms": "MetLife Stadium" + }, + "city": { + "zh": "纽约/新泽西", + "en": "New York / New Jersey", + "zhTw": null, + "vi": "New York / New Jersey", + "km": "New York / New Jersey", + "ms": "New York / New Jersey" + } + }, + "sortOrder": 71, + "isPublished": true + }, + { + "officialMatchNo": 72, + "stage": "group", + "groupName": "L", + "liveMatchId": 20148789, + "additionMatchId": 20260072, + "channelId": null, + "matchName": "Croatia - Ghana", + "league": { + "type": "FOOTBALL", + "en": "FIFA World Cup 2026", + "zh": "2026 世界杯" + }, + "kickoff": { + "utcTimeStart": 1782594000, + "utcTimeStop": 1782601200, + "utcIso": "2026-06-27T21:00:00Z", + "chinaTime": "2026-06-28 05:00:00 Asia/Shanghai", + "venueTime": "2026-06-27 17:00:00 America/New_York", + "venueTimezone": "America/New_York" + }, + "homeTeam": { + "id": 11140, + "name": "Croatia", + "names": { + "zh": "克罗地亚", + "en": "Croatia", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png" + }, + "awayTeam": { + "id": 11179, + "name": "Ghana", + "names": { + "zh": "加纳", + "en": "Ghana", + "zhTw": "", + "vi": null, + "km": null, + "ms": null + }, + "image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png" + }, + "status": { + "state": "scheduled", + "isHot": 1000 + }, + "venue": { + "names": { + "zh": "Lincoln Financial Field", + "en": "Lincoln Financial Field", + "zhTw": null, + "vi": "Lincoln Financial Field", + "km": "Lincoln Financial Field", + "ms": "Lincoln Financial Field" + }, + "city": { + "zh": "费城", + "en": "Philadelphia", + "zhTw": null, + "vi": "Philadelphia", + "km": "Philadelphia", + "ms": "Philadelphia" + } + }, + "sortOrder": 72, + "isPublished": true + } + ] +} diff --git a/apps/api/src/infrastructure/database/seed-data/wc2026-zhibo-team-map.ts b/apps/api/src/infrastructure/database/seed-data/wc2026-zhibo-team-map.ts new file mode 100644 index 0000000..ce5f40e --- /dev/null +++ b/apps/api/src/infrastructure/database/seed-data/wc2026-zhibo-team-map.ts @@ -0,0 +1,103 @@ +/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */ +export const WC2026_ZIBO_ID_TO_CODE: Record = { + 11029: 'NOR', + 11030: 'SCO', + 11032: 'SWE', + 11033: 'BEL', + 11034: 'NED', + 11035: 'AUT', + 11036: 'SUI', + 11037: 'FRA', + 11038: 'GER', + 11109: 'EGY', + 11116: 'ENG', + 11137: 'POR', + 11138: 'ARG', + 11139: 'URU', + 11140: 'CRO', + 11144: 'ESP', + 11147: 'COL', + 11148: 'PAR', + 11150: 'BRA', + 11151: 'ECU', + 11153: 'BIH', + 11154: 'IRN', + 11161: 'CPV', + 11166: 'CAN', + 11168: 'USA', + 11169: 'PAN', + 11173: 'RSA', + 11179: 'GHA', + 11182: 'MAR', + 11184: 'SEN', + 11188: 'MEX', + 11190: 'TUN', + 11238: 'IRQ', + 11239: 'UZB', + 11244: 'JOR', + 11254: 'KSA', + 11261: 'KOR', + 11266: 'JPN', + 11267: 'QAT', + 11270: 'HAI', + 11273: 'AUS', + 12310: 'NZL', + 19979: 'CUW', + 20195: 'ALG', + 50467: 'CZE', + 50468: 'TUR', + 50469: 'CIV', + 50470: 'COD', +}; + +/** zhibo 球队 logo(seed 时写入 teams.logo_url) */ +export const WC2026_TEAM_LOGO_BY_CODE: Record = { + ALG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png', + ARG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png', + AUS: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png', + AUT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png', + BEL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png', + BIH: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png', + BRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png', + CAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png', + CIV: 'https://flagcdn.com/ci.svg', + COD: 'https://flagcdn.com/cd.svg', + COL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png', + CPV: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png', + CRO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png', + CUW: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png', + CZE: 'https://flagcdn.com/cz.svg', + ECU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png', + EGY: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png', + ENG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png', + ESP: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png', + FRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png', + GER: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png', + GHA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png', + HAI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png', + IRN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png', + IRQ: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png', + JOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png', + JPN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png', + KOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png', + KSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png', + MAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png', + MEX: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png', + NED: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png', + NOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png', + NZL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png', + PAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png', + PAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png', + POR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png', + QAT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png', + RSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png', + SCO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png', + SEN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png', + SUI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png', + SWE: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png', + TUN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png', + TUR: 'https://flagcdn.com/tr.svg', + URU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png', + USA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png', + UZB: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png', +}; diff --git a/apps/api/src/infrastructure/database/seed-demo-markets.ts b/apps/api/src/infrastructure/database/seed-demo-markets.ts new file mode 100644 index 0000000..6548a5f --- /dev/null +++ b/apps/api/src/infrastructure/database/seed-demo-markets.ts @@ -0,0 +1,165 @@ +import type { PrismaClient } from '@prisma/client'; + +/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */ +export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) { + const configs: Array<{ + marketType: string; + period: string; + lineValue?: number; + sortOrder: number; + selections: Array<{ code: string; name: string; odds: number }>; + }> = [ + { + marketType: 'FT_1X2', + period: 'FT', + sortOrder: 1, + selections: [ + { code: 'HOME', name: '主胜', odds: 2.5 }, + { code: 'DRAW', name: '和', odds: 3.2 }, + { code: 'AWAY', name: '客胜', odds: 2.8 }, + ], + }, + { + marketType: 'FT_HANDICAP', + period: 'FT', + lineValue: -0.5, + sortOrder: 2, + selections: [ + { code: 'HOME', name: '主 -0.5', odds: 1.9 }, + { code: 'AWAY', name: '客 +0.5', odds: 1.9 }, + ], + }, + { + marketType: 'FT_OVER_UNDER', + period: 'FT', + lineValue: 2.5, + sortOrder: 3, + selections: [ + { code: 'OVER', name: '大 2.5', odds: 1.85 }, + { code: 'UNDER', name: '小 2.5', odds: 1.95 }, + ], + }, + { + marketType: 'FT_ODD_EVEN', + period: 'FT', + sortOrder: 4, + selections: [ + { code: 'ODD', name: '单', odds: 1.9 }, + { code: 'EVEN', name: '双', odds: 1.9 }, + ], + }, + { + marketType: 'HT_1X2', + period: 'HT', + sortOrder: 5, + selections: [ + { code: 'HOME', name: '半场主', odds: 3.0 }, + { code: 'DRAW', name: '半场和', odds: 2.0 }, + { code: 'AWAY', name: '半场客', odds: 3.5 }, + ], + }, + { + marketType: 'HT_HANDICAP', + period: 'HT', + lineValue: -0.5, + sortOrder: 6, + selections: [ + { code: 'HOME', name: '半场主 -0.5', odds: 1.9 }, + { code: 'AWAY', name: '半场客 +0.5', odds: 1.9 }, + ], + }, + { + marketType: 'HT_OVER_UNDER', + period: 'HT', + lineValue: 1.5, + sortOrder: 7, + selections: [ + { code: 'OVER', name: '半场大 1.5', odds: 2.0 }, + { code: 'UNDER', name: '半场小 1.5', odds: 1.75 }, + ], + }, + { + marketType: 'FT_CORRECT_SCORE', + period: 'FT', + sortOrder: 8, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.86 }, + { code: 'SCORE_2_0', name: '2-0', odds: 5.22 }, + { code: 'SCORE_2_1', name: '2-1', odds: 7.92 }, + { code: 'SCORE_3_0', name: '3-0', odds: 8.28 }, + { code: 'SCORE_0_0', name: '0-0', odds: 8.64 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.47 }, + { code: 'SCORE_2_2', name: '2-2', odds: 24.3 }, + { code: 'SCORE_3_3', name: '3-3', odds: 175.5 }, + { code: 'SCORE_0_1', name: '0-1', odds: 14.4 }, + { code: 'SCORE_0_2', name: '0-2', odds: 45.9 }, + { code: 'SCORE_1_2', name: '1-2', odds: 23.4 }, + { code: 'SCORE_0_3', name: '0-3', odds: 207 }, + ], + }, + { + marketType: 'HT_CORRECT_SCORE', + period: 'HT', + sortOrder: 9, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, + { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, + { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, + { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, + { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, + ], + }, + { + marketType: 'SH_CORRECT_SCORE', + period: 'SH', + sortOrder: 10, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, + { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, + { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, + { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, + { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, + ], + }, + ]; + + for (const cfg of configs) { + const exists = await prisma.market.findFirst({ + where: { matchId, marketType: cfg.marketType }, + include: { _count: { select: { selections: true } } }, + }); + if (exists) { + const needRefresh = + cfg.marketType.includes('CORRECT_SCORE') && + exists._count.selections < cfg.selections.length; + if (needRefresh) { + await prisma.market.delete({ where: { id: exists.id } }); + } else { + continue; + } + } + await prisma.market.create({ + data: { + matchId, + marketType: cfg.marketType, + period: cfg.period, + lineValue: cfg.lineValue, + allowSingle: true, + allowParlay: true, + sortOrder: cfg.sortOrder, + status: 'OPEN', + selections: { + create: cfg.selections.map((s, i) => ({ + selectionCode: s.code, + selectionName: s.name, + odds: s.odds, + sortOrder: i, + status: 'OPEN', + })), + }, + }, + }); + } +} diff --git a/apps/api/src/shared/common/app-error.ts b/apps/api/src/shared/common/app-error.ts index 40f9f31..0f0d3ee 100644 --- a/apps/api/src/shared/common/app-error.ts +++ b/apps/api/src/shared/common/app-error.ts @@ -31,6 +31,10 @@ export function appForbidden(code: ApiErrorCode, params?: ApiErrorParams) { return new ForbiddenException(body(code, params)); } +export function appConflict(code: ApiErrorCode, data?: unknown, params?: ApiErrorParams) { + return new HttpException({ ...body(code, params), data: data ?? null }, HttpStatus.CONFLICT); +} + export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) { return new UnauthorizedException(body(code, params)); } diff --git a/apps/api/src/shared/common/filters.ts b/apps/api/src/shared/common/filters.ts index 8c3ca58..8dffe16 100644 --- a/apps/api/src/shared/common/filters.ts +++ b/apps/api/src/shared/common/filters.ts @@ -27,6 +27,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { let status = HttpStatus.INTERNAL_SERVER_ERROR; let code: ApiErrorCode = 'INTERNAL_SERVER_ERROR'; let params: ApiErrorParams | undefined; + let extraData: unknown = null; let message = formatApiErrorMessage('INTERNAL_SERVER_ERROR', locale); if (exception instanceof HttpException) { @@ -37,6 +38,9 @@ export class GlobalExceptionFilter implements ExceptionFilter { code = res.code; params = res.params; message = formatApiErrorMessage(code, locale, params); + if (typeof res === 'object' && res !== null && 'data' in res) { + extraData = (res as { data?: unknown }).data ?? null; + } } else if (typeof res === 'string') { message = res; } else if (typeof res === 'object' && res !== null) { @@ -60,7 +64,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { error: message, code, params: params ?? null, - data: null, + data: extraData, }); } } diff --git a/apps/api/src/shared/uploads/delete-upload-file.ts b/apps/api/src/shared/uploads/delete-upload-file.ts new file mode 100644 index 0000000..cb62a1c --- /dev/null +++ b/apps/api/src/shared/uploads/delete-upload-file.ts @@ -0,0 +1,18 @@ +import { unlink } from 'fs/promises'; +import { join } from 'path'; +import { getUploadRoot } from './upload-paths'; + +const UPLOAD_URL_PREFIX = '/uploads/'; + +/** 按 `/uploads/{category}/{filename}` 删除磁盘文件;路径非法或文件不存在时静默跳过 */ +export async function deleteUploadFileByUrl(url: string): Promise { + if (!url?.startsWith(UPLOAD_URL_PREFIX)) return; + const relative = url.slice(UPLOAD_URL_PREFIX.length); + if (!relative || relative.includes('..') || relative.includes('\\')) return; + const root = getUploadRoot(); + try { + await unlink(join(root, relative)); + } catch { + /* already removed */ + } +} diff --git a/apps/player/src/api/index.ts b/apps/player/src/api/index.ts index 9bfface..5aa3212 100644 --- a/apps/player/src/api/index.ts +++ b/apps/player/src/api/index.ts @@ -1,4 +1,13 @@ import axios from 'axios'; +import { isApiErrorCode } from '@thebet365/shared'; +import { useAuthStore } from '../stores/auth'; + +const ACCOUNT_BLOCKED_CODES = new Set([ + 'ACCOUNT_SUSPENDED', + 'ACCOUNT_DISABLED', + 'AGENT_ACCOUNT_SUSPENDED', + 'PARENT_AGENT_SUSPENDED', +]); const api = axios.create({ baseURL: '/api' }); @@ -12,16 +21,28 @@ api.interceptors.request.use((config) => { return config; }); +function clearSession() { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + try { + useAuthStore().logout(); + } catch { + // Pinia may not be ready during bootstrap + } +} + api.interceptors.response.use( (res) => res, (err) => { - if (err.response?.status === 401) { - const url: string = err.config?.url ?? ''; - // Don't redirect on login/auth failures — let the caller handle the error - if (!url.includes('/auth/login') && !url.includes('/auth/register')) { - localStorage.removeItem('token'); - // 不再强制跳转登录页,让调用方处理 401 - } + const status = err.response?.status; + const url: string = err.config?.url ?? ''; + const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register'); + const code = err.response?.data?.code; + const blockedAccount = + typeof code === 'string' && isApiErrorCode(code) && ACCOUNT_BLOCKED_CODES.has(code); + + if (!isAuthEndpoint && (status === 401 || (status === 403 && blockedAccount))) { + clearSession(); } return Promise.reject(err); }, diff --git a/apps/player/src/components/TeamEmblem.vue b/apps/player/src/components/TeamEmblem.vue index 23acb52..11ef812 100644 --- a/apps/player/src/components/TeamEmblem.vue +++ b/apps/player/src/components/TeamEmblem.vue @@ -52,6 +52,10 @@ watch( .team-emblem { flex-shrink: 0; display: block; +} + +/* 队徽(非国旗):圆角 + 投影 */ +.team-emblem--logo { border-radius: 4px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); } @@ -74,6 +78,8 @@ watch( /* 国旗:横向比例 + 铺满 */ .team-emblem:not(.team-emblem--logo) { object-fit: cover; + border-radius: 3px; + background: transparent; } .team-emblem--sm:not(.team-emblem--logo) { diff --git a/apps/player/src/i18n/en-US.ts b/apps/player/src/i18n/en-US.ts index 277899a..48082c6 100644 --- a/apps/player/src/i18n/en-US.ts +++ b/apps/player/src/i18n/en-US.ts @@ -78,6 +78,8 @@ export default { go_register: 'No account? Register now', have_account: 'Already have an account? Log in', register_btn: 'Register', + registering: 'Registering…', + sending_sms: 'Sending…', register_failed: 'Registration failed, please try again', continue_browsing: 'Skip login', username_placeholder: 'Enter username', @@ -195,7 +197,7 @@ export default { amount_label: 'Amount', amount_placeholder: 'Enter recharge amount', screenshot_label: 'Upload Screenshot', - upload_hint: 'Click to upload screenshot (max 5MB)', + upload_hint: 'Click to upload (max 10MB original, compressed to within 1MB)', compressing: 'Compressing', submit: 'Submit', submitting: 'Submitting', @@ -208,7 +210,8 @@ export default { upload_screenshot: 'Please upload a screenshot', submit_failed: 'Submit failed, please retry', file_must_be_image: 'Please upload an image file', - file_too_large: 'File exceeds 10MB', + file_too_large: 'Original file must be 10MB or less', + compress_failed: 'Could not compress the image to within 1MB. Try a smaller screenshot.', status_pending: 'Processing', status_approved: 'Approved', status_rejected: 'Rejected', diff --git a/apps/player/src/i18n/ms-MY.ts b/apps/player/src/i18n/ms-MY.ts index 0c8f663..ab0a4b4 100644 --- a/apps/player/src/i18n/ms-MY.ts +++ b/apps/player/src/i18n/ms-MY.ts @@ -84,6 +84,8 @@ export default { go_register: 'Tiada akaun? Daftar sekarang', have_account: 'Sudah ada akaun? Log masuk', register_btn: 'Daftar', + registering: 'Mendaftar…', + sending_sms: 'Menghantar…', register_failed: 'Pendaftaran gagal, sila cuba lagi', continue_browsing: 'Langkau log masuk', username_placeholder: 'Masukkan nama pengguna', @@ -201,7 +203,7 @@ export default { amount_label: 'Jumlah', amount_placeholder: 'Masukkan jumlah topup', screenshot_label: 'Muat Naik Screenshot', - upload_hint: 'Klik untuk muat naik (maks 5MB)', + upload_hint: 'Klik untuk muat naik (asal maks 10MB, dimampatkan ≤1MB)', compressing: 'Memampat', submit: 'Hantar', submitting: 'Menghantar', @@ -214,7 +216,8 @@ export default { upload_screenshot: 'Sila muat naik screenshot', submit_failed: 'Gagal, sila cuba lagi', file_must_be_image: 'Sila muat naik fail imej', - file_too_large: 'Fail melebihi 10MB', + file_too_large: 'Fail asal melebihi 10MB', + compress_failed: 'Gagal memampatkan imej ke ≤1MB. Sila gunakan tangkapan skrin lebih kecil.', status_pending: 'Memproses', status_approved: 'Diluluskan', status_rejected: 'Ditolak', diff --git a/apps/player/src/i18n/zh-CN.ts b/apps/player/src/i18n/zh-CN.ts index b8fde02..b2ad79a 100644 --- a/apps/player/src/i18n/zh-CN.ts +++ b/apps/player/src/i18n/zh-CN.ts @@ -78,6 +78,8 @@ export default { go_register: '没有账号?立即注册', have_account: '已有账号?去登录', register_btn: '注册', + registering: '注册中…', + sending_sms: '发送中…', register_failed: '注册失败,请重试', continue_browsing: '暂不登录', username_placeholder: '请输入账号', @@ -195,7 +197,7 @@ export default { amount_label: '充值金额', amount_placeholder: '请输入充值金额', screenshot_label: '上传转账截图', - upload_hint: '点击上传截图(最大 5MB)', + upload_hint: '点击上传截图(原图不超过 10MB,将自动压缩至 1MB 以内)', compressing: '压缩中', submit: '提交充值', submitting: '提交中', @@ -208,7 +210,8 @@ export default { upload_screenshot: '请上传转账截图', submit_failed: '提交失败,请重试', file_must_be_image: '请上传图片文件', - file_too_large: '文件不能超过 10MB', + file_too_large: '原图不能超过 10MB', + compress_failed: '图片压缩失败或未压到 1MB 以内,请换一张更小的截图', status_pending: '充值中', status_approved: '已通过', status_rejected: '已拒绝', diff --git a/apps/player/src/views/MatchDetailView.vue b/apps/player/src/views/MatchDetailView.vue index ea26dab..b9eb83b 100644 --- a/apps/player/src/views/MatchDetailView.vue +++ b/apps/player/src/views/MatchDetailView.vue @@ -67,6 +67,7 @@ interface MatchDetail { status?: string; bettingOpen?: boolean; matchPhase?: MatchPhase; + correctScoreEnabled?: boolean; score?: { htHome: number; htAway: number; @@ -138,6 +139,14 @@ const marketsByType = computed(() => { return map; }); +const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']); + +const visibleMarketTypes = computed(() => { + const csEnabled = match.value?.correctScoreEnabled ?? true; + if (csEnabled) return DETAIL_MARKET_TYPES; + return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t)); +}); + function marketPromoLabel(marketType: string) { const m = marketsByType.value.get(marketType); return m?.promoLabel?.trim() || ''; @@ -472,7 +481,7 @@ function hasSlipPickForMarket(marketType: string) {
{ + const baseOptions = { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + useWebWorker: true, + maxIteration: 15, + } as const; + + const attempts = [ + { ...baseOptions, initialQuality: 0.85 }, + { ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 }, + { ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 }, + ]; + + for (const options of attempts) { + const compressed = (await imageCompression(file, options)) as File; + if (compressed.size <= MAX_SCREENSHOT_BYTES) { + return compressed; + } + } + + throw new Error('COMPRESS_TOO_LARGE'); +} + async function handleFileChange(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; @@ -70,27 +97,22 @@ async function handleFileChange(event: Event) { return; } - // Max 10MB before compression - if (file.size > 10 * 1024 * 1024) { + if (file.size > MAX_ORIGINAL_BYTES) { alert(t('recharge.file_too_large')); input.value = ''; return; } - // Compress image compressing.value = true; try { - const compressed = await imageCompression(file, { - maxSizeMB: 1, - maxWidthOrHeight: 1920, - useWebWorker: true, - }); - screenshotFile.value = compressed as File; + const compressed = await compressScreenshot(file); + screenshotFile.value = compressed; screenshotPreview.value = URL.createObjectURL(compressed); } catch { - // Fallback: use original if compression fails - screenshotFile.value = file; - screenshotPreview.value = URL.createObjectURL(file); + alert(t('recharge.compress_failed')); + screenshotFile.value = null; + screenshotPreview.value = ''; + input.value = ''; } finally { compressing.value = false; } @@ -115,6 +137,10 @@ async function handleSubmit() { alert(t('recharge.upload_screenshot')); return; } + if (screenshotFile.value.size > MAX_SCREENSHOT_BYTES) { + alert(t('recharge.compress_failed')); + return; + } submitting.value = true; try { diff --git a/apps/player/src/views/RegisterView.vue b/apps/player/src/views/RegisterView.vue index 51d8611..2f957f7 100644 --- a/apps/player/src/views/RegisterView.vue +++ b/apps/player/src/views/RegisterView.vue @@ -8,6 +8,7 @@ import { useAppLocale } from '../composables/useAppLocale'; import { useSmsCode } from '../composables/useSmsCode'; import LocaleSwitcher from '../components/LocaleSwitcher.vue'; import PhoneCountrySelect from '../components/PhoneCountrySelect.vue'; +import GoldSpinner from '../components/GoldSpinner.vue'; import loginBg from '../assets/images/h5bg.webp'; const { t, locale } = useI18n(); @@ -104,7 +105,11 @@ const fieldError = () => { -
@@ -144,40 +151,55 @@ const fieldError = () => { inputmode="numeric" maxlength="6" autocomplete="one-time-code" + :disabled="loading" :placeholder="t('auth.sms_code_placeholder')" />
- +
- +
- +

{{ fieldError() }}

- - @@ -212,6 +234,7 @@ const fieldError = () => { } .login-form { + position: relative; width: 100%; max-width: 320px; display: flex; @@ -220,6 +243,40 @@ const fieldError = () => { padding: 12px; } +.login-form.is-busy { + pointer-events: none; +} + +.form-busy-overlay { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + border-radius: inherit; + background: rgba(8, 8, 8, 0.72); + backdrop-filter: blur(2px); + pointer-events: all; +} + +.form-busy-text { + margin: 0; + font-size: 13px; + font-weight: 700; + color: rgba(240, 216, 117, 0.95); + letter-spacing: 0.04em; +} + +.btn-inline-loading { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + .form-title { margin: 0 0 2px; font-size: 16px; @@ -315,6 +372,10 @@ label { cursor: not-allowed; } +.btn-login.is-loading:disabled { + opacity: 1; +} + .btn-skip { margin-top: 2px; padding: 8px 14px; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e5e1711..c3f96c4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -40,25 +40,14 @@ services: context: . dockerfile: docker/api/Dockerfile container_name: thebet365-api + env_file: + - .env.docker environment: DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365 REDIS_URL: redis://redis:6379 - JWT_SECRET: ${JWT_SECRET:-change-me-in-production-use-long-random-string} - JWT_PLAYER_EXPIRES: ${JWT_PLAYER_EXPIRES:-24h} - JWT_ADMIN_EXPIRES: ${JWT_ADMIN_EXPIRES:-2h} - JWT_AGENT_EXPIRES: ${JWT_AGENT_EXPIRES:-8h} PORT: 3000 NODE_ENV: production UPLOAD_DIR: /app/uploads - SEED_DATABASE: ${SEED_DATABASE:-false} - CHUANGLAN_ACCOUNT: ${CHUANGLAN_ACCOUNT} - CHUANGLAN_PASSWORD: ${CHUANGLAN_PASSWORD} - CHUANGLAN_ENDPOINT: ${CHUANGLAN_ENDPOINT:-https://sgap.253.com/send/sms} - CHUANGLAN_CONNECT_TIMEOUT_MS: ${CHUANGLAN_CONNECT_TIMEOUT_MS:-10000} - CHUANGLAN_READ_TIMEOUT_MS: ${CHUANGLAN_READ_TIMEOUT_MS:-10000} - SMS_CODE_TTL_SECONDS: ${SMS_CODE_TTL_SECONDS:-300} - SMS_RATE_LIMIT_SECONDS: ${SMS_RATE_LIMIT_SECONDS:-60} - SMS_DEBUG_LOG_CODE: ${SMS_DEBUG_LOG_CODE:-false} volumes: - uploads_data:/app/uploads depends_on: diff --git a/docs/Docker部署指南.md b/docs/Docker部署指南.md index e2be310..5b8e312 100644 --- a/docs/Docker部署指南.md +++ b/docs/Docker部署指南.md @@ -127,7 +127,7 @@ API 端口 3000 建议**不要**直接公网暴露;Swagger 仅供内网或通 ## 五、演示账号 -`SEED_DATABASE=true` 首次启动后可用(详见 [默认数据说明.md](./默认数据说明.md)): +`SEED_DATABASE=true` 首次启动后可用(生产环境**仅创建 admin**,不含代理/玩家;详见 [默认数据说明.md](./默认数据说明.md)): | 角色 | 用户名 | 密码 | 入口 | |------|--------|------|------| @@ -149,9 +149,14 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker down -v # 仅重建 API docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build api -# 手动种子(容器已运行时) +# 手动种子(容器已运行时,仅增量写入,不会删除已有数据) docker compose -f docker-compose.prod.yml --env-file .env.docker exec api npx prisma db seed +# 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/) +CONFIRM=YES ./scripts/prod-init-db.sh +# Windows PowerShell: +# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1 + # 查看 API 日志 docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api ``` diff --git a/package.json b/package.json index 5014e11..331ca84 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "db:migrate": "pnpm --filter @thebet365/api db:migrate", "db:migrate:deploy": "pnpm --filter @thebet365/api db:migrate:deploy", "db:seed": "pnpm --filter @thebet365/api db:seed", + "db:reset": "pnpm --filter @thebet365/api db:reset", + "db:reset:dev": "pnpm --filter @thebet365/api db:reset:dev", "db:studio": "pnpm --filter @thebet365/api db:studio", "docker:up": "docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build", "docker:down": "docker compose -f docker-compose.prod.yml --env-file .env.docker down", diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts index 5a980cf..70af44a 100644 --- a/packages/shared/src/api-errors.ts +++ b/packages/shared/src/api-errors.ts @@ -19,6 +19,11 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Account disabled', 'ms-MY': 'Akaun telah dinyahaktifkan', }, + ACCOUNT_SUSPENDED: { + 'zh-CN': '账号已冻结', + 'en-US': 'Account suspended', + 'ms-MY': 'Akaun digantung', + }, AGENT_ACCOUNT_SUSPENDED: { 'zh-CN': '代理账号已停用', 'en-US': 'Agent account suspended', @@ -89,10 +94,15 @@ export const API_ERROR_MESSAGES = { 'en-US': 'League not found', 'ms-MY': 'Liga tidak dijumpai', }, - LEAGUE_UNPUBLISH_FORBIDDEN: { - 'zh-CN': '已发布的联赛不可下架', - 'en-US': 'Published leagues cannot be unpublished', - 'ms-MY': 'Liga yang diterbitkan tidak boleh ditarik', + LEAGUE_UNPUBLISH_SETTLED: { + 'zh-CN': '联赛冠军盘已结算,不可下架', + 'en-US': 'Cannot unpublish league after outright market is settled', + 'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan', + }, + MATCH_UNPUBLISH_FORBIDDEN: { + 'zh-CN': '当前状态不可下架', + 'en-US': 'Match cannot be unpublished in current status', + 'ms-MY': 'Perlawanan tidak boleh ditarik dalam status semasa', }, TEAM_CODE_REQUIRED: { 'zh-CN': '请填写球队代码', @@ -159,6 +169,21 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Match has bets and cannot be deleted', 'ms-MY': 'Perlawanan mempunyai pertaruhan dan tidak boleh dipadam', }, + ARCHIVE_BLOCKED: { + 'zh-CN': '存在未结注单或未结算状态,需确认强制删除', + 'en-US': 'Unsettled bets or match state require forced archive', + 'ms-MY': 'Pertaruhan belum selesai atau status perlawanan memerlukan arkib paksa', + }, + LEAGUE_ARCHIVE_NOT_READY: { + 'zh-CN': '联赛下仍有未结算赛事或未结注单,无法删除', + 'en-US': 'League still has unsettled fixtures or pending bets', + 'ms-MY': 'Liga masih mempunyai perlawanan belum selesai atau pertaruhan tertunda', + }, + ALREADY_ARCHIVED: { + 'zh-CN': '已删除或已隐藏', + 'en-US': 'Already archived', + 'ms-MY': 'Sudah diarkibkan', + }, MATCHES_ARRAY_REQUIRED: { 'zh-CN': '请提供 matches 数组', 'en-US': 'matches array is required', @@ -194,6 +219,11 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Pre-match betting only; match has started', 'ms-MY': 'Pertaruhan pra-perlawanan sahaja; perlawanan telah bermula', }, + CORRECT_SCORE_DISABLED: { + 'zh-CN': '该赛事未开放波胆投注', + 'en-US': 'Correct score betting is disabled for this match', + 'ms-MY': 'Pertaruhan skor tepat tidak dibuka untuk perlawanan ini', + }, ODDS_CHANGED: { 'zh-CN': '赔率已变更,请重新确认', 'en-US': 'Odds changed, please confirm again', @@ -289,6 +319,21 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Player not found', 'ms-MY': 'Pemain tidak dijumpai', }, + NOT_PLAYER: { + 'zh-CN': '该用户不是玩家', + 'en-US': 'User is not a player', + 'ms-MY': 'Pengguna bukan pemain', + }, + PLAYER_HAS_PENDING_BETS: { + 'zh-CN': '玩家仍有未结算注单,无法删除', + 'en-US': 'Player has pending bets and cannot be deleted', + 'ms-MY': 'Pemain masih ada pertaruhan belum selesai dan tidak boleh dipadam', + }, + PLAYER_HAS_BALANCE: { + 'zh-CN': '玩家钱包仍有余额,无法删除', + 'en-US': 'Player wallet still has balance and cannot be deleted', + 'ms-MY': 'Dompet pemain masih ada baki dan tidak boleh dipadam', + }, MANAGE_DIRECT_PLAYERS_ONLY: { 'zh-CN': '仅可管理直属玩家', 'en-US': 'Can only manage direct players', @@ -609,6 +654,11 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Invalid selection for this outright event', 'ms-MY': 'Pilihan tidak sah untuk outright ini', }, + OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED: { + 'zh-CN': '该联赛仍有未结算的单场赛事,请先完成单场结算后再结算冠军盘', + 'en-US': 'This league still has unsettled fixture matches. Settle them before settling the outright market.', + 'ms-MY': 'Liga ini masih ada perlawanan belum diselesaikan. Selesaikan dahulu sebelum juara.', + }, OUTRIGHT_EVENT_NOT_FOUND: { 'zh-CN': '冠军盘赛事不存在', 'en-US': 'Outright event not found', @@ -794,6 +844,26 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Order is not in pending status', 'ms-MY': 'Pesanan bukan dalam status menunggu', }, + ORDER_ALREADY_PENDING: { + 'zh-CN': '订单已是待审核状态', + 'en-US': 'Order is already pending review', + 'ms-MY': 'Pesanan sudah menunggu semakan', + }, + ORDER_NOT_APPROVED: { + 'zh-CN': '仅已通过的充值订单可撤销', + 'en-US': 'Only approved deposit orders can be revoked', + 'ms-MY': 'Hanya pesanan deposit yang diluluskan boleh dibatalkan', + }, + DEPOSIT_REVOKE_WINDOW_EXPIRED: { + 'zh-CN': '批准已超过 5 分钟,无法撤回', + 'en-US': 'Approval was more than 5 minutes ago; revoke is no longer allowed', + 'ms-MY': 'Kelulusan melebihi 5 minit; pembatalan tidak dibenarkan', + }, + DEPOSIT_REVOKE_SETTLED_BETS: { + 'zh-CN': '批准后有注单已结算,无法撤回;请先处理相关注单', + 'en-US': 'Bets placed after approval have already settled; revoke is blocked', + 'ms-MY': 'Terdapat pertaruhan selepas kelulusan yang telah diselesaikan; pembatalan disekat', + }, REASON_REQUIRED: { 'zh-CN': '请填写拒绝原因', 'en-US': 'Rejection reason is required', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3b6f86..6cf4778 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: apps/admin: dependencies: + '@thebet365/shared': + specifier: workspace:* + version: link:../../packages/shared axios: specifier: ^1.7.9 version: 1.16.1 @@ -35,6 +38,9 @@ importers: '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(terser@5.48.0))(vue@3.5.35(typescript@5.7.3)) + rollup-plugin-visualizer: + specifier: ^7.0.1 + version: 7.0.1(rollup@4.61.0) typescript: specifier: ^5.7.3 version: 5.7.3 @@ -1629,6 +1635,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1637,6 +1647,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1757,6 +1771,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1856,6 +1874,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -1993,9 +2015,21 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2070,6 +2104,9 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2284,6 +2321,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2408,6 +2449,11 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -2416,6 +2462,15 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -2435,6 +2490,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2921,6 +2980,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -3045,6 +3108,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + pretty-format@29.0.0: resolution: {integrity: sha512-tMkFRn1vxRwZdiDETcveuNeonRKDg4doOvI+iyb1sOAtxYioGzRicqnsr+d3C/lLv9hBiM/2lDBi5ilR81h2bQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3155,6 +3222,19 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.61.0: resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3164,6 +3244,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -3298,6 +3382,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3313,6 +3401,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3721,6 +3813,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3728,6 +3824,10 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3739,10 +3839,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -5287,12 +5395,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + ansis@4.2.0: {} anymatch@3.1.3: @@ -5457,6 +5569,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -5547,10 +5663,16 @@ snapshots: cliui@8.0.1: dependencies: - string-width: 4.2.3 + string-width: 4.2.0 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.0 + clone@1.0.4: {} cluster-key-slot@1.1.1: {} @@ -5663,10 +5785,19 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 + define-lazy-prop@3.0.0: {} + defu@6.1.7: {} delayed-stream@1.0.0: {} @@ -5738,6 +5869,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} empathic@2.0.0: {} @@ -5997,6 +6130,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6140,10 +6275,18 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-number@7.0.0: {} @@ -6154,6 +6297,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6803,6 +6950,15 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -6918,6 +7074,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + pretty-format@29.0.0: dependencies: '@jest/schemas': 29.0.0 @@ -7019,6 +7177,15 @@ snapshots: onetime: 5.1.0 signal-exit: 3.0.7 + rollup-plugin-visualizer@7.0.1(rollup@4.61.0): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.4 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.61.0 + rollup@4.61.0: dependencies: '@types/estree': 1.0.9 @@ -7060,6 +7227,8 @@ snapshots: transitivePeerDependencies: - supports-color + run-applescript@7.1.0: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -7211,6 +7380,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -7227,6 +7402,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -7557,8 +7736,14 @@ snapshots: wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + string-width: 4.1.0 + strip-ansi: 6.0.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 wrappy@1.0.2: {} @@ -7567,12 +7752,19 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + y18n@5.0.8: {} yallist@3.1.1: {} yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -7583,6 +7775,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/scripts/prod-init-db.ps1 b/scripts/prod-init-db.ps1 new file mode 100644 index 0000000..6f832b0 --- /dev/null +++ b/scripts/prod-init-db.ps1 @@ -0,0 +1,65 @@ +# 生产上线:清空全部业务数据,仅保留 admin + WC2026 赛事示例 +# +# 用法(PowerShell,项目根目录): +# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1 +# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1 -SkipBackup + +param( + [switch]$SkipBackup +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$ComposeFile = Join-Path $Root "docker-compose.prod.yml" +$EnvFile = Join-Path $Root ".env.docker" + +if ($env:CONFIRM -ne "YES") { + Write-Host "错误:将删除全部业务数据,仅保留 admin 与 WC2026 赛事示例。" + Write-Host "" + Write-Host '确认后执行: $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1' + exit 1 +} + +$composeArgs = @("-f", $ComposeFile) +if (Test-Path $EnvFile) { + $composeArgs += @("--env-file", $EnvFile) +} + +Set-Location $Root + +Write-Host "[prod-init-db] 检查容器…" +docker compose @composeArgs ps --status running api postgres 2>$null | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Host "[prod-init-db] 请先启动 docker compose 栈" + exit 1 +} + +if (-not $SkipBackup) { + $backupDir = Join-Path $Root "backups" + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $backupFile = Join-Path $backupDir "thebet365-$stamp.sql" + Write-Host "[prod-init-db] 备份 → $backupFile" + docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $backupFile +} else { + Write-Host "[prod-init-db] 已跳过备份" +} + +Write-Host "[prod-init-db] 迁移…" +docker compose @composeArgs exec -T api sh -c "cd /app/apps/api && npx prisma migrate deploy && npx prisma generate" + +Write-Host "[prod-init-db] 生产模式初始化…" +docker compose @composeArgs exec -T ` + -e INIT_DATABASE_CONFIRM=YES ` + -e ALLOW_DB_RESET=true ` + -e SEED_MODE=production ` + -e NODE_ENV=production ` + api ` + node dist/infrastructure/database/reset-and-seed-cli.js --yes --production + +Write-Host "[prod-init-db] 清空 Redis…" +docker compose @composeArgs exec -T redis redis-cli FLUSHALL 2>$null | Out-Null + +Write-Host "" +Write-Host "[prod-init-db] 完成。管理员 admin / Admin@123,含 WC2026 赛事示例。" +Write-Host "请将 .env.docker 中 SEED_DATABASE 设为 false 后 restart api" diff --git a/scripts/prod-init-db.sh b/scripts/prod-init-db.sh new file mode 100644 index 0000000..ba0084a --- /dev/null +++ b/scripts/prod-init-db.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# 生产上线:清空全部业务数据,仅保留 admin + WC2026 赛事示例(无代理/玩家/充值/注单) +# +# 与「pnpm db:seed」的区别: +# db:seed 增量写入,不删已有玩家、充值订单等 +# 本脚本 TRUNCATE 全部业务表 + production seed +# +# 用法(项目根目录,Linux 服务器): +# chmod +x scripts/prod-init-db.sh +# CONFIRM=YES ./scripts/prod-init-db.sh +# CONFIRM=YES ./scripts/prod-init-db.sh --skip-backup +# +# 前置:docker compose -f docker-compose.prod.yml up -d 且 api/postgres 在运行 + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE=(docker compose -f "$ROOT/docker-compose.prod.yml") +ENV_FILE="$ROOT/.env.docker" +SKIP_BACKUP=false + +if [ "${CONFIRM:-}" != "YES" ]; then + echo "错误:将删除全部业务数据(玩家、代理、充值、注单、审计等),仅保留 admin 与 WC2026 赛事示例。" + echo "" + echo "若已确认,请执行:" + echo " CONFIRM=YES $0 [--skip-backup]" + exit 1 +fi + +for arg in "$@"; do + if [ "$arg" = "--skip-backup" ]; then + SKIP_BACKUP=true + fi +done + +if [ -f "$ENV_FILE" ]; then + COMPOSE+=(--env-file "$ENV_FILE") +fi + +cd "$ROOT" + +echo "[prod-init-db] 检查容器…" +"${COMPOSE[@]}" ps --status running api postgres redis >/dev/null 2>&1 || { + echo "[prod-init-db] 请先启动: docker compose -f docker-compose.prod.yml --env-file .env.docker up -d" + exit 1 +} + +if [ "$SKIP_BACKUP" = false ]; then + BACKUP_DIR="$ROOT/backups" + mkdir -p "$BACKUP_DIR" + STAMP="$(date +%Y%m%d-%H%M%S)" + BACKUP_FILE="$BACKUP_DIR/thebet365-$STAMP.sql" + echo "[prod-init-db] 备份 PostgreSQL → $BACKUP_FILE" + "${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$BACKUP_FILE" + echo "[prod-init-db] 备份完成" +else + echo "[prod-init-db] 已跳过备份 (--skip-backup)" +fi + +echo "[prod-init-db] 执行数据库迁移…" +"${COMPOSE[@]}" exec -T api sh -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate' + +echo "[prod-init-db] 生产模式初始化(admin + WC2026 赛事)…" +"${COMPOSE[@]}" exec -T \ + -e INIT_DATABASE_CONFIRM=YES \ + -e ALLOW_DB_RESET=true \ + -e SEED_MODE=production \ + -e NODE_ENV=production \ + api \ + node dist/infrastructure/database/reset-and-seed-cli.js --yes --production + +echo "[prod-init-db] 清空 Redis…" +"${COMPOSE[@]}" exec -T redis redis-cli FLUSHALL >/dev/null || true + +echo "" +echo "[prod-init-db] 完成。" +echo " 管理员: admin / Admin@123(建议登录后立即修改密码)" +echo " 赛事: WC2026 小组赛 72 场 + 48 强优胜盘" +echo "" +echo "后续建议:" +echo " 1. .env.docker 中 SEED_DATABASE=false,避免重启 api 重复 seed" +echo " 2. docker compose -f docker-compose.prod.yml --env-file .env.docker restart api" +echo " 3. 勿长期保留 ALLOW_DB_RESET=true"