feat: split admin dashboard, improve match ops, and player closed-match UX
Admin: add match/player overview sub-nav; refine settlement flow and league match management UI; improve action button enabled/disabled styles; enhance logo upload and outright odds sync. API: expose matchPhase/bettingOpen for closed matches; league publish guards; settlement preview with auto score save; outright team auto-sync. Player: watermark for closed/settled states; keep match and bet details visible; remove default login credentials. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -373,6 +373,10 @@ class CreatePlatformLeagueDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
class CreatePlatformMatchDto {
|
||||
@@ -456,6 +460,59 @@ class CreatePlatformMatchDto {
|
||||
awayTeamLogoUrl?: string;
|
||||
}
|
||||
|
||||
class UpdatePlatformMatchDto {
|
||||
@IsString()
|
||||
homeTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamZh!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
homeTeamMs?: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamZh!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
awayTeamMs?: string;
|
||||
|
||||
@IsString()
|
||||
startTime!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHot?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
homeTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
}
|
||||
|
||||
class BatchMatchOddsDto {
|
||||
@IsArray()
|
||||
updates!: OutrightOddsUpdateItemDto[];
|
||||
@@ -518,6 +575,16 @@ class ScoreDto {
|
||||
winnerTeamId?: number;
|
||||
}
|
||||
|
||||
class SettlementPreviewDto extends ScoreDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/* 智能比分推荐已关闭
|
||||
class SmartScoreSuggestDto {
|
||||
@IsOptional()
|
||||
@@ -1252,6 +1319,7 @@ export class AdminController {
|
||||
leagueMs: body.leagueMs,
|
||||
logoUrl: body.logoUrl,
|
||||
displayOrder: body.displayOrder,
|
||||
isActive: body.isActive,
|
||||
});
|
||||
return jsonResponse(league);
|
||||
}
|
||||
@@ -1260,6 +1328,23 @@ export class AdminController {
|
||||
return jsonResponse(league);
|
||||
}
|
||||
|
||||
@Put('leagues/:leagueId')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateLeague(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Body() dto: CreatePlatformLeagueDto,
|
||||
) {
|
||||
const league = await this.matches.updatePlatformLeague(BigInt(leagueId), {
|
||||
leagueEn: dto.leagueEn,
|
||||
leagueZh: dto.leagueZh,
|
||||
leagueMs: dto.leagueMs,
|
||||
logoUrl: dto.logoUrl,
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
return jsonResponse(league);
|
||||
}
|
||||
|
||||
@Get('leagues')
|
||||
@RequirePermissions(P.matches, P.reports)
|
||||
async listLeagues(
|
||||
@@ -1360,12 +1445,9 @@ export class AdminController {
|
||||
async updateMatch(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreatePlatformMatchDto,
|
||||
@Body() dto: UpdatePlatformMatchDto,
|
||||
) {
|
||||
const match = await this.matches.updatePlatformMatch(BigInt(id), {
|
||||
leagueEn: dto.leagueEn ?? '',
|
||||
leagueZh: dto.leagueZh ?? '',
|
||||
leagueMs: dto.leagueMs,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
homeTeamMs: dto.homeTeamMs,
|
||||
@@ -1378,11 +1460,11 @@ export class AdminController {
|
||||
matchName: dto.matchName,
|
||||
stage: dto.stage,
|
||||
groupName: dto.groupName,
|
||||
leagueLogoUrl: dto.leagueLogoUrl,
|
||||
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@@ -1420,6 +1502,7 @@ export class AdminController {
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
createdBy: operatorId,
|
||||
});
|
||||
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@@ -1712,9 +1795,27 @@ export class AdminController {
|
||||
async settlementPreview(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto?: { page?: number; pageSize?: number },
|
||||
@Body() dto?: SettlementPreviewDto,
|
||||
) {
|
||||
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId, {
|
||||
const matchId = BigInt(id);
|
||||
const hasScore =
|
||||
dto?.htHome !== undefined ||
|
||||
dto?.htAway !== undefined ||
|
||||
dto?.ftHome !== undefined ||
|
||||
dto?.ftAway !== undefined ||
|
||||
dto?.winnerTeamId !== undefined;
|
||||
if (hasScore) {
|
||||
await this.settlement.recordScore(
|
||||
matchId,
|
||||
dto!.htHome ?? 0,
|
||||
dto!.htAway ?? 0,
|
||||
dto!.ftHome ?? 0,
|
||||
dto!.ftAway ?? 0,
|
||||
operatorId,
|
||||
dto!.winnerTeamId != null ? BigInt(dto!.winnerTeamId) : undefined,
|
||||
);
|
||||
}
|
||||
const preview = await this.settlement.previewSettlement(matchId, operatorId, {
|
||||
page: dto?.page ? Math.max(1, dto.page) : 1,
|
||||
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
|
||||
});
|
||||
@@ -1980,6 +2081,24 @@ export class AdminController {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
@Delete('uploads/by-url')
|
||||
@RequirePermissions(P.content, P.matches)
|
||||
async deleteFileByUrl(@Body() body: { url: string }) {
|
||||
const { url } = body;
|
||||
if (!url || typeof url !== 'string') throw new BadRequestException('url is required');
|
||||
|
||||
const record = await this.prisma.uploadedFile.findFirst({ where: { url } });
|
||||
if (!record) return jsonResponse({ ok: true, note: 'not_found' });
|
||||
|
||||
const root = getUploadRoot();
|
||||
try {
|
||||
await unlink(join(root, record.category, record.filename));
|
||||
} catch { /* already gone */ }
|
||||
await this.prisma.uploadedFile.delete({ where: { id: record.id } });
|
||||
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
private async getUsedFileUrls(): Promise<Set<string>> {
|
||||
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
|
||||
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
|
||||
|
||||
@@ -98,14 +98,14 @@ class TransferDto {
|
||||
|
||||
@IsString()
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
class CreditDto extends TransferDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class CreditDto extends TransferDto {}
|
||||
|
||||
class UpdateSubAgentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -249,7 +249,13 @@ export class AgentPortalController {
|
||||
@Param('id') playerId: string,
|
||||
@Body() dto: TransferDto,
|
||||
) {
|
||||
const result = await this.agents.depositToPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
|
||||
const result = await this.agents.depositToPlayer(
|
||||
agentId,
|
||||
BigInt(playerId),
|
||||
dto.amount,
|
||||
dto.requestId,
|
||||
dto.remark,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@@ -259,7 +265,13 @@ export class AgentPortalController {
|
||||
@Param('id') playerId: string,
|
||||
@Body() dto: TransferDto,
|
||||
) {
|
||||
const result = await this.agents.withdrawFromPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
|
||||
const result = await this.agents.withdrawFromPlayer(
|
||||
agentId,
|
||||
BigInt(playerId),
|
||||
dto.amount,
|
||||
dto.requestId,
|
||||
dto.remark,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@@ -335,10 +347,7 @@ export class AgentPortalController {
|
||||
const p = Math.max(1, page ? parseInt(page, 10) : 1);
|
||||
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
|
||||
const skip = (p - 1) * size;
|
||||
const descendants = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
});
|
||||
const agentIds = descendants.map((d) => d.descendantId);
|
||||
const agentIds = await this.agents.getSubtreeAgentIds(agentId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
|
||||
@@ -252,7 +252,7 @@ export class AgentsService {
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark,
|
||||
remark ?? '管理员上分',
|
||||
requestId,
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
@@ -277,7 +277,7 @@ export class AgentsService {
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark,
|
||||
remark ?? '管理员下分',
|
||||
requestId,
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
@@ -432,7 +432,7 @@ export class AgentsService {
|
||||
|
||||
await this.assertAgentDepositLimits(agentId, amt);
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, remark ?? 'Agent deposit', requestId);
|
||||
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
@@ -443,10 +443,11 @@ export class AgentsService {
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
|
||||
await this.wallet.withdraw(playerId, amount, agentId, remark ?? '代理下分', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
@@ -1481,11 +1482,27 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const descendants = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
select: { descendantId: true },
|
||||
});
|
||||
return descendants.map((d) => d.descendantId);
|
||||
const ids: bigint[] = [];
|
||||
const queue: bigint[] = [agentId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(current);
|
||||
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: current },
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const child of children) {
|
||||
queue.push(child.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async getReportSummary(agentId: bigint) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -213,6 +213,7 @@ export class MatchesService {
|
||||
leagueMs?: string;
|
||||
logoUrl?: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const leagueEn = data.leagueEn.trim();
|
||||
const leagueZh = data.leagueZh.trim();
|
||||
@@ -229,12 +230,12 @@ export class MatchesService {
|
||||
'ms-MY': data.leagueMs.trim(),
|
||||
});
|
||||
}
|
||||
const updates: { logoUrl?: string; displayOrder?: number } = {};
|
||||
const updates: { logoUrl?: string; displayOrder?: number; isActive: boolean } = {
|
||||
isActive: data.isActive ?? false,
|
||||
};
|
||||
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 });
|
||||
}
|
||||
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'),
|
||||
@@ -246,6 +247,68 @@ export class MatchesService {
|
||||
code: fresh.code,
|
||||
logoUrl: fresh.logoUrl,
|
||||
displayOrder: fresh.displayOrder,
|
||||
isPublished: fresh.isActive,
|
||||
leagueEn: en,
|
||||
leagueZh: zh,
|
||||
leagueMs: ms,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePlatformLeague(
|
||||
leagueId: bigint,
|
||||
data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs?: string;
|
||||
logoUrl?: string;
|
||||
displayOrder?: number;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const league = await this.prisma.league.findFirst({
|
||||
where: { id: leagueId, deletedAt: null },
|
||||
});
|
||||
if (!league) throw new NotFoundException('赛事不存在');
|
||||
|
||||
const leagueEn = data.leagueEn.trim();
|
||||
const leagueZh = data.leagueZh.trim();
|
||||
if (!leagueEn && !leagueZh) {
|
||||
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
|
||||
}
|
||||
|
||||
await this.upsertEntityTranslations('LEAGUE', leagueId, {
|
||||
'zh-CN': leagueZh,
|
||||
'en-US': leagueEn,
|
||||
'ms-MY': (data.leagueMs ?? '').trim(),
|
||||
});
|
||||
|
||||
const updates: { logoUrl?: string | null; displayOrder?: number; isActive?: boolean } = {};
|
||||
if (data.logoUrl !== undefined) {
|
||||
updates.logoUrl = data.logoUrl.trim() || null;
|
||||
}
|
||||
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||||
if (data.isActive !== undefined) {
|
||||
if (league.isActive && data.isActive === false) {
|
||||
throw new BadRequestException('已发布的联赛不可下架');
|
||||
}
|
||||
updates.isActive = data.isActive;
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
|
||||
}
|
||||
|
||||
const [en, zh, ms] = await Promise.all([
|
||||
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
|
||||
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
|
||||
this.getTranslationExact('LEAGUE', leagueId, 'ms-MY'),
|
||||
]);
|
||||
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: leagueId } });
|
||||
return {
|
||||
id: fresh.id.toString(),
|
||||
code: fresh.code,
|
||||
logoUrl: fresh.logoUrl,
|
||||
displayOrder: fresh.displayOrder,
|
||||
isPublished: fresh.isActive,
|
||||
leagueEn: en,
|
||||
leagueZh: zh,
|
||||
leagueMs: ms,
|
||||
@@ -329,6 +392,7 @@ export class MatchesService {
|
||||
code: league.code,
|
||||
logoUrl: league.logoUrl,
|
||||
displayOrder: league.displayOrder,
|
||||
isPublished: league.isActive,
|
||||
leagueEn,
|
||||
leagueZh,
|
||||
leagueMs,
|
||||
@@ -819,9 +883,6 @@ export class MatchesService {
|
||||
async updatePlatformMatch(
|
||||
matchId: bigint,
|
||||
data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs?: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamMs?: string;
|
||||
@@ -834,7 +895,6 @@ export class MatchesService {
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
leagueLogoUrl?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
updatedBy?: bigint;
|
||||
@@ -853,11 +913,6 @@ export class MatchesService {
|
||||
`${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(),
|
||||
@@ -871,14 +926,6 @@ export class MatchesService {
|
||||
]);
|
||||
|
||||
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({
|
||||
@@ -1093,6 +1140,12 @@ export class MatchesService {
|
||||
homeTeam?: { code: string; logoUrl?: string | null };
|
||||
awayTeam?: { code: string; logoUrl?: string | null };
|
||||
league?: { logoUrl?: string | null };
|
||||
score?: {
|
||||
htHomeScore: number;
|
||||
htAwayScore: number;
|
||||
ftHomeScore: number;
|
||||
ftAwayScore: number;
|
||||
} | null;
|
||||
markets?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const [leagueName, homeName, awayName] = await Promise.all([
|
||||
@@ -1118,6 +1171,30 @@ export class MatchesService {
|
||||
stage: m.stage ?? null,
|
||||
groupName: m.groupName ?? null,
|
||||
status: m.status ?? 'PUBLISHED',
|
||||
score: m.score
|
||||
? {
|
||||
htHome: m.score.htHomeScore,
|
||||
htAway: m.score.htAwayScore,
|
||||
ftHome: m.score.ftHomeScore,
|
||||
ftAway: m.score.ftAwayScore,
|
||||
}
|
||||
: null,
|
||||
bettingOpen: this.isMatchBettingOpen({
|
||||
status: m.status,
|
||||
startTime: m.startTime,
|
||||
markets: (m.markets ?? []) as Array<{
|
||||
status: string;
|
||||
selections: Array<{ status: string }>;
|
||||
}>,
|
||||
}),
|
||||
matchPhase: this.resolvePlayerMatchPhase(m.status ?? 'PUBLISHED', {
|
||||
status: m.status,
|
||||
startTime: m.startTime,
|
||||
markets: (m.markets ?? []) as Array<{
|
||||
status: string;
|
||||
selections: Array<{ status: string }>;
|
||||
}>,
|
||||
}),
|
||||
};
|
||||
if (m.markets) {
|
||||
return {
|
||||
@@ -1126,6 +1203,7 @@ export class MatchesService {
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
||||
@@ -1133,6 +1211,7 @@ export class MatchesService {
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
})),
|
||||
@@ -1142,26 +1221,69 @@ export class MatchesService {
|
||||
return base;
|
||||
}
|
||||
|
||||
private resolvePlayerMatchPhaseFromStatus(
|
||||
status: string,
|
||||
startTime: Date,
|
||||
): 'open' | 'closed_pending' | 'settled' {
|
||||
if (status === 'SETTLED') return 'settled';
|
||||
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
|
||||
if (status === 'PUBLISHED' && !isPreMatchKickoff(startTime)) return 'closed_pending';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
private resolvePlayerMatchPhase(
|
||||
status: string,
|
||||
m: {
|
||||
status?: string;
|
||||
startTime: Date;
|
||||
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
|
||||
},
|
||||
): 'open' | 'closed_pending' | 'settled' {
|
||||
if (status === 'SETTLED') return 'settled';
|
||||
if (status === 'CLOSED' || status === 'PENDING_SETTLEMENT') return 'closed_pending';
|
||||
if (status === 'PUBLISHED' && !this.isMatchBettingOpen(m)) return 'closed_pending';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
private isMatchBettingOpen(m: {
|
||||
status?: string;
|
||||
startTime: Date;
|
||||
markets?: Array<{ status: string; selections: Array<{ status: string }> }>;
|
||||
}): boolean {
|
||||
if (m.status !== 'PUBLISHED') return false;
|
||||
if (!isPreMatchKickoff(m.startTime)) return false;
|
||||
return (m.markets ?? []).some(
|
||||
(mk) => mk.status === 'OPEN' && mk.selections.some((s) => s.status === 'OPEN'),
|
||||
);
|
||||
}
|
||||
|
||||
private playerMarketInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
};
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
const now = new Date();
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
startTime: { gt: now },
|
||||
league: { isActive: true, deletedAt: null },
|
||||
...(leagueId ? { leagueId } : {}),
|
||||
},
|
||||
include: {
|
||||
league: true,
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
markets: {
|
||||
where: { status: 'OPEN' },
|
||||
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
score: true,
|
||||
markets: this.playerMarketInclude,
|
||||
},
|
||||
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
@@ -1176,17 +1298,14 @@ export class MatchesService {
|
||||
deletedAt: null,
|
||||
sportType: 'FOOTBALL',
|
||||
isOutright: false,
|
||||
status: { in: ['PUBLISHED', 'CLOSED'] },
|
||||
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
|
||||
league: { isActive: true, deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
league: true,
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
markets: {
|
||||
where: { status: 'OPEN' },
|
||||
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
markets: this.playerMarketInclude,
|
||||
score: true,
|
||||
},
|
||||
});
|
||||
@@ -1325,6 +1444,7 @@ export class MatchesService {
|
||||
leagueName: string;
|
||||
matchTitle: string;
|
||||
isOutright: boolean;
|
||||
matchPhase: 'open' | 'closed_pending' | 'settled';
|
||||
score: { ht: string | null; ft: string | null } | null;
|
||||
}
|
||||
>();
|
||||
@@ -1348,6 +1468,7 @@ export class MatchesService {
|
||||
leagueName,
|
||||
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
|
||||
isOutright: m.isOutright,
|
||||
matchPhase: this.resolvePlayerMatchPhaseFromStatus(m.status, m.startTime),
|
||||
score: ftScore || htScore ? { ht: htScore, ft: ftScore } : null,
|
||||
});
|
||||
}
|
||||
@@ -1396,6 +1517,7 @@ export class MatchesService {
|
||||
legs,
|
||||
isParlay,
|
||||
matchScore: isParlay ? null : firstScore,
|
||||
matchPhase: isParlay ? null : meta?.matchPhase ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,11 +168,6 @@ export class OutrightService {
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
|
||||
const addableFixtureTeams = await this.listAddableFixtureTeams(
|
||||
match.leagueId,
|
||||
fullMarket.id,
|
||||
);
|
||||
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -192,49 +187,9 @@ export class OutrightService {
|
||||
playerVisible: visibility.playerVisible,
|
||||
playerHiddenReason: visibility.playerHiddenReason,
|
||||
selections,
|
||||
addableFixtureTeams,
|
||||
};
|
||||
}
|
||||
|
||||
private async listAddableFixtureTeams(leagueId: bigint, marketId: bigint) {
|
||||
const teams = await this.collectFixtureTeamsForLeague(leagueId);
|
||||
const openCodes = new Set(
|
||||
(
|
||||
await this.prisma.marketSelection.findMany({
|
||||
where: {
|
||||
marketId,
|
||||
status: 'OPEN',
|
||||
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
|
||||
},
|
||||
select: { selectionCode: true },
|
||||
})
|
||||
).map((s) => s.selectionCode),
|
||||
);
|
||||
|
||||
const result: Array<{
|
||||
teamCode: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
logoUrl: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const team of teams) {
|
||||
if (openCodes.has(team.code)) continue;
|
||||
const [teamZh, teamEn] = await Promise.all([
|
||||
this.getTranslation('TEAM', team.id, 'zh-CN'),
|
||||
this.getTranslation('TEAM', team.id, 'en-US'),
|
||||
]);
|
||||
result.push({
|
||||
teamCode: team.code,
|
||||
teamZh: teamZh || team.code,
|
||||
teamEn: teamEn || team.code,
|
||||
logoUrl: team.logoUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
|
||||
async getOrCreateAndSyncForLeague(leagueId: bigint) {
|
||||
let match = await this.prisma.match.findFirst({
|
||||
@@ -263,6 +218,22 @@ export class OutrightService {
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
}
|
||||
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
|
||||
const data = await this.getForAdmin(match.id);
|
||||
return {
|
||||
...data,
|
||||
fixtureSyncAdded: sync.addedCount,
|
||||
fixtureSyncReopened: sync.reopenedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
|
||||
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { leagueId, isOutright: true, deletedAt: null },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!match) return { addedCount: 0, reopenedCount: 0 };
|
||||
return this.syncSelectionsFromLeagueFixtures(match.id);
|
||||
}
|
||||
|
||||
@@ -279,19 +250,21 @@ export class OutrightService {
|
||||
},
|
||||
});
|
||||
|
||||
// 仅当球队重新出现在单场赛程时,恢复曾被关闭的选项;不因「暂无单场」而自动关闭
|
||||
const existingCodes = new Set(existing.map((s) => s.selectionCode));
|
||||
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
|
||||
let addedCount = 0;
|
||||
let reopenedCount = 0;
|
||||
|
||||
for (const sel of existing) {
|
||||
if (fixtureCodes.has(sel.selectionCode) && sel.status === 'CLOSED') {
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
reopenedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const existingCodes = new Set(existing.map((s) => s.selectionCode));
|
||||
let sortOrder = existing.reduce((max, s) => Math.max(max, s.sortOrder), -1);
|
||||
|
||||
for (const team of teams) {
|
||||
if (existingCodes.has(team.code)) continue;
|
||||
const [teamZh, teamEn] = await Promise.all([
|
||||
@@ -309,9 +282,10 @@ export class OutrightService {
|
||||
status: 'OPEN',
|
||||
},
|
||||
});
|
||||
addedCount += 1;
|
||||
}
|
||||
|
||||
return this.getForAdmin(matchId);
|
||||
return { addedCount, reopenedCount };
|
||||
}
|
||||
|
||||
private async collectFixtureTeamsForLeague(leagueId: bigint) {
|
||||
|
||||
@@ -624,6 +624,20 @@ export async function runSeed(client: PrismaClient) {
|
||||
update: {},
|
||||
});
|
||||
|
||||
const agent2 = await prisma.user.findUnique({ where: { username: 'agent2' }, select: { id: true } });
|
||||
if (agent2) {
|
||||
await prisma.agentClosure.upsert({
|
||||
where: { ancestorId_descendantId: { ancestorId: agent2.id, descendantId: agent2.id } },
|
||||
create: { ancestorId: agent2.id, descendantId: agent2.id, depth: 0 },
|
||||
update: {},
|
||||
});
|
||||
await prisma.agentClosure.upsert({
|
||||
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent2.id } },
|
||||
create: { ancestorId: agent1.id, descendantId: agent2.id, depth: 1 },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'player1' },
|
||||
create: {
|
||||
|
||||
Reference in New Issue
Block a user