feat: add smoke tests, agent credit ledger, and player cashback page

Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 16:05:48 +08:00
parent 9c6c5e51f3
commit d5e7c8edb3
52 changed files with 3357 additions and 67 deletions

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
Controller,
Delete,
ForbiddenException,
Get,
Post,
Put,
@@ -9,13 +10,20 @@ import {
Body,
Param,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
@@ -33,6 +41,7 @@ import { AdminDashboardService } from './admin-dashboard.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import {
IsString,
IsNumber,
@@ -47,6 +56,77 @@ import {
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
const IMAGE_MIME_EXT: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/svg+xml': '.svg',
};
type UploadedImage = {
originalname: string;
mimetype: string;
buffer: Buffer;
size: number;
};
type AdminUploadUser = {
role?: string;
permissions?: string[];
};
function uploadCategory(value?: string): UploadCategory {
const category = (value || 'contents').trim();
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
return category as UploadCategory;
}
throw new BadRequestException('Unsupported upload category');
}
function requiredUploadPermission(category: UploadCategory) {
return category === 'teams' ? P.matches : P.content;
}
function assertUploadPermission(user: AdminUploadUser | undefined, category: UploadCategory) {
if (user?.role === 'SUPER_ADMIN') return;
const required = requiredUploadPermission(category);
if (!user?.permissions?.includes(required)) {
throw new ForbiddenException('Insufficient permissions');
}
}
function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
if (!file?.buffer?.length) {
throw new BadRequestException('Image file is required');
}
if (!IMAGE_MIME_EXT[file.mimetype]) {
throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed');
}
if (file.mimetype === 'image/svg+xml') {
const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase();
if (sample.includes('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
throw new BadRequestException('Unsafe SVG content is not allowed');
}
}
}
function uploadFilename(file: UploadedImage) {
const fromMime = IMAGE_MIME_EXT[file.mimetype];
const fromName = extname(file.originalname || '').toLowerCase();
const ext = fromMime || fromName || '.img';
const base = (file.originalname || 'asset')
.replace(/\.[^.]+$/, '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 42) || 'asset';
return `${Date.now()}-${base}-${randomUUID().slice(0, 8)}${ext}`;
}
class CreateUserDto {
@IsString()
username!: string;
@@ -746,6 +826,7 @@ export class AdminController {
private systemConfig: SystemConfigService,
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
) {}
@Get('dashboard')
@@ -985,6 +1066,25 @@ export class AdminController {
return jsonResponse(result);
}
@Get('agents/credit-transactions')
@RequirePermissions(P.agentsView, P.reports)
async listAgentCreditTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('agentId') agentId?: string,
@Query('keyword') keyword?: string,
@Query('transactionType') transactionType?: string,
) {
const result = await this.agents.listCreditTransactions({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
agentId: agentId ? BigInt(agentId) : undefined,
keyword,
transactionType,
});
return jsonResponse(result);
}
@Get('agents/:id')
@RequirePermissions(P.agentsView)
async getAgentDetail(@Param('id') id: string) {
@@ -1731,6 +1831,33 @@ export class AdminController {
return jsonResponse(result);
}
@Post('uploads')
@RequirePermissions(P.content, P.matches)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async uploadAsset(
@CurrentUser() user: AdminUploadUser,
@UploadedFile() file: UploadedImage | undefined,
@Query('category') rawCategory?: string,
) {
const category = uploadCategory(rawCategory);
assertUploadPermission(user, category);
assertImageFile(file);
const filename = uploadFilename(file);
const root = getUploadRoot();
const targetDir = join(root, category);
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
return jsonResponse({
category,
filename,
size: file.size,
mimeType: file.mimetype,
url: `/uploads/${category}/${filename}`,
});
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@@ -1786,6 +1913,45 @@ export class AdminController {
return jsonResponse(messages);
}
@Get('smoke-tests/suites')
@RequirePermissions(P.settings)
async smokeTestSuites() {
return jsonResponse({
suites: this.smokeTests.listSuites(),
cases: this.smokeTests.listCases(),
lastRun: this.smokeTests.getLastRun(),
});
}
@Get('smoke-tests/last-run')
@RequirePermissions(P.settings)
async smokeTestLastRun() {
return jsonResponse(this.smokeTests.getLastRun());
}
@Post('smoke-tests/run')
@RequirePermissions(P.settings)
async runSmokeTests(
@CurrentUser('id') operatorId: bigint,
@Body() body: { suites?: string[] },
) {
const summary = await this.smokeTests.run(body?.suites, operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RUN_SMOKE_TESTS',
module: 'SYSTEM',
targetId: summary.runId,
afterData: {
passed: summary.passed,
failed: summary.failed,
total: summary.total,
suites: summary.suites,
},
});
return jsonResponse(summary);
}
@Get('audit-logs')
@RequirePermissions(P.audit)
async auditLogs(

View File

@@ -13,6 +13,7 @@ import { ContentModule } from '../../domains/operations/content/content.module';
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
@Module({
imports: [
@@ -27,6 +28,7 @@ import { DatabaseModule } from '../../infrastructure/database/database.module';
I18nModule,
BetsModule,
DatabaseModule,
SmokeTestModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -693,6 +693,108 @@ export class AgentsService {
};
}
async listCreditTransactions(params: {
page?: number;
pageSize?: number;
agentId?: bigint;
keyword?: string;
transactionType?: string;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.AgentCreditTransactionWhereInput = {};
if (params.transactionType?.trim()) {
where.transactionType = params.transactionType.trim();
}
const keyword = params.keyword?.trim();
if (keyword) {
const matched = await this.prisma.user.findMany({
where: {
userType: 'AGENT',
deletedAt: null,
username: { contains: keyword, mode: 'insensitive' },
},
select: { id: true },
take: 50,
});
const agentUserIds = matched.map((u) => u.id);
if (!agentUserIds.length) {
return { items: [], total: 0, page, pageSize };
}
if (params.agentId) {
if (!agentUserIds.some((id) => id === params.agentId)) {
return { items: [], total: 0, page, pageSize };
}
where.agentId = params.agentId;
} else {
where.agentId = { in: agentUserIds };
}
} else if (params.agentId) {
where.agentId = params.agentId;
}
const [rows, total] = await Promise.all([
this.prisma.agentCreditTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.agentCreditTransaction.count({ where }),
]);
const agentIds = [...new Set(rows.map((r) => r.agentId))];
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const [agentUsers, operators] = await Promise.all([
agentIds.length
? this.prisma.user.findMany({
where: { id: { in: agentIds } },
select: { id: true, username: true },
})
: [],
operatorIds.length
? this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [],
]);
const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username]));
const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username]));
return {
items: rows.map((row) => ({
id: row.id.toString(),
agentId: row.agentId.toString(),
agentUsername: agentNameById.get(row.agentId.toString()) ?? null,
transactionType: row.transactionType,
amount: row.amount.toString(),
creditBefore: row.creditBefore.toString(),
creditAfter: row.creditAfter.toString(),
referenceType: row.referenceType,
referenceId: row.referenceId,
operatorId: row.operatorId?.toString() ?? null,
operatorUsername: row.operatorId
? (operatorNameById.get(row.operatorId.toString()) ?? null)
: null,
requestId: row.requestId,
remark: row.remark,
createdAt: row.createdAt,
})),
total,
page,
pageSize,
};
}
async updateAgentAdmin(
agentId: bigint,
data: {

View File

@@ -474,10 +474,33 @@ export class CashbackService {
}
async getUserCashbacks(userId: bigint) {
return this.prisma.cashbackItem.findMany({
where: { userId },
include: { batch: true },
const items = await this.prisma.cashbackItem.findMany({
where: { userId, batch: { status: 'CONFIRMED' } },
include: {
batch: {
select: {
batchNo: true,
periodStart: true,
periodEnd: true,
confirmedAt: true,
status: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return items.map((item) => ({
id: item.id.toString(),
batchNo: item.batch.batchNo,
periodStart: item.batch.periodStart,
periodEnd: item.batch.periodEnd,
confirmedAt: item.batch.confirmedAt,
effectiveStake: item.effectiveStake.toString(),
betCount: item.betCount,
rate: item.rate.toString(),
amount: item.amount.toString(),
createdAt: item.createdAt,
}));
}
}

View File

@@ -0,0 +1,254 @@
import { Decimal } from '@prisma/client/runtime/library';
import type { AgentsService } from '../../agent/agents.service';
import type { BetsService } from '../../betting/bets.service';
import type { SettlementService } from '../../settlement/settlement.service';
import type { WalletService } from '../../ledger/wallet.service';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import { expectEqual, expectThrows, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseDef } from './smoke-test.cases';
import {
BetFlowFixtureIds,
createBetFlowFixture,
teardownBetFlowFixture,
} from './smoke-test.bet-flow.fixture';
export const BET_FLOW_PROBE_COUNT = 5;
export type BetFlowProbeDeps = {
prisma: PrismaService;
wallet: WalletService;
bets: BetsService;
settlement: SettlementService;
agents: AgentsService;
};
async function confirmMatchSettlement(
deps: BetFlowProbeDeps,
fx: BetFlowFixtureIds,
score: { htHome: number; htAway: number; ftHome: number; ftAway: number },
) {
await deps.settlement.recordScore(
fx.matchId,
score.htHome,
score.htAway,
score.ftHome,
score.ftAway,
fx.operatorId,
);
const preview = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId);
await deps.settlement.confirmSettlement(preview.batch.id, fx.operatorId);
}
export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[] {
return [
{
id: 'BF001',
suite: 'bet-flow',
name: '单关赢:冻结后结算派彩',
uatRef: '8/14',
description: '下注 100@2.0 主胜,比分 2-1余额 1000→1100',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
try {
const bet = await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-win-${fx.runId}`,
);
expectEqual('bet.status', bet.status, 'PENDING');
expectEqual('bet.stake', bet.stake.toString(), '100');
let w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after freeze', w.availableBalance.toString(), '900');
expectEqual('frozen after freeze', w.frozenBalance.toString(), '100');
await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 });
const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } });
expectEqual('settled status', settled?.status, 'WON');
expectEqual('actualReturn', settled?.actualReturn.toString(), '200');
w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after settle', w.availableBalance.toString(), '1100');
expectEqual('frozen after settle', w.frozenBalance.toString(), '0');
const txs = await deps.prisma.walletTransaction.findMany({
where: { userId: fx.playerId },
orderBy: { createdAt: 'asc' },
});
expectEqual(
'wallet tx types',
txs.map((t) => t.transactionType).join(','),
'MANUAL_DEPOSIT,BET_FREEZE,BET_SETTLE_WIN',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF002',
suite: 'bet-flow',
name: '单关输:冻结本金被消耗',
uatRef: '14',
description: '和局选项 + 2-1 比分,余额 1000→900',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
try {
const bet = await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.drawSelectionId,
fx.drawOddsVersion,
100,
`smoke-lose-${fx.runId}`,
);
let w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after freeze', w.availableBalance.toString(), '900');
expectEqual('frozen after freeze', w.frozenBalance.toString(), '100');
await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 });
const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } });
expectEqual('settled status', settled?.status, 'LOST');
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after settle', w.availableBalance.toString(), '900');
expectEqual('frozen after settle', w.frozenBalance.toString(), '0');
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF003',
suite: 'bet-flow',
name: '下注幂等:相同 requestId 不重复扣款',
uatRef: '8',
description: 'userId + requestId 唯一,重复提交返回同一注单',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
const requestId = `smoke-idem-${fx.runId}`;
const first = await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
requestId,
);
const second = await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
requestId,
);
expectEqual('same bet id', second.id.toString(), first.id.toString());
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
expectEqual('bet count', count, 1);
const w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available', w.availableBalance.toString(), '450');
expectEqual('frozen', w.frozenBalance.toString(), '50');
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF004',
suite: 'bet-flow',
name: '余额不足:拒绝下注',
uatRef: '8',
description: '可用 50 时下 100 注单应失败且无冻结',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 50 });
try {
await expectThrows(
'placeSingleBet',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-insuf-${fx.runId}`,
);
},
'Insufficient balance',
);
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
expectEqual('bet count', count, 0);
const w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available', w.availableBalance.toString(), '50');
expectEqual('frozen', w.frozenBalance.toString(), '0');
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF005',
suite: 'bet-flow',
name: '代理额度:结算后 usedCredit 同步',
uatRef: '4/14',
description: '玩家输 100 后代理 usedCredit 1000→900',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, {
initialBalance: 1000,
withAgent: true,
});
try {
expectTrue('agent exists', !!fx.agentId);
await deps.agents.recalculateUsedCredit(fx.agentId!);
let profile = await deps.prisma.agentProfile.findUnique({
where: { userId: fx.agentId! },
});
expectEqual('usedCredit before bet', profile?.usedCredit.toString(), '1000');
await deps.bets.placeSingleBet(
fx.playerId,
fx.agentId!,
fx.drawSelectionId,
fx.drawOddsVersion,
100,
`smoke-agent-${fx.runId}`,
);
await confirmMatchSettlement(deps, fx, { htHome: 0, htAway: 0, ftHome: 2, ftAway: 1 });
await deps.agents.recalculateUsedCredit(fx.agentId!);
profile = await deps.prisma.agentProfile.findUnique({ where: { userId: fx.agentId! } });
expectEqual('usedCredit after settle', profile?.usedCredit.toString(), '900');
const w = await deps.wallet.getWallet(fx.playerId);
expectEqual('player available', w.availableBalance.toString(), '900');
expectTrue(
'directPlayerLiability matches wallet',
new Decimal(profile!.directPlayerLiability).eq(w.availableBalance.add(w.frozenBalance)),
{
liability: profile?.directPlayerLiability.toString(),
wallet: w.availableBalance.add(w.frozenBalance).toString(),
},
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
];
}

View File

@@ -0,0 +1,215 @@
import { Decimal } from '@prisma/client/runtime/library';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import type { WalletService } from '../../ledger/wallet.service';
export interface BetFlowFixtureIds {
runId: string;
operatorId: bigint;
playerId: bigint;
agentId?: bigint;
matchId: bigint;
marketId: bigint;
homeSelectionId: bigint;
homeOddsVersion: bigint;
drawSelectionId: bigint;
drawOddsVersion: bigint;
awaySelectionId: bigint;
awayOddsVersion: bigint;
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
}
export async function createBetFlowFixture(
prisma: PrismaService,
wallet: WalletService,
opts?: { initialBalance?: number; withAgent?: boolean },
): Promise<BetFlowFixtureIds> {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const operator = await prisma.user.create({
data: {
username: `smoke_op_${runId}`,
userType: 'ADMIN',
auth: { create: { passwordHash: 'smoke-test' } },
},
});
const league = await prisma.league.create({
data: {
code: `SMK_L_${runId}`,
sportType: 'FOOTBALL',
},
});
const homeTeam = await prisma.team.create({
data: { code: `SMK_H_${runId}`, sportType: 'FOOTBALL' },
});
const awayTeam = await prisma.team.create({
data: { code: `SMK_A_${runId}`, sportType: 'FOOTBALL' },
});
const startTime = new Date(Date.now() + 48 * 60 * 60 * 1000);
const match = await prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime,
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const market = await prisma.market.create({
data: {
matchId: match.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'HOME',
selectionName: 'Home',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
{
selectionCode: 'DRAW',
selectionName: 'Draw',
odds: new Decimal('3.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 1,
},
{
selectionCode: 'AWAY',
selectionName: 'Away',
odds: new Decimal('4.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 2,
},
],
},
},
include: { selections: true },
});
let agentId: bigint | undefined;
if (opts?.withAgent) {
const agent = await prisma.user.create({
data: {
username: `smoke_ag_${runId}`,
userType: 'AGENT',
auth: { create: { passwordHash: 'smoke-test' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(100000) },
},
},
});
await prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
agentId = agent.id;
}
const player = await prisma.user.create({
data: {
username: `smoke_pl_${runId}`,
userType: 'PLAYER',
parentId: agentId,
auth: { create: { passwordHash: 'smoke-test' } },
wallet: { create: { currency: 'USD' } },
},
});
const balance = opts?.initialBalance ?? 1000;
if (balance > 0) {
await wallet.deposit(player.id, balance, operator.id, 'smoke test seed');
}
const home = market.selections.find((s) => s.selectionCode === 'HOME')!;
const draw = market.selections.find((s) => s.selectionCode === 'DRAW')!;
const away = market.selections.find((s) => s.selectionCode === 'AWAY')!;
return {
runId,
operatorId: operator.id,
playerId: player.id,
agentId,
matchId: match.id,
marketId: market.id,
homeSelectionId: home.id,
homeOddsVersion: home.oddsVersion,
drawSelectionId: draw.id,
drawOddsVersion: draw.oddsVersion,
awaySelectionId: away.id,
awayOddsVersion: away.oddsVersion,
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
};
}
export async function teardownBetFlowFixture(
prisma: PrismaService,
fx: BetFlowFixtureIds,
): Promise<void> {
const bets = await prisma.bet.findMany({
where: { userId: fx.playerId },
select: { id: true },
});
const betIds = bets.map((b) => b.id);
const batches = await prisma.settlementBatch.findMany({
where: { matchId: fx.matchId },
select: { id: true },
});
const batchIds = batches.map((b) => b.id);
if (batchIds.length) {
await prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
await prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
}
if (betIds.length) {
await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
await prisma.walletTransaction.deleteMany({ where: { userId: fx.playerId } });
await prisma.wallet.deleteMany({ where: { userId: fx.playerId } });
await prisma.matchScore.deleteMany({ where: { matchId: fx.matchId } });
const markets = await prisma.market.findMany({
where: { matchId: fx.matchId },
select: { id: true },
});
const marketIds = markets.map((m) => m.id);
if (marketIds.length) {
await prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await prisma.match.deleteMany({ where: { id: fx.matchId } });
if (fx.agentId) {
await prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: fx.agentId }, { descendantId: fx.agentId }] },
});
await prisma.agentProfile.deleteMany({ where: { userId: fx.agentId } });
await prisma.userAuth.deleteMany({ where: { userId: fx.agentId } });
await prisma.user.deleteMany({ where: { id: fx.agentId } });
}
await prisma.userAuth.deleteMany({
where: { userId: { in: [fx.playerId, fx.operatorId] } },
});
await prisma.user.deleteMany({ where: { id: { in: [fx.playerId, fx.operatorId] } } });
await prisma.team.deleteMany({ where: { id: { in: [fx.homeTeamId, fx.awayTeamId] } } });
await prisma.league.deleteMany({ where: { id: fx.leagueId } });
}

View File

@@ -0,0 +1,762 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
canSelectForParlay,
isQuarterHandicapOrTotal,
PARLAY_MAX_LEGS,
PARLAY_MIN_LEGS,
} from '@thebet365/shared';
import { BettingLimitsService } from '../../betting/betting-limits.service';
import { resolveSelectionCode } from '../../settlement/domain/settlement-helpers';
import {
calculatePayout,
calculateParlayPayout,
settleSelection,
type ScoreInput,
} from '../../settlement/domain/settlement-calculator';
import { resolveCashbackRateForBet } from '../cashback/cashback-rate.resolver';
import { expectEqual, expectFalse, expectThrows, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseMeta } from './smoke-test.types';
export type SmokeTestRunner = () => void | Promise<void>;
export type SmokeTestCaseDef = SmokeTestCaseMeta & {
run: SmokeTestRunner;
};
const SCORE_2_1: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
const SCORE_0_0: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
const SCORE_1_1: ScoreInput = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
function createBettingLimitsService() {
const prisma = {
systemConfig: { findUnique: async () => null },
bet: {
aggregate: async () => ({ _sum: { stake: new Decimal(0) } }),
},
};
return new BettingLimitsService(prisma as never);
}
export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
// —— Settlement ——
{
id: 'S001',
suite: 'settlement',
name: '全场独赢:主胜/和局',
uatRef: 'S001',
description: '比分 2-1主胜赢、和局输',
run: () => {
expectEqual(
'HOME result',
settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }),
'WIN',
{ market: 'FT_1X2', selection: 'HOME', score: SCORE_2_1 },
);
expectEqual(
'DRAW result',
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_2_1 }),
'LOSE',
{ market: 'FT_1X2', selection: 'DRAW', score: SCORE_2_1 },
);
},
},
{
id: 'S002',
suite: 'settlement',
name: '全场独赢:和局命中',
uatRef: 'S002',
run: () => {
expectEqual(
'DRAW result',
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_1_1 }),
'WIN',
{ market: 'FT_1X2', selection: 'DRAW', score: SCORE_1_1 },
);
},
},
{
id: 'S003',
suite: 'settlement',
name: '半场独赢:半场主胜',
uatRef: 'S003',
run: () => {
expectEqual(
'HT HOME',
settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }),
'WIN',
{ market: 'HT_1X2', ht: '1-0', ft: '2-1' },
);
},
},
{
id: 'S004',
suite: 'settlement',
name: '全场单双0-0 为双',
uatRef: 'S004',
run: () => {
expectEqual(
'EVEN',
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: SCORE_0_0 }),
'WIN',
{ score: SCORE_0_0, totalGoals: 0 },
);
expectEqual(
'ODD',
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'ODD', score: SCORE_0_0 }),
'LOSE',
{ score: SCORE_0_0, totalGoals: 0 },
);
},
},
{
id: 'S005',
suite: 'settlement',
name: '波胆2-1 精确命中',
uatRef: 'S005',
run: () => {
expectEqual(
'SCORE_2_1',
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'SCORE_2_1',
score: SCORE_2_1,
}),
'WIN',
{ score: SCORE_2_1 },
);
},
},
{
id: 'S006',
suite: 'settlement',
name: '波胆:主胜其他',
uatRef: 'S006',
run: () => {
const score = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 };
expectEqual(
'OTHER_HOME',
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'OTHER_HOME',
score,
templateScores: ['SCORE_1_0', 'SCORE_2_0'],
}),
'WIN',
{ score, templateScores: ['SCORE_1_0', 'SCORE_2_0'] },
);
},
},
{
id: 'S008',
suite: 'settlement',
name: '下半场波胆1-1',
uatRef: 'S008',
run: () => {
expectEqual(
'SH SCORE_1_1',
settleSelection({
marketType: 'SH_CORRECT_SCORE',
selectionCode: 'SCORE_1_1',
score: SCORE_2_1,
}),
'WIN',
{ shGoals: '1-1', ft: '2-1' },
);
},
},
{
id: 'S009',
suite: 'settlement',
name: '让球 -1 全赢 + 派彩',
uatRef: 'S009',
run: () => {
const score = { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 };
const result = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score,
});
expectEqual('settlement', result, 'WIN', { line: -1, score });
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 185, {
stake: 100,
odds: 1.85,
});
},
},
{
id: 'S010',
suite: 'settlement',
name: '让球 -1 走水 + 退本',
uatRef: 'S010',
run: () => {
const score = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 };
const result = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score,
});
expectEqual('settlement', result, 'PUSH', { line: -1, score });
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 100, {
stake: 100,
odds: 1.85,
});
},
},
{
id: 'S011',
suite: 'settlement',
name: '让球 -0.25 半输',
uatRef: 'S011',
run: () => {
const result = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -0.25,
score: SCORE_0_0,
});
expectEqual('settlement', result, 'HALF_LOSE', { line: -0.25, score: SCORE_0_0 });
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 50, {
stake: 100,
odds: 1.85,
});
},
},
{
id: 'S011B',
suite: 'settlement',
name: '半赢派彩系数',
description: 'HALF_WIN 派彩 = stake + stake*(odds-1)/2',
run: () => {
expectEqual('HALF_WIN payout', calculatePayout(100, 1.85, 'HALF_WIN').toNumber(), 142.5, {
stake: 100,
odds: 1.85,
});
},
},
{
id: 'S012',
suite: 'settlement',
name: '让球 -0.5 全输0-0',
uatRef: 'S012',
run: () => {
const result = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -0.5,
score: SCORE_0_0,
});
expectEqual('settlement', result, 'LOSE', { line: -0.5, score: SCORE_0_0 });
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 0, {
stake: 100,
odds: 1.85,
});
},
},
{
id: 'S013',
suite: 'settlement',
name: '大小 2.53 球大球赢',
uatRef: 'S013',
run: () => {
expectEqual(
'OVER 2.5',
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2.5,
score: SCORE_2_1,
}),
'WIN',
{ totalGoals: 3, line: 2.5 },
);
},
},
{
id: 'S014',
suite: 'settlement',
name: '大小整数盘 2 球走水',
uatRef: 'S014',
run: () => {
const score = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
expectEqual(
'OVER 2 push',
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2,
score,
}),
'PUSH',
{ totalGoals: 2, line: 2 },
);
},
},
{
id: 'S015',
suite: 'settlement',
name: '0-0 大小 2.5:小球赢',
description: '0-0 下小球赢、大球输',
run: () => {
const under = settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'UNDER',
totalLine: 2.5,
score: SCORE_0_0,
});
const over = settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2.5,
score: SCORE_0_0,
});
expectEqual('UNDER', under, 'WIN', { line: 2.5, score: SCORE_0_0 });
expectEqual('OVER', over, 'LOSE', { line: 2.5, score: SCORE_0_0 });
expectEqual('UNDER payout', calculatePayout(100, 1.95, under).toNumber(), 195, {
stake: 100,
odds: 1.95,
});
},
},
{
id: 'S016',
suite: 'settlement',
name: '串关全中派彩',
uatRef: 'S016',
run: () => {
const legs = [
{ odds: 1.8, result: 'WIN' as const },
{ odds: 2.0, result: 'WIN' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 360, { stake: 100, legs });
},
},
{
id: 'S017',
suite: 'settlement',
name: '串关一关输:全输',
uatRef: 'S017',
run: () => {
const legs = [
{ odds: 1.8, result: 'WIN' as const },
{ odds: 2.0, result: 'LOSE' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
},
},
{
id: 'S018',
suite: 'settlement',
name: '串关一关走水:赔率按 1.00',
uatRef: 'S018',
run: () => {
const legs = [
{ odds: 1.8, result: 'WIN' as const },
{ odds: 2.0, result: 'PUSH' as const },
{ odds: 1.9, result: 'WIN' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 342, { stake: 100, legs });
},
},
{
id: 'S019',
suite: 'settlement',
name: '串关全走水/作废:退本',
uatRef: 'S019',
run: () => {
const legs = [
{ odds: 1.8, result: 'PUSH' as const },
{ odds: 2.0, result: 'VOID' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'PUSH', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
},
},
{
id: 'OUT001',
suite: 'settlement',
name: '冠军盘:猜中/猜错',
description: 'OUTRIGHT_WINNER 按 winnerTeamCode 结算',
run: () => {
const input = { winnerTeamCode: 'BRA', selection: 'BRA' };
expectEqual(
'BRA wins',
settleSelection({
marketType: 'OUTRIGHT_WINNER',
selectionCode: 'BRA',
score: SCORE_0_0,
winnerTeamCode: 'BRA',
}),
'WIN',
input,
);
expectEqual(
'ARG loses',
settleSelection({
marketType: 'OUTRIGHT_WINNER',
selectionCode: 'ARG',
score: SCORE_0_0,
winnerTeamCode: 'BRA',
}),
'LOSE',
{ winnerTeamCode: 'BRA', selection: 'ARG' },
);
},
},
// —— Settlement helpers ——
{
id: 'H001',
suite: 'settlement_helpers',
name: '选项代码解析',
description: '数据库 code 优先,中文快照名兜底',
run: () => {
expectEqual('db code', resolveSelectionCode('UNDER', '小 2.5'), 'UNDER', {
code: 'UNDER',
name: '小 2.5',
});
expectEqual('主胜', resolveSelectionCode(null, '主胜'), 'HOME', { name: '主胜' });
expectEqual('小 2.5', resolveSelectionCode('', '小 2.5'), 'UNDER', { name: '小 2.5' });
expectEqual('大 2.5', resolveSelectionCode(undefined, '大 2.5'), 'OVER', { name: '大 2.5' });
expectEqual('主 -0.5', resolveSelectionCode(null, '主 -0.5'), 'HOME', { name: '主 -0.5' });
},
},
// —— Betting rules ——
{
id: 'B003',
suite: 'betting',
name: '赔率版本不一致应拒绝',
uatRef: 'B003',
run: () => {
const submitted = BigInt(1);
const current = BigInt(2);
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
},
},
{
id: 'B006',
suite: 'betting',
name: '串关 2-5 场范围常量',
uatRef: 'B006',
run: () => {
expectEqual('PARLAY_MIN_LEGS', PARLAY_MIN_LEGS, 2);
expectEqual('PARLAY_MAX_LEGS', PARLAY_MAX_LEGS, 5);
expectTrue('2 legs ok', 2 >= PARLAY_MIN_LEGS && 2 <= PARLAY_MAX_LEGS);
expectTrue('5 legs ok', 5 >= PARLAY_MIN_LEGS && 5 <= PARLAY_MAX_LEGS);
expectTrue('6 legs blocked', 6 > PARLAY_MAX_LEGS);
expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS);
},
},
{
id: 'B008',
suite: 'betting',
name: '四分之一盘不可串关',
uatRef: 'B008',
run: () => {
expectTrue('-0.25 is quarter', isQuarterHandicapOrTotal(-0.25), { line: -0.25 });
expectFalse('2.5 not quarter', isQuarterHandicapOrTotal(2.5), { line: 2.5 });
expectFalse('-1 not quarter', isQuarterHandicapOrTotal(-1), { line: -1 });
const check = canSelectForParlay({
marketType: 'FT_HANDICAP',
lineValue: -0.25,
});
expectFalse('blocked', check.ok, { line: -0.25 }, check.ok ? '' : (check as { reason: string }).reason);
if (!check.ok) {
expectEqual('reason', check.reason, 'QUARTER_LINE', { line: -0.25 });
}
},
},
{
id: 'B009',
suite: 'betting',
name: '串关超过 5 场应拒绝',
uatRef: 'B009',
run: () => {
expectTrue('6 > max', 6 > PARLAY_MAX_LEGS, { legs: 6, max: PARLAY_MAX_LEGS });
},
},
{
id: 'B010',
suite: 'betting',
name: '冠军盘不可串关',
uatRef: 'B010',
run: () => {
const check = canSelectForParlay({
marketType: 'OUTRIGHT_WINNER',
isOutright: true,
});
expectFalse('blocked', check.ok, { marketType: 'OUTRIGHT_WINNER' });
if (!check.ok) {
expectEqual('reason', check.reason, 'OUTRIGHT', { marketType: 'OUTRIGHT_WINNER' });
}
},
},
{
id: 'B011',
suite: 'betting',
name: '常规让球盘可串关',
run: () => {
const check = canSelectForParlay({
marketType: 'FT_HANDICAP',
lineValue: -0.5,
});
expectTrue('allowed', check.ok, { marketType: 'FT_HANDICAP', line: -0.5 });
},
},
{
id: 'B012',
suite: 'betting',
name: '显式禁止串关的盘口',
run: () => {
const check = canSelectForParlay({
marketType: 'FT_1X2',
allowParlay: false,
});
expectFalse('blocked', check.ok, { marketType: 'FT_1X2', allowParlay: false });
if (!check.ok) {
expectEqual('reason', check.reason, 'NOT_ALLOWED', { allowParlay: false });
}
},
},
// —— Betting limits ——
{
id: 'BL001',
suite: 'betting_limits',
name: '低于最小投注额拒绝',
run: async () => {
const service = createBettingLimitsService();
await expectThrows(
'min stake',
() =>
service.validateBet({
userId: BigInt(1),
betType: 'SINGLE',
stake: 0.5,
potentialReturn: new Decimal(1),
}),
'Minimum stake is 1',
{ stake: 0.5, minStake: 1 },
);
},
},
{
id: 'BL002',
suite: 'betting_limits',
name: '超过单关最大投注额拒绝',
run: async () => {
const service = createBettingLimitsService();
await expectThrows(
'max stake',
() =>
service.validateBet({
userId: BigInt(1),
betType: 'SINGLE',
stake: 60000,
potentialReturn: new Decimal(70000),
}),
'Maximum stake is 50000',
{ stake: 60000, maxStakeSingle: 50000 },
);
},
},
{
id: 'BL003',
suite: 'betting_limits',
name: '超过最高派彩拒绝',
run: async () => {
const service = createBettingLimitsService();
await expectThrows(
'max payout',
() =>
service.validateBet({
userId: BigInt(1),
betType: 'SINGLE',
stake: 100,
potentialReturn: new Decimal(600000),
}),
'Potential return exceeds limit',
{ potentialReturn: 600000, maxPayoutSingle: 500000 },
);
},
},
// —— Agent credit ——
{
id: 'A001',
suite: 'agent_credit',
name: '代理上分占用额度',
uatRef: 'A001',
run: () => {
const creditLimit = 10000;
const usedCredit = 1000 + 500;
const available = creditLimit - usedCredit;
expectEqual('available credit', available, 8500, {
creditLimit,
usedCredit,
formula: 'creditLimit - usedCredit',
});
},
},
{
id: 'A002',
suite: 'agent_credit',
name: '下注冻结:总额不变、可用减少',
uatRef: 'A002',
run: () => {
const balance = 1000;
const frozenBefore = 0;
const stake = 100;
const totalBefore = balance + frozenBefore;
const balanceAfter = balance - stake;
const frozenAfter = frozenBefore + stake;
const totalAfter = balanceAfter + frozenAfter;
expectEqual('total unchanged', totalAfter, totalBefore, {
before: { balance, frozen: frozenBefore },
after: { balance: balanceAfter, frozen: frozenAfter },
});
expectEqual('available', balanceAfter, 900, { stake });
},
},
{
id: 'A003',
suite: 'agent_credit',
name: '结算派彩:可用余额增加',
description: '赢单释放本金并增加盈利',
run: () => {
const balance = 900;
const frozen = 100;
const payout = 185;
const balanceAfter = balance + payout;
const frozenAfter = frozen - 100;
expectEqual('balance after win', balanceAfter, 1085, { payout, before: 900 });
expectEqual('frozen released', frozenAfter, 0, { frozenBefore: 100, stake: 100 });
},
},
{
id: 'A005',
suite: 'agent_credit',
name: '额度为负禁止继续放款',
uatRef: 'A005',
run: () => {
const creditLimit = 1000;
const usedCredit = 1200;
const available = creditLimit - usedCredit;
expectTrue('negative available', available < 0, { creditLimit, usedCredit, available });
expectFalse('deposit allowed', available > 0, { available });
},
},
// —— Cashback ——
{
id: 'CB001',
suite: 'cashback',
name: '返水比例:玩家规则优先',
run: () => {
const rate = resolveCashbackRateForBet({
userId: BigInt(100),
agentId: BigInt(200),
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'USER',
targetId: BigInt(100),
rate: new Decimal('0.03'),
marketType: null,
},
],
});
expectEqual('rate', rate.toString(), '0.03', {
userRule: '0.03',
agentDefault: '0.01',
});
},
},
{
id: 'CB002',
suite: 'cashback',
name: '返水比例:玩法专属规则',
run: () => {
const rate = resolveCashbackRateForBet({
userId: BigInt(100),
agentId: BigInt(200),
marketTypes: ['FT_HANDICAP'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'GLOBAL',
targetId: null,
rate: new Decimal('0.005'),
marketType: 'FT_HANDICAP',
},
],
});
expectEqual('rate', rate.toString(), '0.005', { marketType: 'FT_HANDICAP' });
},
},
{
id: 'CB003',
suite: 'cashback',
name: '返水比例:无规则用代理默认',
run: () => {
const rate = resolveCashbackRateForBet({
userId: BigInt(100),
agentId: BigInt(200),
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.02'),
rules: [],
});
expectEqual('rate', rate.toString(), '0.02', { agentDefault: '0.02', rules: [] });
},
},
{
id: 'CB004',
suite: 'cashback',
name: '返水比例:玩法不匹配回退默认',
run: () => {
const rate = resolveCashbackRateForBet({
userId: BigInt(100),
agentId: BigInt(200),
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'GLOBAL',
targetId: null,
rate: new Decimal('0.005'),
marketType: 'FT_HANDICAP',
},
],
});
expectEqual('rate', rate.toString(), '0.01', {
betMarket: 'FT_1X2',
ruleMarket: 'FT_HANDICAP',
agentDefault: '0.01',
});
},
},
];
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
agent_credit: { name: '代理额度', description: '额度占用、冻结与放款规则(纯逻辑)' },
cashback: { name: '返水规则', description: '返水比例优先级与回退' },
database: { name: '数据库', description: '连接、种子账号、赛事盘口与配置探针(只读)' },
'bet-flow': {
name: '下注结算链路',
description: '真实 DB下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
},
};

