feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档
重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "correct_score_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -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")
|
||||
|
||||
152
apps/api/scripts/build-wc2026-seed-json.mjs
Normal file
152
apps/api/scripts/build-wc2026-seed-json.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 从 zhibo 导出的 world-cup-group-stage-matches.json 生成 seed 用裁剪 JSON 与球队映射 TS。
|
||||
* 用法: node apps/api/scripts/build-wc2026-seed-json.mjs <源JSON路径>
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = path.resolve(__dirname, '..');
|
||||
const seedDataDir = path.join(apiRoot, 'src/infrastructure/database/seed-data');
|
||||
const outrightTeamsPath = path.join(apiRoot, 'src/domains/catalog/wc2026-outright-teams.ts');
|
||||
|
||||
const sourcePath = process.argv[2];
|
||||
if (!sourcePath) {
|
||||
console.error('用法: node build-wc2026-seed-json.mjs <源JSON路径>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.resolve(sourcePath), 'utf8'));
|
||||
const outrightSrc = fs.readFileSync(outrightTeamsPath, 'utf8');
|
||||
const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-CN': '([^']+)', 'en-US': '([^']+)'/g)].map((m) => ({
|
||||
code: m[1],
|
||||
zh: m[2],
|
||||
en: m[3],
|
||||
}));
|
||||
|
||||
function pickNames(names) {
|
||||
return {
|
||||
zh: names?.zh ?? null,
|
||||
en: names?.en ?? null,
|
||||
zhTw: names?.zhTw ?? null,
|
||||
vi: names?.vi ?? null,
|
||||
km: names?.km ?? null,
|
||||
ms: names?.ms ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickTeam(team) {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
names: pickNames(team.names),
|
||||
image: team.image ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function slimMatch(m) {
|
||||
return {
|
||||
officialMatchNo: m.officialMatchNo,
|
||||
stage: m.stage,
|
||||
groupName: m.groupName,
|
||||
liveMatchId: m.liveMatchId,
|
||||
additionMatchId: m.additionMatchId,
|
||||
channelId: m.channelId,
|
||||
matchName: m.matchName,
|
||||
league: { type: m.league.type, en: m.league.en, zh: m.league.zh },
|
||||
kickoff: {
|
||||
utcTimeStart: m.kickoff.utcTimeStart,
|
||||
utcTimeStop: m.kickoff.utcTimeStop,
|
||||
utcIso: m.kickoff.utcIso,
|
||||
chinaTime: m.kickoff.chinaTime,
|
||||
venueTime: m.kickoff.venueTime,
|
||||
venueTimezone: m.kickoff.venueTimezone,
|
||||
},
|
||||
homeTeam: pickTeam(m.homeTeam),
|
||||
awayTeam: pickTeam(m.awayTeam),
|
||||
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
|
||||
venue: {
|
||||
names: pickNames(m.venue?.names),
|
||||
city: pickNames(m.venue?.city),
|
||||
},
|
||||
sortOrder: m.sortOrder,
|
||||
isPublished: m.isPublished,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCanonicalCode(team) {
|
||||
if (team.id == null) return null;
|
||||
const en = (team.name || team.names?.en || '').toLowerCase();
|
||||
const zh = team.names?.zh || '';
|
||||
const hit = outrightTeams.find(
|
||||
(o) =>
|
||||
o.en.toLowerCase() === en ||
|
||||
o.zh === zh ||
|
||||
(o.en === 'Turkey' && en.includes('türkiye')) ||
|
||||
(o.en === 'Czech' && en === 'czechia') ||
|
||||
(o.en === 'Bosnia' && en.includes('bosnia')) ||
|
||||
(o.en === 'Ivory Coast' && en.includes('côte')) ||
|
||||
(o.en === 'DR Congo' && en.includes('congo')) ||
|
||||
(o.en === 'Curacao' && en.includes('cura')),
|
||||
);
|
||||
return hit?.code ?? null;
|
||||
}
|
||||
|
||||
const matches = (raw.matches || []).map(slimMatch);
|
||||
const bundle = { count: matches.length, matches };
|
||||
|
||||
const teamById = new Map();
|
||||
for (const m of raw.matches || []) {
|
||||
for (const t of [m.homeTeam, m.awayTeam]) {
|
||||
if (t?.id != null) teamById.set(t.id, t);
|
||||
}
|
||||
}
|
||||
|
||||
const zhiboToCode = {};
|
||||
const logoByCode = {};
|
||||
const unmatched = [];
|
||||
for (const [id, team] of teamById) {
|
||||
const code = resolveCanonicalCode(team);
|
||||
if (code) {
|
||||
zhiboToCode[id] = code;
|
||||
if (team.image) logoByCode[code] = team.image;
|
||||
} else {
|
||||
unmatched.push({ id, name: team.name });
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(seedDataDir, { recursive: true });
|
||||
const jsonOut = path.join(seedDataDir, 'wc2026-group-stage.json');
|
||||
fs.writeFileSync(jsonOut, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
||||
|
||||
const mapLines = Object.entries(zhiboToCode)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([id, code]) => ` ${id}: '${code}',`)
|
||||
.join('\n');
|
||||
|
||||
const logoLines = Object.entries(logoByCode)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
|
||||
.join('\n');
|
||||
|
||||
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
|
||||
export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
${mapLines}
|
||||
};
|
||||
|
||||
/** zhibo 球队 logo(seed 时写入 teams.logo_url) */
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
|
||||
${logoLines}
|
||||
};
|
||||
`;
|
||||
|
||||
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
|
||||
fs.writeFileSync(mapOut, mapTs, 'utf8');
|
||||
|
||||
console.log(`Wrote ${jsonOut} (${matches.length} matches)`);
|
||||
console.log(`Wrote ${mapOut} (${Object.keys(zhiboToCode).length} team mappings)`);
|
||||
if (unmatched.length) {
|
||||
console.warn('Unmatched teams:', unmatched);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
174
apps/api/src/domains/catalog/catalog-archive.service.spec.ts
Normal file
174
apps/api/src/domains/catalog/catalog-archive.service.spec.ts
Normal file
@@ -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<void>) => 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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
302
apps/api/src/domains/catalog/catalog-archive.service.ts
Normal file
302
apps/api/src/domains/catalog/catalog-archive.service.ts
Normal file
@@ -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<MatchArchivePreview> {
|
||||
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<LeagueArchivePreview> {
|
||||
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 ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
115
apps/api/src/domains/catalog/matches.service.spec.ts
Normal file
115
apps/api/src/domains/catalog/matches.service.spec.ts
Normal file
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>) {
|
||||
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,
|
||||
|
||||
@@ -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<number> {
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
}) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof loadChuanglanConfig>;
|
||||
|
||||
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<SmsSendResult> {
|
||||
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<string, string> = {
|
||||
|
||||
@@ -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<string>('CHUANGLAN_ACCOUNT');
|
||||
const password = config.get<string>('CHUANGLAN_PASSWORD');
|
||||
if (!account || !password) {
|
||||
throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
account,
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
242
apps/api/src/domains/settlement/settlement.service.spec.ts
Normal file
242
apps/api/src/domains/settlement/settlement.service.spec.ts
Normal file
@@ -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<string, jest.Mock>;
|
||||
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<void>) =>
|
||||
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<void>) => {
|
||||
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() });
|
||||
});
|
||||
});
|
||||
@@ -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<Parameters<PrismaService['$transaction']>[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<bigint>();
|
||||
|
||||
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<string, (typeof legs)[0]['bet']>();
|
||||
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<string, number> = {};
|
||||
|
||||
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<string, SelAgg>();
|
||||
|
||||
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<bigint>();
|
||||
|
||||
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);
|
||||
|
||||
34
apps/api/src/infrastructure/database/database-init.ts
Normal file
34
apps/api/src/infrastructure/database/database-init.ts
Normal file
@@ -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<void> {
|
||||
const rows = await prisma.$queryRaw<Array<{ tablename: string }>>`
|
||||
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) };
|
||||
}
|
||||
@@ -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<Array<{ tablename: string }>>`
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
59
apps/api/src/infrastructure/database/reset-and-seed-cli.ts
Normal file
59
apps/api/src/infrastructure/database/reset-and-seed-cli.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<string, string>) {
|
||||
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<string, string>,
|
||||
) {
|
||||
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<string, string>]> = [
|
||||
['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<string, { id: bigint }>();
|
||||
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(', ')}`);
|
||||
}
|
||||
|
||||
350
apps/api/src/infrastructure/database/seed-catalog.ts
Normal file
350
apps/api/src/infrastructure/database/seed-catalog.ts
Normal file
@@ -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<string, string>,
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
10
apps/api/src/infrastructure/database/seed-data/index.ts
Normal file
10
apps/api/src/infrastructure/database/seed-data/index.ts
Normal file
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
|
||||
export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
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<string, string> = {
|
||||
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',
|
||||
};
|
||||
165
apps/api/src/infrastructure/database/seed-demo-markets.ts
Normal file
165
apps/api/src/infrastructure/database/seed-demo-markets.ts
Normal file
@@ -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',
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18
apps/api/src/shared/uploads/delete-upload-file.ts
Normal file
18
apps/api/src/shared/uploads/delete-upload-file.ts
Normal file
@@ -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<void> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user