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);
|
||||
}
|
||||
|
||||
194
apps/api/src/domains/identity/admin-staff.service.ts
Normal file
194
apps/api/src/domains/identity/admin-staff.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
apps/api/src/domains/operations/audit/audit-list-scope.ts
Normal file
45
apps/api/src/domains/operations/audit/audit-list-scope.ts
Normal 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non–super-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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
|
||||
|
||||
export const DEMO_ACCOUNTS = [
|
||||
'admin / Admin@123',
|
||||
'matchadmin / MatchAdmin@123',
|
||||
'financeadmin / FinanceAdmin@123',
|
||||
'support1 / Support@123',
|
||||
'agent1 / Agent@123',
|
||||
'player1 / Player@123',
|
||||
] as const;
|
||||
@@ -40,10 +43,11 @@ async function seedRolesAndConfig() {
|
||||
});
|
||||
|
||||
const permCodes = [
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
|
||||
'users.create', 'users.view', 'users.reset_password', 'agents.create', 'agents.view', 'agents.credit',
|
||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
|
||||
'bets.view', 'settings.manage', 'settings.reset_database', 'audit.view',
|
||||
'deposit.manage', 'deposit.review',
|
||||
];
|
||||
|
||||
const permIds = new Map<string, bigint>();
|
||||
@@ -79,14 +83,17 @@ async function seedRolesAndConfig() {
|
||||
return role;
|
||||
}
|
||||
|
||||
await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||
'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
|
||||
const matchAdminRole = await ensureRole('MATCH_ADMIN', 'Match Admin', [
|
||||
'matches.manage', 'settlement.confirm', 'content.manage', 'bets.view', 'reports.view', 'audit.view',
|
||||
]);
|
||||
await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
|
||||
'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
|
||||
const financeAdminRole = await ensureRole('FINANCE_ADMIN', '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',
|
||||
]);
|
||||
await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
|
||||
const supportRole = await ensureRole('SUPPORT', 'Support', [
|
||||
'users.view', 'users.reset_password', 'bets.view', 'reports.view', 'audit.view',
|
||||
]);
|
||||
|
||||
const defaultBettingLimits = [
|
||||
['bet.min_stake', '1', '最小单注金额'],
|
||||
@@ -104,7 +111,7 @@ async function seedRolesAndConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
return superAdminRole;
|
||||
return { superAdminRole, matchAdminRole, financeAdminRole, supportRole };
|
||||
}
|
||||
|
||||
async function seedAdminUser(superAdminRole: { id: bigint }) {
|
||||
@@ -122,6 +129,51 @@ async function seedAdminUser(superAdminRole: { id: bigint }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedStaffDemoUser(
|
||||
username: string,
|
||||
password: string,
|
||||
roleId: bigint,
|
||||
) {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { username },
|
||||
create: {
|
||||
username,
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: hash } },
|
||||
adminRole: { create: { roleId } },
|
||||
},
|
||||
update: {
|
||||
userType: 'ADMIN',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
await prisma.userAuth.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, passwordHash: hash },
|
||||
update: {
|
||||
passwordHash: hash,
|
||||
loginFailCount: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
await prisma.adminUserRole.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, roleId },
|
||||
update: { roleId },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedDevStaffUsers(roles: {
|
||||
matchAdminRole: { id: bigint };
|
||||
financeAdminRole: { id: bigint };
|
||||
supportRole: { id: bigint };
|
||||
}) {
|
||||
await seedStaffDemoUser('matchadmin', 'MatchAdmin@123', roles.matchAdminRole.id);
|
||||
await seedStaffDemoUser('financeadmin', 'FinanceAdmin@123', roles.financeAdminRole.id);
|
||||
await seedStaffDemoUser('support1', 'Support@123', roles.supportRole.id);
|
||||
}
|
||||
|
||||
async function seedDevDemoUsers() {
|
||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||
@@ -356,10 +408,11 @@ export async function runSeed(client: PrismaClient, options?: RunSeedOptions) {
|
||||
const mode = resolveSeedMode(options);
|
||||
console.log(`Seeding database (mode=${mode})...`);
|
||||
|
||||
const superAdminRole = await seedRolesAndConfig();
|
||||
await seedAdminUser(superAdminRole);
|
||||
const roles = await seedRolesAndConfig();
|
||||
await seedAdminUser(roles.superAdminRole);
|
||||
|
||||
if (mode === 'dev') {
|
||||
await seedDevStaffUsers(roles);
|
||||
await seedDevDemoUsers();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user