feat(shared): A5 审计契约、动作目录与配置

新增审计动作目录(10 分类 / 54 动作)、前后值 diff 纯函数、列表 / 详情 / 导出 Zod 契约;system_configs 增加 audit 分组与保留期 / 导出上限 / 日志平台地址 3 项配置。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:26:03 +08:00
1 parent a5a2914214
commit 408dc5e81b
9 files changed
+1211

No files matched your search

+109
View File
@@ -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();
});
});
+179
View File
@@ -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('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('数字边界与布尔类型校验', () => {