@@ -4,19 +4,30 @@ import {
ForbiddenException ,
NotFoundException ,
} from '@nestjs/common' ;
import * as bcrypt from 'bcryptjs' ;
import { Prisma } from '@prisma/client' ;
import { PrismaService } from '../../shared/prisma/prisma.service' ;
import { WalletService } from '../ledger/wallet.service' ;
import { AuthService } from '../identity/auth.service' ;
import { SystemConfigService } from '../../shared/config/system-config.service' ;
import { Decimal } from '@prisma/client/runtime/library' ;
import { generateBatchNo } from '../../shared/common/decorators' ;
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 AgentsService {
constructor (
private prisma : PrismaService ,
private wallet : WalletService ,
private auth : AuthService ,
private systemConfig : SystemConfigService ,
) { }
async getProfile ( agentId : bigint ) {
@@ -84,6 +95,10 @@ export class AgentsService {
const creditAfter = creditBefore . add ( amt ) ;
if ( creditAfter . lt ( 0 ) ) throw new BadRequestException ( 'Credit limit cannot be negative' ) ;
if ( profile . parentAgentId ) {
await this . assertChildCreditWithinParent ( profile . parentAgentId , profile , creditAfter ) ;
}
await this . prisma . $transaction ( async ( tx ) = > {
await tx . agentProfile . update ( {
where : { userId : agentId } ,
@@ -111,16 +126,275 @@ export class AgentsService {
return { creditAfter } ;
}
/** 代理只能操作直属玩家( parentId === 当前代理) */
private async requireDirectPlayer ( agentId : bigint , playerId : bigint ) {
const player = await this . prisma . user . findFirst ( {
where : { id : playerId , userType : 'PLAYER' , deletedAt : null } ,
include : { auth : true , wallet : true , preferences : true } ,
} ) ;
if ( ! player ) throw new NotFoundException ( '玩家不存在' ) ;
if ( player . parentId !== agentId ) {
throw new ForbiddenException ( 'Can only manage direct players' ) ;
}
return player ;
}
private async assertChildAgentWithinParent (
parentAgentId : bigint ,
child : {
creditLimit? : number | Decimal ;
cashbackRate? : number | Decimal ;
maxSingleDeposit? : number | Decimal | null ;
maxDailyDeposit? : number | Decimal | null ;
} ,
) {
const parent = await this . prisma . agentProfile . findUnique ( {
where : { userId : parentAgentId } ,
} ) ;
if ( ! parent ) throw new BadRequestException ( '上级代理不存在' ) ;
if ( child . creditLimit !== undefined ) {
const limit = new Decimal ( child . creditLimit ) ;
if ( limit . lt ( 0 ) ) throw new BadRequestException ( '授信额度不能为负' ) ;
if ( limit . gt ( parent . creditLimit ) ) {
throw new BadRequestException ( '下级代理授信不能超过上级授信额度' ) ;
}
}
if ( child . cashbackRate !== undefined ) {
const rate = new Decimal ( child . cashbackRate ) ;
if ( rate . lt ( 0 ) ) throw new BadRequestException ( '回水比例不能为负' ) ;
if ( rate . gt ( parent . cashbackRate ) ) {
throw new BadRequestException ( '下级代理回水比例不能超过上级' ) ;
}
}
if ( child . maxSingleDeposit != null && parent . maxSingleDeposit != null ) {
if ( new Decimal ( child . maxSingleDeposit ) . gt ( parent . maxSingleDeposit ) ) {
throw new BadRequestException ( '下级代理单笔限额不能超过上级' ) ;
}
}
if ( child . maxSingleDeposit != null && new Decimal ( child . maxSingleDeposit ) . lt ( 0 ) ) {
throw new BadRequestException ( '单笔限额不能为负' ) ;
}
if ( child . maxDailyDeposit != null && parent . maxDailyDeposit != null ) {
if ( new Decimal ( child . maxDailyDeposit ) . gt ( parent . maxDailyDeposit ) ) {
throw new BadRequestException ( '下级代理日限额不能超过上级' ) ;
}
}
if ( child . maxDailyDeposit != null && new Decimal ( child . maxDailyDeposit ) . lt ( 0 ) ) {
throw new BadRequestException ( '日限额不能为负' ) ;
}
}
private resolveEffectiveDepositLimits (
profile : {
maxSingleDeposit : Decimal | null ;
maxDailyDeposit : Decimal | null ;
} ,
parent ? : { maxSingleDeposit : Decimal | null ; maxDailyDeposit : Decimal | null } | null ,
) {
let maxSingleDeposit = profile . maxSingleDeposit ;
let maxDailyDeposit = profile . maxDailyDeposit ;
if ( parent ) {
if ( parent . maxSingleDeposit != null ) {
maxSingleDeposit =
maxSingleDeposit != null
? Decimal . min ( maxSingleDeposit , parent . maxSingleDeposit )
: parent . maxSingleDeposit ;
}
if ( parent . maxDailyDeposit != null ) {
maxDailyDeposit =
maxDailyDeposit != null
? Decimal . min ( maxDailyDeposit , parent . maxDailyDeposit )
: parent . maxDailyDeposit ;
}
}
return { maxSingleDeposit , maxDailyDeposit } ;
}
private normalizeOptionalLimit ( value? : number | null ) {
if ( value == null || value <= 0 ) return null ;
return new Decimal ( value ) ;
}
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit) */
async assertPlayerParentCreditForDeposit ( playerId : bigint , amount : Decimal | number ) {
const user = await this . prisma . user . findFirst ( {
where : { id : playerId , userType : 'PLAYER' , deletedAt : null } ,
select : { parentId : true } ,
} ) ;
if ( ! user ? . parentId ) return ;
await this . recalculateUsedCredit ( user . parentId ) ;
const profile = await this . getProfile ( user . parentId ) ;
const available = new Decimal ( profile . creditLimit ) . sub ( profile . usedCredit ) ;
const amt = new Decimal ( amount ) ;
if ( available . lt ( amt ) ) {
throw new BadRequestException ( '超过玩家上级代理可用授信,无法上分' ) ;
}
}
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
async adminDepositToPlayer (
playerId : bigint ,
amount : number ,
operatorId : bigint ,
remark? : string ,
requestId? : string ,
) {
await this . assertPlayerParentCreditForDeposit ( playerId , amount ) ;
const result = await this . wallet . deposit (
playerId ,
amount ,
operatorId ,
remark ,
requestId ,
) ;
const player = await this . prisma . user . findUnique ( {
where : { id : playerId } ,
select : { parentId : true } ,
} ) ;
if ( player ? . parentId ) {
await this . recalculateUsedCredit ( player . parentId ) ;
}
return result ;
}
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
async getPlayerTransferContext (
playerId : bigint ,
options : { forAdmin? : boolean ; actingAgentId? : bigint } = { } ,
) {
const player = await this . prisma . user . findFirst ( {
where : { id : playerId , userType : 'PLAYER' , deletedAt : null } ,
include : { wallet : true } ,
} ) ;
if ( ! player ) throw new NotFoundException ( '玩家不存在' ) ;
if ( options . actingAgentId ) {
await this . requireDirectPlayer ( options . actingAgentId , playerId ) ;
}
const creditAgentId = options . forAdmin ? player . parentId : ( options . actingAgentId ? ? null ) ;
let credit : Record < string , unknown > | null = null ;
if ( creditAgentId ) {
await this . recalculateUsedCredit ( creditAgentId ) ;
const profile = await this . getProfile ( creditAgentId ) ;
const parent = profile . parentAgentId
? await this . prisma . agentProfile . findUnique ( { where : { userId : profile.parentAgentId } } )
: null ;
const { maxSingleDeposit , maxDailyDeposit } = this . resolveEffectiveDepositLimits ( profile , parent ) ;
let dailyDepositUsed : string | null = null ;
if ( ! options . forAdmin ) {
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const dailyAgg = await this . prisma . walletTransaction . aggregate ( {
where : {
operatorId : creditAgentId ,
transactionType : 'MANUAL_DEPOSIT' ,
createdAt : { gte : today } ,
} ,
_sum : { amount : true } ,
} ) ;
dailyDepositUsed = dec ( dailyAgg . _sum . amount ) ;
}
const agentUser = await this . prisma . user . findUnique ( {
where : { id : creditAgentId } ,
select : { username : true } ,
} ) ;
credit = {
agentId : creditAgentId.toString ( ) ,
agentUsername : agentUser?.username ? ? '' ,
agentLevel : profile.level ,
creditLimit : dec ( profile . creditLimit ) ,
usedCredit : dec ( profile . usedCredit ) ,
availableCredit : dec ( profile . availableCredit ) ,
maxSingleDeposit : maxSingleDeposit?.toString ( ) ? ? null ,
maxDailyDeposit : maxDailyDeposit?.toString ( ) ? ? null ,
dailyDepositUsed ,
appliesDepositLimits : ! options . forAdmin ,
} ;
}
return {
player : {
id : player.id.toString ( ) ,
username : player.username ,
availableBalance : dec ( player . wallet ? . availableBalance ) ,
frozenBalance : dec ( player . wallet ? . frozenBalance ) ,
} ,
credit ,
} ;
}
private async assertAgentDepositLimits ( creditAgentId : bigint , amount : Decimal ) {
const profile = await this . prisma . agentProfile . findUnique ( {
where : { userId : creditAgentId } ,
} ) ;
if ( ! profile ) return ;
const parent = profile . parentAgentId
? await this . prisma . agentProfile . findUnique ( {
where : { userId : profile.parentAgentId } ,
} )
: null ;
const { maxSingleDeposit , maxDailyDeposit } = this . resolveEffectiveDepositLimits ( profile , parent ) ;
if ( maxSingleDeposit && amount . gt ( maxSingleDeposit ) ) {
throw new BadRequestException ( '超过代理单笔上分限额' ) ;
}
if ( maxDailyDeposit ) {
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const dailyAgg = await this . prisma . walletTransaction . aggregate ( {
where : {
operatorId : creditAgentId ,
transactionType : 'MANUAL_DEPOSIT' ,
createdAt : { gte : today } ,
} ,
_sum : { amount : true } ,
} ) ;
const dailyTotal = new Decimal ( dailyAgg . _sum . amount ? ? 0 ) . add ( amount ) ;
if ( dailyTotal . gt ( maxDailyDeposit ) ) {
throw new BadRequestException ( '超过代理日上分限额' ) ;
}
}
}
private async assertChildCreditWithinParent (
parentAgentId : bigint ,
childProfile : { userId : bigint ; creditLimit : Decimal ; usedCredit : Decimal } ,
creditAfter : Decimal ,
) {
await this . assertChildAgentWithinParent ( parentAgentId , { creditLimit : creditAfter } ) ;
const parent = await this . getProfile ( parentAgentId ) ;
const parentAvailable = new Decimal ( parent . creditLimit ) . sub ( parent . usedCredit ) ;
const oldExposure = Decimal . max ( childProfile . creditLimit , childProfile . usedCredit ) ;
const newExposure = Decimal . max ( creditAfter , childProfile . usedCredit ) ;
const exposureDelta = newExposure . sub ( oldExposure ) ;
if ( exposureDelta . gt ( 0 ) && exposureDelta . gt ( parentAvailable ) ) {
throw new BadRequestException ( '上级可用授信不足' ) ;
}
}
async depositToPlayer (
agentId : bigint ,
playerId : bigint ,
amount : number ,
requestId : string ,
remark? : string ,
) {
const player = await this . prisma . user . findUnique ( { where : { id : playerId } } ) ;
if ( ! player || player . parentId !== agentId ) {
throw new ForbiddenException ( 'Can only deposit to direct players' ) ;
}
await this . requireDirectPlayer ( agentId , playerId ) ;
const profile = await this . getProfile ( agentId ) ;
const available = new Decimal ( profile . creditLimit ) . sub ( profile . usedCredit ) ;
@@ -130,7 +404,9 @@ export class AgentsService {
throw new BadRequestException ( 'Insufficient agent credit' ) ;
}
await this . wallet . deposit ( playerId , amt , agentId , 'Agent deposit' , requestId ) ;
await this . assertAgentDepositLimits ( agentId , amt ) ;
await this . wallet . deposit ( playerId , amt , agentId , remark ? ? 'Agent deposit' , requestId ) ;
await this . recalculateUsedCredit ( agentId ) ;
return { success : true } ;
@@ -142,10 +418,7 @@ export class AgentsService {
amount : number ,
requestId : string ,
) {
const player = await this . prisma . user . findUnique ( { where : { id : playerId } } ) ;
if ( ! player || player . parentId !== agentId ) {
throw new ForbiddenException ( 'Can only withdraw from direct players' ) ;
}
await this . requireDirectPlayer ( agentId , playerId ) ;
await this . wallet . withdraw ( playerId , amount , agentId , 'Agent withdraw' , requestId ) ;
await this . recalculateUsedCredit ( agentId ) ;
@@ -153,16 +426,124 @@ export class AgentsService {
return { success : true } ;
}
async getDirectPlayerDetail ( agentId : bigint , playerId : bigint ) {
const user = await this . requireDirectPlayer ( agentId , playerId ) ;
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 {
id : user.id.toString ( ) ,
username : user.username ,
status : user.status ,
phone : user.preferences?.phone ? ? null ,
email : user.preferences?.email ? ? null ,
managedPassword : user.preferences?.managedPassword ? ? null ,
availableBalance : user.wallet?.availableBalance?.toString ( ) ? ? '0' ,
frozenBalance : user.wallet?.frozenBalance?.toString ( ) ? ? '0' ,
lastLoginAt : user.auth?.lastLoginAt ? ? null ,
loginFailCount : user.auth?.loginFailCount ? ? 0 ,
betCount ,
totalStake : betStake._sum.stake?.toString ( ) ? ? '0' ,
totalReturn : betStake._sum.actualReturn?.toString ( ) ? ? '0' ,
createdAt : user.createdAt ,
} ;
}
async updateDirectPlayer (
agentId : bigint ,
playerId : bigint ,
data : {
username? : string ;
password? : string ;
phone? : string ;
email? : string ;
status? : string ;
} ,
) {
const user = await this . requireDirectPlayer ( agentId , playerId ) ;
if ( data . status && ! [ 'ACTIVE' , 'SUSPENDED' ] . includes ( data . status ) ) {
throw new BadRequestException ( '无效状态' ) ;
}
if ( data . username !== undefined ) {
const nextUsername = data . username . trim ( ) ;
if ( ! nextUsername ) throw new BadRequestException ( '账号名称不能为空' ) ;
if ( nextUsername !== user . username ) {
const taken = await this . prisma . user . findUnique ( { where : { username : nextUsername } } ) ;
if ( taken ) throw new BadRequestException ( '账号名称已被占用' ) ;
await this . prisma . user . update ( {
where : { id : playerId } ,
data : { username : nextUsername } ,
} ) ;
}
}
if ( data . password !== undefined ) {
const nextPassword = data . password ;
if ( nextPassword . length < 8 ) throw new BadRequestException ( '密码至少 8 位' ) ;
if ( ! user . auth ) throw new BadRequestException ( '账号认证信息缺失' ) ;
const hash = await bcrypt . hash ( nextPassword , 10 ) ;
await this . prisma . userAuth . update ( {
where : { userId : playerId } ,
data : { passwordHash : hash , loginFailCount : 0 , lockedUntil : null } ,
} ) ;
await this . prisma . userPreference . upsert ( {
where : { userId : playerId } ,
create : { userId : playerId , managedPassword : nextPassword } ,
update : { managedPassword : nextPassword } ,
} ) ;
}
if ( data . status ) {
await this . prisma . user . update ( {
where : { id : playerId } ,
data : { status : data.status } ,
} ) ;
}
const prefPatch : { phone? : string | null ; email? : string | null } = { } ;
if ( data . phone !== undefined ) prefPatch . phone = data . phone . trim ( ) || null ;
if ( data . email !== undefined ) prefPatch . email = data . email . trim ( ) || null ;
if ( Object . keys ( prefPatch ) . length > 0 ) {
await this . prisma . userPreference . upsert ( {
where : { userId : playerId } ,
create : {
userId : playerId ,
phone : prefPatch.phone ? ? null ,
email : prefPatch.email ? ? null ,
} ,
update : prefPatch ,
} ) ;
}
return this . getDirectPlayerDetail ( agentId , playerId ) ;
}
async listAgentsAdmin ( params ? : {
page? : number ;
pageSize? : number ;
keyword? : string ;
parentAgentId? : bigint ;
} ) {
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 = { } ;
if ( params ? . parentAgentId !== undefined ) {
where . parentAgentId = params . parentAgentId ;
} else {
// Default: only show top-level agents (no parent)
where . parentAgentId = null ;
}
const kw = params ? . keyword ? . trim ( ) ;
if ( kw ) {
where . user = { username : { contains : kw , mode : 'insensitive' } } ;
@@ -199,6 +580,18 @@ export class AgentsService {
playerCounts . map ( ( g ) = > [ g . parentId ? . toString ( ) , g . _count . _all ] ) ,
) ;
const childAgentCounts =
agentIds . length > 0
? await this . prisma . agentProfile . groupBy ( {
by : [ 'parentAgentId' ] ,
where : { parentAgentId : { in : agentIds } } ,
_count : { _all : true } ,
} )
: [ ] ;
const childAgentCountMap = new Map (
childAgentCounts . map ( ( g ) = > [ g . parentAgentId ? . toString ( ) , g . _count . _all ] ) ,
) ;
const items = profiles . map ( ( p ) = > {
const available = new Decimal ( p . creditLimit ) . sub ( p . usedCredit ) ;
return {
@@ -215,7 +608,10 @@ export class AgentsService {
directPlayerLiability : p.directPlayerLiability.toString ( ) ,
childAgentExposure : p.childAgentExposure.toString ( ) ,
cashbackRate : p.cashbackRate.toString ( ) ,
maxSingleDeposit : p.maxSingleDeposit?.toString ( ) ? ? null ,
maxDailyDeposit : p.maxDailyDeposit?.toString ( ) ? ? null ,
directPlayerCount : countMap.get ( p . userId . toString ( ) ) ? ? 0 ,
childAgentCount : childAgentCountMap.get ( p . userId . toString ( ) ) ? ? 0 ,
phone : p.user.preferences?.phone ? ? null ,
email : p.user.preferences?.email ? ? null ,
locale : p.user.locale ,
@@ -234,10 +630,13 @@ export class AgentsService {
} ) ;
if ( ! profile ) throw new NotFoundException ( '代理不存在' ) ;
const [ directPlayerCount , recentCredits ] = await Promise . all ( [
const [ directPlayerCount , childAgentCount , recentCredits ] = await Promise . all ( [
this . prisma . user . count ( {
where : { parentId : agentId , userType : 'PLAYER' , deletedAt : null } ,
} ) ,
this . prisma . agentProfile . count ( {
where : { parentAgentId : agentId } ,
} ) ,
this . prisma . agentCreditTransaction . findMany ( {
where : { agentId } ,
orderBy : { createdAt : 'desc' } ,
@@ -270,11 +669,16 @@ export class AgentsService {
directPlayerLiability : profile.directPlayerLiability.toString ( ) ,
childAgentExposure : profile.childAgentExposure.toString ( ) ,
cashbackRate : profile.cashbackRate.toString ( ) ,
maxSingleDeposit : profile.maxSingleDeposit?.toString ( ) ? ? null ,
maxDailyDeposit : profile.maxDailyDeposit?.toString ( ) ? ? null ,
directPlayerCount ,
childAgentCount ,
phone : profile.user.preferences?.phone ? ? null ,
email : profile.user.preferences?.email ? ? null ,
managedPassword : profile.user.preferences?.managedPassword ? ? null ,
locale : profile.user.locale ,
lastLoginAt : profile.user.auth?.lastLoginAt ? ? null ,
loginFailCount : profile.user.auth?.loginFailCount ? ? 0 ,
createdAt : profile.createdAt ,
updatedAt : profile.updatedAt ,
recentCreditTransactions : recentCredits.map ( ( t ) = > ( {
@@ -297,6 +701,11 @@ export class AgentsService {
phone? : string ;
email? : string ;
cashbackRate? : number ;
maxSingleDeposit? : number | null ;
maxDailyDeposit? : number | null ;
username? : string ;
password? : string ;
freezeDirectPlayers? : boolean ;
} ,
) {
const profile = await this . prisma . agentProfile . findUnique ( {
@@ -309,6 +718,38 @@ export class AgentsService {
throw new BadRequestException ( '无效状态' ) ;
}
// Handle username change
if ( data . username !== undefined ) {
const nextUsername = data . username . trim ( ) ;
if ( ! nextUsername ) throw new BadRequestException ( '账号名称不能为空' ) ;
if ( nextUsername !== profile . user . username ) {
const taken = await this . prisma . user . findUnique ( { where : { username : nextUsername } } ) ;
if ( taken ) throw new BadRequestException ( '账号名称已被占用' ) ;
await this . prisma . user . update ( {
where : { id : agentId } ,
data : { username : nextUsername } ,
} ) ;
}
}
// Handle password change
if ( data . password !== undefined ) {
const nextPassword = data . password ;
if ( nextPassword . length < 8 ) throw new BadRequestException ( '密码至少 8 位' ) ;
const hash = await bcrypt . hash ( nextPassword , 10 ) ;
await this . prisma . userAuth . upsert ( {
where : { userId : agentId } ,
create : { userId : agentId , passwordHash : hash , loginFailCount : 0 , lockedUntil : null } ,
update : { passwordHash : hash , loginFailCount : 0 , lockedUntil : null } ,
} ) ;
await this . prisma . userPreference . upsert ( {
where : { userId : agentId } ,
create : { userId : agentId , managedPassword : nextPassword } ,
update : { managedPassword : nextPassword } ,
} ) ;
}
// Handle status change (with optional cascade freeze)
if ( data . status ) {
await this . prisma . $transaction ( [
this . prisma . user . update ( {
@@ -320,6 +761,19 @@ export class AgentsService {
data : { status : data.status } ,
} ) ,
] ) ;
// 级联冻结:需后台开启且管理员/操作方显式勾选( MVP 默认不冻结玩家)
const suspendSettings = await this . systemConfig . getAgentSuspendSettings ( ) ;
if (
data . status === 'SUSPENDED' &&
data . freezeDirectPlayers &&
suspendSettings . suspendFreezeDirectPlayers
) {
await this . prisma . user . updateMany ( {
where : { parentId : agentId , userType : 'PLAYER' , deletedAt : null } ,
data : { status : 'SUSPENDED' } ,
} ) ;
}
}
if ( data . locale ) {
@@ -330,12 +784,40 @@ export class AgentsService {
}
if ( data . cashbackRate !== undefined ) {
if ( profile . parentAgentId ) {
await this . assertChildAgentWithinParent ( profile . parentAgentId , {
cashbackRate : data.cashbackRate ,
} ) ;
}
await this . prisma . agentProfile . update ( {
where : { userId : agentId } ,
data : { cashbackRate : data.cashbackRate } ,
} ) ;
}
const limitPatch : {
maxSingleDeposit? : Decimal | null ;
maxDailyDeposit? : Decimal | null ;
} = { } ;
if ( data . maxSingleDeposit !== undefined ) {
limitPatch . maxSingleDeposit = this . normalizeOptionalLimit ( data . maxSingleDeposit ) ;
}
if ( data . maxDailyDeposit !== undefined ) {
limitPatch . maxDailyDeposit = this . normalizeOptionalLimit ( data . maxDailyDeposit ) ;
}
if ( Object . keys ( limitPatch ) . length > 0 ) {
if ( profile . parentAgentId ) {
await this . assertChildAgentWithinParent ( profile . parentAgentId , {
maxSingleDeposit : limitPatch.maxSingleDeposit ? ? undefined ,
maxDailyDeposit : limitPatch.maxDailyDeposit ? ? undefined ,
} ) ;
}
await this . prisma . agentProfile . update ( {
where : { userId : agentId } ,
data : limitPatch ,
} ) ;
}
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 ;
@@ -389,6 +871,8 @@ export class AgentsService {
data : {
creditLimit : number ;
cashbackRate? : number ;
maxSingleDeposit? : number | null ;
maxDailyDeposit? : number | null ;
phone? : string ;
email? : string ;
} ,
@@ -450,6 +934,8 @@ export class AgentsService {
parentAgentId : null ,
creditLimit : data.creditLimit ,
cashbackRate : data.cashbackRate ? ? 0 ,
maxSingleDeposit : this.normalizeOptionalLimit ( data . maxSingleDeposit ) ,
maxDailyDeposit : this.normalizeOptionalLimit ( data . maxDailyDeposit ) ,
} ,
} ) ;
@@ -481,6 +967,8 @@ export class AgentsService {
phone? : string ;
email? : string ;
cashbackRate? : number ;
maxSingleDeposit? : number | null ;
maxDailyDeposit? : number | null ;
} ,
) {
if ( data . level !== 1 && data . level !== 2 ) {
@@ -490,6 +978,18 @@ export class AgentsService {
throw new BadRequestException ( 'Level 2 agent requires parent' ) ;
}
if ( data . parentAgentId ) {
await this . assertChildAgentWithinParent ( data . parentAgentId , {
creditLimit : data.creditLimit ? ? 0 ,
cashbackRate : data.cashbackRate ? ? 0 ,
maxSingleDeposit : data.maxSingleDeposit ,
maxDailyDeposit : data.maxDailyDeposit ,
} ) ;
}
const maxSingleDeposit = this . normalizeOptionalLimit ( data . maxSingleDeposit ) ;
const maxDailyDeposit = this . normalizeOptionalLimit ( data . maxDailyDeposit ) ;
const hash = await this . auth . hashPassword ( data . password ) ;
return this . prisma . $transaction ( async ( tx ) = > {
@@ -524,6 +1024,8 @@ export class AgentsService {
parentAgentId : data.parentAgentId ,
creditLimit : data.creditLimit ? ? 0 ,
cashbackRate : data.cashbackRate ? ? 0 ,
maxSingleDeposit ,
maxDailyDeposit ,
} ,
} ) ;
@@ -567,8 +1069,12 @@ export class AgentsService {
depositRemark? : string ;
depositRequestId? : string ;
asTier1Agent? : boolean ;
asSubAgent? : boolean ;
parentAgentId? : bigint ;
creditLimit? : number ;
cashbackRate? : number ;
maxSingleDeposit? : number | null ;
maxDailyDeposit? : number | null ;
} ,
) {
if ( data . asTier1Agent ) {
@@ -590,6 +1096,29 @@ export class AgentsService {
} ) ;
}
if ( data . asSubAgent ) {
if ( data . parentAgentId == null && data . parentId == null ) {
throw new BadRequestException ( '二级代理必须指定上级代理' ) ;
}
if ( data . initialDeposit && data . initialDeposit > 0 ) {
throw new BadRequestException ( '设为代理时请使用授信额度,勿填玩家初始余额' ) ;
}
const parentAgentId = data . parentAgentId ? ? data . parentId ;
return this . createAgent ( operatorId , {
username : data.username ,
password : data.password ,
level : 2 ,
parentAgentId ,
creditLimit : data.creditLimit ? ? 0 ,
cashbackRate : data.cashbackRate ? ? 0 ,
maxSingleDeposit : data.maxSingleDeposit ,
maxDailyDeposit : data.maxDailyDeposit ,
locale : data.locale ,
phone : data.phone ,
email : data.email ,
} ) ;
}
let parentId : bigint | null = null ;
if ( data . parentId != null ) {
const parent = await this . prisma . user . findUnique ( { where : { id : data.parentId } } ) ;
@@ -597,6 +1126,11 @@ export class AgentsService {
throw new BadRequestException ( '上级必须为代理账号' ) ;
}
parentId = data . parentId ;
const operator = await this . prisma . user . findUnique ( { where : { id : operatorId } } ) ;
if ( operator ? . userType === 'AGENT' && parentId !== operatorId ) {
throw new ForbiddenException ( 'Can only create direct players' ) ;
}
}
const hash = await this . auth . hashPassword ( data . password ) ;
@@ -641,6 +1175,7 @@ export class AgentsService {
if ( initial > 0 ) {
const requestId =
data . depositRequestId ? ? ` admin-create- ${ user . id } - ${ Date . now ( ) } ` ;
await this . assertPlayerParentCreditForDeposit ( user . id , initial ) ;
await this . wallet . deposit (
user . id ,
initial ,
@@ -660,6 +1195,7 @@ export class AgentsService {
return this . prisma . user . findMany ( {
where : { parentId : agentId , userType : 'PLAYER' } ,
include : { wallet : true } ,
orderBy : { createdAt : 'desc' } ,
} ) ;
}
@@ -670,34 +1206,253 @@ export class AgentsService {
} ) ;
}
async listChildAgentsSummary ( parentAgentId : bigint ) {
const profiles = await this . getChildAgents ( parentAgentId ) ;
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 ] ) ,
) ;
return profiles . map ( ( p ) = > {
const available = new Decimal ( p . creditLimit ) . sub ( p . usedCredit ) ;
return {
userId : p.userId.toString ( ) ,
username : p.user.username ,
userStatus : p.user.status ,
status : p.status ,
level : p.level ,
creditLimit : dec ( p . creditLimit ) ,
usedCredit : dec ( p . usedCredit ) ,
availableCredit : available.toString ( ) ,
directPlayerCount : countMap.get ( p . userId . toString ( ) ) ? ? 0 ,
createdAt : p.createdAt ,
} ;
} ) ;
}
async assertDirectChildAgent ( parentAgentId : bigint , subAgentId : bigint ) {
const profile = await this . prisma . agentProfile . findUnique ( {
where : { userId : subAgentId } ,
} ) ;
if ( ! profile || profile . parentAgentId !== parentAgentId ) {
throw new ForbiddenException ( 'Not your sub-agent' ) ;
}
return profile ;
}
async getSubAgentForParent ( parentAgentId : bigint , subAgentId : bigint ) {
await this . assertDirectChildAgent ( parentAgentId , subAgentId ) ;
return this . getAgentAdminDetail ( subAgentId ) ;
}
async updateSubAgentForParent (
parentAgentId : bigint ,
subAgentId : bigint ,
data : {
username? : string ;
password? : string ;
phone? : string ;
email? : string ;
status? : string ;
freezeDirectPlayers? : boolean ;
} ,
) {
await this . assertDirectChildAgent ( parentAgentId , subAgentId ) ;
const { freezeDirectPlayers : _ignored , . . . safeData } = data ;
return this . updateAgentAdmin ( subAgentId , safeData ) ;
}
async getSubtreeAgentIds ( agentId : bigint ) {
const descendants = await this . prisma . agentClosure . findMany ( {
where : { ancestorId : agentId } ,
select : { descendantId : true } ,
} ) ;
return descendants . map ( ( d ) = > d . descendantId ) ;
}
async getReportSummary ( agentId : bigint ) {
const profile = await this . getProfile ( agentId ) ;
const player s = await this . getDirectPlayer s ( agentId ) ;
const agentId s = await this . getSubtreeAgentId s ( agentId ) ;
const betScope = { agentId : { in : agentIds } } ;
const playerWhere = {
parentId : agentId ,
userType : 'PLAYER' as const ,
deletedAt : null ,
} ;
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const yesterday = new Date ( today . getTime ( ) - 86400000 ) ;
const todayBets = await this . prisma . bet . aggregate ( {
where : {
agentId ,
placedAt : { gte : today } ,
} ,
_sum : { stake : true , actualReturn : true } ,
_count : true ,
} ) ;
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 : { . . . betScope , 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 [
todayBets ,
yesterdayBets ,
pendingBets ,
betStatusToday ,
playerTotal ,
playerActive ,
playerSuspended ,
newPlayersToday ,
subAgentTotal ,
subAgentsActive ,
walletAgg ,
recentBets ,
recentPlayers ,
] = await Promise . all ( [
this . prisma . bet . aggregate ( {
where : { . . . betScope , placedAt : { gte : today } } ,
_sum : { stake : true , actualReturn : true } ,
_count : true ,
} ) ,
this . prisma . bet . aggregate ( {
where : { . . . betScope , placedAt : { gte : yesterday , lt : today } } ,
_sum : { stake : true , actualReturn : true } ,
_count : true ,
} ) ,
this . prisma . bet . count ( { where : { . . . betScope , status : 'PENDING' } } ) ,
this . prisma . bet . groupBy ( {
by : [ 'status' ] ,
where : { . . . betScope , placedAt : { gte : today } } ,
_count : { _all : true } ,
_sum : { stake : true } ,
} ) ,
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 , createdAt : { gte : today } } ,
} ) ,
this . prisma . agentProfile . count ( { where : { parentAgentId : agentId } } ) ,
this . prisma . agentProfile . count ( {
where : { parentAgentId : agentId , status : 'ACTIVE' } ,
} ) ,
this . prisma . wallet . aggregate ( {
where : { user : playerWhere } ,
_sum : { availableBalance : true , frozenBalance : true } ,
_count : { _all : true } ,
} ) ,
this . prisma . bet . findMany ( {
where : betScope ,
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 ,
} ,
} ) ,
] ) ;
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 = profile . creditLimit ? ? new Decimal ( 0 ) ;
const usedCredit = profile . usedCredit ? ? new Decimal ( 0 ) ;
const availableCredit = new Decimal ( creditLimit ) . sub ( usedCredit ) ;
return {
profile ,
directPlayerCount : players.length ,
directPlayerTotalBalance : players.reduce (
( sum , p ) = >
sum +
Number ( p . wallet ? . availableBalance ? ? 0 ) +
Number ( p . wallet ? . frozenBalance ? ? 0 ) ,
0 ,
) ,
todayBetCount : todayBets._count ,
todayStake : todayBets._sum.stake ,
todayReturn : todayBets._sum.actualReturn ,
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 ) ,
} ,
players : {
directTotal : playerTotal ,
active : playerActive ,
suspended : playerSuspended ,
newToday : newPlayersToday ,
} ,
subAgents : {
total : subAgentTotal ,
active : subAgentsActive ,
} ,
wallets : {
totalAvailable : dec ( walletAgg . _sum . availableBalance ) ,
totalFrozen : dec ( walletAgg . _sum . frozenBalance ) ,
playerWalletCount : walletAgg._count._all ,
} ,
credit : {
creditLimit : dec ( creditLimit ) ,
usedCredit : dec ( usedCredit ) ,
availableCredit : availableCredit.toString ( ) ,
directPlayerLiability : dec ( profile . directPlayerLiability ) ,
childAgentExposure : dec ( profile . childAgentExposure ) ,
} ,
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 ,
createdAt : p.createdAt ,
} ) ) ,
} ;
}
}