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,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('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('数字边界与布尔类型校验', () => {
|
||||
|
||||
Reference in new issue
Block a user