feat(db): A5 审计表分区、append-only 与种子

audit_logs 按月 RANGE 分区、复合主键与 6 组索引;ensure_partitions 幂等建分区;UPDATE/DELETE/TRUNCATE 触发器与 lingxi_app 最小权限;audit_export_jobs;种子新增 3 项配置与 6 条演示审计。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:26:20 +08:00
1 parent 408dc5e81b
commit 6215f204ce
6 files changed
+441 -4

No files matched your search

+176
View File
@@ -898,6 +898,181 @@ async function seedDicts(): Promise<void> {
);
}
/**
* A5 审计演示数据(幂等):固定 id + 当前时间附近,写入 6 条覆盖登录 / 客户 / 权限 / 配置 / 审计查看 / 导出。
* 注意:audit_logs 是 append-only(DB 层禁 UPDATE / DELETE),因此只能“存在即跳过”,不能 upsert 覆盖。
* 集成测试使用独立前缀与筛选条件,不受这些演示数据影响。
*/
const AUDIT_DEMO_IDS = [
'01HZZZZZZZZZZZZZZZZZZZZZ01',
'01HZZZZZZZZZZZZZZZZZZZZZ02',
'01HZZZZZZZZZZZZZZZZZZZZZ03',
'01HZZZZZZZZZZZZZZZZZZZZZ04',
'01HZZZZZZZZZZZZZZZZZZZZZ05',
'01HZZZZZZZZZZZZZZZZZZZZZ06',
] as const;
// eslint-disable-next-line max-lines-per-function, complexity -- 6 条演示数据集中定义便于核对
async function seedAuditDemos(): Promise<void> {
const admin = await prisma.user.findUnique({
where: { account: 'admin' },
select: { id: true, name: true, account: true },
});
if (admin === null) {
console.log('[seed] 跳过 A5 审计演示数据(admin 账号不存在)');
return;
}
const customer = await prisma.customer.findFirst({
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
select: { id: true, name: true },
});
const role = await prisma.role.findFirst({
where: { code: 'platform_admin' },
select: { id: true, name: true },
});
const now = Date.now();
const demos = [
{
id: AUDIT_DEMO_IDS[0],
occurredAt: new Date(now - 5 * 60_000),
actionCode: 'identity.auth.login',
category: 'auth',
targetType: 'user',
targetId: admin.id,
targetName: admin.name,
result: 'success',
httpMethod: 'POST',
httpPath: '/api/v1/auth/login',
traceId: 'seed-audit-login',
beforeJson: null,
afterJson: { account: admin.account },
changesJson: null,
},
{
id: AUDIT_DEMO_IDS[1],
occurredAt: new Date(now - 4 * 60_000),
actionCode: 'client.customer.update',
category: 'client',
targetType: 'customer',
targetId: customer?.id ?? null,
targetName: customer?.name ?? '示例客户',
customerId: customer?.id ?? null,
customerName: customer?.name ?? '示例客户',
result: 'success',
httpMethod: 'PATCH',
httpPath: customer === null ? '/api/v1/customers' : `/api/v1/customers/${customer.id}`,
traceId: 'seed-audit-customer',
beforeJson: { remark: '旧备注' },
afterJson: { remark: 'A5 审计演示:客户备注已更新' },
changesJson: null,
},
{
id: AUDIT_DEMO_IDS[2],
occurredAt: new Date(now - 3 * 60_000),
actionCode: 'identity.role.update',
category: 'permission',
targetType: 'role',
targetId: role?.id ?? null,
targetName: role?.name ?? '示例角色',
result: 'success',
httpMethod: 'PATCH',
httpPath: role === null ? '/api/v1/roles' : `/api/v1/roles/${role.id}`,
traceId: 'seed-audit-role',
beforeJson: { permissions: ['user:view'] },
afterJson: { permissions: ['user:view', 'role:view'] },
changesJson: { permissions: { added: ['role:view'], removed: [] } },
},
{
id: AUDIT_DEMO_IDS[3],
occurredAt: new Date(now - 2 * 60_000),
actionCode: 'platform.system_config.update',
category: 'config',
targetType: 'system_config',
targetId: 'platform.page.default_limit',
targetName: '列表默认条数',
result: 'success',
httpMethod: 'PUT',
httpPath: '/api/v1/system/configs',
traceId: 'seed-audit-config',
beforeJson: { 'platform.page.default_limit': 20 },
afterJson: { 'platform.page.default_limit': 20 },
changesJson: [{ key: 'platform.page.default_limit', oldValue: 20, newValue: 20 }],
},
{
id: AUDIT_DEMO_IDS[4],
occurredAt: new Date(now - 60_000),
actionCode: 'audit.log.view',
category: 'sensitive',
targetType: 'audit_log',
targetId: null,
targetName: null,
result: 'success',
httpMethod: 'GET',
httpPath: '/api/v1/audit-logs',
traceId: 'seed-audit-view',
beforeJson: null,
afterJson: null,
changesJson: null,
},
{
id: AUDIT_DEMO_IDS[5],
occurredAt: new Date(now - 30_000),
actionCode: 'audit.export',
category: 'export',
targetType: 'audit_export',
targetId: null,
targetName: '审计导出 CSV',
result: 'success',
httpMethod: 'POST',
httpPath: '/api/v1/audit-logs/export',
traceId: 'seed-audit-export',
beforeJson: null,
afterJson: { format: 'csv', filters: { result: 'failure' }, rowCount: 120 },
changesJson: null,
},
];
let created = 0;
for (const demo of demos) {
const existing = await prisma.auditLog.findFirst({
where: { id: demo.id },
select: { id: true },
});
if (existing !== null) continue;
await prisma.auditLog.create({
data: {
id: demo.id,
occurredAt: demo.occurredAt,
actorId: admin.id,
actorName: admin.name,
actorAccount: admin.account,
actionCode: demo.actionCode,
category: demo.category,
targetType: demo.targetType,
targetId: demo.targetId,
targetName: demo.targetName,
customerId: 'customerId' in demo ? demo.customerId : null,
customerName: 'customerName' in demo ? demo.customerName : null,
result: demo.result,
httpMethod: demo.httpMethod,
httpPath: demo.httpPath,
traceId: demo.traceId,
ip: '127.0.0.1',
userAgent: 'seed',
beforeJson: demo.beforeJson ?? undefined,
afterJson: demo.afterJson ?? undefined,
changesJson: demo.changesJson ?? undefined,
},
});
created += 1;
}
console.log(
`[seed] A5 审计演示数据就绪:新增 ${String(created)} 条 / 共 ${String(AUDIT_DEMO_IDS.length)} 条(幂等)`,
);
}
async function main(): Promise<void> {
await seedConfigs();
await seedPermissions();
@@ -907,6 +1082,7 @@ async function main(): Promise<void> {
await seedDepartmentLeaders();
await seedClient();
await seedDicts();
await seedAuditDemos();
}
void main()