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:
2026-06-10 13:00:14 +08:00
parent 6124313369
commit 03f54ca689
43 changed files with 2787 additions and 519 deletions

View File

@@ -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 } }),

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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,
};
});
}

View File

@@ -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) {

View File

@@ -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: {