feat(shared): A5 审计契约、动作目录与配置
新增审计动作目录(10 分类 / 54 动作)、前后值 diff 纯函数、列表 / 详情 / 导出 Zod 契约;system_configs 增加 audit 分组与保留期 / 导出上限 / 日志平台地址 3 项配置。
This commit is contained in:
1 parent
a5a2914214
commit
408dc5e81b
9 files changed
+1211
No files matched your search
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIT_ACTION_CATALOG,
|
||||||
|
AUDIT_ACTION_CODES,
|
||||||
|
AUDIT_CATEGORIES,
|
||||||
|
AUDIT_CATEGORY_KEYS,
|
||||||
|
AUDIT_TARGET_TYPES,
|
||||||
|
auditActionMeta,
|
||||||
|
auditCategoryLabel,
|
||||||
|
isKnownAuditAction,
|
||||||
|
} from './audit-actions.js';
|
||||||
|
|
||||||
|
/** A1–A4 已落地的 36 个 @Audit(...) code(A5 起必须全部可在目录中查到中文名) */
|
||||||
|
const EXISTING_AUDIT_CODES = [
|
||||||
|
'catalog.dict_item.create',
|
||||||
|
'catalog.dict_item.update',
|
||||||
|
'catalog.dict_type.create',
|
||||||
|
'catalog.dict_type.delete',
|
||||||
|
'catalog.dict_type.status',
|
||||||
|
'catalog.dict_type.update',
|
||||||
|
'client.customer.apply_industry_pack',
|
||||||
|
'client.customer.create',
|
||||||
|
'client.customer.groups',
|
||||||
|
'client.customer.status',
|
||||||
|
'client.customer.transfer',
|
||||||
|
'client.customer.update',
|
||||||
|
'client.customer_field.apply',
|
||||||
|
'client.customer_field.approve',
|
||||||
|
'client.customer_group.create',
|
||||||
|
'client.customer_group.delete',
|
||||||
|
'client.customer_group.members',
|
||||||
|
'client.customer_group.update',
|
||||||
|
'client.industry.create',
|
||||||
|
'client.industry.delete',
|
||||||
|
'client.industry.status',
|
||||||
|
'client.industry.update',
|
||||||
|
'identity.auth.change-password',
|
||||||
|
'identity.department.create',
|
||||||
|
'identity.department.disable',
|
||||||
|
'identity.department.enable',
|
||||||
|
'identity.department.update',
|
||||||
|
'identity.role.create',
|
||||||
|
'identity.role.delete',
|
||||||
|
'identity.role.update',
|
||||||
|
'identity.user.create',
|
||||||
|
'identity.user.disable',
|
||||||
|
'identity.user.enable',
|
||||||
|
'identity.user.transfer_customers',
|
||||||
|
'identity.user.update',
|
||||||
|
'platform.system_config.update',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NEW_AUDIT_CODES = [
|
||||||
|
'identity.auth.login',
|
||||||
|
'identity.auth.logout',
|
||||||
|
'audit.log.view',
|
||||||
|
'audit.export',
|
||||||
|
'audit.export.download',
|
||||||
|
'sensitive.cost.view',
|
||||||
|
'sensitive.asset_pack.view',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('审计动作目录(A5-BE-02)', () => {
|
||||||
|
it('36 个既有 @Audit code 与 A5 新增 code 全部登记', () => {
|
||||||
|
expect(EXISTING_AUDIT_CODES).toHaveLength(36);
|
||||||
|
for (const code of [...EXISTING_AUDIT_CODES, ...NEW_AUDIT_CODES]) {
|
||||||
|
expect(isKnownAuditAction(code)).toBe(true);
|
||||||
|
expect(AUDIT_ACTION_CATALOG[code]?.code).toBe(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('目录条目分类 / 目标类型 / 中文名合法', () => {
|
||||||
|
const categoryKeys = new Set<string>(AUDIT_CATEGORY_KEYS);
|
||||||
|
const targetTypes = new Set<string>(AUDIT_TARGET_TYPES);
|
||||||
|
for (const code of AUDIT_ACTION_CODES) {
|
||||||
|
const meta = AUDIT_ACTION_CATALOG[code];
|
||||||
|
expect(meta).toBeDefined();
|
||||||
|
if (meta === undefined) continue;
|
||||||
|
expect(meta.label.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(categoryKeys.has(meta.category)).toBe(true);
|
||||||
|
if (meta.targetType !== undefined) {
|
||||||
|
expect(targetTypes.has(meta.targetType)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限变更 / 客户 / 字典 / 配置 / 导出 / 敏感访问分类正确', () => {
|
||||||
|
expect(auditActionMeta('identity.role.update').category).toBe('permission');
|
||||||
|
expect(auditActionMeta('client.customer.create').category).toBe('client');
|
||||||
|
expect(auditActionMeta('catalog.dict_item.update').category).toBe('catalog');
|
||||||
|
expect(auditActionMeta('platform.system_config.update').category).toBe('config');
|
||||||
|
expect(auditActionMeta('audit.export').category).toBe('export');
|
||||||
|
expect(auditActionMeta('audit.log.view').category).toBe('sensitive');
|
||||||
|
expect(auditActionMeta('audit.log.view').sensitive).toBe(true);
|
||||||
|
expect(auditActionMeta('identity.auth.login').category).toBe('auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知 code 兜底 label = code、分类 other,不抛错', () => {
|
||||||
|
const meta = auditActionMeta('unknown.action.code');
|
||||||
|
expect(meta.label).toBe('unknown.action.code');
|
||||||
|
expect(meta.category).toBe('other');
|
||||||
|
expect(meta.targetType).toBeUndefined();
|
||||||
|
expect(auditCategoryLabel('other')).toBe('其他');
|
||||||
|
expect(auditCategoryLabel('not-exist')).toBe('not-exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分类目录包含 9 类业务分类 + 兜底,key 唯一', () => {
|
||||||
|
expect(AUDIT_CATEGORIES).toHaveLength(10);
|
||||||
|
expect(new Set(AUDIT_CATEGORIES.map((item) => item.key)).size).toBe(10);
|
||||||
|
expect(AUDIT_CATEGORIES.map((item) => item.key)).toEqual([
|
||||||
|
'auth',
|
||||||
|
'permission',
|
||||||
|
'client',
|
||||||
|
'catalog',
|
||||||
|
'config',
|
||||||
|
'export',
|
||||||
|
'task',
|
||||||
|
'asset',
|
||||||
|
'sensitive',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* 审计动作目录(A5-BE-02 / 工程规范 08 LOG-12)
|
||||||
|
*
|
||||||
|
* - `AUDIT_CATEGORIES`:审计分类(前端筛选与色彩映射),共 9 类业务分类 + 1 类兜底;
|
||||||
|
* - `AUDIT_ACTION_CATALOG`:动作 code → 中文名 / 分类 / 默认 targetType / 是否敏感;
|
||||||
|
* - 动作中文名与分类**只在本文件维护**,`audit_logs` 表只存 code,避免字典漂移。
|
||||||
|
*
|
||||||
|
* 约束:新增 `@Audit('...')` 必须同步登记本目录,否则会走兜底(label = code),
|
||||||
|
* `audit-actions.spec.ts` 会扫描源码断言一致性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AUDIT_CATEGORIES = [
|
||||||
|
{ key: 'auth', label: '登录与会话' },
|
||||||
|
{ key: 'permission', label: '权限与角色' },
|
||||||
|
{ key: 'client', label: '客户与行业' },
|
||||||
|
{ key: 'catalog', label: '字典' },
|
||||||
|
{ key: 'config', label: '系统配置' },
|
||||||
|
{ key: 'export', label: '导出' },
|
||||||
|
{ key: 'task', label: '任务' },
|
||||||
|
{ key: 'asset', label: '资产' },
|
||||||
|
{ key: 'sensitive', label: '敏感访问' },
|
||||||
|
/** 未知动作的兜底分类(新增动作未登记时使用,避免前端筛选出现空分类) */
|
||||||
|
{ key: 'other', label: '其他' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AuditCategoryKey = (typeof AUDIT_CATEGORIES)[number]['key'];
|
||||||
|
|
||||||
|
export const AUDIT_CATEGORY_KEYS = AUDIT_CATEGORIES.map((item) => item.key) as [
|
||||||
|
AuditCategoryKey,
|
||||||
|
...AuditCategoryKey[],
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUDIT_CATEGORY_LABEL_MAP: ReadonlyMap<string, string> = new Map(
|
||||||
|
AUDIT_CATEGORIES.map((item) => [item.key, item.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function auditCategoryLabel(category: string): string {
|
||||||
|
return AUDIT_CATEGORY_LABEL_MAP.get(category) ?? category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一对象类型白名单(target_type),前端按此渲染对象列;未登记类型只做文本展示 */
|
||||||
|
export const AUDIT_TARGET_TYPES = [
|
||||||
|
'user',
|
||||||
|
'department',
|
||||||
|
'role',
|
||||||
|
'customer',
|
||||||
|
'industry',
|
||||||
|
'customer_group',
|
||||||
|
'customer_field',
|
||||||
|
'dict_type',
|
||||||
|
'dict_item',
|
||||||
|
'system_config',
|
||||||
|
'audit_log',
|
||||||
|
'audit_export',
|
||||||
|
/** B1 / A6 预留 */
|
||||||
|
'asset',
|
||||||
|
'task',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number];
|
||||||
|
|
||||||
|
export interface AuditActionMeta {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
category: AuditCategoryKey;
|
||||||
|
/** 未显式声明时由拦截器按请求参数 / Service patchAudit 决定 */
|
||||||
|
targetType?: AuditTargetType;
|
||||||
|
/** 敏感访问(查看成本 / 资产包 / 审计等),需额外留痕与前端提示 */
|
||||||
|
sensitive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作目录:覆盖 A1–A4 已落地的 36 个 `@Audit(...)` code,
|
||||||
|
* 并登记 A5 新增(登录 / 登出 / 审计查看 / 审计导出 / 导出下载)与后续批次预留项。
|
||||||
|
*/
|
||||||
|
export const AUDIT_ACTION_CATALOG: Readonly<Record<string, AuditActionMeta>> = {
|
||||||
|
// ---------------- 登录与会话 ----------------
|
||||||
|
'identity.auth.login': {
|
||||||
|
code: 'identity.auth.login',
|
||||||
|
label: '登录',
|
||||||
|
category: 'auth',
|
||||||
|
targetType: 'user',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
'identity.auth.logout': {
|
||||||
|
code: 'identity.auth.logout',
|
||||||
|
label: '登出',
|
||||||
|
category: 'auth',
|
||||||
|
targetType: 'user',
|
||||||
|
},
|
||||||
|
'identity.auth.change-password': {
|
||||||
|
code: 'identity.auth.change-password',
|
||||||
|
label: '修改密码',
|
||||||
|
category: 'auth',
|
||||||
|
targetType: 'user',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 权限与角色 / 组织 ----------------
|
||||||
|
'identity.role.create': {
|
||||||
|
code: 'identity.role.create',
|
||||||
|
label: '新建角色',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'role',
|
||||||
|
},
|
||||||
|
'identity.role.update': {
|
||||||
|
code: 'identity.role.update',
|
||||||
|
label: '编辑角色',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'role',
|
||||||
|
},
|
||||||
|
'identity.role.delete': {
|
||||||
|
code: 'identity.role.delete',
|
||||||
|
label: '删除角色',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'role',
|
||||||
|
},
|
||||||
|
'identity.user.create': {
|
||||||
|
code: 'identity.user.create',
|
||||||
|
label: '新建用户',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'user',
|
||||||
|
},
|
||||||
|
'identity.user.update': {
|
||||||
|
code: 'identity.user.update',
|
||||||
|
label: '编辑用户',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'user',
|
||||||
|
},
|
||||||
|
'identity.user.disable': {
|
||||||
|
code: 'identity.user.disable',
|
||||||
|
label: '停用用户',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'user',
|
||||||
|
},
|
||||||
|
'identity.user.enable': {
|
||||||
|
code: 'identity.user.enable',
|
||||||
|
label: '启用用户',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'user',
|
||||||
|
},
|
||||||
|
'identity.user.transfer_customers': {
|
||||||
|
code: 'identity.user.transfer_customers',
|
||||||
|
label: '转交客户',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'identity.department.create': {
|
||||||
|
code: 'identity.department.create',
|
||||||
|
label: '新建部门',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'department',
|
||||||
|
},
|
||||||
|
'identity.department.update': {
|
||||||
|
code: 'identity.department.update',
|
||||||
|
label: '编辑部门',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'department',
|
||||||
|
},
|
||||||
|
'identity.department.disable': {
|
||||||
|
code: 'identity.department.disable',
|
||||||
|
label: '停用部门',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'department',
|
||||||
|
},
|
||||||
|
'identity.department.enable': {
|
||||||
|
code: 'identity.department.enable',
|
||||||
|
label: '启用部门',
|
||||||
|
category: 'permission',
|
||||||
|
targetType: 'department',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 客户与行业 ----------------
|
||||||
|
'client.customer.create': {
|
||||||
|
code: 'client.customer.create',
|
||||||
|
label: '新建客户',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer.update': {
|
||||||
|
code: 'client.customer.update',
|
||||||
|
label: '编辑客户',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer.status': {
|
||||||
|
code: 'client.customer.status',
|
||||||
|
label: '变更客户状态',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer.transfer': {
|
||||||
|
code: 'client.customer.transfer',
|
||||||
|
label: '转交客户负责人',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer.groups': {
|
||||||
|
code: 'client.customer.groups',
|
||||||
|
label: '调整客户组归属',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer.apply_industry_pack': {
|
||||||
|
code: 'client.customer.apply_industry_pack',
|
||||||
|
label: '套用行业资产包',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
},
|
||||||
|
'client.customer_field.apply': {
|
||||||
|
code: 'client.customer_field.apply',
|
||||||
|
label: '申请客户字段',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_field',
|
||||||
|
},
|
||||||
|
'client.customer_field.approve': {
|
||||||
|
code: 'client.customer_field.approve',
|
||||||
|
label: '审批客户字段',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_field',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
'client.industry.create': {
|
||||||
|
code: 'client.industry.create',
|
||||||
|
label: '新建行业',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'industry',
|
||||||
|
},
|
||||||
|
'client.industry.update': {
|
||||||
|
code: 'client.industry.update',
|
||||||
|
label: '编辑行业',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'industry',
|
||||||
|
},
|
||||||
|
'client.industry.status': {
|
||||||
|
code: 'client.industry.status',
|
||||||
|
label: '变更行业状态',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'industry',
|
||||||
|
},
|
||||||
|
'client.industry.delete': {
|
||||||
|
code: 'client.industry.delete',
|
||||||
|
label: '删除行业',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'industry',
|
||||||
|
},
|
||||||
|
'client.customer_group.create': {
|
||||||
|
code: 'client.customer_group.create',
|
||||||
|
label: '新建客户组',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_group',
|
||||||
|
},
|
||||||
|
'client.customer_group.update': {
|
||||||
|
code: 'client.customer_group.update',
|
||||||
|
label: '编辑客户组',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_group',
|
||||||
|
},
|
||||||
|
'client.customer_group.delete': {
|
||||||
|
code: 'client.customer_group.delete',
|
||||||
|
label: '删除客户组',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_group',
|
||||||
|
},
|
||||||
|
'client.customer_group.members': {
|
||||||
|
code: 'client.customer_group.members',
|
||||||
|
label: '调整客户组成员',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer_group',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 字典 ----------------
|
||||||
|
'catalog.dict_type.create': {
|
||||||
|
code: 'catalog.dict_type.create',
|
||||||
|
label: '新建字典类型',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_type',
|
||||||
|
},
|
||||||
|
'catalog.dict_type.update': {
|
||||||
|
code: 'catalog.dict_type.update',
|
||||||
|
label: '编辑字典类型',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_type',
|
||||||
|
},
|
||||||
|
'catalog.dict_type.status': {
|
||||||
|
code: 'catalog.dict_type.status',
|
||||||
|
label: '变更字典类型状态',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_type',
|
||||||
|
},
|
||||||
|
'catalog.dict_type.delete': {
|
||||||
|
code: 'catalog.dict_type.delete',
|
||||||
|
label: '删除字典类型',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_type',
|
||||||
|
},
|
||||||
|
'catalog.dict_item.create': {
|
||||||
|
code: 'catalog.dict_item.create',
|
||||||
|
label: '新建字典条目',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_item',
|
||||||
|
},
|
||||||
|
'catalog.dict_item.update': {
|
||||||
|
code: 'catalog.dict_item.update',
|
||||||
|
label: '编辑字典条目',
|
||||||
|
category: 'catalog',
|
||||||
|
targetType: 'dict_item',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 系统配置 ----------------
|
||||||
|
'platform.system_config.update': {
|
||||||
|
code: 'platform.system_config.update',
|
||||||
|
label: '保存系统配置',
|
||||||
|
category: 'config',
|
||||||
|
targetType: 'system_config',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 审计与导出(A5) ----------------
|
||||||
|
'audit.log.view': {
|
||||||
|
code: 'audit.log.view',
|
||||||
|
label: '查看审计日志',
|
||||||
|
category: 'sensitive',
|
||||||
|
targetType: 'audit_log',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
'audit.export': {
|
||||||
|
code: 'audit.export',
|
||||||
|
label: '导出审计日志',
|
||||||
|
category: 'export',
|
||||||
|
targetType: 'audit_export',
|
||||||
|
},
|
||||||
|
'audit.export.download': {
|
||||||
|
code: 'audit.export.download',
|
||||||
|
label: '下载审计导出文件',
|
||||||
|
category: 'export',
|
||||||
|
targetType: 'audit_export',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------- 后续批次预留(B1 资产 / A6 任务) ----------------
|
||||||
|
'asset.create': {
|
||||||
|
code: 'asset.create',
|
||||||
|
label: '上传资产',
|
||||||
|
category: 'asset',
|
||||||
|
targetType: 'asset',
|
||||||
|
},
|
||||||
|
'asset.update': {
|
||||||
|
code: 'asset.update',
|
||||||
|
label: '编辑资产',
|
||||||
|
category: 'asset',
|
||||||
|
targetType: 'asset',
|
||||||
|
},
|
||||||
|
'asset.disable': {
|
||||||
|
code: 'asset.disable',
|
||||||
|
label: '停用资产',
|
||||||
|
category: 'asset',
|
||||||
|
targetType: 'asset',
|
||||||
|
},
|
||||||
|
'asset.delete': {
|
||||||
|
code: 'asset.delete',
|
||||||
|
label: '删除资产',
|
||||||
|
category: 'asset',
|
||||||
|
targetType: 'asset',
|
||||||
|
},
|
||||||
|
'task.submit': { code: 'task.submit', label: '提交任务', category: 'task', targetType: 'task' },
|
||||||
|
'task.cancel': { code: 'task.cancel', label: '取消任务', category: 'task', targetType: 'task' },
|
||||||
|
'task.retry': { code: 'task.retry', label: '重试任务', category: 'task', targetType: 'task' },
|
||||||
|
'task.finished': {
|
||||||
|
code: 'task.finished',
|
||||||
|
label: '任务完成',
|
||||||
|
category: 'task',
|
||||||
|
targetType: 'task',
|
||||||
|
},
|
||||||
|
'task.failed': { code: 'task.failed', label: '任务失败', category: 'task', targetType: 'task' },
|
||||||
|
|
||||||
|
// ---------------- 敏感访问(A5 预留,成本 / 资产包接口就绪后接入) ----------------
|
||||||
|
'sensitive.cost.view': {
|
||||||
|
code: 'sensitive.cost.view',
|
||||||
|
label: '查看成本数据',
|
||||||
|
category: 'sensitive',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
'sensitive.asset_pack.view': {
|
||||||
|
code: 'sensitive.asset_pack.view',
|
||||||
|
label: '查看客户资产包',
|
||||||
|
category: 'sensitive',
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUDIT_ACTION_CODES: readonly string[] = Object.keys(AUDIT_ACTION_CATALOG);
|
||||||
|
|
||||||
|
const AUDIT_ACTION_META_MAP: ReadonlyMap<string, AuditActionMeta> = new Map(
|
||||||
|
Object.entries(AUDIT_ACTION_CATALOG),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取动作元数据;未登记 code 兜底:label 用 code 本身、分类 `other`。
|
||||||
|
* 兜底不抛错,保证新增动作忘登记时审计写入不被阻塞。
|
||||||
|
*/
|
||||||
|
export function auditActionMeta(code: string): AuditActionMeta {
|
||||||
|
return (
|
||||||
|
AUDIT_ACTION_META_MAP.get(code) ?? {
|
||||||
|
code,
|
||||||
|
label: code,
|
||||||
|
category: 'other',
|
||||||
|
sensitive: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownAuditAction(code: string): boolean {
|
||||||
|
return AUDIT_ACTION_META_MAP.has(code);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAuditDiff } from './audit-diff.js';
|
||||||
|
|
||||||
|
describe('审计前后值 diff(A5-BE-03)', () => {
|
||||||
|
it('顶层字段新增 / 删除 / 修改', () => {
|
||||||
|
expect(
|
||||||
|
buildAuditDiff({ name: '旧名', status: 'ACTIVE' }, { name: '新名', remark: '备注' }),
|
||||||
|
).toEqual([
|
||||||
|
{ path: 'name', label: null, before: '旧名', after: '新名', kind: 'changed' },
|
||||||
|
{ path: 'status', label: null, before: 'ACTIVE', after: null, kind: 'removed' },
|
||||||
|
{ path: 'remark', label: null, before: null, after: '备注', kind: 'added' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('对象只展开一层(a.b),值相等不产生行', () => {
|
||||||
|
expect(
|
||||||
|
buildAuditDiff(
|
||||||
|
{ dataScope: { type: 'all', targetIds: [] }, name: '客户' },
|
||||||
|
{ dataScope: { type: 'department', targetIds: [] }, name: '客户' },
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ path: 'dataScope.type', label: null, before: 'all', after: 'department', kind: 'changed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('数组按元素 index 展开(新增 / 删除 / 修改)', () => {
|
||||||
|
expect(buildAuditDiff({ specs: ['1080x1920'] }, { specs: ['1080x1920', '1080x1080'] })).toEqual(
|
||||||
|
[{ path: 'specs[1]', label: null, before: null, after: '1080x1080', kind: 'added' }],
|
||||||
|
);
|
||||||
|
expect(buildAuditDiff({ tags: ['a', 'b'] }, { tags: ['a'] })).toEqual([
|
||||||
|
{ path: 'tags[1]', label: null, before: 'b', after: null, kind: 'removed' },
|
||||||
|
]);
|
||||||
|
expect(buildAuditDiff({ tags: ['a', 'b'] }, { tags: ['a', 'c'] })).toEqual([
|
||||||
|
{ path: 'tags[1]', label: null, before: 'b', after: 'c', kind: 'changed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限点数组按增删语义(顺序变化不误报)', () => {
|
||||||
|
const rows = buildAuditDiff(
|
||||||
|
{ permissions: ['script:read', 'user:view'] },
|
||||||
|
{ permissions: ['user:view', 'role:view'] },
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ path: 'permissions', label: null, before: 'script:read', after: null, kind: 'removed' },
|
||||||
|
{ path: 'permissions', label: null, before: null, after: 'role:view', kind: 'added' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reordered = buildAuditDiff(
|
||||||
|
{ specialPermissions: ['audit:read', 'audit:export'] },
|
||||||
|
{ specialPermissions: ['audit:export', 'audit:read'] },
|
||||||
|
);
|
||||||
|
expect(reordered).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('对象数组按 index 展开为子字段路径', () => {
|
||||||
|
expect(
|
||||||
|
buildAuditDiff(
|
||||||
|
{ fields: [{ code: 'a', name: '旧' }] },
|
||||||
|
{ fields: [{ code: 'a', name: '新' }] },
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ path: 'fields[0].name', label: null, before: '旧', after: '新', kind: 'changed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('before / after 全空或无变化返回空数组;支持标签映射', () => {
|
||||||
|
expect(buildAuditDiff(null, null)).toEqual([]);
|
||||||
|
expect(buildAuditDiff({}, {})).toEqual([]);
|
||||||
|
expect(buildAuditDiff({ a: 1 }, { a: 1 })).toEqual([]);
|
||||||
|
expect(buildAuditDiff(null, { name: '新增' })).toEqual([
|
||||||
|
{ path: 'name', label: null, before: null, after: '新增', kind: 'added' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(buildAuditDiff({ name: '旧' }, { name: '新' }, { name: '名称' })).toEqual([
|
||||||
|
{ path: 'name', label: '名称', before: '旧', after: '新', kind: 'changed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* 审计前后值 diff(A5-BE-03 / 工程规范 08)
|
||||||
|
*
|
||||||
|
* 纯函数,前后端可共用:输入 before / after(JSON 值),输出可读的扁平变更行。
|
||||||
|
* 口径:
|
||||||
|
* - 顶层字段逐键比较;对象字段只展开一层(`a.b`),避免深层噪音;
|
||||||
|
* - 数组默认按元素索引展开(`a[0]`);
|
||||||
|
* - 权限点数组(键名含 permission 或元素形如 `module:action`)按集合增删语义输出,
|
||||||
|
* 避免顺序变化被误报成大量改动;
|
||||||
|
* - 缺失字段 → added / removed,值变化 → changed。
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const auditDiffKindSchema = z.enum(['added', 'removed', 'changed']);
|
||||||
|
export type AuditDiffKind = z.infer<typeof auditDiffKindSchema>;
|
||||||
|
|
||||||
|
export interface AuditDiffRow {
|
||||||
|
/** 字段路径,如 `name` / `dataScope.type` / `permissions` / `specs[0]` */
|
||||||
|
path: string;
|
||||||
|
/** 可选中文字段名(由调用方提供字典映射;默认 null) */
|
||||||
|
label: string | null;
|
||||||
|
before: unknown;
|
||||||
|
after: unknown;
|
||||||
|
kind: AuditDiffKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditDiffRowSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
label: z.string().nullable(),
|
||||||
|
before: z.unknown().nullable(),
|
||||||
|
after: z.unknown().nullable(),
|
||||||
|
kind: auditDiffKindSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeys(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => sortKeys(item));
|
||||||
|
}
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value).sort()) {
|
||||||
|
sorted[key] = sortKeys(value[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结构化相等:忽略对象键顺序,undefined 与缺失视为相同 */
|
||||||
|
function isEqualJson(left: unknown, right: unknown): boolean {
|
||||||
|
if (left === undefined && right === undefined) return true;
|
||||||
|
return JSON.stringify(sortKeys(left)) === JSON.stringify(sortKeys(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionKey(key: string): boolean {
|
||||||
|
return key.toLowerCase().includes('permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikePermissionCodes(value: readonly unknown[]): boolean {
|
||||||
|
return value.length > 0 && value.every((item) => typeof item === 'string' && item.includes(':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数组是否按集合增删语义比较(权限点数组) */
|
||||||
|
function isSetLikeArray(
|
||||||
|
key: string,
|
||||||
|
before: readonly unknown[],
|
||||||
|
after: readonly unknown[],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isPermissionKey(key) || looksLikePermissionCodes(before) || looksLikePermissionCodes(after)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushRow(
|
||||||
|
rows: AuditDiffRow[],
|
||||||
|
path: string,
|
||||||
|
before: unknown,
|
||||||
|
after: unknown,
|
||||||
|
kind: AuditDiffKind,
|
||||||
|
): void {
|
||||||
|
rows.push({
|
||||||
|
path,
|
||||||
|
label: null,
|
||||||
|
before: before === undefined ? null : before,
|
||||||
|
after: after === undefined ? null : after,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffObject(
|
||||||
|
rows: AuditDiffRow[],
|
||||||
|
path: string,
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
after: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const keys = [...Object.keys(before), ...Object.keys(after).filter((key) => !(key in before))];
|
||||||
|
for (const key of keys) {
|
||||||
|
const childPath = path.length === 0 ? key : `${path}.${key}`;
|
||||||
|
const hasBefore = key in before;
|
||||||
|
const hasAfter = key in after;
|
||||||
|
if (!hasBefore) {
|
||||||
|
pushRow(rows, childPath, undefined, after[key], 'added');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!hasAfter) {
|
||||||
|
pushRow(rows, childPath, before[key], undefined, 'removed');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
diffValue(rows, childPath, key, before[key], after[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffArray(
|
||||||
|
rows: AuditDiffRow[],
|
||||||
|
path: string,
|
||||||
|
key: string,
|
||||||
|
before: readonly unknown[],
|
||||||
|
after: readonly unknown[],
|
||||||
|
): void {
|
||||||
|
if (isSetLikeArray(key, before, after)) {
|
||||||
|
const beforeSet = new Set(before.map((item) => JSON.stringify(sortKeys(item))));
|
||||||
|
const afterSet = new Set(after.map((item) => JSON.stringify(sortKeys(item))));
|
||||||
|
for (const item of before) {
|
||||||
|
if (!afterSet.has(JSON.stringify(sortKeys(item)))) {
|
||||||
|
pushRow(rows, path, item, undefined, 'removed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of after) {
|
||||||
|
if (!beforeSet.has(JSON.stringify(sortKeys(item)))) {
|
||||||
|
pushRow(rows, path, undefined, item, 'added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const length = Math.max(before.length, after.length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const childPath = `${path}[${String(index)}]`;
|
||||||
|
const hasBefore = index < before.length;
|
||||||
|
const hasAfter = index < after.length;
|
||||||
|
if (!hasBefore) {
|
||||||
|
pushRow(rows, childPath, undefined, after[index], 'added');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!hasAfter) {
|
||||||
|
pushRow(rows, childPath, before[index], undefined, 'removed');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const beforeItem = before[index];
|
||||||
|
const afterItem = after[index];
|
||||||
|
if (isPlainObject(beforeItem) && isPlainObject(afterItem)) {
|
||||||
|
diffObject(rows, childPath, beforeItem, afterItem);
|
||||||
|
} else if (!isEqualJson(beforeItem, afterItem)) {
|
||||||
|
pushRow(rows, childPath, beforeItem, afterItem, 'changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffValue(
|
||||||
|
rows: AuditDiffRow[],
|
||||||
|
path: string,
|
||||||
|
key: string,
|
||||||
|
before: unknown,
|
||||||
|
after: unknown,
|
||||||
|
): void {
|
||||||
|
if (isPlainObject(before) && isPlainObject(after)) {
|
||||||
|
// 对象只展开一层,避免深层 diff 噪音
|
||||||
|
diffObject(rows, path, before, after);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(before) && Array.isArray(after)) {
|
||||||
|
diffArray(rows, path, key, before, after);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEqualJson(before, after)) {
|
||||||
|
pushRow(rows, path, before, after, 'changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成前后值 diff。before / after 可为 null / undefined(如仅新增或仅删除),
|
||||||
|
* 返回空数组表示无变化。
|
||||||
|
*/
|
||||||
|
export function buildAuditDiff(
|
||||||
|
before: unknown,
|
||||||
|
after: unknown,
|
||||||
|
labels?: Readonly<Record<string, string>>,
|
||||||
|
): AuditDiffRow[] {
|
||||||
|
const rows: AuditDiffRow[] = [];
|
||||||
|
if (isPlainObject(before) && isPlainObject(after)) {
|
||||||
|
diffObject(rows, '', before, after);
|
||||||
|
} else if (isPlainObject(before)) {
|
||||||
|
diffObject(rows, '', before, {});
|
||||||
|
} else if (isPlainObject(after)) {
|
||||||
|
diffObject(rows, '', {}, after);
|
||||||
|
} else if (!isEqualJson(before, after)) {
|
||||||
|
pushRow(rows, '$', before, after, 'changed');
|
||||||
|
}
|
||||||
|
if (labels === undefined) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.map((row) => {
|
||||||
|
const label = labels[row.path];
|
||||||
|
return label === undefined ? row : { ...row, label };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export const SYSTEM_CONFIG_GROUP_KEYS = [
|
|||||||
'notify',
|
'notify',
|
||||||
'limits',
|
'limits',
|
||||||
'threshold',
|
'threshold',
|
||||||
|
'audit',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SystemConfigGroupKey = (typeof SYSTEM_CONFIG_GROUP_KEYS)[number];
|
export type SystemConfigGroupKey = (typeof SYSTEM_CONFIG_GROUP_KEYS)[number];
|
||||||
@@ -44,6 +45,12 @@ export const SYSTEM_CONFIG_GROUPS: readonly {
|
|||||||
description: '爆量判定、审核超时与质量告警',
|
description: '爆量判定、审核超时与质量告警',
|
||||||
sortOrder: 60,
|
sortOrder: 60,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'audit',
|
||||||
|
name: '审计与日志',
|
||||||
|
description: '审计保留期、导出上限与运行日志平台入口',
|
||||||
|
sortOrder: 70,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type SystemConfigInputType = 'text' | 'number' | 'boolean' | 'pattern';
|
export type SystemConfigInputType = 'text' | 'number' | 'boolean' | 'pattern';
|
||||||
@@ -451,6 +458,52 @@ export const SYSTEM_CONFIG_DEFINITIONS: readonly SystemConfigDefinition[] = [
|
|||||||
public: false,
|
public: false,
|
||||||
sortOrder: 260,
|
sortOrder: 260,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 审计与日志(A5)
|
||||||
|
{
|
||||||
|
key: 'audit.retention_months',
|
||||||
|
group: 'audit',
|
||||||
|
label: '审计日志保留期',
|
||||||
|
description: '审计日志在线保留的最短月数;归档任务按该值执行(QT-10 最终值待确认)',
|
||||||
|
impact:
|
||||||
|
'影响审计可检索范围与归档策略;PRD Q-08 要求 ≥ 12 个月,归档只做只读搬迁、不删除审计数据',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: 12,
|
||||||
|
unit: '个月',
|
||||||
|
min: 12,
|
||||||
|
max: 60,
|
||||||
|
editable: true,
|
||||||
|
public: false,
|
||||||
|
sortOrder: 270,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit.export_max_rows',
|
||||||
|
group: 'audit',
|
||||||
|
label: '审计导出单次行数上限',
|
||||||
|
description: '单次异步导出的最大行数,超出部分截断并在导出任务上标记 truncated',
|
||||||
|
impact: '影响导出文件体积与导出耗时;截断后需缩小筛选条件重新导出',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: 50000,
|
||||||
|
unit: '条',
|
||||||
|
min: 1000,
|
||||||
|
max: 200000,
|
||||||
|
editable: true,
|
||||||
|
public: true,
|
||||||
|
sortOrder: 280,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit.logs_platform_url',
|
||||||
|
group: 'audit',
|
||||||
|
label: '运行日志平台地址',
|
||||||
|
description: '后台「运行日志」入口跳转的 Grafana / Loki 地址;只存链接,不含任何密钥',
|
||||||
|
impact: '仅影响前端跳转链接;运行日志不入业务库(工程规范 08)',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: '',
|
||||||
|
maxLength: 500,
|
||||||
|
editable: true,
|
||||||
|
public: true,
|
||||||
|
sortOrder: 290,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFINITION_MAP: ReadonlyMap<string, SystemConfigDefinition> = new Map(
|
const DEFINITION_MAP: ReadonlyMap<string, SystemConfigDefinition> = new Map(
|
||||||
@@ -518,6 +571,10 @@ export function validateSystemConfigValue(
|
|||||||
}
|
}
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
|
// 允许默认值为空串的文本项(如 audit.logs_platform_url:未配置日志平台时为空)
|
||||||
|
if (definition.defaultValue === '') {
|
||||||
|
return { ok: true, value: '' };
|
||||||
|
}
|
||||||
return { ok: false, message: '配置值不能为空' };
|
return { ok: false, message: '配置值不能为空' };
|
||||||
}
|
}
|
||||||
if (definition.type === 'pattern') {
|
if (definition.type === 'pattern') {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * from './audit/audit-actions.js';
|
||||||
|
export * from './audit/audit-diff.js';
|
||||||
export * from './config/system-configs.js';
|
export * from './config/system-configs.js';
|
||||||
export * from './constants.js';
|
export * from './constants.js';
|
||||||
export * from './errors/api-error.js';
|
export * from './errors/api-error.js';
|
||||||
@@ -5,6 +7,7 @@ export * from './errors/codes.js';
|
|||||||
export * from './naming.js';
|
export * from './naming.js';
|
||||||
export * from './permissions.js';
|
export * from './permissions.js';
|
||||||
export * from './schemas/api.js';
|
export * from './schemas/api.js';
|
||||||
|
export * from './schemas/audit.js';
|
||||||
export * from './schemas/client.js';
|
export * from './schemas/client.js';
|
||||||
export * from './schemas/common.js';
|
export * from './schemas/common.js';
|
||||||
export * from './schemas/config.js';
|
export * from './schemas/config.js';
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
auditExportCreateSchema,
|
||||||
|
auditExportJobSchema,
|
||||||
|
auditLogDetailSchema,
|
||||||
|
auditLogItemSchema,
|
||||||
|
auditLogListQuerySchema,
|
||||||
|
auditLogListResponseSchema,
|
||||||
|
} from './audit.js';
|
||||||
|
|
||||||
|
const ITEM = {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
occurred_at: '2026-11-04T08:00:00.000Z',
|
||||||
|
actor_id: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||||
|
actor_name: '江岚',
|
||||||
|
actor_account: 'jiang.lan',
|
||||||
|
action_code: 'client.customer.update',
|
||||||
|
action_label: '编辑客户',
|
||||||
|
category: 'client',
|
||||||
|
category_label: '客户与行业',
|
||||||
|
target_type: 'customer',
|
||||||
|
target_id: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
target_name: '花间集',
|
||||||
|
customer_id: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
customer_name: '花间集',
|
||||||
|
result: 'success' as const,
|
||||||
|
http_method: 'PATCH',
|
||||||
|
http_path: '/api/v1/customers/01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
trace_id: 'trace-0001',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
duration_ms: 12,
|
||||||
|
error_code: null,
|
||||||
|
error_message: null,
|
||||||
|
change_count: 1,
|
||||||
|
has_before: true,
|
||||||
|
has_after: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('审计日志契约(A5-BE-03)', () => {
|
||||||
|
it('列表 query:默认 limit=20、数字强制转换、未知枚举拒绝', () => {
|
||||||
|
const parsed = auditLogListQuerySchema.parse({});
|
||||||
|
expect(parsed.limit).toBe(20);
|
||||||
|
expect(auditLogListQuerySchema.parse({ limit: '50' }).limit).toBe(50);
|
||||||
|
expect(
|
||||||
|
auditLogListQuerySchema.parse({
|
||||||
|
actor: '江岚',
|
||||||
|
result: 'failure',
|
||||||
|
from: '2026-11-01T00:00:00.000Z',
|
||||||
|
to: '2026-11-05T00:00:00.000Z',
|
||||||
|
}).result,
|
||||||
|
).toBe('failure');
|
||||||
|
expect(() => auditLogListQuerySchema.parse({ result: 'unknown' })).toThrow();
|
||||||
|
expect(() => auditLogListQuerySchema.parse({ limit: 101 })).toThrow();
|
||||||
|
expect(() => auditLogListQuerySchema.parse({ customer_id: 'not-ulid' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('列表项可解析;详情在列表基础上增加前后值与 diff', () => {
|
||||||
|
expect(auditLogItemSchema.parse(ITEM).action_label).toBe('编辑客户');
|
||||||
|
const detail = {
|
||||||
|
...ITEM,
|
||||||
|
user_agent: 'Mozilla/5.0',
|
||||||
|
reason: '客户更名',
|
||||||
|
before: { name: '旧名' },
|
||||||
|
after: { name: '花间集' },
|
||||||
|
changes: null,
|
||||||
|
diff: [{ path: 'name', label: null, before: '旧名', after: '花间集', kind: 'changed' }],
|
||||||
|
};
|
||||||
|
expect(auditLogDetailSchema.parse(detail).diff).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
auditLogListResponseSchema.parse({ items: [ITEM], next_cursor: null }).items,
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('导出创建:format 默认 csv、filters 默认空对象、拒绝未知字段', () => {
|
||||||
|
expect(auditExportCreateSchema.parse({}).format).toBe('csv');
|
||||||
|
expect(auditExportCreateSchema.parse({ filters: { result: 'failure' } }).filters.result).toBe(
|
||||||
|
'failure',
|
||||||
|
);
|
||||||
|
expect(() => auditExportCreateSchema.parse({ format: 'json' })).toThrow();
|
||||||
|
expect(() => auditExportCreateSchema.parse({ extra: true })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('导出任务:进度 0–100、状态枚举、行数字段', () => {
|
||||||
|
const job = {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZW',
|
||||||
|
status: 'running',
|
||||||
|
format: 'csv',
|
||||||
|
filters: { result: 'success' },
|
||||||
|
total_rows: 10,
|
||||||
|
row_count: null,
|
||||||
|
processed_rows: 5,
|
||||||
|
truncated: false,
|
||||||
|
progress: 50,
|
||||||
|
file_size: null,
|
||||||
|
error_message: null,
|
||||||
|
requested_at: '2026-11-04T08:00:00.000Z',
|
||||||
|
started_at: '2026-11-04T08:00:01.000Z',
|
||||||
|
finished_at: null,
|
||||||
|
expires_at: null,
|
||||||
|
download_count: 0,
|
||||||
|
last_downloaded_at: null,
|
||||||
|
download_token: null,
|
||||||
|
};
|
||||||
|
expect(auditExportJobSchema.parse(job).status).toBe('running');
|
||||||
|
expect(() => auditExportJobSchema.parse({ ...job, status: 'cancelled' })).toThrow();
|
||||||
|
expect(() => auditExportJobSchema.parse({ ...job, progress: 101 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* 审计日志共享契约(A5-BE-03)
|
||||||
|
*
|
||||||
|
* 依据:PRD §5.4.6 / R-05;技术架构 §5.4.5 / §6.3;开发计划 A5-BE-03 / §7.5。
|
||||||
|
* - 列表 query 用 snake_case(C-01),body 用 camelCase;
|
||||||
|
* - 列表只返回摘要与变更字段数,before / after / changes 仅在详情返回;
|
||||||
|
* - 导出为异步任务,创建后立即返回 queued,进度用 job 查询。
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AUDIT_CATEGORY_KEYS } from '../audit/audit-actions.js';
|
||||||
|
import { auditDiffRowSchema } from '../audit/audit-diff.js';
|
||||||
|
import { isoDateTimeSchema, ulidSchema } from './common.js';
|
||||||
|
|
||||||
|
export const auditCategorySchema = z.enum(AUDIT_CATEGORY_KEYS);
|
||||||
|
|
||||||
|
export const auditResultSchema = z.enum(['success', 'failure']);
|
||||||
|
export type AuditResult = z.infer<typeof auditResultSchema>;
|
||||||
|
|
||||||
|
export const auditExportStatusSchema = z.enum(['queued', 'running', 'succeeded', 'failed']);
|
||||||
|
export type AuditExportStatus = z.infer<typeof auditExportStatusSchema>;
|
||||||
|
|
||||||
|
export const auditExportFormatSchema = z.enum(['csv']);
|
||||||
|
|
||||||
|
/** 列表 / 导出共用筛选条件(query 与 body.filters 同一口径,全部 snake_case) */
|
||||||
|
export const auditLogFilterSchema = z.object({
|
||||||
|
/** 操作人:匹配 actor_id 精确 / 姓名 / 账号包含 */
|
||||||
|
actor: z.string().trim().min(1).max(64).optional(),
|
||||||
|
customer_id: ulidSchema.optional(),
|
||||||
|
action_code: z.string().trim().min(1).max(64).optional(),
|
||||||
|
category: auditCategorySchema.optional(),
|
||||||
|
target_type: z.string().trim().min(1).max(64).optional(),
|
||||||
|
target_id: z.string().trim().min(1).max(128).optional(),
|
||||||
|
result: auditResultSchema.optional(),
|
||||||
|
from: isoDateTimeSchema.optional(),
|
||||||
|
to: isoDateTimeSchema.optional(),
|
||||||
|
/** 关键字:对象名 / 对象 id / 请求路径 / 原因 包含 */
|
||||||
|
keyword: z.string().trim().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogFilter = z.infer<typeof auditLogFilterSchema>;
|
||||||
|
|
||||||
|
export const auditLogListQuerySchema = auditLogFilterSchema.extend({
|
||||||
|
cursor: z.string().min(1).max(512).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogListQuery = z.infer<typeof auditLogListQuerySchema>;
|
||||||
|
|
||||||
|
/** 列表摘要:不含 before / after / changes,只给变更字段数 */
|
||||||
|
export const auditLogItemSchema = z.object({
|
||||||
|
id: ulidSchema,
|
||||||
|
occurred_at: isoDateTimeSchema,
|
||||||
|
actor_id: z.string().nullable(),
|
||||||
|
actor_name: z.string().nullable(),
|
||||||
|
actor_account: z.string().nullable(),
|
||||||
|
action_code: z.string().min(1),
|
||||||
|
action_label: z.string().min(1),
|
||||||
|
category: z.string().min(1),
|
||||||
|
category_label: z.string().min(1),
|
||||||
|
target_type: z.string().nullable(),
|
||||||
|
target_id: z.string().nullable(),
|
||||||
|
target_name: z.string().nullable(),
|
||||||
|
customer_id: z.string().nullable(),
|
||||||
|
customer_name: z.string().nullable(),
|
||||||
|
result: auditResultSchema,
|
||||||
|
http_method: z.string().nullable(),
|
||||||
|
http_path: z.string().nullable(),
|
||||||
|
trace_id: z.string().nullable(),
|
||||||
|
ip: z.string().nullable(),
|
||||||
|
duration_ms: z.number().int().nullable(),
|
||||||
|
error_code: z.string().nullable(),
|
||||||
|
error_message: z.string().nullable(),
|
||||||
|
change_count: z.number().int().nonnegative(),
|
||||||
|
has_before: z.boolean(),
|
||||||
|
has_after: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogItem = z.infer<typeof auditLogItemSchema>;
|
||||||
|
|
||||||
|
/** 详情:含 before / after / changes / diff / UA / 原因 */
|
||||||
|
export const auditLogDetailSchema = auditLogItemSchema.extend({
|
||||||
|
user_agent: z.string().nullable(),
|
||||||
|
reason: z.string().nullable(),
|
||||||
|
before: z.unknown().nullable(),
|
||||||
|
after: z.unknown().nullable(),
|
||||||
|
changes: z.unknown().nullable(),
|
||||||
|
diff: z.array(auditDiffRowSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogDetail = z.infer<typeof auditLogDetailSchema>;
|
||||||
|
|
||||||
|
export const auditLogListResponseSchema = z.object({
|
||||||
|
items: z.array(auditLogItemSchema),
|
||||||
|
next_cursor: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogListResponse = z.infer<typeof auditLogListResponseSchema>;
|
||||||
|
|
||||||
|
/** 服务端动作目录(筛选下拉与中文名映射) */
|
||||||
|
export const auditActionCatalogItemSchema = z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
category: z.string().min(1),
|
||||||
|
category_label: z.string().min(1),
|
||||||
|
target_type: z.string().nullable(),
|
||||||
|
sensitive: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditActionCatalogItem = z.infer<typeof auditActionCatalogItemSchema>;
|
||||||
|
|
||||||
|
export const auditLogActionsResponseSchema = z.object({
|
||||||
|
items: z.array(auditActionCatalogItemSchema),
|
||||||
|
categories: z.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLogActionsResponse = z.infer<typeof auditLogActionsResponseSchema>;
|
||||||
|
|
||||||
|
/** 导出创建入参:format 暂只支持 csv */
|
||||||
|
export const auditExportCreateSchema = z
|
||||||
|
.object({
|
||||||
|
format: auditExportFormatSchema.default('csv'),
|
||||||
|
filters: auditLogFilterSchema.default({}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type AuditExportCreateInput = z.input<typeof auditExportCreateSchema>;
|
||||||
|
export type AuditExportCreateParsed = z.output<typeof auditExportCreateSchema>;
|
||||||
|
|
||||||
|
export const auditExportCreateResponseSchema = z.object({
|
||||||
|
id: ulidSchema,
|
||||||
|
status: z.literal('queued'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditExportCreateResponse = z.infer<typeof auditExportCreateResponseSchema>;
|
||||||
|
|
||||||
|
/** 导出任务(进度用 processed_rows / total_rows 计算,前端不猜状态) */
|
||||||
|
export const auditExportJobSchema = z.object({
|
||||||
|
id: ulidSchema,
|
||||||
|
status: auditExportStatusSchema,
|
||||||
|
format: auditExportFormatSchema,
|
||||||
|
filters: auditLogFilterSchema,
|
||||||
|
total_rows: z.number().int().nonnegative().nullable(),
|
||||||
|
row_count: z.number().int().nonnegative().nullable(),
|
||||||
|
processed_rows: z.number().int().nonnegative().nullable(),
|
||||||
|
truncated: z.boolean(),
|
||||||
|
progress: z.number().min(0).max(100),
|
||||||
|
file_size: z.number().int().nonnegative().nullable(),
|
||||||
|
error_message: z.string().nullable(),
|
||||||
|
requested_at: isoDateTimeSchema,
|
||||||
|
started_at: isoDateTimeSchema.nullable(),
|
||||||
|
finished_at: isoDateTimeSchema.nullable(),
|
||||||
|
expires_at: isoDateTimeSchema.nullable(),
|
||||||
|
download_count: z.number().int().nonnegative(),
|
||||||
|
last_downloaded_at: isoDateTimeSchema.nullable(),
|
||||||
|
/** 成功任务返回短时效 HMAC 下载 token(前端拼 /exports/:id/download?token=) */
|
||||||
|
download_token: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditExportJob = z.infer<typeof auditExportJobSchema>;
|
||||||
|
|
||||||
|
export const auditExportListResponseSchema = z.object({
|
||||||
|
items: z.array(auditExportJobSchema),
|
||||||
|
next_cursor: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditExportListResponse = z.infer<typeof auditExportListResponseSchema>;
|
||||||
|
|
||||||
|
export const auditExportListQuerySchema = z.object({
|
||||||
|
cursor: z.string().min(1).max(512).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditExportListQuery = z.infer<typeof auditExportListQuerySchema>;
|
||||||
@@ -29,6 +29,44 @@ describe('系统配置契约(A4-BE-02 / R-26)', () => {
|
|||||||
expect(systemConfigDefinition('limit.customer.gen_field')?.public).toBe(true);
|
expect(systemConfigDefinition('limit.customer.gen_field')?.public).toBe(true);
|
||||||
expect(systemConfigDefinition('platform.page.default_limit')?.public).toBe(true);
|
expect(systemConfigDefinition('platform.page.default_limit')?.public).toBe(true);
|
||||||
expect(systemConfigDefinition('naming.script.pattern')?.public).toBe(false);
|
expect(systemConfigDefinition('naming.script.pattern')?.public).toBe(false);
|
||||||
|
// A5:导出上限前端可读,保留期与日志平台地址按权限控制
|
||||||
|
expect(systemConfigDefinition('audit.export_max_rows')?.public).toBe(true);
|
||||||
|
expect(systemConfigDefinition('audit.retention_months')?.public).toBe(false);
|
||||||
|
expect(systemConfigDefinition('audit.logs_platform_url')?.public).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A5 审计分组与 3 项配置(保留期 / 导出上限 / 运行日志入口)', () => {
|
||||||
|
const groupKeys = SYSTEM_CONFIG_GROUPS.map((group) => group.key);
|
||||||
|
expect(groupKeys).toContain('audit');
|
||||||
|
expect(groupKeys.indexOf('audit')).toBe(groupKeys.length - 1);
|
||||||
|
const auditDefaults = SYSTEM_CONFIG_DEFINITIONS.filter(
|
||||||
|
(definition) => definition.group === 'audit',
|
||||||
|
);
|
||||||
|
expect(auditDefaults).toHaveLength(3);
|
||||||
|
expect(systemConfigDefinition('audit.retention_months')).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: 12,
|
||||||
|
min: 12,
|
||||||
|
max: 60,
|
||||||
|
});
|
||||||
|
expect(systemConfigDefinition('audit.export_max_rows')).toMatchObject({
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: 50000,
|
||||||
|
min: 1000,
|
||||||
|
max: 200000,
|
||||||
|
});
|
||||||
|
expect(systemConfigDefinition('audit.logs_platform_url')).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: '',
|
||||||
|
maxLength: 500,
|
||||||
|
});
|
||||||
|
// 空串是合法的默认值(未配置日志平台),但非空项仍禁止保存空串
|
||||||
|
const url = systemConfigDefinition('audit.logs_platform_url');
|
||||||
|
expect(url).toBeDefined();
|
||||||
|
if (url === undefined) return;
|
||||||
|
expect(validateSystemConfigValue(url, '').ok).toBe(true);
|
||||||
|
expect(validateSystemConfigValue(url, 'https://grafana.example.com').ok).toBe(true);
|
||||||
|
expect(validateSystemConfigValue(url, 'x'.repeat(501)).ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('数字边界与布尔类型校验', () => {
|
it('数字边界与布尔类型校验', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user