feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
@@ -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])
|
||||
|
||||
209
apps/api/src/applications/admin/admin-dashboard.service.ts
Normal file
209
apps/api/src/applications/admin/admin-dashboard.service.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
64
apps/api/src/domains/catalog/zhibo-match.mapper.ts
Normal file
64
apps/api/src/domains/catalog/zhibo-match.mapper.ts
Normal 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;
|
||||
}
|
||||
67
apps/api/src/domains/catalog/zhibo-match.types.ts
Normal file
67
apps/api/src/domains/catalog/zhibo-match.types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user