feat: 开户备注、账单展示优化与后台代理管理增强

- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示

- 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分

- 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端

- 后台代理/玩家表格操作栏重构,充值订单增加备注列

- 前台个人中心、充值、账单与验证码组件体验优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:23:58 +08:00
parent 10485ecfaf
commit 03e72ca9b2
46 changed files with 3721 additions and 1059 deletions

View File

@@ -7,7 +7,7 @@ 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';
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
@@ -87,6 +87,157 @@ export class AgentsService {
return map;
}
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
if (agentUserIds.length === 0) return new Map();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: agentUserIds } },
select: { userId: true, cashbackRate: true },
});
return new Map(profiles.map((p) => [p.userId.toString(), p.cashbackRate.toString()]));
}
private async playerEffectiveCashbackRateMap(
players: Array<{ id: bigint; parentId: bigint | null }>,
parentCashbackMap: Map<string, string>,
): Promise<Map<string, string>> {
if (players.length === 0) return new Map();
const playerIds = players.map((p) => p.id);
const customRules = await this.prisma.cashbackRule.findMany({
where: {
targetType: 'USER',
targetId: { in: playerIds },
isActive: true,
marketType: null,
},
orderBy: { updatedAt: 'desc' },
});
const customMap = new Map<string, string>();
for (const rule of customRules) {
if (!rule.targetId) continue;
const id = rule.targetId.toString();
if (!customMap.has(id) && new Decimal(rule.rate).gt(0)) {
customMap.set(id, rule.rate.toString());
}
}
const result = new Map<string, string>();
for (const p of players) {
const key = p.id.toString();
const custom = customMap.get(key);
if (custom) {
result.set(key, custom);
continue;
}
if (p.parentId) {
result.set(key, parentCashbackMap.get(p.parentId.toString()) ?? '0');
} else {
result.set(key, '0');
}
}
return result;
}
/** 代理端:从当前登录代理向下构建上级链,不包含更上层代理 */
private async buildScopedAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
const rootKey = rootAgentId.toString();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
pending.add(rootAgentId);
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (
profile.parentAgentId &&
profile.parentAgentId.toString() !== rootKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
}
private async getAgentPortalScope(rootAgentId: bigint) {
const profile = await this.getProfile(rootAgentId);
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
subtreeIdSet,
};
}
private assertAgentInPortalSubtree(
scope: { subtreeIdSet: Set<string> },
agentId: bigint,
) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
}
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
}
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
if (!Number.isInteger(level) || level < 1) {
throw appBadRequest('AGENT_LEVEL_INVALID');
@@ -341,6 +492,7 @@ export class AgentsService {
operatorId,
remark ?? '管理员上分',
requestId,
'ADMIN_DEPOSIT',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
@@ -366,6 +518,7 @@ export class AgentsService {
operatorId,
remark ?? '管理员下分',
requestId,
'ADMIN_WITHDRAW',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
@@ -519,7 +672,7 @@ export class AgentsService {
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
await this.recalculateUsedCredit(agentId);
return { success: true };
@@ -534,7 +687,14 @@ export class AgentsService {
) {
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(playerId, amount, agentId, remark ?? '代理下分', requestId);
await this.wallet.withdraw(
playerId,
amount,
agentId,
remark ?? '代理下分',
requestId,
'AGENT_WITHDRAW',
);
await this.recalculateUsedCredit(agentId);
return { success: true };
@@ -1515,6 +1675,8 @@ export class AgentsService {
const initial = data.initialDeposit ?? 0;
if (initial > 0) {
const remarkResult = validateInitialDepositRemark(initial, data.depositRemark, 'admin');
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.assertPlayerParentCreditForDeposit(user.id, initial);
@@ -1522,8 +1684,9 @@ export class AgentsService {
user.id,
initial,
operatorId,
data.depositRemark ?? '开户初始余额',
remarkResult.remark,
requestId,
'ADMIN_DEPOSIT',
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
@@ -1533,6 +1696,34 @@ export class AgentsService {
return user;
}
async getPortalAgentDirectPlayers(rootAgentId: bigint, targetAgentId: bigint) {
await this.assertDescendantAgent(rootAgentId, targetAgentId);
const players = await this.getDirectPlayers(targetAgentId);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
select: {
cashbackRate: true,
user: { select: { username: true } },
},
});
const rootKey = rootAgentId.toString();
const targetKey = targetAgentId.toString();
const parentAgentUsername = profile?.user.username ?? '—';
const parentCashbackMap = await this.agentCashbackRateMap([targetAgentId]);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
parentCashbackMap,
);
return players.map((p) => ({
...p,
parentAgentId: targetKey,
parentAgentUsername,
cashbackRate: playerCashbackMap.get(p.id) ?? '0',
inChain: true,
isDirect: targetKey === rootKey,
}));
}
async getDirectPlayers(agentId: bigint) {
const rows = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
@@ -1592,6 +1783,8 @@ export class AgentsService {
userStatus: p.user.status,
status: p.status,
level: p.level,
parentAgentId: p.parentAgentId?.toString() ?? null,
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
@@ -1601,6 +1794,403 @@ export class AgentsService {
});
}
/** Read-only downline under a direct sub-agent (all descendant agents + subtree players). */
async getDirectChildDownlineView(parentAgentId: bigint, subAgentId: bigint) {
await this.assertDirectChildAgent(parentAgentId, subAgentId);
const subtreeIds = await this.getSubtreeAgentIds(subAgentId);
const descendantAgentIds = subtreeIds.filter((id) => id !== subAgentId);
let agents: Array<{
userId: string;
username: string;
userStatus: string;
status: string;
level: number;
parentUsername: string;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
createdAt: Date;
}> = [];
if (descendantAgentIds.length > 0) {
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: descendantAgentIds } },
include: { user: true },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
});
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 parentNameMap = new Map(
parentUsers.map((u) => [u.id.toString(), u.username]),
);
const playerCounts = await this.prisma.user.groupBy({
by: ['parentId'],
where: {
userType: 'PLAYER',
parentId: { in: descendantAgentIds },
deletedAt: null,
},
_count: { _all: true },
});
const countMap = new Map(
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
agents = 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,
parentUsername: p.parentAgentId
? parentNameMap.get(p.parentAgentId.toString()) ?? '—'
: '—',
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
createdAt: p.createdAt,
};
});
}
const playerRows =
subtreeIds.length > 0
? await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
parentId: { in: subtreeIds },
},
include: {
wallet: true,
usedInvite: { select: { code: true } },
parent: { select: { username: true } },
},
orderBy: { createdAt: 'desc' },
})
: [];
const parentAgentIdsForPlayers = [
...new Set(
playerRows
.map((u) => u.parentId)
.filter((id): id is bigint => id != null),
),
];
const parentCashbackMap = await this.agentCashbackRateMap(parentAgentIdsForPlayers);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
playerRows.map((u) => ({ id: u.id, parentId: u.parentId })),
parentCashbackMap,
);
const players = playerRows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
createdAt: u.createdAt,
inviteCode: u.usedInvite?.code ?? null,
parentAgentUsername: u.parent?.username ?? '—',
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
wallet: u.wallet
? {
availableBalance: u.wallet.availableBalance.toString(),
frozenBalance: u.wallet.frozenBalance.toString(),
}
: undefined,
}));
return {
agents: agents.map((a) => ({
...a,
createdAt: a.createdAt.toISOString(),
})),
players,
};
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
}
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
if (scope.descendantIds.length === 0) return {} as Record<number, number>;
const groups = await this.prisma.agentProfile.groupBy({
by: ['level'],
where: {
userId: { in: scope.descendantIds },
level: { gt: scope.rootLevel },
user: { deletedAt: null },
},
_count: { _all: true },
});
const out: Record<number, number> = {};
for (const g of groups) {
if (g.level > scope.rootLevel) {
out[g.level] = g._count._all;
}
}
return out;
}
async listSubtreeAgentsAtLevel(
rootAgentId: bigint,
level: number,
params?: { page?: number; pageSize?: number; keyword?: string; status?: string },
) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
const skip = (page - 1) * pageSize;
const scope = await this.getAgentPortalScope(rootAgentId);
if (level <= scope.rootLevel || scope.descendantIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where: Prisma.AgentProfileWhereInput = {
userId: { in: scope.descendantIds },
level,
};
const kw = params?.keyword?.trim();
const status = params?.status?.trim();
const userWhere: Prisma.UserWhereInput = { deletedAt: null };
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
userWhere.status = status;
}
if (kw) {
userWhere.username = { contains: kw, mode: 'insensitive' };
}
where.user = userWhere;
const [profiles, total] = await Promise.all([
this.prisma.agentProfile.findMany({
where,
include: { user: 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 parentAgentIds = [
...new Set(
profiles
.map((p) => p.parentAgentId)
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString())),
),
];
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 parentChainMap = await this.buildScopedAncestorChainMap(
profiles.map((p) => p.parentAgentId),
rootAgentId,
);
const items = profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
const parentChain = p.parentAgentId
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
: [];
const parentUsername =
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
: null;
return {
userId: p.userId.toString(),
username: p.user.username,
userStatus: p.user.status,
status: p.status,
level: p.level,
parentAgentId: p.parentAgentId?.toString() ?? null,
parentUsername,
parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
cashbackRate: p.cashbackRate.toString(),
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
createdAt: p.createdAt.toISOString(),
};
});
return { items, total, page, pageSize };
}
async listSubtreePlayersForPortal(
rootAgentId: bigint,
params?: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
parentAgentId?: string;
},
) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
const skip = (page - 1) * pageSize;
const scope = await this.getAgentPortalScope(rootAgentId);
const where: Prisma.UserWhereInput = {
userType: 'PLAYER',
deletedAt: null,
parentId: { in: scope.subtreeIds },
};
if (params?.parentAgentId) {
this.assertAgentInPortalSubtree(scope, BigInt(params.parentAgentId));
where.parentId = BigInt(params.parentAgentId);
}
const kw = params?.keyword?.trim();
if (kw) {
where.username = { contains: kw, mode: 'insensitive' };
}
const status = params?.status?.trim();
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
where.status = status;
}
const [rows, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: {
wallet: true,
usedInvite: { select: { code: true } },
parent: { select: { id: true, username: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.user.count({ where }),
]);
const parentIds = [
...new Set(
rows
.map((u) => u.parentId)
.filter((id): id is bigint => id != null),
),
];
const parentCashbackMap = await this.agentCashbackRateMap(parentIds);
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
rows.map((u) => ({ id: u.id, parentId: u.parentId })),
parentCashbackMap,
);
const rootKey = rootAgentId.toString();
const items = rows.map((u) => {
const parentId = u.parentId!.toString();
return {
id: u.id.toString(),
username: u.username,
status: u.status,
createdAt: u.createdAt.toISOString(),
inviteCode: u.usedInvite?.code ?? null,
parentAgentId: parentId,
parentAgentUsername: u.parent?.username ?? '—',
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
inChain: true,
isDirect: parentId === rootKey,
wallet: u.wallet
? {
availableBalance: u.wallet.availableBalance.toString(),
frozenBalance: u.wallet.frozenBalance.toString(),
}
: undefined,
};
});
return { items, total, page, pageSize };
}
async listSubtreeAgentOptions(rootAgentId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const profiles = await this.prisma.agentProfile.findMany({
where: {
userId: { in: scope.subtreeIds },
OR: [{ userId: rootAgentId }, { level: { gt: scope.rootLevel } }],
},
include: { user: { select: { username: true } } },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
});
const parentIds = profiles
.map((p) => p.parentAgentId)
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString()));
const parentUsers =
parentIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: [...new Set(parentIds)] } },
select: { id: true, username: true },
})
: [];
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
return profiles.map((p) => ({
id: p.userId.toString(),
username: p.user.username,
level: p.level,
parentUsername:
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
: null,
}));
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },