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,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 };
}
}