feat: 管理端 RBAC 权限体系与员工管理
新增多角色权限控制(赛事/财务/客服管理员),支持员工 CRUD、路由菜单按权限显隐、审计日志范围过滤;登录返回角色与权限列表。玩家端赛事列表增加静默刷新避免图片闪烁。Seed 补充演示员工账号与充值相关权限。附带 RBAC/审计范围单元测试及 UAT 文档更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
100
apps/api/src/applications/admin/admin-rbac.spec.ts
Normal file
100
apps/api/src/applications/admin/admin-rbac.spec.ts
Normal 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 (SEC010–SEC012). */
|
||||
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 (SEC010–SEC012)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user