feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user