feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "external_id" INTEGER,
ADD COLUMN IF NOT EXISTS "logo_url" VARCHAR(500);
-- AlterTable
ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "official_match_no" INTEGER,
ADD COLUMN IF NOT EXISTS "stage" VARCHAR(32),
ADD COLUMN IF NOT EXISTS "group_name" VARCHAR(8),
ADD COLUMN IF NOT EXISTS "live_match_id" BIGINT,
ADD COLUMN IF NOT EXISTS "addition_match_id" BIGINT,
ADD COLUMN IF NOT EXISTS "channel_id" VARCHAR(64),
ADD COLUMN IF NOT EXISTS "match_name" VARCHAR(200),
ADD COLUMN IF NOT EXISTS "venue_json" JSONB,
ADD COLUMN IF NOT EXISTS "kickoff_json" JSONB,
ADD COLUMN IF NOT EXISTS "external_status" VARCHAR(32);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "teams_external_id_key" ON "teams"("external_id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "matches_live_match_id_key" ON "matches"("live_match_id");

View File

@@ -227,12 +227,14 @@ model League {
}
model Team {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
code String @unique @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
code String @unique @db.VarChar(64)
externalId Int? @unique @map("external_id")
logoUrl String? @map("logo_url") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
homeMatches Match[] @relation("HomeTeam")
awayMatches Match[] @relation("AwayTeam")
@@ -256,23 +258,33 @@ model EntityTranslation {
}
model Match {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
leagueId BigInt @map("league_id")
homeTeamId BigInt @map("home_team_id")
awayTeamId BigInt @map("away_team_id")
startTime DateTime @map("start_time")
status String @default("DRAFT") @db.VarChar(32)
isHot Boolean @default(false) @map("is_hot")
displayOrder Int @default(0) @map("display_order")
publishTime DateTime? @map("publish_time")
closeTime DateTime? @map("close_time")
isOutright Boolean @default(false) @map("is_outright")
createdBy BigInt? @map("created_by")
updatedBy BigInt? @map("updated_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
leagueId BigInt @map("league_id")
homeTeamId BigInt @map("home_team_id")
awayTeamId BigInt @map("away_team_id")
startTime DateTime @map("start_time")
status String @default("DRAFT") @db.VarChar(32)
isHot Boolean @default(false) @map("is_hot")
displayOrder Int @default(0) @map("display_order")
publishTime DateTime? @map("publish_time")
closeTime DateTime? @map("close_time")
isOutright Boolean @default(false) @map("is_outright")
officialMatchNo Int? @map("official_match_no")
stage String? @db.VarChar(32)
groupName String? @map("group_name") @db.VarChar(8)
liveMatchId BigInt? @unique @map("live_match_id")
additionMatchId BigInt? @map("addition_match_id")
channelId String? @map("channel_id") @db.VarChar(64)
matchName String? @map("match_name") @db.VarChar(200)
venueJson Json? @map("venue_json")
kickoffJson Json? @map("kickoff_json")
externalStatus String? @map("external_status") @db.VarChar(32)
createdBy BigInt? @map("created_by")
updatedBy BigInt? @map("updated_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
league League @relation(fields: [leagueId], references: [id])
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])

View File

@@ -0,0 +1,209 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
}
function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
return new Decimal(a ?? 0).sub(b ?? 0).toString();
}
@Injectable()
export class AdminDashboardService {
constructor(private prisma: PrismaService) {}
async getOverview() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const trend7d = await Promise.all(
Array.from({ length: 7 }, (_, i) => {
const dayStart = new Date(today);
dayStart.setDate(dayStart.getDate() - (6 - i));
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
return this.prisma.bet
.aggregate({
where: { placedAt: { gte: dayStart, lt: dayEnd } },
_sum: { stake: true, actualReturn: true },
_count: true,
})
.then((agg) => ({
date: dayStart.toISOString().slice(0, 10),
label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
betCount: agg._count,
stake: dec(agg._sum.stake),
payout: dec(agg._sum.actualReturn),
ggr: sub(agg._sum.stake, agg._sum.actualReturn),
}));
}),
);
const playerWhere = { userType: 'PLAYER', deletedAt: null };
const [
todayBets,
yesterdayBets,
pendingBets,
betStatusToday,
matchGroups,
matchTotal,
playerTotal,
playerActive,
playerSuspended,
playerDirect,
newPlayersToday,
agentProfiles,
agentsActive,
walletAgg,
recentBets,
recentPlayers,
] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.bet.aggregate({
where: {
placedAt: {
gte: new Date(today.getTime() - 86400000),
lt: today,
},
},
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.bet.count({ where: { status: 'PENDING' } }),
this.prisma.bet.groupBy({
by: ['status'],
where: { placedAt: { gte: today } },
_count: { _all: true },
_sum: { stake: true },
}),
this.prisma.match.groupBy({
by: ['status'],
where: { deletedAt: null },
_count: { _all: true },
}),
this.prisma.match.count({ where: { deletedAt: null } }),
this.prisma.user.count({ where: playerWhere }),
this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
this.prisma.user.count({
where: { ...playerWhere, parentId: null },
}),
this.prisma.user.count({
where: { ...playerWhere, createdAt: { gte: today } },
}),
this.prisma.agentProfile.aggregate({
_sum: { creditLimit: true, usedCredit: true },
_count: { _all: true },
}),
this.prisma.agentProfile.count({ where: { status: 'ACTIVE' } }),
this.prisma.wallet.aggregate({
where: { user: playerWhere },
_sum: { availableBalance: true, frozenBalance: true },
_count: { _all: true },
}),
this.prisma.bet.findMany({
take: 8,
orderBy: { placedAt: 'desc' },
include: { user: { select: { username: true } } },
}),
this.prisma.user.findMany({
where: playerWhere,
take: 6,
orderBy: { createdAt: 'desc' },
select: {
id: true,
username: true,
status: true,
createdAt: true,
parent: { select: { username: true } },
},
}),
]);
const matchByStatus: Record<string, number> = {};
for (const g of matchGroups) {
matchByStatus[g.status] = g._count._all;
}
const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
for (const g of betStatusToday) {
todayBetByStatus[g.status] = {
count: g._count._all,
stake: dec(g._sum.stake),
};
}
const creditLimit = agentProfiles._sum.creditLimit ?? new Decimal(0);
const usedCredit = agentProfiles._sum.usedCredit ?? new Decimal(0);
return {
generatedAt: new Date().toISOString(),
trend7d,
today: {
betCount: todayBets._count,
stake: dec(todayBets._sum.stake),
payout: dec(todayBets._sum.actualReturn),
ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
newPlayers: newPlayersToday,
},
yesterday: {
betCount: yesterdayBets._count,
stake: dec(yesterdayBets._sum.stake),
payout: dec(yesterdayBets._sum.actualReturn),
ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
},
users: {
playersTotal: playerTotal,
playersActive: playerActive,
playersSuspended: playerSuspended,
playersDirect: playerDirect,
agentsTotal: agentProfiles._count._all,
agentsActive,
},
wallets: {
totalAvailable: dec(walletAgg._sum.availableBalance),
totalFrozen: dec(walletAgg._sum.frozenBalance),
playerWalletCount: walletAgg._count._all,
},
agents: {
totalCreditLimit: dec(creditLimit),
totalUsedCredit: dec(usedCredit),
totalAvailableCredit: creditLimit.sub(usedCredit).toString(),
},
matches: {
total: matchTotal,
draft: matchByStatus.DRAFT ?? 0,
published: matchByStatus.PUBLISHED ?? 0,
closed: matchByStatus.CLOSED ?? 0,
cancelled: matchByStatus.CANCELLED ?? 0,
pendingSettlement: matchByStatus.PENDING_SETTLEMENT ?? 0,
settled: matchByStatus.SETTLED ?? 0,
},
bets: {
pendingTotal: pendingBets,
todayByStatus: todayBetByStatus,
},
recentBets: recentBets.map((b) => ({
betNo: b.betNo,
username: b.user.username,
stake: dec(b.stake),
status: b.status,
placedAt: b.placedAt,
})),
recentPlayers: recentPlayers.map((p) => ({
id: p.id.toString(),
username: p.username,
status: p.status,
parentUsername: p.parent?.username ?? null,
createdAt: p.createdAt,
})),
};
}
}

