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

@@ -3,6 +3,7 @@ export const P = {
reports: 'reports.view',
usersView: 'users.view',
usersCreate: 'users.create',
usersResetPassword: 'users.reset_password',
settings: 'settings.manage',
agentsView: 'agents.view',
agentsCreate: 'agents.create',

View File

@@ -0,0 +1,100 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from '../../domains/identity/guards';
import { isAuditListUnrestricted } from '../../domains/operations/audit/audit-list-scope';
import { PERMISSIONS_KEY } from '../../shared/common/decorators';
/** Mirrors run-seed.ts role permission assignments (SEC010SEC012). */
const ROLE_PERMISSIONS: Record<string, string[]> = {
SUPER_ADMIN: ['*'],
MATCH_ADMIN: [
'matches.manage',
'settlement.confirm',
'content.manage',
'bets.view',
'reports.view',
'audit.view',
],
FINANCE_ADMIN: [
'wallet.deposit',
'wallet.withdraw',
'cashback.confirm',
'agents.view',
'agents.credit',
'users.view',
'users.create',
'deposit.manage',
'deposit.review',
'reports.view',
'bets.view',
'audit.view',
],
SUPPORT: ['users.view', 'users.reset_password', 'bets.view', 'reports.view', 'audit.view'],
};
function mockContext(user: Record<string, unknown>): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
getHandler: () => ({}),
getClass: () => ({}),
} as ExecutionContext;
}
function guardAllows(user: Record<string, unknown>, ...required: string[]): boolean {
const reflector = {
getAllAndOverride: (key: string) => (key === PERMISSIONS_KEY ? required : undefined),
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
try {
return guard.canActivate(mockContext(user));
} catch {
return false;
}
}
function userWithRole(role: string) {
return {
userType: 'ADMIN',
role,
permissions: ROLE_PERMISSIONS[role] ?? [],
};
}
describe('Admin RBAC (SEC010SEC012)', () => {
it('SEC010: SUPPORT cannot perform wallet deposit', () => {
const user = userWithRole('SUPPORT');
expect(guardAllows(user, 'wallet.deposit')).toBe(false);
expect(guardAllows(user, 'users.view')).toBe(true);
expect(guardAllows(user, 'users.reset_password')).toBe(true);
});
it('SEC011: FINANCE_ADMIN cannot manage matches', () => {
const user = userWithRole('FINANCE_ADMIN');
expect(guardAllows(user, 'matches.manage')).toBe(false);
expect(guardAllows(user, 'wallet.deposit')).toBe(true);
expect(guardAllows(user, 'agents.credit')).toBe(true);
});
it('SEC012: MATCH_ADMIN cannot perform wallet deposit', () => {
const user = userWithRole('MATCH_ADMIN');
expect(guardAllows(user, 'wallet.deposit')).toBe(false);
expect(guardAllows(user, 'settlement.confirm')).toBe(true);
expect(guardAllows(user, 'content.manage')).toBe(true);
expect(guardAllows(user, 'settlement.resettle')).toBe(false);
});
it('SUPER_ADMIN bypasses permission checks', () => {
const user = userWithRole('SUPER_ADMIN');
expect(guardAllows(user, 'wallet.deposit')).toBe(true);
expect(guardAllows(user, 'matches.manage')).toBe(true);
expect(guardAllows(user, 'settings.manage')).toBe(true);
});
it('audit list scope: only SUPER_ADMIN is unrestricted', () => {
expect(isAuditListUnrestricted('SUPER_ADMIN')).toBe(true);
expect(isAuditListUnrestricted('FINANCE_ADMIN')).toBe(false);
expect(isAuditListUnrestricted('MATCH_ADMIN')).toBe(false);
});
});

View File

@@ -24,6 +24,7 @@ import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { AdminStaffService } from '../../domains/identity/admin-staff.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
@@ -255,6 +256,40 @@ class UpdatePlayerAdminDto {
cashbackRate?: number | null;
}
class CreateStaffDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
roleCode!: string;
}
class UpdateStaffDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED', 'DISABLED'])
status?: string;
@IsOptional()
@IsString()
roleCode?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class ResetPlayerPasswordDto {
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class PlatformDirectCashbackSettingsDto {
@IsOptional()
@IsNumber()
@@ -1168,6 +1203,7 @@ export class AdminController {
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
private staff: AdminStaffService,
) {}
@Get('dashboard')
@@ -1178,7 +1214,7 @@ export class AdminController {
}
@Get('users/page-init')
@RequirePermissions(P.agentsView)
@RequirePermissions(P.agentsView, P.usersView)
async getUsersPageInit() {
const [
playerSettings,
@@ -1380,6 +1416,84 @@ export class AdminController {
return jsonResponse(detail);
}
@Post('users/:id/reset-password')
@RequirePermissions(P.usersResetPassword)
async resetPlayerPassword(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ResetPlayerPasswordDto,
) {
const { password } = await this.staff.resetPlayerPassword(BigInt(id), dto.password);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RESET_PLAYER_PASSWORD',
module: 'USERS',
targetId: id,
});
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
return jsonResponse({ ...detail, password });
}
@Get('staff/roles')
@RequirePermissions(P.settings)
async listStaffRoles() {
const roles = await this.staff.listRoles();
return jsonResponse(roles);
}
@Get('staff')
@RequirePermissions(P.settings)
async listStaff(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
const result = await this.staff.listStaff(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 20,
keyword,
);
return jsonResponse(result);
}
@Post('staff')
@RequirePermissions(P.settings)
async createStaff(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateStaffDto,
) {
const created = await this.staff.createStaff(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_STAFF',
module: 'STAFF',
targetId: created.id,
afterData: { username: created.username, role: created.role },
});
return jsonResponse(created);
}
@Patch('staff/:id')
@RequirePermissions(P.settings)
async updateStaff(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateStaffDto,
) {
const updated = await this.staff.updateStaff(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_STAFF',
module: 'STAFF',
targetId: id,
afterData: JSON.stringify({ status: dto.status, roleCode: dto.roleCode }),
});
return jsonResponse(updated);
}
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@@ -2803,6 +2917,9 @@ export class AdminController {
@Get('audit-logs')
@RequirePermissions(P.audit)
async auditLogs(
@CurrentUser('id') viewerId: bigint,
@CurrentUser('role') viewerRole: string | undefined,
@CurrentUser('userType') viewerUserType: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('module') module?: string,
@@ -2811,6 +2928,7 @@ export class AdminController {
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
module || undefined,
{ viewerId, viewerRole, viewerUserType },
);
return jsonResponse(result);
}