feat: 充值订单审计与重新申请,优化赛事展示和余额刷新

- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 14:52:05 +08:00
parent 73a94e6be3
commit afb5c5437e
55 changed files with 3050 additions and 160 deletions

View File

@@ -9,7 +9,7 @@ describe('CatalogArchiveService', () => {
match: { findFirst: jest.Mock; update: jest.Mock; findMany: jest.Mock; updateMany: jest.Mock };
league: { findFirst: jest.Mock; update: jest.Mock };
bet: { findMany: jest.Mock };
settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock };
settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock; deleteMany: jest.Mock };
market: { updateMany: jest.Mock };
marketSelection: { updateMany: jest.Mock };
entityTranslation: { findFirst: jest.Mock };
@@ -28,7 +28,11 @@ describe('CatalogArchiveService', () => {
},
league: { findFirst: jest.fn(), update: jest.fn() },
bet: { findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
market: { updateMany: jest.fn() },
marketSelection: { updateMany: jest.fn() },
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
@@ -71,6 +75,40 @@ describe('CatalogArchiveService', () => {
});
});
it('archives settled match with stale preview batch when force is true', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' });
prisma.bet.findMany.mockResolvedValue([]);
prisma.settlementBatch.findFirst.mockResolvedValue({ id: BigInt(99) });
const result = await service.archiveMatch(matchId, { force: true });
expect(result.matchId).toBe(matchId.toString());
expect(prisma.settlementBatch.deleteMany).toHaveBeenCalledWith({
where: { matchId, status: 'PREVIEW' },
});
expect(prisma.match.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
deletedAt: expect.any(Date),
status: 'SETTLED',
}),
}),
);
});
it('archives settled match without force when no warnings', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' });
prisma.bet.findMany.mockResolvedValue([]);
await service.archiveMatch(matchId, { force: false });
expect(prisma.match.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'SETTLED' }),
}),
);
});
it('archive with force rejects draft matches', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });

View File

@@ -70,9 +70,6 @@ export class CatalogArchiveService {
if (match.status === 'DRAFT') {
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
if (match.status === 'SETTLED') {
throw appBadRequest('ARCHIVE_BLOCKED');
}
const preview = await this.getMatchArchivePreview(matchId);
if (preview.requiresForce && !opts.force) {
throw appConflict('ARCHIVE_BLOCKED', preview);
@@ -80,6 +77,9 @@ export class CatalogArchiveService {
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
await tx.marketSelection.updateMany({
where: { market: { matchId } },
data: { status: 'CLOSED' },
@@ -92,8 +92,7 @@ export class CatalogArchiveService {
where: { id: matchId },
data: {
deletedAt: now,
status:
match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED',
status: TERMINAL_MATCH_STATUSES.has(match.status) ? match.status : 'CANCELLED',
},
});
});

View File

@@ -1256,7 +1256,7 @@ export class MatchesService {
homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
startTime: m.startTime.toISOString(),
isHot: m.isHot ?? false,
isHot: m.status === 'SETTLED' ? false : (m.isHot ?? false),
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,