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