重构
This commit is contained in:
@@ -15,7 +15,7 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let matches: { betStatsForMatches: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: CatalogArchiveService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,8 +34,8 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
|
||||
};
|
||||
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matches as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
const baseMatch = {
|
||||
@@ -71,21 +71,13 @@ describe('CatalogArchiveService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('archive with force soft-deletes and cancels match', async () => {
|
||||
it('archive with force rejects draft matches', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
|
||||
prisma.match.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.archiveMatch(matchId, { force: true });
|
||||
|
||||
expect(result.matchId).toBe(matchId.toString());
|
||||
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.market.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.match.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: matchId },
|
||||
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
await expect(service.archiveMatch(matchId, { force: true })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'MATCH_DELETE_DRAFT_ONLY' }),
|
||||
});
|
||||
expect(prisma.match.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('league preview blocks when child match is not terminal', async () => {
|
||||
@@ -102,7 +94,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -129,7 +121,7 @@ describe('CatalogArchiveService', () => {
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -163,7 +155,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
|
||||
|
||||
@@ -39,7 +39,7 @@ export type LeagueArchivePreview = {
|
||||
export class CatalogArchiveService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private matches: MatchesService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
|
||||
@@ -114,7 +114,7 @@ export class CatalogArchiveService {
|
||||
});
|
||||
|
||||
const matchIds = matches.map((m) => m.id);
|
||||
const stats = await this.matches.betStatsForMatches(matchIds);
|
||||
const stats = await this.matchBetStats.betStatsForMatches(matchIds);
|
||||
const previewBatches = matchIds.length
|
||||
? await this.prisma.settlementBatch.findMany({
|
||||
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
|
||||
|
||||
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
describe('MatchBetStatsService', () => {
|
||||
let prisma: {
|
||||
betSelection: { findMany: jest.Mock };
|
||||
};
|
||||
let service: MatchBetStatsService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
betSelection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new MatchBetStatsService(prisma as never);
|
||||
});
|
||||
|
||||
it('returns empty stats without querying when match ids are empty', async () => {
|
||||
const stats = await service.betStatsForMatches([]);
|
||||
|
||||
expect(stats.size).toBe(0);
|
||||
expect(prisma.betSelection.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates bets and fills zero stats for matches without bets', async () => {
|
||||
const matchId = BigInt(10);
|
||||
const emptyMatchId = BigInt(11);
|
||||
prisma.betSelection.findMany.mockResolvedValue([
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(101),
|
||||
bet: { stake: new Decimal(25), status: 'WON' },
|
||||
},
|
||||
{
|
||||
matchId: null,
|
||||
betId: BigInt(102),
|
||||
bet: { stake: new Decimal(99), status: 'PENDING' },
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.betStatsForMatches([matchId, emptyMatchId]);
|
||||
|
||||
expect(prisma.betSelection.findMany).toHaveBeenCalledWith({
|
||||
where: { matchId: { in: [matchId, emptyMatchId] } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
expect(stats.get(matchId.toString())).toEqual({
|
||||
betCount: 2,
|
||||
totalStake: '75',
|
||||
pendingCount: 1,
|
||||
});
|
||||
expect(stats.get(emptyMatchId.toString())).toEqual({
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchBetStatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketsModule } from '../odds/markets.module';
|
||||
import { CatalogArchiveService } from './catalog-archive.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
@Module({
|
||||
imports: [MarketsModule],
|
||||
providers: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
providers: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
jest.mock('@thebet365/shared', () => ({
|
||||
isPreMatchKickoff: jest.fn(() => true),
|
||||
PARLAY_MARKET_TYPES: [],
|
||||
resolveTranslationFallback: jest.fn(
|
||||
(translations: Map<string, string>, locale: string) =>
|
||||
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
|
||||
),
|
||||
}));
|
||||
|
||||
import { MatchesService } from './matches.service';
|
||||
|
||||
describe('MatchesService publish/unpublish', () => {
|
||||
@@ -11,6 +20,7 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.Mock };
|
||||
};
|
||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,7 +38,8 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new MatchesService(prisma as never, outright as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new MatchesService(prisma as never, outright as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
describe('updatePlatformLeague unpublish', () => {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import {
|
||||
defaultMarketName,
|
||||
defaultSelectionName,
|
||||
isPreMatchKickoff,
|
||||
isSettlementSupportedMarketType,
|
||||
PARLAY_MARKET_TYPES,
|
||||
resolveMarketText,
|
||||
resolveTranslationFallback,
|
||||
sanitizeLocalizedText,
|
||||
} from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
@@ -25,17 +29,11 @@ import {
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
export type { MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
const PLAYER_PARLAY_MARKET_TYPES = [
|
||||
MarketType.FT_HANDICAP,
|
||||
MarketType.FT_OVER_UNDER,
|
||||
MarketType.FT_1X2,
|
||||
MarketType.FT_ODD_EVEN,
|
||||
MarketType.HT_HANDICAP,
|
||||
MarketType.HT_OVER_UNDER,
|
||||
MarketType.HT_1X2,
|
||||
] as const;
|
||||
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
|
||||
|
||||
export type ListPublishedOptions = {
|
||||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||||
@@ -49,6 +47,7 @@ export class MatchesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private outright: OutrightService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async createLeague(code: string, translations: Record<string, string>) {
|
||||
@@ -89,7 +88,6 @@ export class MatchesService {
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
status?: string;
|
||||
@@ -115,7 +113,6 @@ export class MatchesService {
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status,
|
||||
@@ -624,59 +621,7 @@ export class MatchesService {
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return this.matchBetStats.betStatsForMatches(matchIds);
|
||||
}
|
||||
|
||||
private async upsertTeamByCode(data: {
|
||||
@@ -718,7 +663,6 @@ export class MatchesService {
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
@@ -833,7 +777,6 @@ export class MatchesService {
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
@@ -881,7 +824,6 @@ export class MatchesService {
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
correctScoreEnabled: match.correctScoreEnabled,
|
||||
displayOrder: match.displayOrder,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -909,21 +851,36 @@ export class MatchesService {
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
homeCorners: scoreRow.homeCorners ?? null,
|
||||
awayCorners: scoreRow.awayCorners ?? null,
|
||||
homeYellowCards: scoreRow.homeYellowCards ?? null,
|
||||
awayYellowCards: scoreRow.awayYellowCards ?? null,
|
||||
homeRedCards: scoreRow.homeRedCards ?? null,
|
||||
awayRedCards: scoreRow.awayRedCards ?? null,
|
||||
homeCards: scoreRow.homeCards ?? null,
|
||||
awayCards: scoreRow.awayCards ?? null,
|
||||
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey ?? m.marketType,
|
||||
lineKey: m.lineKey ?? null,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||
paramsJson: m.paramsJson ?? null,
|
||||
status: m.status,
|
||||
showOnPlayer: m.showOnPlayer,
|
||||
promoLabel: m.promoLabel ?? '',
|
||||
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
||||
nameI18n: sanitizeLocalizedText(m.nameI18n),
|
||||
sortOrder: m.sortOrder,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: sanitizeLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
@@ -949,7 +906,6 @@ export class MatchesService {
|
||||
groupName?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
correctScoreEnabled?: boolean;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -1006,7 +962,6 @@ export class MatchesService {
|
||||
matchName,
|
||||
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
@@ -1261,7 +1216,6 @@ export class MatchesService {
|
||||
startTime: Date;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string | null;
|
||||
stage?: string | null;
|
||||
@@ -1270,10 +1224,18 @@ export class MatchesService {
|
||||
awayTeam?: { code: string; logoUrl?: string | null };
|
||||
league?: { logoUrl?: string | null };
|
||||
score?: {
|
||||
htHomeScore: number;
|
||||
htAwayScore: number;
|
||||
ftHomeScore: number;
|
||||
ftAwayScore: number;
|
||||
htHomeScore: number | null;
|
||||
htAwayScore: number | null;
|
||||
ftHomeScore: number | null;
|
||||
ftAwayScore: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
markets?: Array<Record<string, unknown>>;
|
||||
};
|
||||
@@ -1295,7 +1257,6 @@ export class MatchesService {
|
||||
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||
startTime: m.startTime.toISOString(),
|
||||
isHot: m.isHot ?? false,
|
||||
correctScoreEnabled: m.correctScoreEnabled ?? true,
|
||||
displayOrder: m.displayOrder ?? 0,
|
||||
matchName: m.matchName ?? null,
|
||||
stage: m.stage ?? null,
|
||||
@@ -1303,10 +1264,18 @@ export class MatchesService {
|
||||
status: m.status ?? 'PUBLISHED',
|
||||
score: m.score
|
||||
? {
|
||||
htHome: m.score.htHomeScore,
|
||||
htAway: m.score.htAwayScore,
|
||||
ftHome: m.score.ftHomeScore,
|
||||
ftAway: m.score.ftAwayScore,
|
||||
htHome: m.score.htHomeScore ?? null,
|
||||
htAway: m.score.htAwayScore ?? null,
|
||||
ftHome: m.score.ftHomeScore ?? null,
|
||||
ftAway: m.score.ftAwayScore ?? null,
|
||||
homeCorners: m.score.homeCorners ?? null,
|
||||
awayCorners: m.score.awayCorners ?? null,
|
||||
homeYellowCards: m.score.homeYellowCards ?? null,
|
||||
awayYellowCards: m.score.awayYellowCards ?? null,
|
||||
homeRedCards: m.score.homeRedCards ?? null,
|
||||
awayRedCards: m.score.awayRedCards ?? null,
|
||||
homeCards: m.score.homeCards ?? null,
|
||||
awayCards: m.score.awayCards ?? null,
|
||||
}
|
||||
: null,
|
||||
bettingOpen: this.isMatchBettingOpen({
|
||||
@@ -1327,29 +1296,49 @@ export class MatchesService {
|
||||
}),
|
||||
};
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
const csEnabled = m.correctScoreEnabled ?? true;
|
||||
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets
|
||||
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
|
||||
.filter(
|
||||
(market) =>
|
||||
((market.showOnPlayer as boolean | undefined) ?? true) &&
|
||||
isSettlementSupportedMarketType(market.marketType as string),
|
||||
)
|
||||
.map((market) => ({
|
||||
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,
|
||||
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,
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
|
||||
lineKey: (market.lineKey as string | null | undefined) ?? null,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
marketDisplayName:
|
||||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
|
||||
(market.marketType as string),
|
||||
promoLabel:
|
||||
resolveMarketText(
|
||||
market.promoLabelI18n,
|
||||
locale,
|
||||
(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,
|
||||
selectionDisplayName:
|
||||
resolveMarketText(
|
||||
s.nameI18n,
|
||||
locale,
|
||||
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
|
||||
(s.selectionName as string),
|
||||
) || (s.selectionName as string),
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
@@ -1392,7 +1381,7 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
private playerMarketInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
@@ -1404,7 +1393,7 @@ export class MatchesService {
|
||||
|
||||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||||
private playerMarketStatusInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
select: {
|
||||
status: true,
|
||||
selections: { select: { status: true } },
|
||||
@@ -1421,6 +1410,7 @@ export class MatchesService {
|
||||
|
||||
const marketWhere = {
|
||||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||||
showOnPlayer: true,
|
||||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||||
};
|
||||
|
||||
@@ -1576,8 +1566,8 @@ export class MatchesService {
|
||||
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
|
||||
};
|
||||
const entry = labels[marketType];
|
||||
if (!entry) return marketType;
|
||||
return entry[locale] ?? entry['en-US'] ?? marketType;
|
||||
if (!entry) return defaultMarketName(marketType, locale);
|
||||
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
|
||||
}
|
||||
|
||||
async enrichBetsForHistory(
|
||||
@@ -1594,6 +1584,7 @@ export class MatchesService {
|
||||
selections: Array<{
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
marketNameSnapshot?: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
odds: unknown;
|
||||
resultStatus?: string | null;
|
||||
@@ -1662,7 +1653,7 @@ export class MatchesService {
|
||||
const m = mid ? matchMeta.get(mid) : undefined;
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
||||
marketLabel: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
|
||||
selectionName: sel.selectionNameSnapshot,
|
||||
odds: sel.odds,
|
||||
resultStatus: sel.resultStatus,
|
||||
|
||||
@@ -92,19 +92,30 @@ export async function syncWc2026OutrightMarket(
|
||||
startTime: new Date('2027-07-01T00:00:00Z'),
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
isHot: true,
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
},
|
||||
});
|
||||
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
publishTime: match.publishTime ?? new Date(),
|
||||
closeTime: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const shouldPublish =
|
||||
match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED';
|
||||
if (match.isHot || shouldPublish) {
|
||||
const updateData: {
|
||||
isHot: boolean;
|
||||
status?: string;
|
||||
publishTime?: Date;
|
||||
closeTime?: null;
|
||||
} = { isHot: false };
|
||||
if (shouldPublish) {
|
||||
updateData.status = 'PUBLISHED';
|
||||
updateData.publishTime = match.publishTime ?? new Date();
|
||||
updateData.closeTime = null;
|
||||
}
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let market = await prisma.market.findFirst({
|
||||
|
||||
Reference in New Issue
Block a user