View File

@@ -1,5 +1,7 @@
import {
BadRequestException,
Controller,
Delete,
Get,
Post,
Put,
@@ -24,7 +26,18 @@ import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
import { AdminDashboardService } from './admin-dashboard.service';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
IsBoolean,
MinLength,
IsIn,
Min,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
class CreateUserDto {
@IsString()
@@ -43,6 +56,116 @@ class CreateUserDto {
creditLimit?: number;
}
class CreatePlayerAdminDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
initialDeposit?: number;
@IsOptional()
@IsString()
remark?: string;
}
class UpdatePlayerAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
/** 传空字符串表示改为平台直属(无代理) */
@IsOptional()
@IsString()
parentId?: string;
}
class CreateAgentAdminDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsNumber()
@Min(0)
creditLimit!: number;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
}
class UpdateAgentAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
}
class DepositDto {
@IsNumber()
amount!: number;
@@ -55,15 +178,24 @@ class DepositDto {
remark?: string;
}
class CreateMatchDto {
class CreatePlatformMatchDto {
@IsString()
leagueId!: string;
leagueEn!: string;
@IsString()
homeTeamId!: string;
leagueZh!: string;
@IsString()
awayTeamId!: string;
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsString()
startTime!: string;
@@ -71,6 +203,15 @@ class CreateMatchDto {
@IsOptional()
@IsBoolean()
isHot?: boolean;
@IsOptional()
@IsNumber()
displayOrder?: number;
}
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
if (!body || typeof body !== 'object') return false;
return Array.isArray((body as ZhiboMatchesBundleExport).matches);
}
class ScoreDto {
@@ -123,44 +264,73 @@ export class AdminController {
private audit: AuditService,
private bets: BetsService,
private prisma: PrismaService,
private readonly dashboardService: AdminDashboardService,
) {}
@Get('dashboard')
async dashboard() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
]);
return jsonResponse({
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayPayout: todayBets._sum.actualReturn,
pendingSettlement: pendingMatches,
totalPlayers,
});
async getDashboard() {
const overview = await this.dashboardService.getOverview();
return jsonResponse(overview);
}
@Get('users')
async listUsers(@Query('page') page?: string) {
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
async listUsers(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('parentId') parentId?: string,
@Query('status') status?: string,
) {
const result = await this.users.listPlayers(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
{
keyword,
parentId: parentId ? BigInt(parentId) : undefined,
status,
},
);
return jsonResponse(result);
}
@Get('users/:id')
async getUserDetail(@Param('id') id: string) {
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('users/:id')
async updateUser(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdatePlayerAdminDto,
) {
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('users')
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
async createPlayer(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerAdminDto,
) {
const user = await this.agents.createPlayer(operatorId, {
username: dto.username,
password: dto.password,
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
parentId: dto.parentId ? BigInt(dto.parentId) : undefined,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
initialDeposit: dto.initialDeposit,
depositRemark: dto.remark,
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
});
await this.audit.log({
operatorId,
@@ -169,24 +339,73 @@ export class AdminController {
module: 'USERS',
targetId: user.id.toString(),
});
return jsonResponse(user);
const detail = await this.users.getPlayerAdminDetail(user.id);
return jsonResponse(detail);
}
@Get('agents/options')
async listAgentOptions() {
const agents = await this.prisma.user.findMany({
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
select: { id: true, username: true },
orderBy: { username: 'asc' },
});
return jsonResponse(
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
);
}
@Get('agents')
async listAgents() {
const agents = await this.prisma.agentProfile.findMany({
include: { user: true },
async listAgents(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
});
return jsonResponse(agents);
return jsonResponse(result);
}
@Get('agents/:id')
async getAgentDetail(@Param('id') id: string) {
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('agents/:id')
async updateAgent(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateAgentAdminDto,
) {
const detail = await this.agents.updateAgentAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT',
module: 'AGENTS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('agents')
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
async createAgent(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateAgentAdminDto,
) {
const user = await this.agents.createAgent(operatorId, {
username: dto.username,
password: dto.password,
level: 1,
creditLimit: dto.creditLimit,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
cashbackRate: dto.cashbackRate,
});
await this.audit.log({
operatorId,
@@ -195,7 +414,8 @@ export class AdminController {
module: 'AGENTS',
targetId: user.id.toString(),
});
return jsonResponse(user);
const detail = await this.agents.getAgentAdminDetail(user.id);
return jsonResponse(detail);
}
@Post('agents/:id/credit')
@@ -257,27 +477,100 @@ export class AdminController {
}
@Get('matches')
async listMatches() {
const matches = await this.prisma.match.findMany({
include: { markets: { include: { selections: true } } },
orderBy: { startTime: 'desc' },
async listMatches(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const skip = (p - 1) * size;
const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null };
if (status) where.status = status;
const kw = keyword?.trim();
if (kw) {
where.OR = [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const [items, total] = await Promise.all([
this.prisma.match.findMany({
where,
include: {
homeTeam: true,
awayTeam: true,
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip,
take: size,
}),
this.prisma.match.count({ where }),
]);
return jsonResponse({ items, total, page: p, pageSize: size });
}
@Get('matches/:id')
async getMatch(@Param('id') id: string) {
const match = await this.matches.getAdminMatchDetail(BigInt(id));
return jsonResponse(match);
}
@Put('matches/:id')
async updateMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: CreatePlatformMatchDto,
) {
const match = await this.matches.updatePlatformMatch(BigInt(id), {
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
updatedBy: operatorId,
});
return jsonResponse(matches);
return jsonResponse(match);
}
@Delete('matches/:id')
async deleteMatch(@Param('id') id: string) {
await this.matches.deleteMatch(BigInt(id));
return jsonResponse({ deleted: true });
}
@Post('matches')
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
const match = await this.matches.createMatch({
leagueId: BigInt(dto.leagueId),
homeTeamId: BigInt(dto.homeTeamId),
awayTeamId: BigInt(dto.awayTeamId),
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
const match = await this.matches.createPlatformMatch({
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
createdBy: operatorId,
});
return jsonResponse(match);
}
@Post('matches/import')
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
if (!isZhiboBundlePayload(dto)) {
throw new BadRequestException('Invalid import payload: matches[] required');
}
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
return jsonResponse(result);
}
@Post('matches/:id/publish')
async publishMatch(@Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
@@ -343,20 +636,31 @@ export class AdminController {
}
@Get('bets')
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const where = status ? { status } : {};
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where }),
]);
return jsonResponse({ items, total });
async listBets(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('betType') betType?: string,
@Query('placedFrom') placedFrom?: string,
@Query('placedTo') placedTo?: string,
) {
const result = await this.bets.listBetsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status: status || undefined,
betType: betType || undefined,
placedFrom,
placedTo,
});
return jsonResponse(result);
}
@Get('bets/:id')
async getBet(@Param('id') id: string) {
const detail = await this.bets.getBetAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Post('cashbacks/preview')
@@ -393,8 +697,16 @@ export class AdminController {
}
@Get('audit-logs')
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
async auditLogs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('module') module?: string,
) {
const result = await this.audit.list(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
module || undefined,
);
return jsonResponse(result);
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminDashboardService } from './admin-dashboard.service';
import { UsersModule } from '../../domains/identity/users.module';
import { AgentsModule } from '../../domains/agent/agents.module';
import { WalletModule } from '../../domains/ledger/wallet.module';
@@ -25,5 +26,6 @@ import { BetsModule } from '../../domains/betting/bets.module';
BetsModule,
],
controllers: [AdminController],
providers: [AdminDashboardService],
})
export class AdminModule {}

View File

@@ -147,8 +147,14 @@ export class AgentPortalController {
}
@Get('bets')
async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
async listBets(
@CurrentUser('id') agentId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const skip = (p - 1) * size;
const descendants = await this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
});
@@ -160,11 +166,11 @@ export class AgentPortalController {
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
take: size,
}),
this.prisma.bet.count({ where: { agentId: { in: agentIds } } }),
]);
return jsonResponse({ items, total });
return jsonResponse({ items, total, page: p, pageSize: size });
}
@Get('reports/summary')

