feat: 管理端 RBAC 权限体系与员工管理

新增多角色权限控制(赛事/财务/客服管理员),支持员工 CRUD、路由菜单按权限显隐、审计日志范围过滤;登录返回角色与权限列表。玩家端赛事列表增加静默刷新避免图片闪烁。Seed 补充演示员工账号与充值相关权限。附带 RBAC/审计范围单元测试及 UAT 文档更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:52:39 +08:00
parent be5b4a4921
commit 567ec9ec8a
44 changed files with 1717 additions and 125 deletions

View File

@@ -0,0 +1,194 @@
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
const STAFF_ROLE_CODES = ['SUPER_ADMIN', 'MATCH_ADMIN', 'FINANCE_ADMIN', 'SUPPORT'] as const;
function generatePassword(length = 10): string {
const raw = randomBytes(12).toString('base64url').replace(/[^a-zA-Z0-9]/g, '');
const base = (raw + 'Aa1').slice(0, Math.max(8, length));
return base;
}
@Injectable()
export class AdminStaffService {
constructor(private prisma: PrismaService) {}
async listRoles() {
const roles = await this.prisma.role.findMany({
where: { code: { in: [...STAFF_ROLE_CODES] } },
orderBy: { code: 'asc' },
select: { id: true, code: true, name: true },
});
return roles.map((r) => ({
id: r.id.toString(),
code: r.code,
name: r.name,
}));
}
async listStaff(page = 1, pageSize = 20, keyword?: string) {
const where = {
userType: 'ADMIN' as const,
deletedAt: null,
...(keyword?.trim()
? { username: { contains: keyword.trim(), mode: 'insensitive' as const } }
: {}),
};
const [total, items] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
auth: { select: { lastLoginAt: true } },
adminRole: { include: { role: { select: { code: true, name: true } } } },
},
}),
]);
return {
total,
items: items.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
role: u.adminRole?.role?.code ?? null,
roleName: u.adminRole?.role?.name ?? null,
lastLoginAt: u.auth?.lastLoginAt ?? null,
createdAt: u.createdAt,
})),
};
}
async createStaff(data: { username: string; password: string; roleCode: string }) {
const username = data.username.trim();
if (!username) throw appBadRequest('USERNAME_REQUIRED');
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
throw appBadRequest('INVALID_ROLE');
}
const existing = await this.prisma.user.findUnique({ where: { username } });
if (existing) throw appBadRequest('USERNAME_TAKEN');
const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
if (!role) throw appBadRequest('INVALID_ROLE');
const hash = await bcrypt.hash(data.password, 10);
const user = await this.prisma.user.create({
data: {
username,
userType: 'ADMIN',
auth: { create: { passwordHash: hash } },
adminRole: { create: { roleId: role.id } },
},
include: {
adminRole: { include: { role: { select: { code: true, name: true } } } },
},
});
await ensureUserInviteCode(this.prisma, user.id);
return {
id: user.id.toString(),
username: user.username,
status: user.status,
role: user.adminRole?.role?.code ?? null,
roleName: user.adminRole?.role?.name ?? null,
};
}
async updateStaff(
staffId: bigint,
data: { status?: string; roleCode?: string; password?: string },
) {
const user = await this.prisma.user.findFirst({
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
include: { auth: true, adminRole: true },
});
if (!user) throw appNotFound('USER_NOT_FOUND');
if (data.status !== undefined) {
if (!['ACTIVE', 'SUSPENDED', 'DISABLED'].includes(data.status)) {
throw appBadRequest('INVALID_STATUS');
}
await this.prisma.user.update({
where: { id: staffId },
data: { status: data.status },
});
}
if (data.roleCode !== undefined) {
if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
throw appBadRequest('INVALID_ROLE');
}
const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
if (!role) throw appBadRequest('INVALID_ROLE');
if (user.adminRole) {
await this.prisma.adminUserRole.update({
where: { userId: staffId },
data: { roleId: role.id },
});
} else {
await this.prisma.adminUserRole.create({
data: { userId: staffId, roleId: role.id },
});
}
}
let plainPassword: string | undefined;
if (data.password !== undefined) {
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
plainPassword = data.password;
const hash = await bcrypt.hash(data.password, 10);
await this.prisma.userAuth.update({
where: { userId: staffId },
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
});
}
const refreshed = await this.prisma.user.findUnique({
where: { id: staffId },
include: { adminRole: { include: { role: { select: { code: true, name: true } } } } },
});
return {
id: refreshed!.id.toString(),
username: refreshed!.username,
status: refreshed!.status,
role: refreshed!.adminRole?.role?.code ?? null,
roleName: refreshed!.adminRole?.role?.name ?? null,
...(plainPassword ? { password: plainPassword } : {}),
};
}
async resetPlayerPassword(playerId: bigint, password?: string) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true },
});
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
const nextPassword = password?.trim() || generatePassword();
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
const hash = await bcrypt.hash(nextPassword, 10);
await this.prisma.userAuth.update({
where: { userId: playerId },
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
});
await this.prisma.userPreference.upsert({
where: { userId: playerId },
create: { userId: playerId, managedPassword: nextPassword },
update: { managedPassword: nextPassword },
});
return { password: nextPassword };
}
}

View File

