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

@@ -556,6 +556,12 @@ class UpdatePlatformMatchDto {
awayTeamLogoUrl?: string;
}
class ReopenMatchDto {
@IsOptional()
@IsString()
startTime?: string;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
@@ -1643,6 +1649,14 @@ export class AdminController {
return jsonResponse(match);
}
@Post('matches/:id/reopen')
@RequirePermissions(P.matches)
async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) {
const startTime = dto.startTime ? new Date(dto.startTime) : undefined;
const match = await this.matches.reopenMatch(BigInt(id), startTime);
return jsonResponse(match);
}
@Post('matches/:id/cancel')
@RequirePermissions(P.matches)
async cancelMatch(@Param('id') id: string) {

View File

@@ -12,7 +12,8 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appForbidden } from '../../shared/common/app-error';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { validateInitialDepositRemark } from '@thebet365/shared';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { BetsService } from '../../domains/betting/bets.service';
@@ -164,7 +165,11 @@ export class AgentPortalController {
const profile = await this.agents.getProfile(agentId);
const maxLevel = await this.agents.getMaxAgentLevel();
return jsonResponse({
...profile,
level: profile.level,
creditLimit: profile.creditLimit.toString(),
usedCredit: profile.usedCredit.toString(),
availableCredit: profile.availableCredit.toString(),
cashbackRate: profile.cashbackRate.toString(),
maxAgentLevel: maxLevel,
canManageSubAgents: this.agents.canCreateSubAgent(level, maxLevel),
});
@@ -176,6 +181,25 @@ export class AgentPortalController {
return jsonResponse(players);
}
@Get('players/scoped')
async listScopedPlayers(
@CurrentUser('id') agentId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const result = await this.agents.listSubtreePlayersForPortal(agentId, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword,
status,
parentAgentId,
});
return jsonResponse(result);
}
@Post('players')
async createPlayer(@CurrentUser('id') agentId: bigint, @Body() dto: CreatePlayerDto) {
const user = await this.agents.createPlayer(agentId, {
@@ -188,12 +212,14 @@ export class AgentPortalController {
});
if (dto.initialDeposit != null && dto.initialDeposit > 0) {
const remarkResult = validateInitialDepositRemark(dto.initialDeposit, dto.remark, 'agent');
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
await this.agents.depositToPlayer(
agentId,
user.id,
dto.initialDeposit,
`agent-create-${user.id}-${Date.now()}`,
dto.remark ?? '开户初始余额',
remarkResult.remark,
);
}
@@ -245,6 +271,40 @@ export class AgentPortalController {
return jsonResponse(agents);
}
@Get('agents/level-counts')
async subtreeAgentLevelCounts(@CurrentUser('id') agentId: bigint) {
const counts = await this.agents.countSubtreeAgentsByLevel(agentId);
return jsonResponse(counts);
}
@Get('agents/options')
async subtreeAgentOptions(@CurrentUser('id') agentId: bigint) {
const options = await this.agents.listSubtreeAgentOptions(agentId);
return jsonResponse(options);
}
@Get('agents/by-level')
async subtreeAgentsByLevel(
@CurrentUser('id') agentId: bigint,
@Query('level') level: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
const lvl = parseInt(level, 10);
if (!Number.isFinite(lvl) || lvl < 1) {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
const result = await this.agents.listSubtreeAgentsAtLevel(agentId, lvl, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword,
status,
});
return jsonResponse(result);
}
@Post('agents')
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
const maxLevel = await this.agents.getMaxAgentLevel();
@@ -323,6 +383,19 @@ export class AgentPortalController {
return jsonResponse(detail);
}
@Get('agents/:id/downline')
async getSubAgentDownline(
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse({ agents: [], players: [] });
}
const downline = await this.agents.getDirectChildDownlineView(agentId, BigInt(subAgentId));
return jsonResponse(downline);
}
@Get('agents/:id/players')
async listSubAgentPlayers(
@CurrentUser('id') agentId: bigint,
@@ -332,8 +405,8 @@ export class AgentPortalController {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse([]);
}
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getDirectPlayers(BigInt(subAgentId));
await this.agents.assertDescendantAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getPortalAgentDirectPlayers(agentId, BigInt(subAgentId));
return jsonResponse(players);
}
@@ -494,16 +567,7 @@ export class AgentPortalController {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
if (playerId) {
const player = await this.prisma.user.findFirst({
where: { id: BigInt(playerId), userType: 'PLAYER', deletedAt: null },
select: { id: true, parentId: true },
});
if (
!player?.parentId ||
!scopedParentAgentIds.some((id) => id === player.parentId)
) {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
await this.agents.requirePlayerInPortalSubtree(agentId, BigInt(playerId));
}
const result = await this.wallet.listWalletTransactionsAdmin({
page: page ? parseInt(page, 10) : 1,

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 },

View File

@@ -1101,6 +1101,29 @@ export class MatchesService {
});
}
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
if (match.status !== 'CLOSED') throw appBadRequest('MATCH_NOT_REOPENABLE');
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
const effectiveStart = startTime ?? match.startTime;
if (!isPreMatchKickoff(effectiveStart)) {
throw appBadRequest('MATCH_REOPEN_KICKOFF_REQUIRED');
}
return this.prisma.match.update({
where: { id: matchId },
data: {
status: 'PUBLISHED',
closeTime: null,
...(startTime ? { startTime } : {}),
},
});
}
async cancelMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },

View File

@@ -432,6 +432,7 @@ export class DepositService {
reviewerId: operatorId,
reviewedAt: new Date(),
rejectReason: reason,
remark: reason,
},
});

View File

@@ -83,6 +83,7 @@ export class WalletService {
operatorId: bigint,
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
@@ -106,7 +107,7 @@ export class WalletService {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
transactionType,
amount: amt.neg(),
balanceBefore,
balanceAfter,
@@ -260,26 +261,178 @@ export class WalletService {
return this.prisma.$transaction(run);
}
private static readonly DEPOSIT_TX_TYPES = [
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'MANUAL_ADJUST',
'PLAYER_DEPOSIT',
] as const;
private static readonly WITHDRAW_TX_TYPES = [
'MANUAL_WITHDRAW',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'WITHDRAW',
] as const;
private static readonly SYSTEM_REMARKS = new Set([
'管理员上分',
'管理员下分',
'代理上分',
'代理下分',
'开户初始余额',
'Resettlement adjustment',
]);
private resolveDisplayType(
transactionType: string,
operatorType?: string | null,
): string {
const type = transactionType.toUpperCase();
if (type === 'INITIAL_DEPOSIT') {
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return 'ADMIN_DEPOSIT';
}
if (
[
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'PLAYER_DEPOSIT',
].includes(type)
) {
return type;
}
if (type === 'MANUAL_DEPOSIT') {
if (operatorType === 'ADMIN') return 'ADMIN_DEPOSIT';
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return type;
}
if (type === 'MANUAL_WITHDRAW') {
if (operatorType === 'ADMIN') return 'ADMIN_WITHDRAW';
if (operatorType === 'AGENT') return 'AGENT_WITHDRAW';
return type;
}
return type;
}
private isCustomRemark(remark: string | null | undefined): boolean {
const r = remark?.trim();
if (!r) return false;
if (WalletService.SYSTEM_REMARKS.has(r)) return false;
if (r.startsWith('Cashback batch ')) return false;
if (r.startsWith('Deposit order ')) return false;
return true;
}
private buildPlayerTxSummary(
tx: {
transactionType: string;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
},
depositMethodName?: string | null,
): string | null {
const type = tx.transactionType.toUpperCase();
if (tx.referenceType === 'BET' && tx.referenceId) {
return tx.referenceId;
}
if (type === 'CASHBACK' || type === 'CASHBACK_DEPOSIT') {
return tx.referenceId ?? null;
}
if (type === 'PLAYER_DEPOSIT') {
const parts = [depositMethodName?.trim(), tx.referenceId?.trim()].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}
if (this.isCustomRemark(tx.remark)) return tx.remark!.trim();
return null;
}
private resolveSummaryKind(remark: string | null | undefined): 'opening_bonus' | null {
const r = remark?.trim();
if (r === '开户初始余额') return 'opening_bonus';
return null;
}
private async enrichPlayerTransactions(
rows: Array<{
id: bigint;
transactionId: string;
transactionType: string;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
frozenBefore: Decimal;
frozenAfter: Decimal;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
operatorId: bigint | null;
createdAt: Date;
}>,
) {
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, userType: true },
})
: [];
const operatorTypeById = new Map(operators.map((o) => [o.id.toString(), o.userType]));
const depositMethodByRowId = await this.resolveDepositMethodsForRows(rows);
return rows.map((row) => {
const operatorType = row.operatorId
? operatorTypeById.get(row.operatorId.toString())
: null;
const displayType = this.resolveDisplayType(row.transactionType, operatorType);
const depositMethod = depositMethodByRowId.get(row.id.toString());
const summary = this.buildPlayerTxSummary(
row,
depositMethod?.depositMethodName,
);
const summaryKind = summary ? null : this.resolveSummaryKind(row.remark);
return {
transactionId: row.transactionId,
transactionType: row.transactionType,
displayType,
summaryKind,
amount: row.amount.toString(),
balanceBefore: row.balanceBefore.toString(),
balanceAfter: row.balanceAfter.toString(),
frozenBefore: row.frozenBefore.toString(),
frozenAfter: row.frozenAfter.toString(),
referenceType: row.referenceType,
referenceId: row.referenceId,
remark: row.remark,
summary,
createdAt: row.createdAt.toISOString(),
betNo: row.referenceType === 'BET' ? row.referenceId : null,
cashbackBatchNo:
row.transactionType === 'CASHBACK' || row.transactionType === 'CASHBACK_DEPOSIT'
? row.referenceId
: null,
};
});
}
async getTransactionDetail(userId: bigint, transactionId: string) {
const tx = await this.prisma.walletTransaction.findFirst({
where: { userId, transactionId },
});
if (!tx) return null;
return {
transactionId: tx.transactionId,
transactionType: tx.transactionType,
amount: tx.amount.toString(),
balanceBefore: tx.balanceBefore.toString(),
balanceAfter: tx.balanceAfter.toString(),
frozenBefore: tx.frozenBefore.toString(),
frozenAfter: tx.frozenAfter.toString(),
referenceType: tx.referenceType,
referenceId: tx.referenceId,
remark: tx.remark,
createdAt: tx.createdAt.toISOString(),
betNo: tx.referenceType === 'BET' ? tx.referenceId : null,
};
const [enriched] = await this.enrichPlayerTransactions([tx]);
return enriched;
}
async getTransactions(userId: bigint, page = 1, pageSize = 20, typeFilter?: string) {
@@ -287,9 +440,9 @@ export class WalletService {
let typeWhere: Record<string, unknown> = {};
if (typeFilter === 'deposit') {
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
typeWhere = { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
} else if (typeFilter === 'withdraw') {
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
typeWhere = { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
} else if (typeFilter === 'bet') {
typeWhere = { transactionType: { in: ['BET_FREEZE', 'BET_DEDUCT', 'BET_SETTLE_WIN', 'BET_SETTLE_LOSE', 'BET_SETTLE_PUSH', 'BET_WIN', 'BET_REFUND', 'BET_VOID', 'BET_VOID_REFUND', 'RESETTLE_REVERSE'] } };
} else if (typeFilter === 'cashback') {
@@ -306,16 +459,21 @@ export class WalletService {
}),
this.prisma.walletTransaction.count({ where }),
]);
return { items, total, page, pageSize };
return {
items: await this.enrichPlayerTransactions(items),
total,
page,
pageSize,
};
}
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
const cat = category?.trim();
if (cat === 'deposit') {
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
return { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
}
if (cat === 'withdraw') {
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
return { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
}
if (cat === 'bet') {
return {
@@ -343,6 +501,9 @@ export class WalletService {
private static readonly DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
@@ -644,7 +805,15 @@ export class WalletService {
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const transferTypes = ['MANUAL_DEPOSIT', 'MANUAL_WITHDRAW'];
const transferTypes = [
'MANUAL_DEPOSIT',
'MANUAL_WITHDRAW',
'ADMIN_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_DEPOSIT',
'AGENT_WITHDRAW',
'INITIAL_DEPOSIT',
];
const where: Prisma.WalletTransactionWhereInput = {
transactionType: params.transactionType?.trim()
? params.transactionType.trim()