View File

@@ -1,4 +1,10 @@
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import {
Injectable,
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AuthService } from '../identity/auth.service';
@@ -147,6 +153,211 @@ export class AgentsService {
return { success: true };
}
async listAgentsAdmin(params?: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
const skip = (page - 1) * pageSize;
const where: Prisma.AgentProfileWhereInput = {};
const kw = params?.keyword?.trim();
if (kw) {
where.user = { username: { contains: kw, mode: 'insensitive' } };
}
const [profiles, total] = await Promise.all([
this.prisma.agentProfile.findMany({
where,
include: {
user: { include: { preferences: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.agentProfile.count({ where }),
]);
const agentIds = profiles.map((p) => p.userId);
const playerCounts =
agentIds.length > 0
? await this.prisma.user.groupBy({
by: ['parentId'],
where: {
userType: 'PLAYER',
parentId: { in: agentIds },
deletedAt: null,
},
_count: { _all: true },
})
: [];
const countMap = new Map(
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
const items = profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
return {
id: p.id.toString(),
userId: p.userId.toString(),
username: p.user.username,
userStatus: p.user.status,
level: p.level,
status: p.status,
parentAgentId: p.parentAgentId?.toString() ?? null,
creditLimit: p.creditLimit.toString(),
usedCredit: p.usedCredit.toString(),
availableCredit: available.toString(),
directPlayerLiability: p.directPlayerLiability.toString(),
childAgentExposure: p.childAgentExposure.toString(),
cashbackRate: p.cashbackRate.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
phone: p.user.preferences?.phone ?? null,
email: p.user.preferences?.email ?? null,
locale: p.user.locale,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
});
return { items, total, page, pageSize };
}
async getAgentAdminDetail(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
include: { user: { include: { preferences: true, auth: true } } },
});
if (!profile) throw new NotFoundException('代理不存在');
const [directPlayerCount, recentCredits] = await Promise.all([
this.prisma.user.count({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
}),
this.prisma.agentCreditTransaction.findMany({
where: { agentId },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
let parentUsername: string | null = null;
if (profile.parentAgentId) {
const parent = await this.prisma.user.findUnique({
where: { id: profile.parentAgentId },
select: { username: true },
});
parentUsername = parent?.username ?? null;
}
return {
id: profile.id.toString(),
userId: profile.userId.toString(),
username: profile.user.username,
userStatus: profile.user.status,
level: profile.level,
status: profile.status,
parentAgentId: profile.parentAgentId?.toString() ?? null,
parentUsername,
creditLimit: profile.creditLimit.toString(),
usedCredit: profile.usedCredit.toString(),
availableCredit: available.toString(),
directPlayerLiability: profile.directPlayerLiability.toString(),
childAgentExposure: profile.childAgentExposure.toString(),
cashbackRate: profile.cashbackRate.toString(),
directPlayerCount,
phone: profile.user.preferences?.phone ?? null,
email: profile.user.preferences?.email ?? null,
locale: profile.user.locale,
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
recentCreditTransactions: recentCredits.map((t) => ({
id: t.id.toString(),
transactionType: t.transactionType,
amount: t.amount.toString(),
creditBefore: t.creditBefore.toString(),
creditAfter: t.creditAfter.toString(),
remark: t.remark,
createdAt: t.createdAt,
})),
};
}
async updateAgentAdmin(
agentId: bigint,
data: {
status?: string;
locale?: string;
phone?: string;
email?: string;
cashbackRate?: number;
},
) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
include: { user: true },
});
if (!profile) throw new NotFoundException('代理不存在');
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
throw new BadRequestException('无效状态');
}
if (data.status) {
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: agentId },
data: { status: data.status },
}),
this.prisma.agentProfile.update({
where: { userId: agentId },
data: { status: data.status },
}),
]);
}
if (data.locale) {
await this.prisma.user.update({
where: { id: agentId },
data: { locale: data.locale },
});
}
if (data.cashbackRate !== undefined) {
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: { cashbackRate: data.cashbackRate },
});
}
if (data.phone !== undefined || data.email !== undefined || data.locale) {
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
await this.prisma.userPreference.upsert({
where: { userId: agentId },
create: {
userId: agentId,
locale: data.locale ?? profile.user.locale,
phone: phone ?? null,
email: email ?? null,
},
update: {
...(data.locale ? { locale: data.locale } : {}),
...(phone !== undefined ? { phone } : {}),
...(email !== undefined ? { email } : {}),
},
});
}
return this.getAgentAdminDetail(agentId);
}
async createAgent(
operatorId: bigint,
data: {
@@ -155,6 +366,10 @@ export class AgentsService {
level: number;
parentAgentId?: bigint;
creditLimit?: number;
locale?: string;
phone?: string;
email?: string;
cashbackRate?: number;
},
) {
if (data.level === 2 && !data.parentAgentId) {
@@ -164,12 +379,14 @@ export class AgentsService {
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const locale = data.locale ?? 'zh-CN';
const user = await tx.user.create({
data: {
username: data.username,
userType: 'AGENT',
parentId: data.parentAgentId,
agentLevel: data.level,
locale,
},
});
@@ -177,12 +394,22 @@ export class AgentsService {
data: { userId: user.id, passwordHash: hash },
});
await tx.userPreference.create({
data: {
userId: user.id,
locale,
phone: data.phone?.trim() || null,
email: data.email?.trim() || null,
},
});
await tx.agentProfile.create({
data: {
userId: user.id,
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
},
});
@@ -215,38 +442,81 @@ export class AgentsService {
async createPlayer(
operatorId: bigint,
data: { username: string; password: string; parentId: bigint },
data: {
username: string;
password: string;
parentId?: bigint;
locale?: string;
phone?: string;
email?: string;
initialDeposit?: number;
depositRemark?: string;
depositRequestId?: string;
},
) {
const hash = await this.auth.hashPassword(data.password);
let parentId: bigint | null = null;
if (data.parentId != null) {
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
if (!parent || parent.userType !== 'AGENT') {
throw new BadRequestException('上级必须为代理账号');
}
parentId = data.parentId;
}
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
const hash = await this.auth.hashPassword(data.password);
const locale = data.locale ?? 'zh-CN';
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username: data.username,
userType: 'PLAYER',
parentId: data.parentId,
parentId,
locale,
},
});
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
data: { userId: created.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: user.id },
data: { userId: created.id },
});
await tx.userPreference.create({
data: { userId: user.id },
data: {
userId: created.id,
locale,
phone: data.phone?.trim() || null,
email: data.email?.trim() || null,
},
});
const parent = await tx.user.findUnique({ where: { id: data.parentId } });
if (parent?.userType === 'AGENT') {
await this.recalculateUsedCredit(data.parentId);
}
return user;
return created;
});
if (parentId) {
await this.recalculateUsedCredit(parentId);
}
const initial = data.initialDeposit ?? 0;
if (initial > 0) {
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.wallet.deposit(
user.id,
initial,
operatorId,
data.depositRemark ?? '开户初始余额',
requestId,
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
}
}
return user;
}
async getDirectPlayers(agentId: bigint) {

View File

@@ -1,4 +1,5 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { Injectable, BadRequestException, ConflictException, NotFoundException } 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';
@@ -208,4 +209,157 @@ export class BetsService {
include: { selections: true },
});
}
private dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
}
private formatBetListRow(
b: {
id: bigint;
betNo: string;
userId: bigint;
agentId: bigint | null;
betType: string;
stake: Decimal;
totalOdds: Decimal | null;
potentialReturn: Decimal | null;
actualReturn: Decimal;
status: string;
settlementStatus: string | null;
currency: string;
placedAt: Date;
settledAt: Date | null;
user: { id: bigint; username: string; parent: { username: string } | null };
_count: { selections: number };
},
) {
return {
id: b.id.toString(),
betNo: b.betNo,
userId: b.userId.toString(),
username: b.user.username,
parentUsername: b.user.parent?.username ?? null,
agentId: b.agentId?.toString() ?? null,
betType: b.betType,
stake: this.dec(b.stake),
totalOdds: b.totalOdds ? this.dec(b.totalOdds) : null,
potentialReturn: b.potentialReturn ? this.dec(b.potentialReturn) : null,
actualReturn: this.dec(b.actualReturn),
status: b.status,
settlementStatus: b.settlementStatus,
currency: b.currency,
placedAt: b.placedAt,
settledAt: b.settledAt,
selectionCount: b._count.selections,
};
}
async listBetsAdmin(params: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
betType?: string;
placedFrom?: string;
placedTo?: string;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(Math.max(1, params.pageSize ?? 10), 100);
const skip = (page - 1) * pageSize;
const where: Prisma.BetWhereInput = {};
if (params.status) where.status = params.status;
if (params.betType) where.betType = params.betType;
if (params.placedFrom || params.placedTo) {
where.placedAt = {};
if (params.placedFrom) {
const from = new Date(params.placedFrom);
from.setHours(0, 0, 0, 0);
where.placedAt.gte = from;
}
if (params.placedTo) {
const to = new Date(params.placedTo);
to.setHours(23, 59, 59, 999);
where.placedAt.lte = to;
}
}
const kw = params.keyword?.trim();
if (kw) {
where.OR = [
{ betNo: { contains: kw, mode: 'insensitive' } },
{ user: { username: { contains: kw, mode: 'insensitive' } } },
];
}
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: {
user: {
select: {
id: true,
username: true,
parent: { select: { username: true } },
},
},
_count: { select: { selections: true } },
},
orderBy: { placedAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.bet.count({ where }),
]);
return {
items: items.map((b) => this.formatBetListRow(b)),
total,
page,
pageSize,
};
}
async getBetAdminDetail(betId: bigint) {
const bet = await this.prisma.bet.findUnique({
where: { id: betId },
include: {
user: {
select: {
id: true,
username: true,
parent: { select: { username: true } },
},
},
selections: { orderBy: { sortOrder: 'asc' } },
},
});
if (!bet) throw new NotFoundException('注单不存在');
return {
...this.formatBetListRow({
...bet,
_count: { selections: bet.selections.length },
}),
requestId: bet.requestId,
createdAt: bet.createdAt,
updatedAt: bet.updatedAt,
selections: bet.selections.map((s) => ({
id: s.id.toString(),
matchId: s.matchId?.toString() ?? null,
marketType: s.marketType,
period: s.period,
selectionName: s.selectionNameSnapshot,
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
odds: this.dec(s.odds),
resultStatus: s.resultStatus,
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
sortOrder: s.sortOrder,
})),
};
}
}

View File

@@ -1,6 +1,18 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
import {
leagueCodeFromExport,
resolveInternalStatus,
resolveIsHot,
resolveStartTime,
teamCodeFromExport,
toKickoffJson,
toVenueJson,
translationsFromZhiboNames,
} from './zhibo-match.mapper';
@Injectable()
export class MatchesService {
@@ -44,8 +56,24 @@ export class MatchesService {
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
createdBy?: bigint;
status?: string;
publishTime?: Date;
zhibo?: Partial<{
officialMatchNo: number;
stage: string;
groupName: string;
liveMatchId?: bigint;
additionMatchId: bigint | null;
channelId: string | null;
matchName: string;
venueJson: Prisma.InputJsonValue;
kickoffJson: Prisma.InputJsonValue;
externalStatus: string;
}>;
}) {
const status = data.status ?? 'DRAFT';
return this.prisma.match.create({
data: {
leagueId: data.leagueId,
@@ -53,12 +81,384 @@ export class MatchesService {
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
status,
publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined),
officialMatchNo: data.zhibo?.officialMatchNo,
stage: data.zhibo?.stage,
groupName: data.zhibo?.groupName,
liveMatchId: data.zhibo?.liveMatchId,
additionMatchId: data.zhibo?.additionMatchId ?? undefined,
channelId: data.zhibo?.channelId ?? undefined,
matchName: data.zhibo?.matchName,
venueJson: data.zhibo?.venueJson,
kickoffJson: data.zhibo?.kickoffJson,
externalStatus: data.zhibo?.externalStatus,
},
});
}
private async upsertEntityTranslations(
entityType: 'LEAGUE' | 'TEAM',
entityId: bigint,
translations: Record<string, string>,
) {
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType,
entityId,
locale,
fieldName: 'name',
},
},
create: { entityType, entityId, locale, fieldName: 'name', value },
update: { value },
});
}
}
async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) {
const code = leagueCodeFromExport(league);
const record = await this.prisma.league.upsert({
where: { code },
create: { code, sportType: league.type || 'FOOTBALL' },
update: { sportType: league.type || 'FOOTBALL' },
});
await this.upsertEntityTranslations('LEAGUE', record.id, {
'zh-CN': league.zh,
'en-US': league.en,
});
return record;
}
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
const code = teamCodeFromExport(team);
const translations = translationsFromZhiboNames(team.names, team.name);
let record =
team.id != null
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
: await this.prisma.team.findUnique({ where: { code } });
if (!record) {
record = await this.prisma.team.create({
data: {
code,
externalId: team.id ?? undefined,
logoUrl: team.image || undefined,
},
});
} else {
record = await this.prisma.team.update({
where: { id: record.id },
data: {
logoUrl: team.image || record.logoUrl,
externalId: team.id ?? record.externalId,
},
});
}
await this.upsertEntityTranslations('TEAM', record.id, translations);
return record;
}
private async findExistingZhiboMatch(
leagueId: bigint,
homeTeamId: bigint,
awayTeamId: bigint,
item: ZhiboMatchExport,
) {
if (item.liveMatchId != null) {
return this.prisma.match.findUnique({
where: { liveMatchId: BigInt(item.liveMatchId) },
});
}
if (item.officialMatchNo != null) {
return this.prisma.match.findFirst({
where: {
leagueId,
homeTeamId,
awayTeamId,
officialMatchNo: item.officialMatchNo,
},
});
}
return null;
}
async createPlatformMatch(data: {
leagueEn: string;
leagueZh: string;
homeTeamZh: string;
homeTeamEn: string;
awayTeamZh: string;
awayTeamEn: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
createdBy?: bigint;
}) {
const homeEn = data.homeTeamEn.trim();
const homeZh = data.homeTeamZh.trim();
const awayEn = data.awayTeamEn.trim();
const awayZh = data.awayTeamZh.trim();
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
throw new BadRequestException('请填写主客队中英文名至少各一项');
}
const league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
en: data.leagueEn.trim(),
zh: data.leagueZh.trim(),
});
const [homeTeam, awayTeam] = await Promise.all([
this.upsertTeamFromZhiboExport({
id: null,
name: homeEn || homeZh,
names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null },
image: '',
}),
this.upsertTeamFromZhiboExport({
id: null,
name: awayEn || awayZh,
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
image: '',
}),
]);
return this.createMatch({
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime: data.startTime,
isHot: data.isHot ?? false,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
zhibo: {
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
},
});
}
private async requireAdminMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
});
if (!match) throw new NotFoundException('赛事不存在');
return match;
}
async getAdminMatchDetail(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
this.getTranslation('TEAM', match.homeTeamId, 'en-US'),
this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslation('TEAM', match.awayTeamId, 'en-US'),
this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'),
]);
return {
id: match.id.toString(),
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
startTime: match.startTime.toISOString(),
leagueEn,
leagueZh,
homeTeamEn: homeEn,
homeTeamZh: homeZh,
awayTeamEn: awayEn,
awayTeamZh: awayZh,
matchName: match.matchName ?? '',
};
}
async updatePlatformMatch(
matchId: bigint,
data: {
leagueEn: string;
leagueZh: string;
homeTeamZh: string;
homeTeamEn: string;
awayTeamZh: string;
awayTeamEn: string;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
updatedBy?: bigint;
},
) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘请通过盘口管理维护');
}
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
throw new BadRequestException('当前状态不可编辑');
}
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
await Promise.all([
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
'zh-CN': data.leagueZh.trim(),
'en-US': data.leagueEn.trim(),
}),
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
'zh-CN': data.homeTeamZh.trim(),
'en-US': data.homeTeamEn.trim(),
}),
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
'zh-CN': data.awayTeamZh.trim(),
'en-US': data.awayTeamEn.trim(),
}),
]);
return this.prisma.match.update({
where: { id: matchId },
data: {
startTime: data.startTime,
isHot: data.isHot ?? match.isHot,
displayOrder: data.displayOrder ?? match.displayOrder,
matchName,
updatedBy: data.updatedBy,
},
});
}
async deleteMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘不可删除');
}
if (match.status !== 'DRAFT') {
throw new BadRequestException('仅草稿状态可删除');
}
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
if (betCount > 0) {
throw new BadRequestException('该赛事已有注单关联,无法删除');
}
return this.prisma.match.update({
where: { id: matchId },
data: { deletedAt: new Date() },
});
}
async createMatchFromZhiboExport(
item: ZhiboMatchExport,
createdBy?: bigint,
opts?: { asDraft?: boolean },
) {
const league = await this.upsertLeagueFromZhiboExport(item.league);
const [homeTeam, awayTeam] = await Promise.all([
this.upsertTeamFromZhiboExport(item.homeTeam),
this.upsertTeamFromZhiboExport(item.awayTeam),
]);
const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item);
const startTime = resolveStartTime(item.kickoff);
const liveMatchId =
item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
const payload = {
leagueId: league.id,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime,
isHot: resolveIsHot(item),
displayOrder: item.sortOrder,
createdBy,
status,
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
zhibo: {
officialMatchNo: item.officialMatchNo,
stage: item.stage,
groupName: item.groupName,
liveMatchId,
additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null,
channelId: item.channelId,
matchName: item.matchName,
venueJson: toVenueJson(item.venue),
kickoffJson: toKickoffJson(item.kickoff),
externalStatus: item.status.state,
},
};
const existing = await this.findExistingZhiboMatch(
league.id,
homeTeam.id,
awayTeam.id,
item,
);
if (existing) {
return this.prisma.match.update({
where: { id: existing.id },
data: {
leagueId: payload.leagueId,
homeTeamId: payload.homeTeamId,
awayTeamId: payload.awayTeamId,
startTime: payload.startTime,
isHot: payload.isHot,
displayOrder: payload.displayOrder,
status: payload.status,
publishTime: existing.publishTime ?? payload.publishTime,
officialMatchNo: payload.zhibo.officialMatchNo,
stage: payload.zhibo.stage,
groupName: payload.zhibo.groupName,
liveMatchId: payload.zhibo.liveMatchId ?? undefined,
additionMatchId: payload.zhibo.additionMatchId ?? undefined,
channelId: payload.zhibo.channelId ?? undefined,
matchName: payload.zhibo.matchName,
venueJson: payload.zhibo.venueJson,
kickoffJson: payload.zhibo.kickoffJson,
externalStatus: payload.zhibo.externalStatus,
updatedBy: createdBy,
},
});
}
return this.createMatch(payload);
}
async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
if (!bundle.matches?.length) {
throw new BadRequestException('matches array is required');
}
const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];
for (const item of bundle.matches) {
try {
const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true });
results.push({
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
id: match.id.toString(),
status: match.status,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'import failed';
results.push({
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
id: '',
status: 'error',
reason: message,
});
}
}
return {
total: bundle.matches.length,
imported: results.filter((r) => !r.skipped && r.status !== 'error').length,
skipped: results.filter((r) => r.skipped).length,
failed: results.filter((r) => r.status === 'error').length,
results,
};
}
async publishMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },

View File

@@ -0,0 +1,64 @@
import { Prisma } from '@prisma/client';
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboTeamExport } from './zhibo-match.types';
const LOCALE_MAP: Record<string, string> = {
zh: 'zh-CN',
en: 'en-US',
ms: 'ms-MY',
};
export function slugTeamCode(name: string): string {
return name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.toUpperCase()
.slice(0, 48) || 'TEAM';
}
export function teamCodeFromExport(team: ZhiboTeamExport): string {
if (team.id != null) return `ZIBO_${team.id}`;
return `NAME_${slugTeamCode(team.name)}`;
}
export function leagueCodeFromExport(league: ZhiboLeagueExport): string {
if (league.en.includes('World Cup 2026')) return 'WC2026';
return slugTeamCode(league.en).slice(0, 32);
}
export function translationsFromZhiboNames(
names: ZhiboTeamExport['names'],
fallbackEn: string,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, locale] of Object.entries(LOCALE_MAP)) {
const v = names[key as keyof typeof names];
if (typeof v === 'string' && v.trim()) out[locale] = v.trim();
}
if (!out['en-US'] && fallbackEn) out['en-US'] = fallbackEn;
if (!out['zh-CN'] && names.zh) out['zh-CN'] = names.zh;
return out;
}
export function resolveStartTime(kickoff: ZhiboMatchExport['kickoff']): Date {
if (kickoff.utcIso) return new Date(kickoff.utcIso);
return new Date(kickoff.utcTimeStart * 1000);
}
export function resolveInternalStatus(item: ZhiboMatchExport): string {
if (!item.isPublished || item.status.state === 'off') return 'DRAFT';
return 'PUBLISHED';
}
export function resolveIsHot(item: ZhiboMatchExport): boolean {
return (item.status.isHot ?? 0) > 0;
}
export function toKickoffJson(kickoff: ZhiboMatchExport['kickoff']): Prisma.InputJsonValue {
return kickoff as unknown as Prisma.InputJsonValue;
}
export function toVenueJson(venue: ZhiboMatchExport['venue']): Prisma.InputJsonValue {
return venue as unknown as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,67 @@
/** zhibo 导出world_cup_match_ext + live_matches对齐结构 */
export interface ZhiboLocalizedNames {
zh?: string | null;
en?: string | null;
zhTw?: string | null;
vi?: string | null;
km?: string | null;
ms?: string | null;
}
export interface ZhiboLeagueExport {
type: string;
en: string;
zh: string;
}
export interface ZhiboKickoffExport {
utcTimeStart: number;
utcTimeStop: number;
utcIso: string;
chinaTime: string;
venueTime: string;
venueTimezone: string;
}
export interface ZhiboTeamExport {
id: number | null;
name: string;
names: ZhiboLocalizedNames;
image: string;
}
export interface ZhiboMatchExport {
officialMatchNo: number;
stage: string;
groupName: string;
liveMatchId: number | null;
additionMatchId: number | null;
channelId: string | null;
matchName: string;
league: ZhiboLeagueExport;
kickoff: ZhiboKickoffExport;
homeTeam: ZhiboTeamExport;
awayTeam: ZhiboTeamExport;
score: { home: number | string | null; away: number | string | null };
status: {
state: string;
nowPlaying: number;
isLive: number;
isHot: number;
};
venue: {
names: ZhiboLocalizedNames;
city: ZhiboLocalizedNames;
};
sortOrder: number;
isPublished: boolean;
}
export interface ZhiboMatchesBundleExport {
exportedAt?: string;
source?: Record<string, unknown>;
count?: number;
groups?: string[];
matches: ZhiboMatchExport[];
}

View File

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { AgentsModule } from '../agent/agents.module';
@Module({
imports: [AgentsModule],
providers: [UsersService],
exports: [UsersService],
})

View File

@@ -1,9 +1,77 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AgentsService } from '../agent/agents.service';
export type PlayerListFilters = {
keyword?: string;
parentId?: bigint;
status?: string;
};
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private agents: AgentsService,
) {}
private formatPlayerRow(
u: {
id: bigint;
username: string;
status: string;
locale: string;
parentId: bigint | null;
createdAt: Date;
updatedAt: Date;
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
preferences?: { phone: string | null; email: string | null } | null;
parent?: { username: string } | null;
auth?: { lastLoginAt: Date | null } | null;
},
bet?: { count: number; totalStake: string; totalReturn: string },
) {
return {
id: u.id.toString(),
username: u.username,
status: u.status,
locale: u.locale,
parentId: u.parentId?.toString() ?? null,
parentUsername: u.parent?.username ?? null,
phone: u.preferences?.phone ?? null,
email: u.preferences?.email ?? null,
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
lastLoginAt: u.auth?.lastLoginAt ?? null,
betCount: bet?.count ?? 0,
totalStake: bet?.totalStake ?? '0',
totalReturn: bet?.totalReturn ?? '0',
createdAt: u.createdAt,
updatedAt: u.updatedAt,
};
}
private async loadBetStatsMap(userIds: bigint[]) {
if (userIds.length === 0) return new Map<string, { count: number; totalStake: string; totalReturn: string }>();
const groups = await this.prisma.bet.groupBy({
by: ['userId'],
where: { userId: { in: userIds } },
_count: { _all: true },
_sum: { stake: true, actualReturn: true },
});
return new Map(
groups.map((g) => [
g.userId.toString(),
{
count: g._count._all,
totalStake: g._sum.stake?.toString() ?? '0',
totalReturn: g._sum.actualReturn?.toString() ?? '0',
},
]),
);
}
async findById(id: bigint) {
return this.prisma.user.findUnique({
@@ -36,19 +104,170 @@ export class UsersService {
return { locale };
}
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
async listPlayers(
page = 1,
pageSize = 10,
filters: PlayerListFilters = {},
) {
const where: {
userType: string;
deletedAt: null;
parentId?: bigint;
status?: string;
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
} = {
userType: 'PLAYER',
deletedAt: null,
};
if (filters.parentId) where.parentId = filters.parentId;
if (filters.status) where.status = filters.status;
if (filters.keyword?.trim()) {
const kw = filters.keyword.trim();
where.OR = [{ username: { contains: kw, mode: 'insensitive' } }];
}
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
const [rows, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: { wallet: true },
include: {
wallet: true,
preferences: true,
parent: { select: { id: true, username: true } },
auth: { select: { lastLoginAt: true } },
},
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return { items, total, page, pageSize };
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
return {
items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))),
total,
page,
pageSize,
};
}
async getPlayerAdminDetail(playerId: bigint) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: {
wallet: true,
preferences: true,
parent: { select: { id: true, username: true, agentLevel: true } },
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
},
});
if (!user) throw new NotFoundException('玩家不存在');
const [betCount, betStake] = await Promise.all([
this.prisma.bet.count({ where: { userId: playerId } }),
this.prisma.bet.aggregate({
where: { userId: playerId },
_sum: { stake: true, actualReturn: true },
}),
]);
return {
...this.formatPlayerRow(user),
lastLoginAt: user.auth?.lastLoginAt ?? null,
loginFailCount: user.auth?.loginFailCount ?? 0,
lockedUntil: user.auth?.lockedUntil ?? null,
betCount,
totalStake: betStake._sum.stake?.toString() ?? '0',
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
};
}
async updatePlayerAdmin(
playerId: bigint,
data: {
status?: string;
locale?: string;
phone?: string;
email?: string;
parentId?: string | null;
},
) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
});
if (!user) throw new NotFoundException('玩家不存在');
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
throw new BadRequestException('无效状态');
}
if (data.status) {
await this.prisma.user.update({
where: { id: playerId },
data: { status: data.status },
});
}
if (data.parentId !== undefined) {
const newParentId =
data.parentId === null || data.parentId === ''
? null
: BigInt(data.parentId);
if (newParentId !== null) {
const parent = await this.prisma.user.findUnique({
where: { id: newParentId },
});
if (!parent || parent.userType !== 'AGENT') {
throw new BadRequestException('上级必须为代理账号');
}
}
const oldParentId = user.parentId;
const changed =
(oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null);
if (changed) {
await this.prisma.user.update({
where: { id: playerId },
data: { parentId: newParentId },
});
if (oldParentId) {
await this.agents.recalculateUsedCredit(oldParentId);
}
if (newParentId) {
await this.agents.recalculateUsedCredit(newParentId);
}
}
}
if (data.locale) {
await this.prisma.user.update({
where: { id: playerId },
data: { locale: data.locale },
});
}
if (data.phone !== undefined || data.email !== undefined || data.locale) {
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
await this.prisma.userPreference.upsert({
where: { userId: playerId },
create: {
userId: playerId,
locale: data.locale ?? user.locale,
phone: phone ?? null,
email: email ?? null,
},
update: {
...(data.locale ? { locale: data.locale } : {}),
...(phone !== undefined ? { phone } : {}),
...(email !== undefined ? { email } : {}),
},
});
}
return this.getPlayerAdminDetail(playerId);
}
}

View File

@@ -31,7 +31,7 @@ export class AuditService {
});
}
async list(page = 1, pageSize = 50, module?: string) {
async list(page = 1, pageSize = 10, module?: string) {
const skip = (page - 1) * pageSize;
const where = module ? { module } : {};
const [items, total] = await Promise.all([