feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

@@ -1,6 +1,18 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
import {
leagueCodeFromExport,
resolveInternalStatus,
resolveIsHot,
resolveStartTime,
teamCodeFromExport,
toKickoffJson,
toVenueJson,
translationsFromZhiboNames,
} from './zhibo-match.mapper';
@Injectable()
export class MatchesService {
@@ -44,8 +56,24 @@ export class MatchesService {
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
createdBy?: bigint;
status?: string;
publishTime?: Date;
zhibo?: Partial<{
officialMatchNo: number;
stage: string;
groupName: string;
liveMatchId?: bigint;
additionMatchId: bigint | null;
channelId: string | null;
matchName: string;
venueJson: Prisma.InputJsonValue;
kickoffJson: Prisma.InputJsonValue;
externalStatus: string;
}>;
}) {
const status = data.status ?? 'DRAFT';
return this.prisma.match.create({
data: {
leagueId: data.leagueId,
@@ -53,12 +81,384 @@ export class MatchesService {
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
status,
publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined),
officialMatchNo: data.zhibo?.officialMatchNo,
stage: data.zhibo?.stage,
groupName: data.zhibo?.groupName,
liveMatchId: data.zhibo?.liveMatchId,
additionMatchId: data.zhibo?.additionMatchId ?? undefined,
channelId: data.zhibo?.channelId ?? undefined,
matchName: data.zhibo?.matchName,
venueJson: data.zhibo?.venueJson,
kickoffJson: data.zhibo?.kickoffJson,
externalStatus: data.zhibo?.externalStatus,
},
});
}
private async upsertEntityTranslations(
entityType: 'LEAGUE' | 'TEAM',
entityId: bigint,
translations: Record<string, string>,
) {
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType,
entityId,
locale,
fieldName: 'name',
},
},
create: { entityType, entityId, locale, fieldName: 'name', value },
update: { value },
});
}
}
async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) {
const code = leagueCodeFromExport(league);
const record = await this.prisma.league.upsert({
where: { code },
create: { code, sportType: league.type || 'FOOTBALL' },
update: { sportType: league.type || 'FOOTBALL' },
});
await this.upsertEntityTranslations('LEAGUE', record.id, {
'zh-CN': league.zh,
'en-US': league.en,
});
return record;
}
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
const code = teamCodeFromExport(team);
const translations = translationsFromZhiboNames(team.names, team.name);
let record =
team.id != null
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
: await this.prisma.team.findUnique({ where: { code } });
if (!record) {
record = await this.prisma.team.create({
data: {
code,
externalId: team.id ?? undefined,
logoUrl: team.image || undefined,
},
});
} else {
record = await this.prisma.team.update({
where: { id: record.id },
data: {
logoUrl: team.image || record.logoUrl,
externalId: team.id ?? record.externalId,
},
});
}
await this.upsertEntityTranslations('TEAM', record.id, translations);
return record;
}
private async findExistingZhiboMatch(
leagueId: bigint,
homeTeamId: bigint,
awayTeamId: bigint,
item: ZhiboMatchExport,
) {
if (item.liveMatchId != null) {
return this.prisma.match.findUnique({
where: { liveMatchId: BigInt(item.liveMatchId) },
});
}
if (item.officialMatchNo != null) {
return this.prisma.match.findFirst({
where: {
leagueId,
homeTeamId,
awayTeamId,
officialMatchNo: item.officialMatchNo,
},
});
}
return null;
}
async createPlatformMatch(data: {
leagueEn: string;
leagueZh: string;
homeTeamZh: string;
homeTeamEn: string;
awayTeamZh: string;
awayTeamEn: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
createdBy?: bigint;
}) {
const homeEn = data.homeTeamEn.trim();
const homeZh = data.homeTeamZh.trim();
const awayEn = data.awayTeamEn.trim();
const awayZh = data.awayTeamZh.trim();
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
throw new BadRequestException('请填写主客队中英文名至少各一项');
}
const league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
en: data.leagueEn.trim(),
zh: data.leagueZh.trim(),
});
const [homeTeam, awayTeam] = await Promise.all([
this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh,
names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null },
image: '',
}),
this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh,
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
image: '',
}),
]);
return this.createMatch({
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime: data.startTime,
isHot: data.isHot ?? false,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
zhibo: {
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
},
});
}
private async requireAdminMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
});
if (!match) throw new NotFoundException('赛事不存在');
return match;
}
async getAdminMatchDetail(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
this.getTranslation('TEAM', match.homeTeamId, 'en-US'),
this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslation('TEAM', match.awayTeamId, 'en-US'),
this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'),
]);
return {
id: match.id.toString(),
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
startTime: match.startTime.toISOString(),
leagueEn,
leagueZh,
homeTeamEn: homeEn,
homeTeamZh: homeZh,
awayTeamEn: awayEn,
awayTeamZh: awayZh,
matchName: match.matchName ?? '',
};
}
async updatePlatformMatch(
matchId: bigint,
data: {
leagueEn: string;
leagueZh: string;
homeTeamZh: string;
homeTeamEn: string;
awayTeamZh: string;
awayTeamEn: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
updatedBy?: bigint;
},
) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘请通过盘口管理维护');
}
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
throw new BadRequestException('当前状态不可编辑');
}
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
await Promise.all([
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
'zh-CN': data.leagueZh.trim(),
'en-US': data.leagueEn.trim(),
}),
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
'zh-CN': data.homeTeamZh.trim(),
'en-US': data.homeTeamEn.trim(),
}),
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
'zh-CN': data.awayTeamZh.trim(),
'en-US': data.awayTeamEn.trim(),
}),
]);
return this.prisma.match.update({
where: { id: matchId },
data: {
startTime: data.startTime,
isHot: data.isHot ?? match.isHot,
displayOrder: data.displayOrder ?? match.displayOrder,
matchName,
updatedBy: data.updatedBy,
},
});
}
async deleteMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘不可删除');
}
if (match.status !== 'DRAFT') {
throw new BadRequestException('仅草稿状态可删除');
}
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
if (betCount > 0) {
throw new BadRequestException('该赛事已有注单关联,无法删除');
}
return this.prisma.match.update({
where: { id: matchId },
data: { deletedAt: new Date() },
});
}
async createMatchFromZhiboExport(
item: ZhiboMatchExport,
createdBy?: bigint,
opts?: { asDraft?: boolean },
) {
const league = await this.upsertLeagueFromZhiboExport(item.league);
const [homeTeam, awayTeam] = await Promise.all([
this.upsertTeamFromZhiboExport(item.homeTeam),
this.upsertTeamFromZhiboExport(item.awayTeam),
]);
const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item);
const startTime = resolveStartTime(item.kickoff);
const liveMatchId =
item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
const payload = {
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime,
isHot: resolveIsHot(item),
displayOrder: item.sortOrder,
createdBy,
status,
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
zhibo: {
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 this.findExistingZhiboMatch(
league.id,
homeTeam.id,
awayTeam.id,
item,
);
if (existing) {
return this.prisma.match.update({
where: { id: existing.id },
data: {
leagueId: payload.leagueId,
homeTeamId: payload.homeTeamId,
awayTeamId: payload.awayTeamId,
startTime: payload.startTime,
isHot: payload.isHot,
displayOrder: payload.displayOrder,
status: payload.status,
publishTime: existing.publishTime ?? payload.publishTime,
officialMatchNo: payload.zhibo.officialMatchNo,
stage: payload.zhibo.stage,
groupName: payload.zhibo.groupName,
liveMatchId: payload.zhibo.liveMatchId ?? undefined,
additionMatchId: payload.zhibo.additionMatchId ?? undefined,
channelId: payload.zhibo.channelId ?? undefined,
matchName: payload.zhibo.matchName,
venueJson: payload.zhibo.venueJson,
kickoffJson: payload.zhibo.kickoffJson,
externalStatus: payload.zhibo.externalStatus,
updatedBy: createdBy,
},
});
}
return this.createMatch(payload);
}
async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
if (!bundle.matches?.length) {
throw new BadRequestException('matches array is required');
}
const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];
for (const item of bundle.matches) {
try {
const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true });
results.push({
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
id: match.id.toString(),
status: match.status,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'import failed';
results.push({
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
id: '',
status: 'error',
reason: message,
});
}
}
return {
total: bundle.matches.length,
imported: results.filter((r) => !r.skipped && r.status !== 'error').length,
skipped: results.filter((r) => r.skipped).length,
failed: results.filter((r) => r.status === 'error').length,
results,
};
}
async publishMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },