Files
thebet365/apps/api/src/domains/agent/agents.service.ts
Mars 03f54ca689 feat: split admin dashboard, improve match ops, and player closed-match UX
Admin: add match/player overview sub-nav; refine settlement flow and league
match management UI; improve action button enabled/disabled styles; enhance
logo upload and outright odds sync.

API: expose matchPhase/bettingOpen for closed matches; league publish guards;
settlement preview with auto score save; outright team auto-sync.

Player: watermark for closed/settled states; keep match and bet details visible;
remove default login credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:00:14 +08:00

1681 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Injectable,
BadRequestException,
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';
import { assertPlayerUsername } from '@thebet365/shared';
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) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent profile not found');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
async recalculateUsedCredit(agentId: bigint) {
const directPlayers = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
});
let directLiability = new Decimal(0);
for (const p of directPlayers) {
if (p.wallet) {
directLiability = directLiability
.add(p.wallet.availableBalance)
.add(p.wallet.frozenBalance);
}
}
const childAgents = await this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
});
let childExposure = new Decimal(0);
for (const child of childAgents) {
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
childExposure = childExposure.add(exposure);
}
const usedCredit = directLiability.add(childExposure);
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: {
usedCredit,
directPlayerLiability: directLiability,
childAgentExposure: childExposure,
},
});
return usedCredit;
}
async adjustCredit(
agentId: bigint,
amount: Decimal | number,
operatorId: bigint,
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent not found');
const creditBefore = profile.creditLimit;
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 },
data: { creditLimit: creditAfter },
});
await tx.agentCreditTransaction.create({
data: {
agentId,
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
amount: amt,
creditBefore,
creditAfter,
operatorId,
requestId,
remark,
},
});
});
if (profile.parentAgentId) {
await this.recalculateUsedCredit(profile.parentAgentId);
}
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 adminWithdrawFromPlayer(
playerId: bigint,
amount: number,
operatorId: bigint,
remark?: string,
requestId?: string,
) {
const result = await this.wallet.withdraw(
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,
) {
await this.requireDirectPlayer(agentId, playerId);
const profile = await this.getProfile(agentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw new BadRequestException('Insufficient agent credit');
}
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
}
async withdrawFromPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(playerId, amount, agentId, remark ?? '代理下分', requestId);
await this.recalculateUsedCredit(agentId);
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('账号名称不能为空');
try {
assertPlayerUsername(nextUsername);
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
}
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;
status?: string;
level?: 1 | 2;
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?.level === 2) {
where.level = 2;
} else if (params?.level === 1) {
where.level = 1;
} else if (params?.parentAgentId !== undefined) {
where.parentAgentId = params.parentAgentId;
} else {
where.parentAgentId = null;
}
const kw = params?.keyword?.trim();
const status = params?.status?.trim();
const userWhere: Prisma.UserWhereInput = {};
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
userWhere.status = status;
}
if (kw) {
userWhere.username = { contains: kw, mode: 'insensitive' };
}
if (Object.keys(userWhere).length > 0) {
where.user = userWhere;
}
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 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 parentAgentIds = [
...new Set(profiles.map((p) => p.parentAgentId).filter((id): id is bigint => id != null)),
];
const parentUsers =
parentAgentIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: parentAgentIds } },
select: { id: true, username: true },
})
: [];
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
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,
parentUsername: p.parentAgentId
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
: null,
creditLimit: p.creditLimit.toString(),
usedCredit: p.usedCredit.toString(),
availableCredit: available.toString(),
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,
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, 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' },
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(),
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) => ({
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 listCreditTransactions(params: {
page?: number;
pageSize?: number;
agentId?: bigint;
keyword?: string;
operatorKeyword?: string;
transactionType?: string;
scopedAgentIds?: bigint[];
dateFrom?: Date;
dateTo?: Date;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.AgentCreditTransactionWhereInput = {};
if (params.transactionType?.trim()) {
where.transactionType = params.transactionType.trim();
}
if (params.dateFrom || params.dateTo) {
where.createdAt = {};
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
if (params.dateTo) where.createdAt.lte = params.dateTo;
}
const scopedIds = params.scopedAgentIds?.length ? params.scopedAgentIds : undefined;
const operatorKeyword = params.operatorKeyword?.trim();
if (operatorKeyword) {
const matchedOps = await this.prisma.user.findMany({
where: {
deletedAt: null,
username: { contains: operatorKeyword, mode: 'insensitive' },
},
select: { id: true },
take: 50,
});
const operatorIds = matchedOps.map((u) => u.id);
if (!operatorIds.length) {
return { items: [], total: 0, page, pageSize };
}
where.operatorId = { in: operatorIds };
}
const keyword = params.keyword?.trim();
if (keyword) {
const matched = await this.prisma.user.findMany({
where: {
userType: 'AGENT',
deletedAt: null,
username: { contains: keyword, mode: 'insensitive' },
},
select: { id: true },
take: 50,
});
let agentUserIds = matched.map((u) => u.id);
if (scopedIds) {
const scopedSet = new Set(scopedIds.map((id) => id.toString()));
agentUserIds = agentUserIds.filter((id) => scopedSet.has(id.toString()));
}
if (!agentUserIds.length) {
return { items: [], total: 0, page, pageSize };
}
if (params.agentId) {
if (!agentUserIds.some((id) => id === params.agentId)) {
return { items: [], total: 0, page, pageSize };
}
where.agentId = params.agentId;
} else {
where.agentId = { in: agentUserIds };
}
} else if (params.agentId) {
if (scopedIds && !scopedIds.some((id) => id === params.agentId)) {
return { items: [], total: 0, page, pageSize };
}
where.agentId = params.agentId;
} else if (scopedIds) {
where.agentId = { in: scopedIds };
}
const [rows, total] = await Promise.all([
this.prisma.agentCreditTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.agentCreditTransaction.count({ where }),
]);
const agentIds = [...new Set(rows.map((r) => r.agentId))];
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const [agentUsers, operators] = await Promise.all([
agentIds.length
? this.prisma.user.findMany({
where: { id: { in: agentIds } },
select: { id: true, username: true },
})
: [],
operatorIds.length
? this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [],
]);
const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username]));
const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username]));
return {
items: rows.map((row) => ({
id: row.id.toString(),
agentId: row.agentId.toString(),
agentUsername: agentNameById.get(row.agentId.toString()) ?? null,
transactionType: row.transactionType,
amount: row.amount.toString(),
creditBefore: row.creditBefore.toString(),
creditAfter: row.creditAfter.toString(),
referenceType: row.referenceType,
referenceId: row.referenceId,
operatorId: row.operatorId?.toString() ?? null,
operatorUsername: row.operatorId
? (operatorNameById.get(row.operatorId.toString()) ?? null)
: null,
requestId: row.requestId,
remark: row.remark,
createdAt: row.createdAt,
})),
total,
page,
pageSize,
};
}
async updateAgentAdmin(
agentId: bigint,
data: {
status?: string;
locale?: string;
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({
where: { userId: agentId },
include: { user: true },
});
if (!profile) throw new NotFoundException('代理不存在');
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
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({
where: { id: agentId },
data: { status: data.status },
}),
this.prisma.agentProfile.update({
where: { userId: agentId },
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) {
await this.prisma.user.update({
where: { id: agentId },
data: { locale: data.locale },
});
}
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;
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 listPromotablePlayers(keyword?: string) {
const q = keyword?.trim();
return this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
agentProfile: null,
...(q
? { username: { contains: q, mode: 'insensitive' } }
: {}),
},
select: {
id: true,
username: true,
status: true,
parentId: true,
preferences: { select: { phone: true, email: true } },
parent: { select: { username: true } },
},
orderBy: { id: 'desc' },
take: 50,
});
}
/** 将已有玩家账号升级为一级代理(不新建用户) */
async promotePlayerToTier1Agent(
userId: bigint,
data: {
creditLimit: number;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
phone?: string;
email?: string;
},
) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { agentProfile: true, preferences: true },
});
if (!user || user.deletedAt) {
throw new NotFoundException('用户不存在');
}
if (user.userType !== 'PLAYER') {
throw new BadRequestException('仅玩家账号可设为代理');
}
if (user.agentProfile) {
throw new BadRequestException('该用户已是代理');
}
const oldParentId = user.parentId;
const phone =
data.phone !== undefined
? data.phone.trim() || null
: user.preferences?.phone ?? null;
const email =
data.email !== undefined
? data.email.trim() || null
: user.preferences?.email ?? null;
await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
userType: 'AGENT',
agentLevel: 1,
parentId: null,
},
});
if (user.preferences) {
await tx.userPreference.update({
where: { userId },
data: { phone, email },
});
} else {
await tx.userPreference.create({
data: {
userId,
locale: user.locale,
phone,
email,
},
});
}
await tx.agentProfile.create({
data: {
userId,
level: 1,
parentAgentId: null,
creditLimit: data.creditLimit,
cashbackRate: data.cashbackRate ?? 0,
maxSingleDeposit: this.normalizeOptionalLimit(data.maxSingleDeposit),
maxDailyDeposit: this.normalizeOptionalLimit(data.maxDailyDeposit),
},
});
await tx.agentClosure.create({
data: { ancestorId: userId, descendantId: userId, depth: 0 },
});
});
if (oldParentId) {
await this.recalculateUsedCredit(oldParentId);
}
const updated = await this.prisma.user.findUnique({ where: { id: userId } });
if (!updated) {
throw new NotFoundException('用户不存在');
}
return updated;
}
async createAgent(
operatorId: bigint,
data: {
username: string;
password: string;
level: number;
parentAgentId?: bigint;
creditLimit?: number;
locale?: string;
phone?: string;
email?: string;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
},
) {
if (data.level !== 1 && data.level !== 2) {
throw new BadRequestException('Agent level must be 1 or 2');
}
if (data.level === 2 && !data.parentAgentId) {
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) => {
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,
},
});
await tx.userAuth.create({
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,
maxSingleDeposit,
maxDailyDeposit,
},
});
// Build closure table
await tx.agentClosure.create({
data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
});
if (data.parentAgentId) {
const ancestors = await tx.agentClosure.findMany({
where: { descendantId: data.parentAgentId },
});
for (const a of ancestors) {
await tx.agentClosure.create({
data: {
ancestorId: a.ancestorId,
descendantId: user.id,
depth: a.depth + 1,
},
});
}
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
}
return user;
});
}
async createPlayer(
operatorId: bigint,
data: {
username: string;
password: string;
parentId?: bigint;
locale?: string;
phone?: string;
email?: string;
initialDeposit?: number;
depositRemark?: string;
depositRequestId?: string;
asTier1Agent?: boolean;
asSubAgent?: boolean;
parentAgentId?: bigint;
creditLimit?: number;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
},
) {
if (data.asTier1Agent) {
if (data.parentId != null) {
throw new BadRequestException('一级代理不可设置上级玩家');
}
if (data.initialDeposit && data.initialDeposit > 0) {
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
}
return this.createAgent(operatorId, {
username: data.username,
password: data.password,
level: 1,
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
locale: data.locale,
phone: data.phone,
email: data.email,
});
}
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 } });
if (!parent || parent.userType !== 'AGENT') {
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');
}
}
try {
assertPlayerUsername(data.username);
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
}
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,
locale,
},
});
await tx.userAuth.create({
data: { userId: created.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: created.id },
});
await tx.userPreference.create({
data: {
userId: created.id,
locale,
phone: data.phone?.trim() || null,
email: data.email?.trim() || null,
managedPassword: data.password,
},
});
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.assertPlayerParentCreditForDeposit(user.id, initial);
await this.wallet.deposit(
user.id,
initial,
operatorId,
data.depositRemark ?? '开户初始余额',
requestId,
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
}
}
return user;
}
async getDirectPlayers(agentId: bigint) {
return this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
orderBy: { createdAt: 'desc' },
});
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
}
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 ids: bigint[] = [];
const queue: bigint[] = [agentId];
const seen = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
const key = current.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(current);
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: current },
select: { userId: true },
});
for (const child of children) {
queue.push(child.userId);
}
}
return ids;
}
async getReportSummary(agentId: bigint) {
const profile = await this.getProfile(agentId);
const agentIds = await this.getSubtreeAgentIds(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 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 {
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,
})),
};
}
}