feat: refactor agent manager, media library, and player UX
- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs - Add media library with uploaded_files migration, list/delete unused files API, and admin nav route - Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package - Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,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';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
@@ -475,6 +476,11 @@ export class AgentsService {
|
||||
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('账号名称已被占用');
|
||||
@@ -531,6 +537,8 @@ export class AgentsService {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
level?: 1 | 2;
|
||||
parentAgentId?: bigint;
|
||||
}) {
|
||||
const page = Math.max(1, params?.page ?? 1);
|
||||
@@ -538,15 +546,26 @@ export class AgentsService {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AgentProfileWhereInput = {};
|
||||
if (params?.parentAgentId !== undefined) {
|
||||
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 {
|
||||
// Default: only show top-level agents (no parent)
|
||||
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) {
|
||||
where.user = { username: { contains: kw, mode: 'insensitive' } };
|
||||
userWhere.username = { contains: kw, mode: 'insensitive' };
|
||||
}
|
||||
if (Object.keys(userWhere).length > 0) {
|
||||
where.user = userWhere;
|
||||
}
|
||||
|
||||
const [profiles, total] = await Promise.all([
|
||||
@@ -592,6 +611,18 @@ export class AgentsService {
|
||||
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 {
|
||||
@@ -602,6 +633,9 @@ export class AgentsService {
|
||||
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(),
|
||||
@@ -1235,6 +1269,12 @@ export class AgentsService {
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared';
|
||||
import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
@@ -8,6 +8,7 @@ import { AgentsService } from '../agent/agents.service';
|
||||
export type PlayerListFilters = {
|
||||
keyword?: string;
|
||||
parentId?: bigint;
|
||||
platformDirect?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
@@ -19,6 +20,20 @@ export class UsersService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private buildAffiliationAgents(
|
||||
parent?: {
|
||||
username: string;
|
||||
agentLevel: number | null;
|
||||
parent?: { username: string; agentLevel: number | null } | null;
|
||||
} | null,
|
||||
): string[] {
|
||||
if (!parent) return [];
|
||||
if (parent.agentLevel === 2 && parent.parent?.username) {
|
||||
return [parent.parent.username, parent.username];
|
||||
}
|
||||
return [parent.username];
|
||||
}
|
||||
|
||||
private formatPlayerRow(
|
||||
u: {
|
||||
id: bigint;
|
||||
@@ -34,11 +49,16 @@ export class UsersService {
|
||||
email: string | null;
|
||||
managedPassword?: string | null;
|
||||
} | null;
|
||||
parent?: { username: string } | null;
|
||||
parent?: {
|
||||
username: string;
|
||||
agentLevel: number | null;
|
||||
parent?: { username: string; agentLevel: number | null } | null;
|
||||
} | null;
|
||||
auth?: { lastLoginAt: Date | null } | null;
|
||||
},
|
||||
bet?: { count: number; totalStake: string; totalReturn: string },
|
||||
) {
|
||||
const affiliationAgents = this.buildAffiliationAgents(u.parent);
|
||||
return {
|
||||
id: u.id.toString(),
|
||||
username: u.username,
|
||||
@@ -46,6 +66,7 @@ export class UsersService {
|
||||
locale: u.locale,
|
||||
parentId: u.parentId?.toString() ?? null,
|
||||
parentUsername: u.parent?.username ?? null,
|
||||
affiliationAgents,
|
||||
phone: u.preferences?.phone ?? null,
|
||||
email: u.preferences?.email ?? null,
|
||||
managedPassword: u.preferences?.managedPassword ?? null,
|
||||
@@ -102,6 +123,11 @@ export class UsersService {
|
||||
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 : '玩家用户名格式无效');
|
||||
}
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
if (!settings.allowUsernameChange) {
|
||||
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
|
||||
@@ -172,14 +198,18 @@ export class UsersService {
|
||||
const where: {
|
||||
userType: string;
|
||||
deletedAt: null;
|
||||
parentId?: bigint;
|
||||
parentId?: bigint | null;
|
||||
status?: string;
|
||||
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
||||
} = {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
};
|
||||
if (filters.parentId) where.parentId = filters.parentId;
|
||||
if (filters.platformDirect) {
|
||||
where.parentId = null;
|
||||
} else if (filters.parentId) {
|
||||
where.parentId = filters.parentId;
|
||||
}
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.keyword?.trim()) {
|
||||
const kw = filters.keyword.trim();
|
||||
@@ -193,7 +223,14 @@ export class UsersService {
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true } },
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
agentLevel: true,
|
||||
parent: { select: { username: true, agentLevel: true } },
|
||||
},
|
||||
},
|
||||
auth: { select: { lastLoginAt: true } },
|
||||
},
|
||||
skip,
|
||||
@@ -218,7 +255,14 @@ export class UsersService {
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true, agentLevel: true } },
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
agentLevel: true,
|
||||
parent: { select: { username: true, agentLevel: true } },
|
||||
},
|
||||
},
|
||||
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
||||
},
|
||||
});
|
||||
@@ -250,7 +294,6 @@ export class UsersService {
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parentId?: string | null;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
@@ -268,6 +311,11 @@ export class UsersService {
|
||||
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('账号名称已被占用');
|
||||
@@ -301,39 +349,6 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.parentId !== undefined) {
|
||||
const newParentId =
|
||||
data.parentId === null || data.parentId === ''
|
||||
? null
|
||||
: BigInt(data.parentId);
|
||||
|
||||
if (newParentId !== null) {
|
||||
const parent = await this.prisma.user.findUnique({
|
||||
where: { id: newParentId },
|
||||
});
|
||||
if (!parent || parent.userType !== 'AGENT') {
|
||||
throw new BadRequestException('上级必须为代理账号');
|
||||
}
|
||||
}
|
||||
|
||||
const oldParentId = user.parentId;
|
||||
const changed =
|
||||
(oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null);
|
||||
|
||||
if (changed) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
if (oldParentId) {
|
||||
await this.agents.recalculateUsedCredit(oldParentId);
|
||||
}
|
||||
if (newParentId) {
|
||||
await this.agents.recalculateUsedCredit(newParentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
|
||||
Reference in New Issue
Block a user