feat(admin,api,player): 返水流程优化、账单详情与数据库重置

优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 11:14:22 +08:00
parent 24fa1b275c
commit b2216abd0c
24 changed files with 2253 additions and 849 deletions

View File

@@ -16,4 +16,5 @@ export const P = {
cashback: 'cashback.confirm',
content: 'content.manage',
audit: 'audit.view',
resetDatabase: 'settings.reset_database',
} as const;

View File

@@ -32,6 +32,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
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 {
IsString,
IsNumber,
@@ -41,6 +42,7 @@ import {
MinLength,
IsIn,
Min,
Equals,
ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
@@ -152,6 +154,12 @@ class PlayerAccountSettingsDto {
allowUsernameChange?: boolean;
}
class ResetDatabaseDto {
@IsString()
@Equals('RESET')
confirmPhrase!: string;
}
class CreateAgentAdminDto {
/** 已有玩家用户 ID升级为一级代理 */
@IsString()
@@ -675,6 +683,7 @@ export class AdminController {
private readonly dashboardService: AdminDashboardService,
private systemConfig: SystemConfigService,
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
) {}
@Get('dashboard')
@@ -732,6 +741,32 @@ export class AdminController {
return jsonResponse(limits);
}
@Get('system/reset-database')
@RequirePermissions(P.resetDatabase)
getResetDatabaseStatus() {
return jsonResponse({ allowed: this.databaseReset.isAllowed() });
}
@Post('system/reset-database')
@RequirePermissions(P.resetDatabase)
async resetDatabase(
@CurrentUser('id') operatorId: bigint,
@Body() dto: ResetDatabaseDto,
) {
if (dto.confirmPhrase !== 'RESET') {
throw new BadRequestException('确认短语不正确,请输入 RESET');
}
const result = await this.databaseReset.resetDatabase();
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RESET_DATABASE',
module: 'SYSTEM',
afterData: { demoAccounts: result.demoAccounts },
});
return jsonResponse(result);
}
@Get('users')
@RequirePermissions(P.usersView)
async listUsers(
@@ -1545,6 +1580,28 @@ export class AdminController {
return jsonResponse(preview);
}
@Get('cashbacks')
@RequirePermissions(P.cashback, P.reports)
async listCashbacks(
@Query('page') page = '1',
@Query('pageSize') pageSize = '10',
@Query('status') status?: string,
) {
const result = await this.cashback.listBatches({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
status,
});
return jsonResponse(result);
}
@Get('cashbacks/:batchId')
@RequirePermissions(P.cashback, P.reports)
async getCashbackBatch(@Param('batchId') batchId: string) {
const detail = await this.cashback.getBatchDetail(BigInt(batchId));
return jsonResponse(detail);
}
@Post('cashbacks/:batchId/confirm')
@RequirePermissions(P.cashback)
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
@@ -1559,6 +1616,20 @@ export class AdminController {
return jsonResponse(result);
}
@Post('cashbacks/:batchId/cancel')
@RequirePermissions(P.cashback)
async cashbackCancel(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.cashback.cancelBatch(BigInt(batchId));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CANCEL_CASHBACK',
module: 'CASHBACK',
targetId: batchId,
});
return jsonResponse(result);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(

View File

@@ -12,6 +12,7 @@ import { CashbackModule } from '../../domains/operations/cashback/cashback.modul
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';
@Module({
imports: [
@@ -25,6 +26,7 @@ import { BetsModule } from '../../domains/betting/bets.module';
ContentModule,
I18nModule,
BetsModule,
DatabaseModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -255,6 +255,15 @@ export class PlayerController {
return jsonResponse(result);
}
@Get('wallet/transactions/:transactionId')
async transactionDetail(
@CurrentUser('id') userId: bigint,
@Param('transactionId') transactionId: string,
) {
const detail = await this.wallet.getTransactionDetail(userId, transactionId);
return jsonResponse(detail);
}
@Get('cashbacks')
async cashbacks(@CurrentUser('id') userId: bigint) {
const items = await this.cashback.getUserCashbacks(userId);

View File

@@ -259,6 +259,28 @@ export class WalletService {
return this.prisma.$transaction(run);
}
async getTransactionDetail(userId: bigint, transactionId: string) {
const tx = await this.prisma.walletTransaction.findFirst({
where: { userId, transactionId },
});
if (!tx) return null;
return {
transactionId: tx.transactionId,
transactionType: tx.transactionType,
amount: tx.amount.toString(),
balanceBefore: tx.balanceBefore.toString(),
balanceAfter: tx.balanceAfter.toString(),
frozenBefore: tx.frozenBefore.toString(),
frozenAfter: tx.frozenAfter.toString(),
referenceType: tx.referenceType,
referenceId: tx.referenceId,
remark: tx.remark,
createdAt: tx.createdAt.toISOString(),
betNo: tx.referenceType === 'BET' ? tx.referenceId : null,
};
}
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([

View File

@@ -1,4 +1,5 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { WalletService } from '../../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
@@ -8,6 +9,16 @@ import {
type CashbackRuleRow,
} from './cashback-rate.resolver';
type AggregatedItem = {
userId: bigint;
effectiveStake: Decimal;
betCount: number;
rate: Decimal;
amount: Decimal;
username: string;
agentUsername: string | null;
};
@Injectable()
export class CashbackService {
constructor(
@@ -15,7 +26,12 @@ export class CashbackService {
private wallet: WalletService,
) {}
async previewBatch(periodStart: Date, periodEnd: Date) {
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
items: AggregatedItem[];
totalAmount: Decimal;
totalEffectiveStake: Decimal;
totalBetCount: number;
}> {
const [settledBets, rules, agentProfiles] = await Promise.all([
this.prisma.bet.findMany({
where: {
@@ -45,7 +61,7 @@ export class CashbackService {
const playerAgg = new Map<
string,
{ userId: bigint; stake: Decimal; amount: Decimal }
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
>();
for (const bet of settledBets) {
@@ -69,22 +85,25 @@ export class CashbackService {
userId: bet.userId,
stake: new Decimal(0),
amount: new Decimal(0),
betCount: 0,
};
existing.stake = existing.stake.add(bet.stake);
existing.amount = existing.amount.add(bet.stake.mul(rate));
existing.betCount += 1;
playerAgg.set(key, existing);
}
const items = Array.from(playerAgg.values())
const rawItems = Array.from(playerAgg.values())
.map((p) => ({
userId: p.userId,
effectiveStake: p.stake,
betCount: p.betCount,
rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0),
amount: p.amount,
}))
.sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0));
const userIds = items.map((i) => i.userId);
const userIds = rawItems.map((i) => i.userId);
const users =
userIds.length > 0
? await this.prisma.user.findMany({
@@ -98,7 +117,7 @@ export class CashbackService {
: [];
const userById = new Map(users.map((u) => [u.id.toString(), u]));
const enrichedItems = items.map((item) => {
const items: AggregatedItem[] = rawItems.map((item) => {
const user = userById.get(item.userId.toString());
return {
...item,
@@ -107,32 +126,205 @@ export class CashbackService {
};
});
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
const batch = await this.prisma.cashbackBatch.create({
data: {
batchNo: generateBatchNo('CB'),
periodStart,
periodEnd,
status: 'PREVIEW',
totalAmount,
playerCount: items.length,
},
return { items, totalAmount, totalEffectiveStake, totalBetCount };
}
private normalizePeriodStart(input: Date): Date {
const d = new Date(input);
d.setHours(0, 0, 0, 0);
return d;
}
private normalizePeriodEnd(input: Date): Date {
const d = new Date(input);
d.setHours(23, 59, 59, 999);
return d;
}
private async removePreviewBatchesForPeriod(
periodStart: Date,
periodEnd: Date,
tx: Prisma.TransactionClient,
) {
const stale = await tx.cashbackBatch.findMany({
where: { status: 'PREVIEW', periodStart, periodEnd },
select: { id: true },
});
for (const b of stale) {
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
await tx.cashbackBatch.delete({ where: { id: b.id } });
}
return stale.length;
}
for (const item of enrichedItems) {
await this.prisma.cashbackItem.create({
data: {
batchId: batch.id,
userId: item.userId,
effectiveStake: item.effectiveStake,
rate: item.rate,
amount: item.amount,
},
});
async previewBatch(periodStart: Date, periodEnd: Date) {
const start = this.normalizePeriodStart(periodStart);
const end = this.normalizePeriodEnd(periodEnd);
if (start > end) {
throw new BadRequestException('开始日期不能晚于结束日期');
}
return { batch, items: enrichedItems, totalAmount };
const alreadyPaid = await this.prisma.cashbackBatch.findFirst({
where: { status: 'CONFIRMED', periodStart: start, periodEnd: end },
});
if (alreadyPaid) {
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
}
const { items, totalAmount, totalEffectiveStake, totalBetCount } =
await this.aggregatePeriod(start, end);
if (items.length === 0 || totalAmount.lte(0)) {
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
}
let batch!: Awaited<ReturnType<typeof this.prisma.cashbackBatch.create>>;
const replacedPreviewCount = await this.prisma.$transaction(async (tx) => {
const replaced = await this.removePreviewBatchesForPeriod(start, end, tx);
batch = await tx.cashbackBatch.create({
data: {
batchNo: generateBatchNo('CB'),
periodStart: start,
periodEnd: end,
status: 'PREVIEW',
totalAmount,
totalEffectiveStake,
totalBetCount,
playerCount: items.length,
},
});
for (const item of items) {
await tx.cashbackItem.create({
data: {
batchId: batch.id,
userId: item.userId,
effectiveStake: item.effectiveStake,
betCount: item.betCount,
rate: item.rate,
amount: item.amount,
},
});
}
return replaced;
});
const avgRate = totalEffectiveStake.gt(0)
? totalAmount.div(totalEffectiveStake)
: new Decimal(0);
return {
batch,
items,
totalAmount,
totalEffectiveStake,
totalBetCount,
avgRate,
replacedPreviewCount,
};
}
async listBatches(params: { page: number; pageSize: number; status?: string }) {
const page = Math.max(1, params.page);
const pageSize = Math.min(100, Math.max(1, params.pageSize));
const where = params.status?.trim() ? { status: params.status.trim() } : {};
const [rows, total] = await Promise.all([
this.prisma.cashbackBatch.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.cashbackBatch.count({ where }),
]);
const operatorIds = rows
.map((r) => r.operatorId)
.filter((id): id is bigint => id != null);
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [];
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
const items = rows.map((row) => ({
...row,
operatorUsername: row.operatorId
? operatorById.get(row.operatorId.toString()) ?? null
: null,
}));
return { items, total, page, pageSize };
}
async getBatchDetail(batchId: bigint) {
const batch = await this.prisma.cashbackBatch.findUnique({
where: { id: batchId },
include: {
items: { orderBy: { amount: 'desc' } },
},
});
if (!batch) throw new BadRequestException('Batch not found');
const userIds = batch.items.map((i) => i.userId);
const users =
userIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
username: true,
parent: { select: { username: true } },
},
})
: [];
const userById = new Map(users.map((u) => [u.id.toString(), u]));
let operatorUsername: string | null = null;
if (batch.operatorId) {
const op = await this.prisma.user.findUnique({
where: { id: batch.operatorId },
select: { username: true },
});
operatorUsername = op?.username ?? null;
}
const items = batch.items.map((item) => {
const user = userById.get(item.userId.toString());
return {
id: item.id,
userId: item.userId,
username: user?.username ?? '',
agentUsername: user?.parent?.username ?? null,
effectiveStake: item.effectiveStake,
betCount: item.betCount,
rate: item.rate,
amount: item.amount,
};
});
const avgRate = batch.totalEffectiveStake.gt(0)
? batch.totalAmount.div(batch.totalEffectiveStake)
: new Decimal(0);
return {
batch: { ...batch, operatorUsername },
items,
totalAmount: batch.totalAmount,
totalEffectiveStake: batch.totalEffectiveStake,
totalBetCount: batch.totalBetCount,
avgRate,
};
}
async confirmBatch(batchId: bigint, operatorId: bigint) {
@@ -141,7 +333,22 @@ export class CashbackService {
include: { items: true },
});
if (!batch) throw new BadRequestException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
if (batch.items.length === 0 || batch.totalAmount.lte(0)) {
throw new BadRequestException('批次无有效返水金额');
}
const duplicate = await this.prisma.cashbackBatch.findFirst({
where: {
status: 'CONFIRMED',
periodStart: batch.periodStart,
periodEnd: batch.periodEnd,
id: { not: batchId },
},
});
if (duplicate) {
throw new BadRequestException('该统计周期已发放返水');
}
for (const item of batch.items) {
if (item.amount.gt(0)) {
@@ -164,6 +371,21 @@ export class CashbackService {
return { success: true };
}
async cancelBatch(batchId: bigint) {
const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new BadRequestException('Batch not found');
if (batch.status !== 'PREVIEW') {
throw new BadRequestException('只能作废待发放批次');
}
await this.prisma.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CANCELLED' },
});
return { success: true };
}
async getUserCashbacks(userId: bigint) {
return this.prisma.cashbackItem.findMany({
where: { userId },

View File

@@ -0,0 +1,40 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { DEMO_ACCOUNTS, runSeed } from './run-seed';
@Injectable()
export class DatabaseResetService {
constructor(private prisma: PrismaService) {}
isAllowed(): boolean {
if (process.env.ALLOW_DB_RESET === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
async resetDatabase(): Promise<{ demoAccounts: readonly string[] }> {
if (!this.isAllowed()) {
throw new ForbiddenException('生产环境禁止重置数据库(需设置 ALLOW_DB_RESET=true');
}
await this.truncateApplicationTables();
await runSeed(this.prisma);
return { demoAccounts: DEMO_ACCOUNTS };
}
private async truncateApplicationTables() {
const rows = await this.prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations'
`;
const tableNames = rows.map((r) => `"${r.tablename}"`);
if (tableNames.length === 0) return;
await this.prisma.$executeRawUnsafe(
`TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`,
);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { DatabaseResetService } from './database-reset.service';
@Module({
providers: [DatabaseResetService],
exports: [DatabaseResetService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,736 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
export const DEMO_ACCOUNTS = [
'admin / Admin@123',
'agent1 / Agent@123',
'player1 / Player@123',
] as const;
let prisma: PrismaClient;
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
async function seedDemoMarkets(matchId: bigint) {
const configs: Array<{
marketType: string;
period: string;
lineValue?: number;
sortOrder: number;
selections: Array<{ code: string; name: string; odds: number }>;
}> = [
{
marketType: 'FT_1X2',
period: 'FT',
sortOrder: 1,
selections: [
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
{
marketType: 'FT_HANDICAP',
period: 'FT',
lineValue: -0.5,
sortOrder: 2,
selections: [
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
],
},
{
marketType: 'FT_OVER_UNDER',
period: 'FT',
lineValue: 2.5,
sortOrder: 3,
selections: [
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
],
},
{
marketType: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
{
marketType: 'HT_1X2',
period: 'HT',
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客', odds: 3.5 },
],
},
{
marketType: 'HT_HANDICAP',
period: 'HT',
lineValue: -0.5,
sortOrder: 6,
selections: [
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
],
},
{
marketType: 'HT_OVER_UNDER',
period: 'HT',
lineValue: 1.5,
sortOrder: 7,
selections: [
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
],
},
{
marketType: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
],
},
{
marketType: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
{
marketType: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
];
for (const cfg of configs) {
const exists = await prisma.market.findFirst({
where: { matchId, marketType: cfg.marketType },
include: { _count: { select: { selections: true } } },
});
if (exists) {
const needRefresh =
cfg.marketType.includes('CORRECT_SCORE') &&
exists._count.selections < cfg.selections.length;
if (needRefresh) {
await prisma.market.delete({ where: { id: exists.id } });
} else {
continue;
}
}
await prisma.market.create({
data: {
matchId,
marketType: cfg.marketType,
period: cfg.period,
lineValue: cfg.lineValue,
allowSingle: true,
allowParlay: true,
sortOrder: cfg.sortOrder,
status: 'OPEN',
selections: {
create: cfg.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
}
async function upsertLeagueName(leagueId: bigint, names: Record<string, string>) {
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'LEAGUE',
entityId: leagueId,
locale,
fieldName: 'name',
},
},
create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value },
update: { value },
});
}
}
async function upsertTeam(
code: string,
names: Record<string, string>,
) {
const team = await prisma.team.upsert({
where: { code },
create: { code },
update: {},
});
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
},
},
create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value },
update: { value },
});
}
return team;
}
async function ensurePublishedMatch(opts: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
}) {
let match = await prisma.match.findFirst({
where: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
status: 'PUBLISHED',
},
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
startTime: opts.startTime,
status: 'PUBLISHED',
isHot: opts.isHot ?? false,
displayOrder: opts.displayOrder ?? 0,
publishTime: new Date(),
},
});
} else {
match = await prisma.match.update({
where: { id: match.id },
data: {
startTime: opts.startTime,
isHot: opts.isHot ?? match.isHot,
displayOrder: opts.displayOrder ?? match.displayOrder,
},
});
}
await seedDemoMarkets(match.id);
return match;
}
function hoursFromNow(hours: number) {
return new Date(Date.now() + hours * 3600 * 1000);
}
async function seedSportsDemo() {
const epl = await prisma.league.upsert({
where: { code: 'EPL' },
create: { code: 'EPL' },
update: {},
});
await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' });
const wc = await prisma.league.upsert({
where: { code: 'WC2026' },
create: { code: 'WC2026' },
update: {},
});
await upsertLeagueName(wc.id, {
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
'en-US': '2026 FIFA World Cup',
});
const teams: Array<[string, Record<string, string>]> = [
['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }],
['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }],
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }],
['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }],
['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }],
['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }],
['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }],
['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }],
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }],
['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }],
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }],
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }],
['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }],
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }],
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }],
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }],
];
const teamMap = new Map<string, { id: bigint }>();
for (const [code, names] of teams) {
teamMap.set(code, await upsertTeam(code, names));
}
const get = (code: string) => {
const t = teamMap.get(code);
if (!t) throw new Error(`Team ${code} missing`);
return t;
};
// 英超:明日开赛 → 早盘
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('MUN').id,
awayTeamId: get('CHE').id,
startTime: hoursFromNow(26),
isHot: true,
displayOrder: 1,
});
// 英超:今晚开赛 → 今日
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('CHE').id,
awayTeamId: get('MUN').id,
startTime: hoursFromNow(8),
isHot: false,
displayOrder: 2,
});
const wcFixtures: Array<{
home: string;
away: string;
start: Date;
hot?: boolean;
order: number;
}> = [
{ home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 },
{ home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 },
{ home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 },
{ home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 },
{ home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 },
{ home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 },
{ home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 },
];
for (const f of wcFixtures) {
await ensurePublishedMatch({
leagueId: wc.id,
homeTeamId: get(f.home).id,
awayTeamId: get(f.away).id,
startTime: f.start,
isHot: f.hot,
displayOrder: f.order,
});
}
console.log(` Sports demo: ${wcFixtures.length + 2} published matches`);
}
async function seedOutrightDemo() {
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
if (!wc) return;
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
forceCanonical: true,
});
const count = await prisma.marketSelection.count({ where: { marketId } });
console.log(` WC2026 outright: match ${matchId}, ${count} selections`);
}
async function seedPlayerDemo() {
const player = await prisma.user.findUnique({
where: { username: 'player1' },
include: { wallet: true },
});
if (!player?.wallet) return;
await prisma.wallet.update({
where: { id: player.wallet.id },
data: { availableBalance: 88888.88 },
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-001' },
create: {
transactionId: 'DEMO-DEP-001',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 50000,
balanceBefore: 1000,
balanceAfter: 51000,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值',
},
update: {},
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-002' },
create: {
transactionId: 'DEMO-DEP-002',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 37888.88,
balanceBefore: 51000,
balanceAfter: 88888.88,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值(二笔)',
},
update: {},
});
const sampleSel = await prisma.marketSelection.findFirst({
where: {
status: 'OPEN',
market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } },
},
include: { market: { include: { match: true } } },
});
if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) {
const odds = Number(sampleSel.odds);
const stake = 200;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-001',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
status: 'PENDING',
requestId: 'seed-demo-bet-001',
selections: {
create: {
matchId: sampleSel.market.matchId,
marketId: sampleSel.marketId,
selectionId: sampleSel.id,
marketType: sampleSel.market.marketType,
period: sampleSel.market.period,
selectionNameSnapshot: sampleSel.selectionName,
odds: sampleSel.odds,
oddsVersion: sampleSel.oddsVersion,
},
},
},
});
}
const settledSel = await prisma.marketSelection.findFirst({
where: {
market: { marketType: 'FT_1X2' },
selectionCode: 'DRAW',
},
include: { market: true },
});
if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) {
const odds = Number(settledSel.odds);
const stake = 50;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-002',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
actualReturn: stake * odds,
status: 'WON',
settlementStatus: 'SETTLED',
settledAt: new Date(Date.now() - 86400000),
requestId: 'seed-demo-bet-002',
selections: {
create: {
matchId: settledSel.market.matchId,
marketId: settledSel.marketId,
selectionId: settledSel.id,
marketType: settledSel.market.marketType,
period: settledSel.market.period,
selectionNameSnapshot: settledSel.selectionName,
odds: settledSel.odds,
oddsVersion: settledSel.oddsVersion,
resultStatus: 'WIN',
effectiveOdds: settledSel.odds,
},
},
},
});
}
console.log(' Player demo: wallet + transactions + sample bets');
}
export async function runSeed(client: PrismaClient) {
prisma = client;
console.log('Seeding database...');
const superAdminRole = await prisma.role.upsert({
where: { code: 'SUPER_ADMIN' },
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
update: {},
});
const permCodes = [
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
'bets.view', 'settings.manage', 'settings.reset_database', 'audit.view',
];
const permIds = new Map<string, bigint>();
for (const code of permCodes) {
const perm = await prisma.permission.upsert({
where: { code },
create: { code, name: code, module: code.split('.')[0] },
update: {},
});
permIds.set(code, perm.id);
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
create: { roleId: superAdminRole.id, permissionId: perm.id },
update: {},
});
}
async function ensureRole(code: string, name: string, permissions: string[]) {
const role = await prisma.role.upsert({
where: { code },
create: { code, name, description: name },
update: { name },
});
for (const p of permissions) {
const pid = permIds.get(p);
if (!pid) continue;
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId: pid } },
create: { roleId: role.id, permissionId: pid },
update: {},
});
}
return role;
}
await ensureRole('MATCH_ADMIN', 'Match Admin', [
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
]);
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
'reports.view', 'bets.view', 'audit.view',
]);
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
const defaultBettingLimits = [
['bet.min_stake', '1', '最小单注金额'],
['bet.max_stake_single', '50000', '单关最大投注额'],
['bet.max_stake_parlay', '20000', '串关最大投注额'],
['bet.max_payout_single', '500000', '单关最高派彩'],
['bet.max_payout_parlay', '1000000', '串关最高派彩'],
['bet.daily_stake_limit', '200000', '玩家每日投注上限'],
] as const;
for (const [key, value, desc] of defaultBettingLimits) {
await prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, configValue: value, description: desc },
update: {},
});
}
const hash = await bcrypt.hash('Admin@123', 10);
const agentHash = await bcrypt.hash('Agent@123', 10);
const playerHash = await bcrypt.hash('Player@123', 10);
await prisma.user.upsert({
where: { username: 'admin' },
create: {
username: 'admin',
userType: 'ADMIN',
auth: { create: { passwordHash: hash } },
adminRole: { create: { roleId: superAdminRole.id } },
},
update: {},
});
const agent1 = await prisma.user.upsert({
where: { username: 'agent1' },
create: {
username: 'agent1',
userType: 'AGENT',
agentLevel: 1,
auth: { create: { passwordHash: agentHash } },
agentProfile: { create: { level: 1, creditLimit: 100000 } },
},
update: {},
});
await prisma.agentClosure.upsert({
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent1.id } },
create: { ancestorId: agent1.id, descendantId: agent1.id, depth: 0 },
update: {},
});
await prisma.user.upsert({
where: { username: 'agent2' },
create: {
username: 'agent2',
userType: 'AGENT',
agentLevel: 2,
parentId: agent1.id,
auth: { create: { passwordHash: agentHash } },
agentProfile: { create: { level: 2, parentAgentId: agent1.id, creditLimit: 30000 } },
},
update: {},
});
await prisma.user.upsert({
where: { username: 'player1' },
create: {
username: 'player1',
userType: 'PLAYER',
parentId: agent1.id,
auth: { create: { passwordHash: playerHash } },
wallet: { create: { availableBalance: 1000 } },
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
},
update: {},
});
const messages = [
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
{ key: 'bet.place_bet', zh: '确认下注', ms: 'Letak Pertaruhan', en: 'Place Bet' },
{ key: 'error.insufficient_balance', zh: '余额不足', ms: 'Baki tidak mencukupi', en: 'Insufficient balance' },
];
for (const m of messages) {
for (const [locale, value] of [['zh-CN', m.zh], ['ms-MY', m.ms], ['en-US', m.en]] as const) {
await prisma.i18nMessage.upsert({
where: { msgKey_locale: { msgKey: m.key, locale } },
create: { msgKey: m.key, locale, value },
update: { value },
});
}
}
await seedSportsDemo();
await seedOutrightDemo();
await seedPlayerDemo();
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 1,
linkType: 'ROUTE',
linkTarget: '/football',
translations: {
create: [
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中', imageUrl: '/uploads/banners/welcome.svg' },
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available', imageUrl: '/uploads/banners/welcome.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 2,
translations: {
create: [
{ locale: 'zh-CN', title: '首存礼遇', body: '新会员专属优惠', imageUrl: '/uploads/banners/promo.svg' },
{ locale: 'en-US', title: 'First Deposit', body: 'New member offer', imageUrl: '/uploads/banners/promo.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 3,
linkType: 'ROUTE',
linkTarget: '/football',
translations: {
create: [
{ locale: 'zh-CN', title: '热门赛事', body: '五大联赛天天有球', imageUrl: '/uploads/banners/hot-matches.svg' },
{ locale: 'en-US', title: 'Hot Matches', body: 'Top leagues daily', imageUrl: '/uploads/banners/hot-matches.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'TICKER',
status: 'ACTIVE',
sortOrder: 1,
translations: {
create: [
{ locale: 'zh-CN', body: '欢迎来到 TheBet365 · 热门赛事每日更新 · 请理性投注' },
{ locale: 'en-US', body: 'Welcome to TheBet365 · Daily hot matches · Bet responsibly' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'NOTICE',
status: 'ACTIVE',
sortOrder: 1,
translations: {
create: [
{ locale: 'zh-CN', title: '系统维护通知:每周一 04:00-05:00 例行维护,敬请谅解' },
{ locale: 'en-US', title: 'Maintenance: Every Mon 04:00-05:00 UTC' },
],
},
},
}).catch(() => {});
console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`);
}

View File

@@ -7,6 +7,7 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });