diff --git a/packages/shared/src/audit/audit-actions.spec.ts b/packages/shared/src/audit/audit-actions.spec.ts new file mode 100644 index 0000000..a2f9442 --- /dev/null +++ b/packages/shared/src/audit/audit-actions.spec.ts @@ -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(AUDIT_CATEGORY_KEYS); + const targetTypes = new Set(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', + ]); + }); +}); diff --git a/packages/shared/src/audit/audit-actions.ts b/packages/shared/src/audit/audit-actions.ts new file mode 100644 index 0000000..2bf0779 --- /dev/null +++ b/packages/shared/src/audit/audit-actions.ts @@ -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 = 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> = { + // ---------------- 登录与会话 ---------------- + '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 = 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); +} diff --git a/packages/shared/src/audit/audit-diff.spec.ts b/packages/shared/src/audit/audit-diff.spec.ts new file mode 100644 index 0000000..361de4b --- /dev/null +++ b/packages/shared/src/audit/audit-diff.spec.ts @@ -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' }, + ]); + }); +}); diff --git a/packages/shared/src/audit/audit-diff.ts b/packages/shared/src/audit/audit-diff.ts new file mode 100644 index 0000000..e7ad07c --- /dev/null +++ b/packages/shared/src/audit/audit-diff.ts @@ -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; + +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 { + 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 = {}; + 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, + after: Record, +): 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>, +): 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 }; + }); +} diff --git a/packages/shared/src/config/system-configs.ts b/packages/shared/src/config/system-configs.ts index ddb7a53..1f2da5b 100644 --- a/packages/shared/src/config/system-configs.ts +++ b/packages/shared/src/config/system-configs.ts @@ -13,6 +13,7 @@ export const SYSTEM_CONFIG_GROUP_KEYS = [ 'notify', 'limits', 'threshold', + 'audit', ] as const; export type SystemConfigGroupKey = (typeof SYSTEM_CONFIG_GROUP_KEYS)[number]; @@ -44,6 +45,12 @@ export const SYSTEM_CONFIG_GROUPS: readonly { description: '爆量判定、审核超时与质量告警', sortOrder: 60, }, + { + key: 'audit', + name: '审计与日志', + description: '审计保留期、导出上限与运行日志平台入口', + sortOrder: 70, + }, ]; export type SystemConfigInputType = 'text' | 'number' | 'boolean' | 'pattern'; @@ -451,6 +458,52 @@ export const SYSTEM_CONFIG_DEFINITIONS: readonly SystemConfigDefinition[] = [ public: false, 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 = new Map( @@ -518,6 +571,10 @@ export function validateSystemConfigValue( } const text = value.trim(); if (text.length === 0) { + // 允许默认值为空串的文本项(如 audit.logs_platform_url:未配置日志平台时为空) + if (definition.defaultValue === '') { + return { ok: true, value: '' }; + } return { ok: false, message: '配置值不能为空' }; } if (definition.type === 'pattern') { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8fd81f0..397f148 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,5 @@ +export * from './audit/audit-actions.js'; +export * from './audit/audit-diff.js'; export * from './config/system-configs.js'; export * from './constants.js'; export * from './errors/api-error.js'; @@ -5,6 +7,7 @@ export * from './errors/codes.js'; export * from './naming.js'; export * from './permissions.js'; export * from './schemas/api.js'; +export * from './schemas/audit.js'; export * from './schemas/client.js'; export * from './schemas/common.js'; export * from './schemas/config.js'; diff --git a/packages/shared/src/schemas/audit.spec.ts b/packages/shared/src/schemas/audit.spec.ts new file mode 100644 index 0000000..01ce363 --- /dev/null +++ b/packages/shared/src/schemas/audit.spec.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/schemas/audit.ts b/packages/shared/src/schemas/audit.ts new file mode 100644 index 0000000..d19d032 --- /dev/null +++ b/packages/shared/src/schemas/audit.ts @@ -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; + +export const auditExportStatusSchema = z.enum(['queued', 'running', 'succeeded', 'failed']); +export type AuditExportStatus = z.infer; + +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; + +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; + +/** 列表摘要:不含 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; + +/** 详情:含 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; + +export const auditLogListResponseSchema = z.object({ + items: z.array(auditLogItemSchema), + next_cursor: z.string().nullable(), +}); + +export type AuditLogListResponse = z.infer; + +/** 服务端动作目录(筛选下拉与中文名映射) */ +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; + +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; + +/** 导出创建入参:format 暂只支持 csv */ +export const auditExportCreateSchema = z + .object({ + format: auditExportFormatSchema.default('csv'), + filters: auditLogFilterSchema.default({}), + }) + .strict(); + +export type AuditExportCreateInput = z.input; +export type AuditExportCreateParsed = z.output; + +export const auditExportCreateResponseSchema = z.object({ + id: ulidSchema, + status: z.literal('queued'), +}); + +export type AuditExportCreateResponse = z.infer; + +/** 导出任务(进度用 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; + +export const auditExportListResponseSchema = z.object({ + items: z.array(auditExportJobSchema), + next_cursor: z.string().nullable(), +}); + +export type AuditExportListResponse = z.infer; + +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; diff --git a/packages/shared/src/schemas/config.spec.ts b/packages/shared/src/schemas/config.spec.ts index 5d7c2ac..d9d4928 100644 --- a/packages/shared/src/schemas/config.spec.ts +++ b/packages/shared/src/schemas/config.spec.ts @@ -29,6 +29,44 @@ describe('系统配置契约(A4-BE-02 / R-26)', () => { expect(systemConfigDefinition('limit.customer.gen_field')?.public).toBe(true); expect(systemConfigDefinition('platform.page.default_limit')?.public).toBe(true); 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('数字边界与布尔类型校验', () => {