View File

@@ -0,0 +1,164 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import { expectEqual, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseDef } from './smoke-test.cases';
export const DATABASE_PROBE_COUNT = 8;
export function createDatabaseProbes(prisma: PrismaService): SmokeTestCaseDef[] {
return [
{
id: 'DB001',
suite: 'database',
name: '数据库连接',
description: 'SELECT 1 探活',
run: async () => {
const rows = await prisma.$queryRaw<Array<{ ok: number }>>`SELECT 1 AS ok`;
expectEqual('ping', rows[0]?.ok, 1, { query: 'SELECT 1' });
},
},
{
id: 'DB002',
suite: 'database',
name: '演示管理员账号',
description: 'seed admin / Admin@123',
run: async () => {
const user = await prisma.user.findUnique({
where: { username: 'admin' },
select: { id: true, userType: true, deletedAt: true },
});
expectTrue('admin exists', !!user, { username: 'admin' });
expectEqual('userType', user?.userType, 'ADMIN', { username: 'admin' });
expectEqual('not deleted', user?.deletedAt ?? null, null, { username: 'admin' });
},
},
{
id: 'DB003',
suite: 'database',
name: '演示代理账号与额度',
description: 'seed agent1 授信额度',
run: async () => {
const agent = await prisma.user.findUnique({
where: { username: 'agent1' },
select: {
id: true,
userType: true,
agentProfile: { select: { creditLimit: true, level: true } },
},
});
expectTrue('agent1 exists', !!agent, { username: 'agent1' });
expectEqual('userType', agent?.userType, 'AGENT', { username: 'agent1' });
const credit = agent?.agentProfile?.creditLimit;
expectTrue('creditLimit > 0', !!credit && credit.gt(0), {
username: 'agent1',
creditLimit: credit?.toString(),
});
},
},
{
id: 'DB004',
suite: 'database',
name: '演示玩家与钱包',
description: 'seed player1 钱包余额',
run: async () => {
const player = await prisma.user.findUnique({
where: { username: 'player1' },
select: {
id: true,
userType: true,
wallet: { select: { availableBalance: true, frozenBalance: true } },
},
});
expectTrue('player1 exists', !!player, { username: 'player1' });
expectEqual('userType', player?.userType, 'PLAYER', { username: 'player1' });
expectTrue('wallet exists', !!player?.wallet, { username: 'player1' });
expectTrue('availableBalance >= 0', !!player?.wallet && player.wallet.availableBalance.gte(0), {
availableBalance: player?.wallet?.availableBalance.toString(),
frozenBalance: player?.wallet?.frozenBalance.toString(),
});
},
},
{
id: 'DB005',
suite: 'database',
name: '演示赛事与盘口',
description: '至少一场 OPEN 赛事且含 FT_1X2 盘口',
run: async () => {
const matchCount = await prisma.match.count({
where: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED', 'SUSPENDED', 'CLOSED'] } },
});
expectTrue('match count > 0', matchCount > 0, { matchCount });
const market = await prisma.market.findFirst({
where: {
marketType: 'FT_1X2',
match: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED'] } },
},
select: {
id: true,
marketType: true,
selections: { select: { id: true, selectionCode: true, odds: true }, take: 3 },
},
});
expectTrue('FT_1X2 market exists', !!market, { marketType: 'FT_1X2' });
expectTrue('has 3 selections', (market?.selections.length ?? 0) >= 3, {
selectionCount: market?.selections.length,
});
},
},
{
id: 'DB006',
suite: 'database',
name: '投注限额配置完整',
description: '6 项 bet.* 限额键可读',
run: async () => {
const keys = Object.values(BET_LIMIT_KEYS);
for (const key of keys) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
const value = row?.configValue ?? '(default)';
const n = row ? Number(row.configValue) : NaN;
const valid = !row || (Number.isFinite(n) && n >= 0);
expectTrue(`${key} valid`, valid, { key, value });
}
},
},
{
id: 'DB007',
suite: 'database',
name: '权限与角色种子',
description: '超级管理员角色含 settings.manage',
run: async () => {
const role = await prisma.role.findFirst({
where: { code: 'SUPER_ADMIN' },
include: { permissions: { include: { permission: true } } },
});
expectTrue('SUPER_ADMIN role exists', !!role, { code: 'SUPER_ADMIN' });
const codes = role?.permissions.map((rp) => rp.permission.code) ?? [];
expectTrue('has settings.manage', codes.includes('settings.manage'), { permissions: codes });
},
},
{
id: 'DB008',
suite: 'database',
name: '今日注单聚合可查',
description: 'bet 表聚合查询(只读)',
run: async () => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const agg = await prisma.bet.aggregate({
where: { placedAt: { gte: start } },
_sum: { stake: true },
_count: true,
});
expectTrue('aggregate ok', agg._count >= 0, {
todayBetCount: agg._count,
todayStakeSum: agg._sum.stake?.toString() ?? '0',
});
expectEqual('stake sum type', agg._sum.stake instanceof Decimal || agg._sum.stake == null, true, {
stake: agg._sum.stake?.toString() ?? '0',
});
},
},
];
}

