feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化

- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持

- 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮

- API 新增联赛列表与子场查询,按 locale 返回队名并修复编译

- 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
2026-06-04 16:25:03 +08:00
parent c68abadceb
commit cc737e2924
39 changed files with 3330 additions and 378 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "leagues" ADD COLUMN "logo_url" VARCHAR(500);
-- AlterTable
ALTER TABLE "markets" ADD COLUMN "promo_label" VARCHAR(100);

View File

@@ -219,6 +219,7 @@ model League {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
code String @unique @db.VarChar(64)
logoUrl String? @map("logo_url") @db.VarChar(500)
displayOrder Int @default(0) @map("display_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
@@ -330,6 +331,7 @@ model Market {
allowSingle Boolean @default(true) @map("allow_single")
allowParlay Boolean @default(true) @map("allow_parlay")
sortOrder Int @default(0) @map("sort_order")
promoLabel String? @map("promo_label") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -39,6 +39,7 @@ import {
MinLength,
IsIn,
Min,
ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
@@ -207,25 +208,63 @@ class DepositDto {
remark?: string;
}
class CreatePlatformMatchDto {
class CreatePlatformLeagueDto {
@IsString()
leagueEn!: string;
@IsString()
leagueZh!: string;
@IsOptional()
@IsString()
leagueMs?: string;
@IsOptional()
@IsString()
logoUrl?: string;
@IsOptional()
@IsNumber()
displayOrder?: number;
}
class CreatePlatformMatchDto {
@IsOptional()
@IsString()
leagueId?: string;
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
@IsString()
leagueEn?: string;
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
@IsString()
leagueZh?: string;
@IsOptional()
@IsString()
leagueMs?: string;
@IsString()
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsOptional()
@IsString()
homeTeamMs?: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsOptional()
@IsString()
awayTeamMs?: string;
@IsString()
startTime!: string;
@@ -236,6 +275,64 @@ class CreatePlatformMatchDto {
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
@IsString()
stage?: string;
@IsOptional()
@IsString()
groupName?: string;
@IsOptional()
@IsString()
leagueLogoUrl?: string;
@IsOptional()
@IsString()
homeTeamLogoUrl?: string;
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
}
class UpdateMarketDto {
@IsOptional()
@IsString()
promoLabel?: string | null;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
lineValue?: number | null;
}
class UpdateSelectionDto {
@IsOptional()
@IsString()
selectionName?: string;
@IsOptional()
@IsNumber()
@Min(1.01)
odds?: number;
@IsOptional()
@IsString()
status?: string;
}
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
@@ -704,11 +801,58 @@ export class AdminController {
}
@Post('leagues')
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
const league = await this.matches.createLeague(dto.code, dto.translations);
async createLeague(
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
) {
if ('leagueZh' in dto || 'leagueEn' in dto) {
const body = dto as CreatePlatformLeagueDto;
const league = await this.matches.createPlatformLeague({
leagueEn: body.leagueEn,
leagueZh: body.leagueZh,
leagueMs: body.leagueMs,
logoUrl: body.logoUrl,
displayOrder: body.displayOrder,
});
return jsonResponse(league);
}
const legacy = dto as { code: string; translations: Record<string, string> };
const league = await this.matches.createLeague(legacy.code, legacy.translations);
return jsonResponse(league);
}
@Get('leagues')
async listLeagues(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const result = await this.matches.listAdminLeagues({
page: p,
pageSize: size,
status: status || undefined,
keyword: keyword || undefined,
});
return jsonResponse(result);
}
@Get('leagues/:leagueId/matches')
async listLeagueMatches(
@Param('leagueId') leagueId: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('locale') locale?: string,
) {
const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
keyword: keyword || undefined,
locale: locale || undefined,
});
return jsonResponse({ items });
}
@Post('teams')
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
const team = await this.matches.createTeam(dto.code, dto.translations);
@@ -764,15 +908,24 @@ export class AdminController {
@Body() dto: CreatePlatformMatchDto,
) {
const match = await this.matches.updatePlatformMatch(BigInt(id), {
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
leagueEn: dto.leagueEn ?? '',
leagueZh: dto.leagueZh ?? '',
leagueMs: dto.leagueMs,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
groupName: dto.groupName,
leagueLogoUrl: dto.leagueLogoUrl,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
updatedBy: operatorId,
});
return jsonResponse(match);
@@ -787,15 +940,25 @@ export class AdminController {
@Post('matches')
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
const match = await this.matches.createPlatformMatch({
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
leagueEn: dto.leagueEn ?? '',
leagueZh: dto.leagueZh ?? '',
leagueMs: dto.leagueMs,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
groupName: dto.groupName,
leagueLogoUrl: dto.leagueLogoUrl,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
createdBy: operatorId,
});
return jsonResponse(match);
@@ -835,6 +998,48 @@ export class AdminController {
return jsonResponse(markets);
}
@Put('matches/:id/odds')
async batchUpdateMatchOdds(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: BatchMatchOddsDto,
) {
const updates = dto.updates.map((u) => ({
selectionId: BigInt(u.selectionId),
odds: u.odds,
}));
const results = await this.markets.batchUpdateOdds(updates, operatorId);
return jsonResponse({ matchId: id, updated: results.length });
}
@Patch('markets/:id')
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
const market = await this.markets.updateMarket(BigInt(id), {
promoLabel: dto.promoLabel,
status: dto.status,
lineValue: dto.lineValue,
});
return jsonResponse(market);
}
@Patch('selections/:id')
async updateSelection(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateSelectionDto,
) {
const selection = await this.markets.updateSelection(
BigInt(id),
{
selectionName: dto.selectionName,
odds: dto.odds,
status: dto.status,
},
operatorId,
);
return jsonResponse(selection);
}
@Put('selections/:id/odds')
async updateOdds(
@CurrentUser('id') operatorId: bigint,

View File

@@ -193,46 +193,284 @@ export class MatchesService {
return null;
}
async createPlatformMatch(data: {
async createPlatformLeague(data: {
leagueEn: string;
leagueZh: string;
leagueMs?: string;
logoUrl?: string;
displayOrder?: number;
}) {
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
}
const league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
en: leagueEn || leagueZh,
zh: leagueZh || leagueEn,
});
if (data.leagueMs?.trim()) {
await this.upsertEntityTranslations('LEAGUE', league.id, {
'ms-MY': data.leagueMs.trim(),
});
}
const updates: { logoUrl?: string; displayOrder?: number } = {};
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (Object.keys(updates).length) {
await this.prisma.league.update({ where: { id: league.id }, data: updates });
}
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
]);
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } });
return {
id: fresh.id.toString(),
code: fresh.code,
logoUrl: fresh.logoUrl,
displayOrder: fresh.displayOrder,
leagueEn: en,
leagueZh: zh,
leagueMs: ms,
};
}
async listAdminLeagues(opts: {
page: number;
pageSize: number;
keyword?: string;
status?: string;
}) {
const skip = (opts.page - 1) * opts.pageSize;
const kw = opts.keyword?.trim();
let idFilter: bigint[] | undefined;
if (kw || opts.status) {
const ids = new Set<bigint>();
if (kw) {
const trRows = await this.prisma.entityTranslation.findMany({
where: {
entityType: 'LEAGUE',
fieldName: 'name',
value: { contains: kw, mode: 'insensitive' },
},
select: { entityId: true },
});
for (const r of trRows) ids.add(r.entityId);
}
const matchWhere: Prisma.MatchWhereInput = {
deletedAt: null,
isOutright: false,
};
if (opts.status) matchWhere.status = opts.status;
if (kw) {
matchWhere.OR = [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const matchLeagues = await this.prisma.match.findMany({
where: matchWhere,
select: { leagueId: true },
distinct: ['leagueId'],
});
for (const m of matchLeagues) ids.add(m.leagueId);
idFilter = [...ids];
if (!idFilter.length) {
return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize };
}
}
const where: Prisma.LeagueWhereInput = { deletedAt: null };
if (idFilter) where.id = { in: idFilter };
const [leagues, total] = await Promise.all([
this.prisma.league.findMany({
where,
orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }],
skip,
take: opts.pageSize,
}),
this.prisma.league.count({ where }),
]);
const items = await Promise.all(
leagues.map(async (league) => {
const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
this.prisma.match.count({
where: {
leagueId: league.id,
deletedAt: null,
isOutright: false,
...(opts.status ? { status: opts.status } : {}),
},
}),
]);
return {
id: league.id.toString(),
code: league.code,
logoUrl: league.logoUrl,
displayOrder: league.displayOrder,
leagueEn,
leagueZh,
leagueMs,
matchCount,
};
}),
);
return { items, total, page: opts.page, pageSize: opts.pageSize };
}
async listAdminLeagueMatches(
leagueId: bigint,
opts: { status?: string; keyword?: string; locale?: string },
) {
const where: Prisma.MatchWhereInput = {
leagueId,
deletedAt: null,
isOutright: false,
};
if (opts.status) where.status = opts.status;
const kw = opts.keyword?.trim();
if (kw) {
where.OR = [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ 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 locale = opts.locale ?? 'zh-CN';
return Promise.all(
items.map(async (m) => {
const [homeTeamName, awayTeamName] = await Promise.all([
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
return {
id: m.id.toString(),
status: m.status,
isOutright: m.isOutright,
isHot: m.isHot,
displayOrder: m.displayOrder,
startTime: m.startTime,
matchName: m.matchName,
homeTeamName,
awayTeamName,
homeTeam: { code: m.homeTeam.code },
awayTeam: { code: m.awayTeam.code },
};
}),
);
}
async createPlatformMatch(data: {
leagueId?: bigint;
leagueEn?: string;
leagueZh?: string;
leagueMs?: string;
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
awayTeamZh: string;
awayTeamEn: string;
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
groupName?: string;
leagueLogoUrl?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
createdBy?: bigint;
}) {
const homeEn = data.homeTeamEn.trim();
const homeZh = data.homeTeamZh.trim();
const homeMs = data.homeTeamMs?.trim() ?? '';
const awayEn = data.awayTeamEn.trim();
const awayZh = data.awayTeamZh.trim();
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
throw new BadRequestException('请填写主客队中英文名至少各一项');
const awayMs = data.awayTeamMs?.trim() ?? '';
if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)');
}
const league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
en: data.leagueEn.trim(),
zh: data.leagueZh.trim(),
});
let league;
if (data.leagueId) {
league = await this.prisma.league.findFirst({
where: { id: data.leagueId, deletedAt: null },
});
if (!league) throw new NotFoundException('赛事不存在');
} else {
const leagueEn = data.leagueEn?.trim() ?? '';
const leagueZh = data.leagueZh?.trim() ?? '';
const leagueMs = data.leagueMs?.trim() ?? '';
if (!leagueEn && !leagueZh && !leagueMs) {
throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)');
}
league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
en: leagueEn || leagueZh || leagueMs,
zh: leagueZh || leagueEn || leagueMs,
});
if (leagueMs) {
await this.upsertEntityTranslations('LEAGUE', league.id, {
'ms-MY': leagueMs,
});
}
if (data.leagueLogoUrl?.trim()) {
await this.prisma.league.update({
where: { id: league.id },
data: { logoUrl: data.leagueLogoUrl.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: '',
name: homeEn || homeZh || homeMs,
names: {
zh: homeZh || null,
en: homeEn || null,
zhTw: '',
vi: null,
km: null,
ms: homeMs || null,
},
image: data.homeTeamLogoUrl?.trim() || '',
}),
this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh,
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
image: '',
name: awayEn || awayZh || awayMs,
names: {
zh: awayZh || null,
en: awayEn || null,
zhTw: '',
vi: null,
km: null,
ms: awayMs || null,
},
image: data.awayTeamLogoUrl?.trim() || '',
}),
]);
const matchName =
data.matchName?.trim() ||
`${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`;
return this.createMatch({
leagueId: league.id,
homeTeamId: homeTeam.id,
@@ -243,7 +481,9 @@ export class MatchesService {
createdBy: data.createdBy,
status: 'DRAFT',
zhibo: {
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
matchName,
stage: data.stage?.trim() || undefined,
groupName: data.groupName?.trim() || undefined,
},
});
}
@@ -251,7 +491,7 @@ export class MatchesService {
private async requireAdminMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
include: { homeTeam: true, awayTeam: true, league: true },
});
if (!match) throw new NotFoundException('赛事不存在');
return match;
@@ -259,27 +499,66 @@ export class MatchesService {
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'),
const markets = await this.prisma.market.findMany({
where: { matchId },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
});
const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] =
await Promise.all([
this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'),
this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'),
this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'),
this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'),
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'),
this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'),
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'),
]);
return {
id: match.id.toString(),
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
displayOrder: match.displayOrder,
startTime: match.startTime.toISOString(),
leagueId: match.leagueId.toString(),
leagueCode: match.league.code,
leagueEn,
leagueZh,
leagueMs,
leagueLogoUrl: match.league.logoUrl ?? '',
homeTeamEn: homeEn,
homeTeamZh: homeZh,
homeTeamMs: homeMs,
homeTeamCode: match.homeTeam.code,
homeTeamLogoUrl: match.homeTeam.logoUrl ?? '',
awayTeamEn: awayEn,
awayTeamZh: awayZh,
awayTeamMs: awayMs,
awayTeamCode: match.awayTeam.code,
awayTeamLogoUrl: match.awayTeam.logoUrl ?? '',
matchName: match.matchName ?? '',
stage: match.stage ?? '',
groupName: match.groupName ?? '',
markets: markets.map((m) => ({
id: m.id.toString(),
marketType: m.marketType,
period: m.period,
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
status: m.status,
promoLabel: m.promoLabel ?? '',
sortOrder: m.sortOrder,
selections: m.selections.map((s) => ({
id: s.id.toString(),
selectionCode: s.selectionCode,
selectionName: s.selectionName,
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
};
}
@@ -288,13 +567,22 @@ export class MatchesService {
data: {
leagueEn: string;
leagueZh: string;
leagueMs?: string;
homeTeamZh: string;
homeTeamEn: string;
homeTeamMs?: string;
awayTeamZh: string;
awayTeamEn: string;
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
groupName?: string;
leagueLogoUrl?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
updatedBy?: bigint;
},
) {
@@ -306,23 +594,55 @@ export class MatchesService {
throw new BadRequestException('当前状态不可编辑');
}
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
const matchName =
data.matchName?.trim() ||
`${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;
await Promise.all([
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
'zh-CN': data.leagueZh.trim(),
'en-US': data.leagueEn.trim(),
'ms-MY': (data.leagueMs ?? '').trim(),
}),
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
'zh-CN': data.homeTeamZh.trim(),
'en-US': data.homeTeamEn.trim(),
'ms-MY': (data.homeTeamMs ?? '').trim(),
}),
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
'zh-CN': data.awayTeamZh.trim(),
'en-US': data.awayTeamEn.trim(),
'ms-MY': (data.awayTeamMs ?? '').trim(),
}),
]);
const logoUpdates: Promise<unknown>[] = [];
if (data.leagueLogoUrl !== undefined) {
logoUpdates.push(
this.prisma.league.update({
where: { id: match.leagueId },
data: { logoUrl: data.leagueLogoUrl.trim() || null },
}),
);
}
if (data.homeTeamLogoUrl !== undefined) {
logoUpdates.push(
this.prisma.team.update({
where: { id: match.homeTeamId },
data: { logoUrl: data.homeTeamLogoUrl.trim() || null },
}),
);
}
if (data.awayTeamLogoUrl !== undefined) {
logoUpdates.push(
this.prisma.team.update({
where: { id: match.awayTeamId },
data: { logoUrl: data.awayTeamLogoUrl.trim() || null },
}),
);
}
if (logoUpdates.length) await Promise.all(logoUpdates);
return this.prisma.match.update({
where: { id: matchId },
data: {
@@ -330,6 +650,8 @@ export class MatchesService {
isHot: data.isHot ?? match.isHot,
displayOrder: data.displayOrder ?? match.displayOrder,
matchName,
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
updatedBy: data.updatedBy,
},
});
@@ -484,6 +806,13 @@ export class MatchesService {
});
}
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 ?? '';
}
async getTranslation(entityType: string, entityId: bigint, locale: string) {
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType, entityId },
@@ -500,25 +829,63 @@ export class MatchesService {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
homeTeam?: { code: string };
awayTeam?: { code: string };
markets?: unknown[];
startTime: Date;
status?: string;
isHot?: boolean;
displayOrder?: number;
matchName?: string | null;
stage?: string | null;
groupName?: string | null;
homeTeam?: { code: string; logoUrl?: string | null };
awayTeam?: { code: string; logoUrl?: string | null };
league?: { logoUrl?: string | null };
markets?: Array<Record<string, unknown>>;
};
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
return {
...match,
const base = {
id: m.id.toString(),
leagueId: m.leagueId.toString(),
leagueName,
leagueLogoUrl: m.league?.logoUrl ?? null,
homeTeamName: homeName,
awayTeamName: awayName,
homeTeamCode: m.homeTeam?.code ?? '',
awayTeamCode: m.awayTeam?.code ?? '',
homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
startTime: m.startTime.toISOString(),
isHot: m.isHot ?? false,
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,
groupName: m.groupName ?? null,
status: m.status ?? 'PUBLISHED',
};
if (m.markets) {
return {
...base,
markets: m.markets.map((market) => ({
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
period: market.period as string,
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
id: (s.id as bigint).toString(),
selectionCode: s.selectionCode as string,
selectionName: s.selectionName as string,
odds: Number(s.odds),
oddsVersion: (s.oddsVersion as bigint).toString(),
})),
})),
};
}
return base;
}
async listPublished(locale = 'en-US', leagueId?: bigint) {
@@ -528,27 +895,37 @@ export class MatchesService {
status: 'PUBLISHED',
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
startTime: { gt: now },
...(leagueId ? { leagueId } : {}),
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
},
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
});
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
}
async getMatchDetail(matchId: bigint, locale = 'en-US') {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
const match = await this.prisma.match.findFirst({
where: {
id: matchId,
deletedAt: null,
sportType: 'FOOTBALL',
isOutright: false,
status: { in: ['PUBLISHED', 'CLOSED'] },
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
markets: {
@@ -560,9 +937,6 @@ export class MatchesService {
},
});
if (!match) throw new NotFoundException('Match not found');
if (match.sportType !== 'FOOTBALL') {
throw new NotFoundException('Match not found');
}
return this.enrichMatch(match, locale);
}

View File

@@ -49,6 +49,22 @@ export class MarketsService {
return created;
}
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
private formatOuName(side: 'over' | 'under', line: number, half = false) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
private formatScoreName(code: string) {
return code.replace('SCORE_', '').replace('_', '-');
}
private getMarketConfig(marketType: string) {
const configs: Record<string, {
period: string;
@@ -62,9 +78,9 @@ export class MarketsService {
allowParlay: true,
sortOrder: 1,
selections: [
{ code: 'HOME', name: 'Home', odds: 2.5 },
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
{ code: 'AWAY', name: 'Away', odds: 2.8 },
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
HT_1X2: {
@@ -72,9 +88,9 @@ export class MarketsService {
allowParlay: true,
sortOrder: 5,
selections: [
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
],
},
FT_HANDICAP: {
@@ -83,8 +99,8 @@ export class MarketsService {
allowParlay: true,
sortOrder: 2,
selections: [
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
],
},
HT_HANDICAP: {
@@ -93,8 +109,8 @@ export class MarketsService {
allowParlay: true,
sortOrder: 6,
selections: [
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
],
},
FT_OVER_UNDER: {
@@ -103,8 +119,8 @@ export class MarketsService {
allowParlay: true,
sortOrder: 3,
selections: [
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
],
},
HT_OVER_UNDER: {
@@ -113,8 +129,8 @@ export class MarketsService {
allowParlay: true,
sortOrder: 7,
selections: [
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
],
},
FT_ODD_EVEN: {
@@ -122,8 +138,8 @@ export class MarketsService {
allowParlay: true,
sortOrder: 4,
selections: [
{ code: 'ODD', name: 'Odd', odds: 1.9 },
{ code: 'EVEN', name: 'Even', odds: 1.9 },
{ code: 'ODD', name: '', odds: 1.9 },
{ code: 'EVEN', name: '', odds: 1.9 },
],
},
FT_CORRECT_SCORE: {
@@ -132,7 +148,7 @@ export class MarketsService {
sortOrder: 8,
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
name: this.formatScoreName(code),
odds: 8.0,
})),
},
@@ -142,7 +158,7 @@ export class MarketsService {
sortOrder: 9,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
@@ -152,7 +168,7 @@ export class MarketsService {
sortOrder: 10,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
@@ -206,4 +222,45 @@ export class MarketsService {
}
return results;
}
async updateMarket(
marketId: bigint,
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
) {
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
if (!market) throw new NotFoundException('Market not found');
return this.prisma.market.update({
where: { id: marketId },
data: {
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
},
});
}
async updateSelection(
selectionId: bigint,
data: { selectionName?: string; odds?: number; status?: string },
operatorId?: bigint,
) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
});
if (!selection) throw new NotFoundException('Selection not found');
if (data.odds != null) {
if (!operatorId) throw new BadRequestException('Operator required for odds update');
return this.updateOdds(selectionId, data.odds, operatorId);
}
return this.prisma.marketSelection.update({
where: { id: selectionId },
data: {
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
},
});
}
}