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:
2026-06-09 17:56:28 +08:00
parent d5e7c8edb3
commit df20444be9
27 changed files with 2136 additions and 563 deletions

View File

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