View File

@@ -0,0 +1,88 @@
export type SmokeTestStep = {
label: string;
input?: string;
expected: string;
actual: string;
};
let activeSteps: SmokeTestStep[] = [];
export function beginSmokeSteps() {
activeSteps = [];
}
export function drainSmokeSteps(): SmokeTestStep[] {
const steps = [...activeSteps];
activeSteps = [];
return steps;
}
function formatValue(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
export function recordStep(
label: string,
input: unknown,
expected: unknown,
actual: unknown,
ok: boolean,
) {
activeSteps.push({
label,
input: input === undefined ? undefined : formatValue(input),
expected: formatValue(expected),
actual: formatValue(actual),
});
if (!ok) {
throw new Error(`${label}: expected ${formatValue(expected)}, got ${formatValue(actual)}`);
}
}
export function expectEqual<T>(label: string, actual: T, expected: T, input?: unknown) {
const ok = actual === expected;
recordStep(label, input, expected, actual, ok);
}
export function expectTrue(label: string, condition: boolean, input?: unknown, failHint?: string) {
recordStep(label, input, 'true', condition ? 'true' : failHint ?? 'false', condition);
}
export function expectFalse(label: string, condition: boolean, input?: unknown, failHint?: string) {
recordStep(label, input, 'false', condition ? failHint ?? 'true' : 'false', !condition);
}
export async function expectThrows(
label: string,
fn: () => void | Promise<void>,
messageIncludes: string,
input?: unknown,
) {
try {
await fn();
recordStep(label, input, `error contains "${messageIncludes}"`, 'no error thrown', false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
recordStep(label, input, `error contains "${messageIncludes}"`, msg, msg.includes(messageIncludes));
}
}
export function formatStepsForResult(steps: SmokeTestStep[]): string[] {
return steps.map((step, index) => {
const lines = [`${index + 1}. ${step.label}`];
if (step.input) lines.push(` input: ${step.input}`);
lines.push(` expected: ${step.expected}`);
lines.push(` actual: ${step.actual}`);
return lines.join('\n');
});
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AgentsModule } from '../../agent/agents.module';
import { BetsModule } from '../../betting/bets.module';
import { WalletModule } from '../../ledger/wallet.module';
import { SettlementModule } from '../../settlement/settlement.module';
import { PrismaModule } from '../../../shared/prisma/prisma.module';
import { SmokeTestService } from './smoke-test.service';
@Module({
imports: [PrismaModule, WalletModule, BetsModule, SettlementModule, AgentsModule],
providers: [SmokeTestService],
exports: [SmokeTestService],
})
export class SmokeTestModule {}

View File

@@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { AgentsService } from '../../agent/agents.service';
import { BetsService } from '../../betting/bets.service';
import { SettlementService } from '../../settlement/settlement.service';
import { WalletService } from '../../ledger/wallet.service';
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
import {
beginSmokeSteps,
drainSmokeSteps,
formatStepsForResult,
} from './smoke-test.helpers';
import type {
SmokeTestCaseResult,
SmokeTestRunSummary,
SmokeTestSuiteInfo,
} from './smoke-test.types';
@Injectable()
export class SmokeTestService {
private lastRun: SmokeTestRunSummary | null = null;
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private bets: BetsService,
private settlement: SettlementService,
private agents: AgentsService,
) {}
listSuites(): SmokeTestSuiteInfo[] {
const counts = new Map<string, number>();
for (const c of SMOKE_TEST_CASES) {
counts.set(c.suite, (counts.get(c.suite) ?? 0) + 1);
}
counts.set('database', DATABASE_PROBE_COUNT);
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
return [...counts.entries()].map(([id, caseCount]) => ({
id,
name: SMOKE_SUITE_META[id]?.name ?? id,
description: SMOKE_SUITE_META[id]?.description ?? '',
caseCount,
}));
}
listCases(suites?: string[]) {
const allow = suites?.length ? new Set(suites) : null;
return SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite)).map(
({ id, suite, name, description, uatRef }) => ({ id, suite, name, description, uatRef }),
);
}
getLastRun() {
return this.lastRun;
}
async run(suites?: string[], operatorId?: bigint): Promise<SmokeTestRunSummary> {
const started = Date.now();
const runId = `SMOKE-${started}-${operatorId?.toString() ?? '0'}`;
const allow = suites?.length ? new Set(suites) : null;
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
const runDb = !allow || allow.has('database');
const runBetFlow = !allow || allow.has('bet-flow');
const results: SmokeTestCaseResult[] = [];
for (const testCase of staticCases) {
results.push(await this.executeCase(testCase));
}
if (runDb) {
for (const probe of createDatabaseProbes(this.prisma)) {
results.push(await this.executeCase(probe));
}
}
if (runBetFlow) {
for (const probe of createBetFlowProbes({
prisma: this.prisma,
wallet: this.wallet,
bets: this.bets,
settlement: this.settlement,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
const finished = Date.now();
const passed = results.filter((r) => r.status === 'PASS').length;
const failed = results.filter((r) => r.status === 'FAIL').length;
const skipped = results.filter((r) => r.status === 'SKIP').length;
const summary: SmokeTestRunSummary = {
runId,
startedAt: new Date(started).toISOString(),
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
total: results.length,
passed,
failed,
skipped,
suites: [...new Set(results.map((r) => r.suite))],
results,
};
this.lastRun = summary;
return summary;
}
private async executeCase(testCase: SmokeTestCaseDef): Promise<SmokeTestCaseResult> {
const t0 = performance.now();
const base = {
id: testCase.id,
suite: testCase.suite,
name: testCase.name,
description: testCase.description,
uatRef: testCase.uatRef,
};
beginSmokeSteps();
try {
await testCase.run();
const steps = drainSmokeSteps();
const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100);
return {
...base,
status: 'PASS',
durationMs,
stepCount: steps.length,
message: steps.length ? `${steps.length} steps passed` : 'OK',
details: formatStepsForResult(steps),
};
} catch (err) {
const steps = drainSmokeSteps();
const message = err instanceof Error ? err.message : String(err);
const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100);
return {
...base,
status: 'FAIL',
durationMs,
stepCount: steps.length,
error: message,
details: formatStepsForResult(steps),
};
}
}
}

View File

@@ -0,0 +1,38 @@
export type SmokeTestStatus = 'PASS' | 'FAIL' | 'SKIP';
export type SmokeTestCaseMeta = {
id: string;
suite: string;
name: string;
description?: string;
uatRef?: string;
};
export type SmokeTestCaseResult = SmokeTestCaseMeta & {
status: SmokeTestStatus;
durationMs: number;
stepCount?: number;
message?: string;
error?: string;
details?: string[];
};
export type SmokeTestRunSummary = {
runId: string;
startedAt: string;
finishedAt: string;
durationMs: number;
total: number;
passed: number;
failed: number;
skipped: number;
suites: string[];
results: SmokeTestCaseResult[];
};
export type SmokeTestSuiteInfo = {
id: string;
name: string;
description: string;
caseCount: number;
};

View File

@@ -2,17 +2,16 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './shared/common/filters';
import { getUploadRoot } from './shared/uploads/upload-paths';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
app.useGlobalFilters(new GlobalExceptionFilter());
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
app.setGlobalPrefix('api');
app.enableCors({ origin: true, credentials: true });

View File

@@ -0,0 +1,16 @@
import { existsSync } from 'fs';
import { resolve } from 'path';
export function getUploadRoot() {
if (process.env.UPLOAD_DIR?.trim()) {
return resolve(process.env.UPLOAD_DIR.trim());
}
const candidates = [
resolve(process.cwd(), '..', '..', 'uploads'),
resolve(process.cwd(), 'uploads'),
resolve(__dirname, '..', '..', 'uploads'),
];
return candidates.find((p) => existsSync(p)) ?? candidates[0];
}