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

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "uploaded_files" (
"id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"category" VARCHAR(32) NOT NULL,
"mime_type" VARCHAR(64) NOT NULL,
"size" INTEGER NOT NULL,
"url" VARCHAR(500) NOT NULL,
"uploaded_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uploaded_files_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "uploaded_files_filename_key" ON "uploaded_files"("filename");
-- CreateIndex
CREATE INDEX "uploaded_files_category_idx" ON "uploaded_files"("category");

View File

@@ -595,6 +595,22 @@ model I18nMessage {
@@map("i18n_messages")
}
// ============ Media / File Uploads ============
model UploadedFile {
id String @id @default(uuid())
filename String @unique
category String @db.VarChar(32)
mimeType String @map("mime_type") @db.VarChar(64)
size Int
url String @db.VarChar(500)
uploadedBy BigInt? @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
@@index([category])
@@map("uploaded_files")
}
// ============ System Config & Audit ============
model SystemConfig {

View File

@@ -17,7 +17,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { mkdir, writeFile, unlink } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
@@ -228,11 +228,6 @@ class UpdatePlayerAdminDto {
@IsString()
email?: string;
/** 传空字符串表示改为平台直属(无代理) */
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
username?: string;
@@ -941,6 +936,7 @@ export class AdminController {
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('parentId') parentId?: string,
@Query('platformDirect') platformDirect?: string,
@Query('status') status?: string,
) {
const result = await this.users.listPlayers(
@@ -949,6 +945,7 @@ export class AdminController {
{
keyword,
parentId: parentId ? BigInt(parentId) : undefined,
platformDirect: platformDirect === 'true' || platformDirect === '1',
status,
},
);
@@ -1040,12 +1037,22 @@ export class AdminController {
@RequirePermissions(P.agentsView)
async listAgentOptions() {
const agents = await this.prisma.user.findMany({
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
select: { id: true, username: true },
orderBy: { username: 'asc' },
where: { userType: 'AGENT', deletedAt: null },
select: {
id: true,
username: true,
agentLevel: true,
parent: { select: { username: true } },
},
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
});
return jsonResponse(
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
agents.map((a) => ({
id: a.id.toString(),
username: a.username,
level: a.agentLevel ?? 1,
parentUsername: a.parent?.username ?? null,
})),
);
}
@@ -1055,12 +1062,17 @@ export class AdminController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('level') level?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status,
level: parsedLevel,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
});
return jsonResponse(result);
@@ -1835,7 +1847,7 @@ export class AdminController {
@RequirePermissions(P.content, P.matches)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async uploadAsset(
@CurrentUser() user: AdminUploadUser,
@CurrentUser() user: AdminUploadUser & { id?: bigint },
@UploadedFile() file: UploadedImage | undefined,
@Query('category') rawCategory?: string,
) {
@@ -1849,13 +1861,104 @@ export class AdminController {
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
return jsonResponse({
category,
filename,
size: file.size,
mimeType: file.mimetype,
url: `/uploads/${category}/${filename}`,
const url = `/uploads/${category}/${filename}`;
await this.prisma.uploadedFile.create({
data: {
filename,
category,
mimeType: file.mimetype,
size: file.size,
url,
uploadedBy: user.id ?? null,
},
});
return jsonResponse({ category, filename, size: file.size, mimeType: file.mimetype, url });
}
@Get('files')
@RequirePermissions(P.content, P.matches)
async listFiles(
@Query('category') category?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
const [files, total] = await Promise.all([
this.prisma.uploadedFile.findMany({ where, orderBy: { createdAt: 'desc' }, take, skip }),
this.prisma.uploadedFile.count({ where }),
]);
const usedUrls = await this.getUsedFileUrls();
const items = files.map((f) => ({ ...f, inUse: usedUrls.has(f.url) }));
return jsonResponse({ items, total, page: skip / take + 1, pageSize: take });
}
@Delete('files/unused')
@RequirePermissions(P.content)
async purgeUnusedFiles(@CurrentUser('id') operatorId: bigint) {
const all = await this.prisma.uploadedFile.findMany();
const usedUrls = await this.getUsedFileUrls();
const unused = all.filter((f) => !usedUrls.has(f.url));
const root = getUploadRoot();
let deleted = 0;
for (const f of unused) {
try {
await unlink(join(root, f.category, f.filename));
} catch { /* file already missing from disk */ }
await this.prisma.uploadedFile.delete({ where: { id: f.id } });
deleted++;
}
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'PURGE_UNUSED_FILES',
module: 'MEDIA',
afterData: JSON.stringify({ deleted }),
});
return jsonResponse({ deleted });
}
@Delete('files/:id')
@RequirePermissions(P.content, P.matches)
async deleteFile(
@CurrentUser() user: AdminUploadUser,
@Param('id') id: string,
) {
const record = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!record) throw new BadRequestException('File not found');
assertUploadPermission(user, record.category as any);
const root = getUploadRoot();
try {
await unlink(join(root, record.category, record.filename));
} catch { /* already gone */ }
await this.prisma.uploadedFile.delete({ where: { id } });
return jsonResponse({ ok: true });
}
private async getUsedFileUrls(): Promise<Set<string>> {
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
this.prisma.league.findMany({ select: { logoUrl: true } }),
this.prisma.team.findMany({ select: { logoUrl: true } }),
this.prisma.userPreference.findMany({ select: { avatarKey: true } }),
]);
const urls = new Set<string>();
for (const r of ctRows) if (r.imageUrl) urls.add(r.imageUrl);
for (const r of leagueRows) if (r.logoUrl) urls.add(r.logoUrl);
for (const r of teamRows) if (r.logoUrl) urls.add(r.logoUrl);
for (const r of prefRows) if (r.avatarKey) urls.add(r.avatarKey);
return urls;
}
@Get('contents')

View File

@@ -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';

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