@@ -171,6 +171,7 @@ export class AuthController {
@CurrentUser('userType') userType: string,
@CurrentUser('locale') locale: string | undefined,
@CurrentUser('role') role: string | undefined,
@CurrentUser('permissions') permissions: string[] | undefined,
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
) {
const level = userType === 'AGENT' ? agentLevel ?? null : null;
@@ -194,6 +195,7 @@ export class AuthController {
userType,
locale,
role,
permissions: userType === 'ADMIN' ? permissions ?? [] : undefined,
agentLevel: level,
maxAgentLevel,
canManageSubAgents,

View File

@@ -98,7 +98,18 @@ export class AuthService {
} else {
user = await this.prisma.user.findUnique({
where: { username: username.trim() },
include: { auth: true, adminRole: { include: { role: true } } },
include: {
auth: true,
adminRole: {
include: {
role: {
include: {
permissions: { include: { permission: true } },
},
},
},
},
},
});
}
@@ -178,6 +189,15 @@ export class AuthService {
const token = this.jwt.sign(payload, { expiresIn });
const rolePerms = user.adminRole?.role as
| { permissions?: Array<{ permission: { code: string } }> }
| undefined
| null;
const adminPermissions =
user.userType === 'ADMIN'
? (rolePerms?.permissions?.map((rp) => rp.permission.code) ?? [])
: undefined;
return {
token,
user: {
@@ -187,6 +207,7 @@ export class AuthService {
locale: user.locale,
role: user.adminRole?.role?.code,
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
...(adminPermissions ? { permissions: adminPermissions } : {}),
},
};
}

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { AdminStaffService } from './admin-staff.service';
import { AgentsModule } from '../agent/agents.module';
import { CashbackModule } from '../operations/cashback/cashback.module';
@Module({
imports: [AgentsModule, CashbackModule],
providers: [UsersService],
exports: [UsersService],
providers: [UsersService, AdminStaffService],
exports: [UsersService, AdminStaffService],
})
export class UsersModule {}

View File

@@ -0,0 +1,20 @@
import { isAuditListUnrestricted } from './audit-list-scope';
describe('audit-list-scope', () => {
describe('isAuditListUnrestricted', () => {
it('allows SUPER_ADMIN to see all logs', () => {
expect(isAuditListUnrestricted('SUPER_ADMIN')).toBe(true);
});
it('scopes MATCH_ADMIN, FINANCE_ADMIN, and SUPPORT', () => {
expect(isAuditListUnrestricted('MATCH_ADMIN')).toBe(false);
expect(isAuditListUnrestricted('FINANCE_ADMIN')).toBe(false);
expect(isAuditListUnrestricted('SUPPORT')).toBe(false);
});
it('scopes unknown or missing role', () => {
expect(isAuditListUnrestricted(undefined)).toBe(false);
expect(isAuditListUnrestricted(null)).toBe(false);
});
});
});

View File

@@ -0,0 +1,45 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
export type AuditViewerScope = {
viewerId: bigint;
viewerRole?: string | null;
viewerUserType: string;
};
/** SUPER_ADMIN (and unrestricted legacy admins) see all audit rows. */
export function isAuditListUnrestricted(role?: string | null): boolean {
return role === 'SUPER_ADMIN';
}
/**
* Nonsuper-admin staff may only see audit rows whose operator shares their admin role,
* or (for SUPPORT) player-initiated identity actions.
*/
export async function buildAuditListScopeWhere(
prisma: PrismaService,
scope: AuditViewerScope,
): Promise<Prisma.AuditLogWhereInput | undefined> {
if (isAuditListUnrestricted(scope.viewerRole)) {
return undefined;
}
if (!scope.viewerRole) {
return { operatorId: scope.viewerId };
}
const sameRoleUsers = await prisma.user.findMany({
where: { adminRole: { role: { code: scope.viewerRole } } },
select: { id: true },
});
const sameRoleOperatorIds = sameRoleUsers.map((u) => u.id);
const or: Prisma.AuditLogWhereInput[] = [{ operatorId: { in: sameRoleOperatorIds } }];
// SUPPORT handles player account recovery; include player-initiated audit entries.
if (scope.viewerRole === 'SUPPORT') {
or.push({ operatorType: 'PLAYER' });
}
return { OR: or };
}

View File

@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { AuditViewerScope, buildAuditListScopeWhere } from './audit-list-scope';
@Injectable()
export class AuditService {
@@ -31,10 +33,19 @@ export class AuditService {
});
}
async list(page = 1, pageSize = 10, module?: string) {
async list(
page = 1,
pageSize = 10,
module?: string,
viewer?: AuditViewerScope,
) {
const skip = (page - 1) * pageSize;
const where = module ? { module } : {};
const [items, total] = await Promise.all([
const scopeWhere = viewer ? await buildAuditListScopeWhere(this.prisma, viewer) : undefined;
const where: Prisma.AuditLogWhereInput = {
...(module ? { module } : {}),
...(scopeWhere ?? {}),
};
const [rows, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
@@ -43,6 +54,42 @@ export class AuditService {
}),
this.prisma.auditLog.count({ where }),
]);
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,
username: true,
userType: true,
adminRole: { select: { role: { select: { code: true } } } },
},
})
: [];
const operatorById = new Map(operators.map((u) => [u.id.toString(), u]));
const items = rows.map((row) => {
const op = row.operatorId ? operatorById.get(row.operatorId.toString()) : null;
return {
id: row.id.toString(),
action: row.action,
module: row.module,
targetType: row.targetType,
targetId: row.targetId,
operatorId: row.operatorId?.toString() ?? null,
operatorUsername: op?.username ?? null,
operatorRole: op?.adminRole?.role?.code ?? null,
operatorUserType: op?.userType ?? null,
operatorType: row.operatorType,
ipAddress: row.ipAddress,
createdAt: row.createdAt,
};
});
return { items, total, page, pageSize };
}
}