feat(api): A5 审计采集、检索与导出
AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
This commit is contained in:
1 parent
6215f204ce
commit
6bdda6ec62
44 files changed
+3845
-127
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||
|
||||
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||
|
||||
describe('审计游标编解码(A5-BE-03)', () => {
|
||||
it('编码 → 解码保持 occurred_at 与 id,且为 base64url', () => {
|
||||
const at = new Date('2026-11-04T08:00:00.123Z');
|
||||
const cursor = encodeAuditCursor(at, ID);
|
||||
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
const decoded = decodeAuditCursor(cursor);
|
||||
expect(decoded?.id).toBe(ID);
|
||||
expect(decoded?.at.toISOString()).toBe(at.toISOString());
|
||||
});
|
||||
|
||||
it('接受 ISO 字符串并保持毫秒精度(同毫秒分页稳定)', () => {
|
||||
const decoded = decodeAuditCursor(encodeAuditCursor('2026-11-04T08:00:00.001Z', ID));
|
||||
expect(decoded?.at.toISOString()).toBe('2026-11-04T08:00:00.001Z');
|
||||
});
|
||||
|
||||
it('非法游标返回 null(非 base64 / 缺分隔符 / 非法时间 / 非法 ULID)', () => {
|
||||
expect(decodeAuditCursor('')).toBeNull();
|
||||
expect(decodeAuditCursor('不是base64')).toBeNull();
|
||||
expect(decodeAuditCursor(Buffer.from('no-separator').toString('base64url'))).toBeNull();
|
||||
expect(decodeAuditCursor(Buffer.from(`not-a-time|${ID}`).toString('base64url'))).toBeNull();
|
||||
expect(
|
||||
decodeAuditCursor(Buffer.from('2026-11-04T08:00:00.000Z|lower-id').toString('base64url')),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 游标分页编解码(A5-BE-03)
|
||||
*
|
||||
* 格式:`base64url(<ISO occurred_at>|<ULID id>)`,对应稳定排序 `occurred_at DESC, id DESC`。
|
||||
* 同一毫秒内按 id 二次排序,保证翻页不重不漏;非法游标返回 null,由 Service 映射为 40001。
|
||||
*/
|
||||
import { isIsoDateString, ULID_PATTERN } from '@lingxi/shared';
|
||||
|
||||
export interface AuditCursor {
|
||||
at: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeAuditCursor(at: Date | string, id: string): string {
|
||||
const iso = at instanceof Date ? at.toISOString() : at;
|
||||
return Buffer.from(`${iso}|${id}`, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeAuditCursor(value: string): AuditCursor | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(value, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const separator = decoded.lastIndexOf('|');
|
||||
if (separator <= 0) return null;
|
||||
const iso = decoded.slice(0, separator);
|
||||
const id = decoded.slice(separator + 1);
|
||||
if (!isIsoDateString(iso) || !ULID_PATTERN.test(id)) return null;
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
return { at, id };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AuditLog } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AUDIT_EXPORT_BOM,
|
||||
AUDIT_EXPORT_COLUMNS,
|
||||
auditExportHeader,
|
||||
auditLogToCsvLine,
|
||||
csvEscape,
|
||||
} from './audit-export-csv';
|
||||
|
||||
function logRow(overrides: Partial<AuditLog> = {}): AuditLog {
|
||||
return {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
occurredAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||
actorId: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||
actorName: '江岚',
|
||||
actorAccount: 'jiang.lan',
|
||||
actionCode: 'client.customer.update',
|
||||
category: 'client',
|
||||
targetType: 'customer',
|
||||
targetId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||
targetName: '花间集',
|
||||
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||
customerName: '花间集',
|
||||
result: 'success',
|
||||
httpMethod: 'PATCH',
|
||||
httpPath: '/api/v1/customers/1',
|
||||
traceId: 'trace-1',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'vitest',
|
||||
beforeJson: { name: '旧名' },
|
||||
afterJson: { name: '花间集' },
|
||||
changesJson: null,
|
||||
reason: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
durationMs: 12,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('审计导出 CSV(A5-BE-03)', () => {
|
||||
it('BOM 与表头字段固定;行尾 CRLF', () => {
|
||||
expect(AUDIT_EXPORT_BOM).toBe('\uFEFF');
|
||||
const header = auditExportHeader();
|
||||
expect(header).toBe(
|
||||
'occurred_at,actor,account,action_code,action_label,category,target_type,target_id,target_name,customer_id,customer_name,result,ip,trace_id,before,after\r\n',
|
||||
);
|
||||
expect(AUDIT_EXPORT_COLUMNS).toContain('action_label');
|
||||
expect(AUDIT_EXPORT_COLUMNS).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('RFC 4180 转义:逗号 / 引号 / 换行 / null / 对象', () => {
|
||||
expect(csvEscape('纯文本')).toBe('纯文本');
|
||||
expect(csvEscape('a,b')).toBe('"a,b"');
|
||||
expect(csvEscape('say "hi"')).toBe('"say ""hi"""');
|
||||
expect(csvEscape('line1\nline2')).toBe('"line1\nline2"');
|
||||
expect(csvEscape(null)).toBe('');
|
||||
expect(csvEscape(undefined)).toBe('');
|
||||
expect(csvEscape({ name: '花间集' })).toBe('"{""name"":""花间集""}"');
|
||||
expect(csvEscape(3)).toBe('3');
|
||||
});
|
||||
|
||||
it('行内容包含标签与前后值,顺序与表头一致', () => {
|
||||
const line = auditLogToCsvLine(logRow(), '编辑客户');
|
||||
expect(line.endsWith('\r\n')).toBe(true);
|
||||
expect(
|
||||
line.startsWith(
|
||||
'2026-11-04T08:00:00.000Z,江岚,jiang.lan,client.customer.update,编辑客户,client,customer,',
|
||||
),
|
||||
);
|
||||
expect(line).toContain('"{""name"":""旧名""}"');
|
||||
expect(line.split('\r\n')[0]?.split('","').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('含逗号字段被正确加引号(不会串列)', () => {
|
||||
const line = auditLogToCsvLine(
|
||||
logRow({ targetName: '花间集, 二店', afterJson: null }),
|
||||
'编辑客户',
|
||||
);
|
||||
expect(line).toContain('"花间集, 二店"');
|
||||
// after 为空时输出空列
|
||||
expect(line.endsWith(',\r\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 审计导出 CSV 工具(A5-BE-03)
|
||||
*
|
||||
* - UTF-8 BOM(Excel 直开不乱码);
|
||||
* - RFC 4180 转义:含逗号 / 引号 / 换行的字段加引号,内部引号双写;
|
||||
* - 对象字段(before / after)序列化为 JSON 字符串。
|
||||
*/
|
||||
import type { AuditLog } from '@prisma/client';
|
||||
|
||||
export const AUDIT_EXPORT_BOM = '\uFEFF';
|
||||
|
||||
export const AUDIT_EXPORT_COLUMNS = [
|
||||
'occurred_at',
|
||||
'actor',
|
||||
'account',
|
||||
'action_code',
|
||||
'action_label',
|
||||
'category',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_name',
|
||||
'customer_id',
|
||||
'customer_name',
|
||||
'result',
|
||||
'ip',
|
||||
'trace_id',
|
||||
'before',
|
||||
'after',
|
||||
] as const;
|
||||
|
||||
function cell(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function csvEscape(value: unknown): string {
|
||||
const text = cell(value);
|
||||
if (/[",\r\n]/.test(text)) {
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function auditExportHeader(): string {
|
||||
return `${AUDIT_EXPORT_COLUMNS.join(',')}\r\n`;
|
||||
}
|
||||
|
||||
export function auditLogToCsvLine(row: AuditLog, actionLabel: string): string {
|
||||
const values: readonly unknown[] = [
|
||||
row.occurredAt.toISOString(),
|
||||
row.actorName ?? '',
|
||||
row.actorAccount ?? '',
|
||||
row.actionCode,
|
||||
actionLabel,
|
||||
row.category,
|
||||
row.targetType ?? '',
|
||||
row.targetId ?? '',
|
||||
row.targetName ?? '',
|
||||
row.customerId ?? '',
|
||||
row.customerName ?? '',
|
||||
row.result,
|
||||
row.ip ?? '',
|
||||
row.traceId ?? '',
|
||||
row.beforeJson,
|
||||
row.afterJson,
|
||||
];
|
||||
return `${values.map((value) => csvEscape(value)).join(',')}\r\n`;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 审计异步导出执行器(A5-BE-03)
|
||||
*
|
||||
* - `POST /audit-logs/export` 只创建 queued 任务并立即返回,后台 `setImmediate` 执行
|
||||
* (不新增依赖;A6 任务中心协议就绪后替换为 BullMQ 任务);
|
||||
* - 每批 500 行游标遍历(`occurred_at DESC, id DESC`),行数上限读 `audit.export_max_rows`;
|
||||
* - 超出上限截断并在任务上标 `truncated=true`,`total_rows` 保留命中总数;
|
||||
* - 导出成功 / 失败都写 `audit.export` 审计(自身留痕,PRD R-05);
|
||||
* - 启动时把卡在 queued/running 超过 10 分钟的任务标记 failed(服务重启中断)。
|
||||
*/
|
||||
import { once } from 'node:events';
|
||||
import { createWriteStream, type WriteStream } from 'node:fs';
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { auditActionMeta, auditLogFilterSchema, ErrorCode } from '@lingxi/shared';
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import type { AuditExportJob, AuditLog } from '@prisma/client';
|
||||
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
import { SystemConfigService } from '../system-config/system-config.service';
|
||||
import { AUDIT_EXPORT_BOM, auditExportHeader, auditLogToCsvLine } from './audit-export-csv';
|
||||
import { buildAuditWhere } from './audit-query';
|
||||
import { AuditWriterService } from './audit-writer.service';
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
const INTERRUPTED_AFTER_MS = 10 * 60 * 1000;
|
||||
const INTERRUPTED_MESSAGE = '服务重启中断,请重新导出';
|
||||
|
||||
export function auditExportDirectory(): string {
|
||||
return resolve(process.cwd(), '.data', 'audit-exports');
|
||||
}
|
||||
|
||||
export function auditExportFilePath(jobId: string): string {
|
||||
return resolve(auditExportDirectory(), `audit-export-${jobId}.csv`);
|
||||
}
|
||||
|
||||
async function writeChunk(stream: WriteStream, text: string): Promise<void> {
|
||||
if (!stream.write(text)) {
|
||||
await once(stream, 'drain');
|
||||
}
|
||||
}
|
||||
|
||||
async function finishStream(stream: WriteStream): Promise<void> {
|
||||
stream.end();
|
||||
await once(stream, 'finish');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditExportRunnerService implements OnModuleInit {
|
||||
private readonly logger = new Logger('AuditExport');
|
||||
private readonly running = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(SystemConfigService) private readonly configs: SystemConfigService,
|
||||
@Inject(AuditWriterService) private readonly writer: AuditWriterService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
const interrupted = await this.prisma.auditExportJob.updateMany({
|
||||
where: {
|
||||
status: { in: ['queued', 'running'] },
|
||||
requestedAt: { lt: new Date(Date.now() - INTERRUPTED_AFTER_MS) },
|
||||
},
|
||||
data: {
|
||||
status: 'failed',
|
||||
finishedAt: new Date(),
|
||||
errorMessage: INTERRUPTED_MESSAGE,
|
||||
},
|
||||
});
|
||||
if (interrupted.count > 0) {
|
||||
this.logger.warn(`已将 ${String(interrupted.count)} 个中断的审计导出任务标记为失败`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`审计导出启动恢复失败:${error instanceof Error ? error.message : '未知错误'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
schedule(jobId: string): void {
|
||||
setImmediate(() => {
|
||||
void this.run(jobId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行导出;不抛出异常(失败写任务状态 + 失败审计) */
|
||||
async run(jobId: string): Promise<void> {
|
||||
if (this.running.has(jobId)) return;
|
||||
this.running.add(jobId);
|
||||
try {
|
||||
const job = await this.prisma.auditExportJob.findUnique({ where: { id: jobId } });
|
||||
if (job?.status !== 'queued') {
|
||||
return;
|
||||
}
|
||||
await this.execute(job);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`审计导出执行失败 job=${jobId}:${error instanceof Error ? error.message : '未知错误'}`,
|
||||
);
|
||||
} finally {
|
||||
this.running.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- 导出流程(取数 / 写文件 / 状态机 / 审计)集中保证顺序
|
||||
private async execute(job: AuditExportJob): Promise<void> {
|
||||
await this.prisma.auditExportJob.update({
|
||||
where: { id: job.id },
|
||||
data: { status: 'running', startedAt: new Date(), processedRows: 0 },
|
||||
});
|
||||
|
||||
try {
|
||||
const parsedFilters = auditLogFilterSchema.safeParse(job.filtersJson);
|
||||
const filters = parsedFilters.success ? parsedFilters.data : {};
|
||||
const where = buildAuditWhere(filters);
|
||||
const maxRows = await this.configs.getNumber('audit.export_max_rows', 50000);
|
||||
const totalRows = await this.prisma.auditLog.count({ where });
|
||||
const targetRows = Math.min(totalRows, maxRows);
|
||||
|
||||
const directory = auditExportDirectory();
|
||||
await mkdir(directory, { recursive: true });
|
||||
const filePath = auditExportFilePath(job.id);
|
||||
const stream = createWriteStream(filePath, { encoding: 'utf8' });
|
||||
let written = 0;
|
||||
let cursor: { id: string; occurredAt: Date } | null = null;
|
||||
|
||||
try {
|
||||
await writeChunk(stream, `${AUDIT_EXPORT_BOM}${auditExportHeader()}`);
|
||||
while (written < targetRows) {
|
||||
const take = Math.min(BATCH_SIZE, targetRows - written);
|
||||
const batch: AuditLog[] = await this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
take,
|
||||
...(cursor === null
|
||||
? {}
|
||||
: {
|
||||
cursor: { id_occurredAt: { id: cursor.id, occurredAt: cursor.occurredAt } },
|
||||
skip: 1,
|
||||
}),
|
||||
});
|
||||
if (batch.length === 0) break;
|
||||
for (const row of batch) {
|
||||
await writeChunk(stream, auditLogToCsvLine(row, auditActionMeta(row.actionCode).label));
|
||||
written += 1;
|
||||
}
|
||||
const last = batch[batch.length - 1];
|
||||
if (last !== undefined) {
|
||||
cursor = { id: last.id, occurredAt: last.occurredAt };
|
||||
}
|
||||
await this.prisma.auditExportJob.update({
|
||||
where: { id: job.id },
|
||||
data: { processedRows: written },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await finishStream(stream);
|
||||
}
|
||||
|
||||
const fileStat = await stat(filePath);
|
||||
const ttl = await this.configs.getNumber('platform.export.signed_url_ttl_seconds', 900);
|
||||
const truncated = totalRows > maxRows;
|
||||
await this.prisma.auditExportJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'succeeded',
|
||||
finishedAt: new Date(),
|
||||
totalRows,
|
||||
rowCount: written,
|
||||
processedRows: written,
|
||||
truncated,
|
||||
filePath,
|
||||
fileSize: fileStat.size,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||
},
|
||||
});
|
||||
await this.writer.recordDirect({
|
||||
actionCode: 'audit.export',
|
||||
result: 'success',
|
||||
actorId: job.requestedBy,
|
||||
targetType: 'audit_export',
|
||||
targetId: job.id,
|
||||
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||
after: { format: job.format, filters, rowCount: written, totalRows, truncated },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误';
|
||||
await this.prisma.auditExportJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
finishedAt: new Date(),
|
||||
errorMessage: message.slice(0, 500),
|
||||
},
|
||||
});
|
||||
await this.writer.recordDirect({
|
||||
actionCode: 'audit.export',
|
||||
result: 'failure',
|
||||
actorId: job.requestedBy,
|
||||
targetType: 'audit_export',
|
||||
targetId: job.id,
|
||||
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||
errorCode: String(ErrorCode.INTERNAL_ERROR),
|
||||
errorMessage: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 审计导出下载签名(A5-BE-03)
|
||||
*
|
||||
* - HMAC-SHA256,密钥由 `JWT_ACCESS_SECRET` 派生(不新增环境变量 / 运行时依赖);
|
||||
* - token 载荷含 jobId + exp(秒),base64url 编码,签名做时序安全比较;
|
||||
* - 校验失败 / 过期返回 null,由 Service 映射为 42201。
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { APP_CONFIG } from '../../../config/config.module';
|
||||
import type { AppConfig } from '../../../config/env';
|
||||
|
||||
const DERIVE_LABEL = 'lingxi.audit-export.download.v1';
|
||||
|
||||
export interface AuditExportTokenPayload {
|
||||
jobId: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditExportTokenService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||
this.key = createHmac('sha256', config.jwtAccessSecret).update(DERIVE_LABEL).digest();
|
||||
}
|
||||
|
||||
sign(jobId: string, expiresAt: Date): string {
|
||||
const exp = Math.floor(expiresAt.getTime() / 1000);
|
||||
const payload = `${jobId}.${String(exp)}`;
|
||||
const signature = createHmac('sha256', this.key).update(payload).digest('base64url');
|
||||
return `${Buffer.from(payload, 'utf8').toString('base64url')}.${signature}`;
|
||||
}
|
||||
|
||||
verify(token: string): AuditExportTokenPayload | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const encoded = parts[0];
|
||||
const signature = parts[1];
|
||||
if (encoded === undefined || signature === undefined || encoded.length === 0) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const expected = createHmac('sha256', this.key).update(payload).digest('base64url');
|
||||
const actualBuffer = Buffer.from(signature, 'utf8');
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
if (
|
||||
actualBuffer.length !== expectedBuffer.length ||
|
||||
!timingSafeEqual(actualBuffer, expectedBuffer)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separator = payload.lastIndexOf('.');
|
||||
if (separator <= 0) return null;
|
||||
const jobId = payload.slice(0, separator);
|
||||
const exp = Number(payload.slice(separator + 1));
|
||||
if (!Number.isInteger(exp) || exp <= 0) return null;
|
||||
const expiresAt = new Date(exp * 1000);
|
||||
if (expiresAt.getTime() <= Date.now()) return null;
|
||||
return { jobId, expiresAt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AppConfig } from '../../../config/env';
|
||||
import { testConfig } from '../../../testing/test-app';
|
||||
import { AuditExportTokenService } from './audit-export-token.service';
|
||||
|
||||
const JOB_ID = '01HZZZZZZZZZZZZZZZZZZZZZZW';
|
||||
const config: AppConfig = { ...testConfig };
|
||||
|
||||
describe('审计导出下载签名(A5-BE-03)', () => {
|
||||
const tokens = new AuditExportTokenService(config);
|
||||
|
||||
it('签名 → 校验通过,jobId 与过期时间一致', () => {
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
const token = tokens.sign(JOB_ID, expiresAt);
|
||||
const payload = tokens.verify(token);
|
||||
expect(payload?.jobId).toBe(JOB_ID);
|
||||
expect(payload?.expiresAt.getTime()).toBe(Math.floor(expiresAt.getTime() / 1000) * 1000);
|
||||
});
|
||||
|
||||
it('已过期返回 null', () => {
|
||||
const token = tokens.sign(JOB_ID, new Date(Date.now() - 1000));
|
||||
expect(tokens.verify(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('篡改载荷 / 签名 / 缺少分段均返回 null', () => {
|
||||
const token = tokens.sign(JOB_ID, new Date(Date.now() + 60_000));
|
||||
const [encoded, signature] = token.split('.');
|
||||
expect(encoded).toBeDefined();
|
||||
expect(signature).toBeDefined();
|
||||
const tamperedPayload = Buffer.from(`${JOB_ID}.9999999999`, 'utf8').toString('base64url');
|
||||
expect(tokens.verify(`${tamperedPayload}.${signature ?? ''}`)).toBeNull();
|
||||
expect(tokens.verify(`${encoded ?? ''}.${'a'.repeat(signature?.length ?? 0)}`)).toBeNull();
|
||||
expect(tokens.verify(encoded ?? '')).toBeNull();
|
||||
expect(tokens.verify(`${encoded ?? ''}.${signature ?? ''}.extra`)).toBeNull();
|
||||
});
|
||||
|
||||
it('不同密钥签发的 token 不可互认', () => {
|
||||
const other = new AuditExportTokenService({
|
||||
...config,
|
||||
jwtAccessSecret: 'another-secret-at-least-32-characters!!',
|
||||
});
|
||||
const token = other.sign(JOB_ID, new Date(Date.now() + 60_000));
|
||||
expect(tokens.verify(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 审计导出(A5-BE-03):创建异步任务、查询进度、签名下载。
|
||||
*
|
||||
* - 创建:queued 任务立即返回,`AuditExportRunnerService` 后台执行;
|
||||
* - 下载:`@Public()` + HMAC token(jobId + exp),校验任务状态 / 过期 / 文件存在;
|
||||
* - 导出与下载都直接写审计(创建由 @Audit 拦截器记录,完成 / 下载由 Runner / 本服务直写)。
|
||||
*/
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
type AuditExportCreateParsed,
|
||||
type AuditExportCreateResponse,
|
||||
type AuditExportJob,
|
||||
type AuditExportListQuery,
|
||||
type AuditExportListResponse,
|
||||
ErrorCode,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { AuditExportJob as AuditExportJobRow } from '@prisma/client';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { AppException } from '../../../common/errors/app-exception';
|
||||
import { toPrismaJsonValue } from '../../../common/security/audit-sanitize';
|
||||
import type { AuthContext } from '../../../common/security/auth.types';
|
||||
import { RequestContextService } from '../../../common/security/request-context';
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
import { toAuditExportJob } from './audit.mapper';
|
||||
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||
import { AuditExportRunnerService } from './audit-export-runner.service';
|
||||
import { AuditExportTokenService } from './audit-export-token.service';
|
||||
import { AuditWriterService } from './audit-writer.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditExportService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(AuditExportRunnerService) private readonly runner: AuditExportRunnerService,
|
||||
@Inject(AuditExportTokenService) private readonly tokens: AuditExportTokenService,
|
||||
@Inject(AuditWriterService) private readonly writer: AuditWriterService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
input: AuditExportCreateParsed,
|
||||
actor: AuthContext,
|
||||
): Promise<AuditExportCreateResponse> {
|
||||
const id = ulid();
|
||||
await this.prisma.auditExportJob.create({
|
||||
data: {
|
||||
id,
|
||||
status: 'queued',
|
||||
format: input.format,
|
||||
filtersJson: toPrismaJsonValue(input.filters),
|
||||
requestedBy: actor.userId,
|
||||
},
|
||||
});
|
||||
this.requestContext.patchAudit({
|
||||
targetType: 'audit_export',
|
||||
targetId: id,
|
||||
targetName: `审计导出 ${input.format.toUpperCase()}`,
|
||||
after: { format: input.format, filters: input.filters },
|
||||
});
|
||||
this.runner.schedule(id);
|
||||
return { id, status: 'queued' };
|
||||
}
|
||||
|
||||
async listExports(query: AuditExportListQuery, userId: string): Promise<AuditExportListResponse> {
|
||||
const cursor =
|
||||
query.cursor === undefined
|
||||
? null
|
||||
: (() => {
|
||||
const decoded = decodeAuditCursor(query.cursor);
|
||||
if (decoded === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '分页游标无效,请刷新后重试',
|
||||
});
|
||||
}
|
||||
return decoded;
|
||||
})();
|
||||
const rows = await this.prisma.auditExportJob.findMany({
|
||||
where: { requestedBy: userId },
|
||||
orderBy: [{ requestedAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.limit + 1,
|
||||
...(cursor === null ? {} : { cursor: { id: cursor.id }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const last = page[page.length - 1];
|
||||
return {
|
||||
items: page.map((row) => this.mapJob(row)),
|
||||
next_cursor:
|
||||
rows.length > query.limit && last !== undefined
|
||||
? encodeAuditCursor(last.requestedAt, last.id)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getExport(id: string, userId: string): Promise<AuditExportJob> {
|
||||
const row = await this.prisma.auditExportJob.findFirst({
|
||||
where: { id, requestedBy: userId },
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '导出任务不存在' });
|
||||
}
|
||||
return this.mapJob(row);
|
||||
}
|
||||
|
||||
/** 已完成任务返回短时效签名 token(过期任务不返回) */
|
||||
private mapJob(row: AuditExportJobRow): AuditExportJob {
|
||||
const token =
|
||||
row.status === 'succeeded' && row.expiresAt !== null && row.filePath !== null
|
||||
? this.tokens.sign(row.id, row.expiresAt)
|
||||
: null;
|
||||
return toAuditExportJob(row, token);
|
||||
}
|
||||
|
||||
/** 签名下载:token 校验 → 任务 / 过期 / 文件校验 → 计数 + 审计 → 流式返回 */
|
||||
// eslint-disable-next-line max-lines-per-function -- 下载校验链集中保证顺序与错误码一致
|
||||
async download(id: string, token: string, reply: FastifyReply): Promise<void> {
|
||||
const payload = this.tokens.verify(token);
|
||||
if (payload?.jobId !== id) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '下载链接无效或已过期,请重新导出',
|
||||
});
|
||||
}
|
||||
const job = await this.prisma.auditExportJob.findUnique({ where: { id } });
|
||||
if (job === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '导出任务不存在' });
|
||||
}
|
||||
if (job.status !== 'succeeded' || job.filePath === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '导出任务尚未完成或已失败',
|
||||
});
|
||||
}
|
||||
if ((job.expiresAt?.getTime() ?? 0) <= Date.now()) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '下载链接已过期,请重新导出',
|
||||
});
|
||||
}
|
||||
|
||||
let fileSize: number;
|
||||
try {
|
||||
const fileStat = await stat(job.filePath);
|
||||
fileSize = fileStat.size;
|
||||
} catch {
|
||||
throw new AppException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: '导出文件不存在或已被清理',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await this.prisma.auditExportJob.update({
|
||||
where: { id },
|
||||
data: { downloadCount: { increment: 1 }, lastDownloadedAt: new Date() },
|
||||
});
|
||||
await this.writer.recordDirect({
|
||||
actionCode: 'audit.export.download',
|
||||
result: 'success',
|
||||
actorId: job.requestedBy,
|
||||
targetType: 'audit_export',
|
||||
targetId: job.id,
|
||||
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||
after: { downloadCount: updated.downloadCount, fileSize },
|
||||
});
|
||||
|
||||
reply.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
reply.header('Content-Disposition', `attachment; filename="audit-export-${job.id}.csv"`);
|
||||
reply.header('Content-Length', String(fileSize));
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.send(createReadStream(job.filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 审计分区维护(A5-BE-01)
|
||||
*
|
||||
* `audit_logs` 按月 RANGE 分区,写入落在哪个月依赖分区存在:
|
||||
* - 应用启动时立即 ensure(并作为「缺失分区」兜底);
|
||||
* - 进程内每 24h ensure 一次(`setInterval(...).unref()`,不阻止进程退出);
|
||||
* - 迁移本身也会调用 ensure 创建当月 ~ +13 月分区。
|
||||
*
|
||||
* 不使用 default 分区(避免后续 ATTACH / 归档冲突);写入范围由 ensure 保证。
|
||||
*/
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationShutdown,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
|
||||
/** 预创建月数:当月 ~ +13 月(覆盖 12 个月保留期 + 1 个月缓冲) */
|
||||
export const AUDIT_PARTITION_MONTHS_AHEAD = 13;
|
||||
const DAILY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class AuditPartitionService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger('AuditPartition');
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.ensure(AUDIT_PARTITION_MONTHS_AHEAD);
|
||||
this.timer = setInterval(() => {
|
||||
void this.ensure(AUDIT_PARTITION_MONTHS_AHEAD);
|
||||
}, DAILY_MS);
|
||||
// 不阻止进程退出(对齐 Node 定时器约定)
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 幂等创建分区;返回本次新建数量。失败只记日志,不阻断启动。 */
|
||||
async ensure(monthsAhead = AUDIT_PARTITION_MONTHS_AHEAD): Promise<number> {
|
||||
try {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
{ created: number }[]
|
||||
>`SELECT audit_logs_ensure_partitions(${monthsAhead}::int) AS created`;
|
||||
const created = rows[0]?.created ?? 0;
|
||||
if (created > 0) {
|
||||
this.logger.log(`已创建 ${String(created)} 个审计月分区(+${String(monthsAhead)} 月)`);
|
||||
}
|
||||
return created;
|
||||
} catch (error) {
|
||||
this.logger.error(`审计分区创建失败:${error instanceof Error ? error.message : '未知错误'}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 审计查询条件 → Prisma where(A5-BE-03)
|
||||
*
|
||||
* 所有过滤在 DB 完成(禁止先查全量再过滤);客户维度只作为筛选条件,
|
||||
* 审计是平台级特殊权限、跨客户可见(见 AuditService ScopeContext 注释)。
|
||||
*/
|
||||
import type { AuditLogFilter } from '@lingxi/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
function contains(value: string): Prisma.StringFilter {
|
||||
return { contains: value, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
export function buildAuditWhere(filter: AuditLogFilter): Prisma.AuditLogWhereInput {
|
||||
const and: Prisma.AuditLogWhereInput[] = [];
|
||||
|
||||
if (filter.actor !== undefined) {
|
||||
and.push({
|
||||
OR: [
|
||||
{ actorId: filter.actor },
|
||||
{ actorName: contains(filter.actor) },
|
||||
{ actorAccount: contains(filter.actor) },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (filter.keyword !== undefined) {
|
||||
and.push({
|
||||
OR: [
|
||||
{ targetName: contains(filter.keyword) },
|
||||
{ targetId: contains(filter.keyword) },
|
||||
{ httpPath: contains(filter.keyword) },
|
||||
{ reason: contains(filter.keyword) },
|
||||
{ actorName: contains(filter.keyword) },
|
||||
{ actorAccount: contains(filter.keyword) },
|
||||
{ traceId: contains(filter.keyword) },
|
||||
{ ip: contains(filter.keyword) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const occurredAt: Prisma.DateTimeFilter = {};
|
||||
if (filter.from !== undefined) occurredAt.gte = new Date(filter.from);
|
||||
if (filter.to !== undefined) occurredAt.lte = new Date(filter.to);
|
||||
if (occurredAt.gte !== undefined || occurredAt.lte !== undefined) {
|
||||
and.push({ occurredAt });
|
||||
}
|
||||
|
||||
return {
|
||||
...(and.length > 0 ? { AND: and } : {}),
|
||||
...(filter.customer_id === undefined ? {} : { customerId: filter.customer_id }),
|
||||
...(filter.action_code === undefined ? {} : { actionCode: filter.action_code }),
|
||||
...(filter.category === undefined ? {} : { category: filter.category }),
|
||||
...(filter.target_type === undefined ? {} : { targetType: filter.target_type }),
|
||||
...(filter.target_id === undefined ? {} : { targetId: filter.target_id }),
|
||||
...(filter.result === undefined ? {} : { result: filter.result }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 审计保留与归档(A5-BE-04 占位)
|
||||
*
|
||||
* 口径(PRD Q-08 / QT-10,最终保留期仍待产品确认):
|
||||
* - `audit.retention_months` 默认 12、下限 12(不得低于 12 个月);
|
||||
* - `audit_logs` 是 append-only:**本服务不执行 UPDATE / DELETE**,只提供归档骨架;
|
||||
* - 真正的归档(冷存储搬迁)依赖对象存储与任务中心,A5 只登记占位;
|
||||
* - 归档动作本身也应写审计(后续批次接 `audit.retention.archive`)。
|
||||
*
|
||||
* 当前实现只读取配置并返回计划,不会修改任何审计数据。
|
||||
*/
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SystemConfigService } from '../system-config/system-config.service';
|
||||
|
||||
export interface AuditArchivePlan {
|
||||
/** 计划保留月数(来自 system_configs,下限 12) */
|
||||
retentionMonths: number;
|
||||
/** 该时间之前的审计可归档(只读搬迁,不删除) */
|
||||
before: Date;
|
||||
/** 当前仅占位:实际搬迁未启用 */
|
||||
supported: false;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditRetentionService {
|
||||
private readonly logger = new Logger('AuditRetention');
|
||||
|
||||
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||
|
||||
/** 计算归档窗口;`before` 由调用方传入用于日志 / 任务参数 */
|
||||
async archiveBefore(before: Date): Promise<AuditArchivePlan> {
|
||||
const retentionMonths = await this.configs.getNumber('audit.retention_months', 12);
|
||||
this.logger.warn(
|
||||
`审计归档为 A5 占位:before=${before.toISOString()} retentionMonths=${String(retentionMonths)}(未执行任何删除 / 更新)`,
|
||||
);
|
||||
return {
|
||||
retentionMonths: Math.max(12, retentionMonths),
|
||||
before,
|
||||
supported: false,
|
||||
message:
|
||||
'审计日志 append-only,归档仅做只读冷存储搬迁、不删除数据;待对象存储与任务中心就绪后接入(QT-10 保留期最终值待确认)',
|
||||
};
|
||||
}
|
||||
|
||||
/** 计划归档窗口:now - retentionMonths;供后台任务 / 运维查询 */
|
||||
async plan(): Promise<AuditArchivePlan> {
|
||||
const retentionMonths = await this.configs.getNumber('audit.retention_months', 12);
|
||||
const months = Math.max(12, retentionMonths);
|
||||
const before = new Date();
|
||||
before.setMonth(before.getMonth() - months);
|
||||
return this.archiveBefore(before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 审计落库实现(A5-BE-02)
|
||||
*
|
||||
* - 实现 common 层 `AuditSink` 端口,由 AuditModule 提供(PlatformModule 全局导出);
|
||||
* - 写入失败只记 Pino error,绝不向上抛(审计失败不得影响业务请求);
|
||||
* - 落库前统一脱敏(password / token / secret 等键剔除);
|
||||
* - 直接写入(导出完成 / 下载等无 HTTP 审计上下文的场景)复用 `recordDirect`。
|
||||
*/
|
||||
import { auditActionMeta, type AuditResult, ulid } from '@lingxi/shared';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { sanitizeAuditValue, toPrismaJson } from '../../../common/security/audit-sanitize';
|
||||
import type { AuditSink, AuditWriteInput } from '../../../common/security/audit-sink';
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
|
||||
export interface DirectAuditInput {
|
||||
actionCode: string;
|
||||
result: AuditResult;
|
||||
actorId?: string | null;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
targetName?: string | null;
|
||||
customerId?: string | null;
|
||||
customerName?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
changes?: unknown;
|
||||
reason?: string | null;
|
||||
httpMethod?: string | null;
|
||||
httpPath?: string | null;
|
||||
traceId?: string | null;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
/** 按 DB 列长度截断,避免超长字段导致整条审计写入失败(审计宁可截断也不能丢) */
|
||||
function cut(value: string | null, max: number): string | null {
|
||||
if (value === null) return null;
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
}
|
||||
|
||||
export interface ResolvedActor {
|
||||
actorName: string | null;
|
||||
actorAccount: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditWriterService implements AuditSink {
|
||||
private readonly logger = new Logger('Audit');
|
||||
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async write(input: AuditWriteInput): Promise<void> {
|
||||
await this.persist({
|
||||
occurredAt: input.occurredAt,
|
||||
actorId: input.actorId,
|
||||
actorName: input.actorName,
|
||||
actorAccount: input.actorAccount,
|
||||
actionCode: input.actionCode,
|
||||
category: input.category,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
targetName: input.targetName,
|
||||
customerId: input.customerId,
|
||||
customerName: input.customerName,
|
||||
result: input.result,
|
||||
httpMethod: input.httpMethod,
|
||||
httpPath: input.httpPath,
|
||||
traceId: input.traceId,
|
||||
ip: input.ip,
|
||||
userAgent: input.userAgent,
|
||||
before: input.before,
|
||||
after: input.after,
|
||||
changes: input.changes,
|
||||
reason: input.reason,
|
||||
errorCode: input.errorCode,
|
||||
errorMessage: input.errorMessage,
|
||||
durationMs: input.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** 无请求上下文的直接审计(导出完成 / 导出下载 / 启动恢复等) */
|
||||
// eslint-disable-next-line complexity -- 直接审计需为所有可选字段兜底默认值
|
||||
async recordDirect(input: DirectAuditInput): Promise<void> {
|
||||
const actor = await this.resolveActor(input.actorId ?? null);
|
||||
const meta = auditActionMeta(input.actionCode);
|
||||
await this.persist({
|
||||
occurredAt: new Date(),
|
||||
actorId: input.actorId ?? null,
|
||||
actorName: actor.actorName,
|
||||
actorAccount: actor.actorAccount,
|
||||
actionCode: input.actionCode,
|
||||
category: meta.category,
|
||||
targetType: input.targetType ?? meta.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
targetName: input.targetName ?? null,
|
||||
customerId: input.customerId ?? null,
|
||||
customerName: input.customerName ?? null,
|
||||
result: input.result,
|
||||
httpMethod: input.httpMethod ?? null,
|
||||
httpPath: input.httpPath ?? null,
|
||||
traceId: input.traceId ?? null,
|
||||
ip: input.ip ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
before: input.before,
|
||||
after: input.after,
|
||||
changes: input.changes,
|
||||
reason: input.reason ?? null,
|
||||
errorCode: input.errorCode ?? null,
|
||||
errorMessage: input.errorMessage ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 解析操作人姓名 / 账号(审计冗余字段,避免列表再 join) */
|
||||
async resolveActor(userId: string | null): Promise<ResolvedActor> {
|
||||
if (userId === null) return { actorName: null, actorAccount: null };
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { name: true, account: true },
|
||||
});
|
||||
return {
|
||||
actorName: user?.name ?? null,
|
||||
actorAccount: user?.account ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { actorName: null, actorAccount: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(row: InternalAuditRow): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
occurredAt: row.occurredAt,
|
||||
actorId: cut(row.actorId, 26),
|
||||
actorName: cut(row.actorName, 64),
|
||||
actorAccount: cut(row.actorAccount, 64),
|
||||
actionCode: cut(row.actionCode, 64) ?? 'unknown',
|
||||
category: cut(row.category, 32) ?? 'other',
|
||||
targetType: cut(row.targetType, 64),
|
||||
targetId: cut(row.targetId, 128),
|
||||
targetName: cut(row.targetName, 160),
|
||||
customerId: cut(row.customerId, 26),
|
||||
customerName: cut(row.customerName, 128),
|
||||
result: row.result,
|
||||
httpMethod: cut(row.httpMethod, 8),
|
||||
httpPath: cut(row.httpPath, 255),
|
||||
traceId: cut(row.traceId, 64),
|
||||
ip: cut(row.ip, 64),
|
||||
userAgent: cut(row.userAgent, 255),
|
||||
beforeJson:
|
||||
row.before === undefined ? Prisma.DbNull : toPrismaJson(sanitizeAuditValue(row.before)),
|
||||
afterJson:
|
||||
row.after === undefined ? Prisma.DbNull : toPrismaJson(sanitizeAuditValue(row.after)),
|
||||
changesJson:
|
||||
row.changes === undefined
|
||||
? Prisma.DbNull
|
||||
: toPrismaJson(sanitizeAuditValue(row.changes)),
|
||||
reason: cut(row.reason, 255),
|
||||
errorCode: cut(row.errorCode, 32),
|
||||
errorMessage: cut(row.errorMessage, 500),
|
||||
durationMs: row.durationMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 审计失败只影响日志,不改变业务结果(A5-BE-02)
|
||||
this.logger.error(
|
||||
`审计落库失败 action=${row.actionCode} result=${row.result} error=${
|
||||
error instanceof Error ? error.message : '未知错误'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface InternalAuditRow {
|
||||
occurredAt: Date;
|
||||
actorId: string | null;
|
||||
actorName: string | null;
|
||||
actorAccount: string | null;
|
||||
actionCode: string;
|
||||
category: string;
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
targetName: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
result: AuditResult;
|
||||
httpMethod: string | null;
|
||||
httpPath: string | null;
|
||||
traceId: string | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
changes?: unknown;
|
||||
reason: string | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
durationMs: number | null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
type AuditExportCreateParsed,
|
||||
type AuditExportCreateResponse,
|
||||
auditExportCreateResponseSchema,
|
||||
auditExportCreateSchema,
|
||||
type AuditExportJob,
|
||||
type AuditExportListQuery,
|
||||
auditExportListQuerySchema,
|
||||
type AuditExportListResponse,
|
||||
type AuditLogActionsResponse,
|
||||
type AuditLogDetail,
|
||||
type AuditLogListQuery,
|
||||
auditLogListQuerySchema,
|
||||
type AuditLogListResponse,
|
||||
ErrorCode,
|
||||
Permission,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, Get, Inject, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppException } from '../../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentUser,
|
||||
Public,
|
||||
RequirePermissions,
|
||||
} from '../../../common/security/decorators';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditExportService } from './audit-export.service';
|
||||
|
||||
const downloadQuerySchema = z.object({ token: z.string().min(1).max(4096) });
|
||||
type DownloadQuery = z.infer<typeof downloadQuerySchema>;
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计日志(A5-BE-03 / 开发计划 §7.5):
|
||||
* - 检索 / 详情:特殊权限 `audit:read`(缺失走 40303);
|
||||
* - 导出 / 导出历史 / 下载:特殊权限 `audit:export`,下载走短时效签名链接(公开端点自行校验 token);
|
||||
* - 只读,没有任何修改入口。
|
||||
*/
|
||||
@ApiTags('platform-audit')
|
||||
@ApiBearerAuth()
|
||||
@Controller('audit-logs')
|
||||
export class AuditController {
|
||||
constructor(
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(AuditExportService) private readonly exports: AuditExportService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.AUDIT_READ)
|
||||
@Audit('audit.log.view')
|
||||
@ApiOperation({
|
||||
summary: '审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC)',
|
||||
})
|
||||
list(
|
||||
@Query(new ZodValidationPipe(auditLogListQuerySchema)) query: AuditLogListQuery,
|
||||
): Promise<AuditLogListResponse> {
|
||||
return this.audit.list(query);
|
||||
}
|
||||
|
||||
@Get('actions')
|
||||
@RequirePermissions(Permission.AUDIT_READ)
|
||||
@ApiOperation({ summary: '审计动作目录(中文名 / 分类 / 对象类型,供筛选下拉)' })
|
||||
actions(): AuditLogActionsResponse {
|
||||
return this.audit.actions();
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||
@Audit('audit.export')
|
||||
@ApiOperation({
|
||||
summary: '创建审计导出任务(异步,立即返回 queued;导出本身写审计)',
|
||||
})
|
||||
async createExport(
|
||||
@Body(new ZodValidationPipe(auditExportCreateSchema)) input: AuditExportCreateParsed,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<AuditExportCreateResponse> {
|
||||
const result = await this.exports.create(input, requireAuth(auth));
|
||||
return auditExportCreateResponseSchema.parse(result);
|
||||
}
|
||||
|
||||
@Get('exports')
|
||||
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||
@ApiOperation({ summary: '我的审计导出任务(按发起时间倒序)' })
|
||||
listExports(
|
||||
@Query(new ZodValidationPipe(auditExportListQuerySchema)) query: AuditExportListQuery,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<AuditExportListResponse> {
|
||||
return this.exports.listExports(query, requireAuth(auth).userId);
|
||||
}
|
||||
|
||||
@Get('exports/:id')
|
||||
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||
@ApiOperation({ summary: '导出任务进度(status / progress / rowCount / fileSize / expiresAt)' })
|
||||
getExport(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<AuditExportJob> {
|
||||
return this.exports.getExport(id, requireAuth(auth).userId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('exports/:id/download')
|
||||
@ApiOperation({ summary: '下载审计导出 CSV(短时效 HMAC 签名链接,无需登录)' })
|
||||
async download(
|
||||
@Param('id') id: string,
|
||||
@Query(new ZodValidationPipe(downloadQuerySchema)) query: DownloadQuery,
|
||||
@Res() reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
await this.exports.download(id, query.token, reply);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.AUDIT_READ)
|
||||
@Audit('audit.log.view')
|
||||
@ApiOperation({ summary: '审计日志详情(before / after / changes / diff / trace / ip / ua)' })
|
||||
detail(@Param('id') id: string): Promise<AuditLogDetail> {
|
||||
return this.audit.detail(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../../bootstrap';
|
||||
import { loadDotenv } from '../../../config/load-dotenv';
|
||||
|
||||
/**
|
||||
* A5 审计集成用例(真实 PG / Redis,进程内 Fastify inject)
|
||||
*
|
||||
* 覆盖:七类动作落库(登录 / 登出、权限、客户、字典、配置、审计查看、导出)、
|
||||
* 失败请求落库、before/after diff、游标分页稳定、筛选、append-only(UPDATE / DELETE / TRUNCATE
|
||||
* 与 lingxi_app 权限)、分区幂等、导出全链路(创建 → 轮询 → 下载 → 自身审计)、特殊权限 40303。
|
||||
*
|
||||
* 说明:`audit_logs` 是 append-only,**afterAll 不删除审计数据**(删除即违反 DB 约束);
|
||||
* 通过独立账号 / 客户 / 字典前缀与动作筛选隔离,重复执行不互相干扰。
|
||||
*/
|
||||
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const ACCOUNT_PREFIX = 'ita5.';
|
||||
const ROLE_PREFIX = 'ITA5-角色-';
|
||||
const INDUSTRY_PREFIX = 'ITA5-';
|
||||
const CUSTOMER_PREFIX = 'ITA5-客户-';
|
||||
const DICT_PREFIX = 'ita5_';
|
||||
const PREFIX_ROLE_CODE = 'ita5_role_';
|
||||
const API = API_PREFIX;
|
||||
const LOGS_URL = 'https://logs.lingxi.test/grafana';
|
||||
|
||||
loadDotenv();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface JsonResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
interface ErrorBody {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LoginBody {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface AuditItemBody {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor_id: string | null;
|
||||
actor_name: string | null;
|
||||
actor_account: string | null;
|
||||
action_code: string;
|
||||
action_label: string;
|
||||
category: string;
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
customer_id: string | null;
|
||||
customer_name: string | null;
|
||||
result: 'success' | 'failure';
|
||||
change_count: number;
|
||||
}
|
||||
|
||||
interface AuditListBody {
|
||||
items: AuditItemBody[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
interface AuditDetailBody extends AuditItemBody {
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
changes: unknown;
|
||||
diff: { path: string; label: string | null; before: unknown; after: unknown; kind: string }[];
|
||||
trace_id: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
interface ExportJobBody {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
progress: number;
|
||||
total_rows: number | null;
|
||||
row_count: number | null;
|
||||
truncated: boolean;
|
||||
file_size: number | null;
|
||||
expires_at: string | null;
|
||||
download_count: number;
|
||||
download_token: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
interface CustomerBody {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let phoneCounter = 0;
|
||||
let app: CreatedApiApp['app'];
|
||||
let adminToken = '';
|
||||
let adminRefreshToken = '';
|
||||
let adminUserId = '';
|
||||
let limitedToken = '';
|
||||
let industryId = '';
|
||||
let customerId = '';
|
||||
const dictTypeCode = `${DICT_PREFIX}audit_demo`;
|
||||
const createdRoleIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
const createdDictCodes: string[] = [];
|
||||
const createdExportJobIds: string[] = [];
|
||||
|
||||
function nextPhone(): string {
|
||||
phoneCounter += 1;
|
||||
return `136${String(phoneCounter).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
async function createRole(
|
||||
suffix: string,
|
||||
permissions: string[],
|
||||
special: string[] = [],
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `${PREFIX_ROLE_CODE}${suffix}`,
|
||||
name: `${ROLE_PREFIX}${suffix}`,
|
||||
description: 'A5 审计集成测试角色',
|
||||
status: 'ACTIVE',
|
||||
permissions: {
|
||||
create: [...permissions, ...special].map((permissionCode) => ({ permissionCode })),
|
||||
},
|
||||
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createUser(suffix: string, roleId: string): Promise<string> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `A5 审计集成${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: nextPhone(),
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
createdUserIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function login(account: string): Promise<LoginBody> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||
}
|
||||
return response.json<LoginBody>();
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<JsonResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}`, 'user-agent': 'ita5-vitest' },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||
return { status: response.statusCode, body };
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForExport(jobId: string, token: string): Promise<ExportJobBody> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const result = await call<ExportJobBody>('GET', `/audit-logs/exports/${jobId}`, token);
|
||||
expect(result.status).toBe(200);
|
||||
if (result.body.status === 'succeeded' || result.body.status === 'failed') {
|
||||
return result.body;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error('审计导出任务超时未完成');
|
||||
}
|
||||
|
||||
async function createCustomer(name: string): Promise<string> {
|
||||
const created = await call<CustomerBody>('POST', '/customers', adminToken, {
|
||||
industryId,
|
||||
name,
|
||||
shortName: name.slice(-6),
|
||||
ownerUserId: adminUserId,
|
||||
remark: 'A5 审计集成测试客户',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
return created.body.id;
|
||||
}
|
||||
|
||||
async function auditRows(where: Record<string, unknown>): Promise<AuditItemBody[]> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
take: 100,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
occurred_at: row.occurredAt.toISOString(),
|
||||
actor_id: row.actorId,
|
||||
actor_name: row.actorName,
|
||||
actor_account: row.actorAccount,
|
||||
action_code: row.actionCode,
|
||||
action_label: '',
|
||||
category: row.category,
|
||||
target_type: row.targetType,
|
||||
target_id: row.targetId,
|
||||
target_name: row.targetName,
|
||||
customer_id: row.customerId,
|
||||
customer_name: row.customerName,
|
||||
result: row.result === 'failure' ? 'failure' : 'success',
|
||||
change_count: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function cleanupFixtures(): Promise<void> {
|
||||
await prisma.auditExportJob.deleteMany({ where: { requestedBy: { in: createdUserIds } } });
|
||||
await prisma.customerField.deleteMany({
|
||||
where: { customer: { name: { startsWith: CUSTOMER_PREFIX } } },
|
||||
});
|
||||
await prisma.customerGroupMember.deleteMany({
|
||||
where: { customer: { name: { startsWith: CUSTOMER_PREFIX } } },
|
||||
});
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: CUSTOMER_PREFIX } } });
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||
}
|
||||
if (createdDictCodes.length > 0) {
|
||||
await prisma.dictType.deleteMany({ where: { code: { in: createdDictCodes } } });
|
||||
}
|
||||
await prisma.industry.deleteMany({ where: { name: { startsWith: INDUSTRY_PREFIX } } });
|
||||
}
|
||||
|
||||
let testStartedAt = new Date();
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanupFixtures();
|
||||
createdRoleIds.length = 0;
|
||||
createdUserIds.length = 0;
|
||||
createdDictCodes.length = 0;
|
||||
createdExportJobIds.length = 0;
|
||||
testStartedAt = new Date();
|
||||
|
||||
const adminRole = await createRole(
|
||||
'admin',
|
||||
[
|
||||
Permission.CUSTOMER_READ,
|
||||
Permission.CUSTOMER_MANAGE,
|
||||
Permission.ROLE_VIEW,
|
||||
Permission.ROLE_MANAGE,
|
||||
Permission.DICT_MANAGE,
|
||||
Permission.SYSTEM_CONFIGURE,
|
||||
Permission.USER_VIEW,
|
||||
],
|
||||
[Permission.AUDIT_READ, Permission.AUDIT_EXPORT],
|
||||
);
|
||||
adminUserId = await createUser('admin', adminRole);
|
||||
const limitedRole = await createRole('limited', [Permission.CUSTOMER_READ]);
|
||||
await createUser('limited', limitedRole);
|
||||
|
||||
const root = await prisma.industry.create({
|
||||
data: { id: ulid(), parentId: null, level: 1, name: `${INDUSTRY_PREFIX}电商`, sortOrder: 10 },
|
||||
});
|
||||
const leaf = await prisma.industry.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
parentId: root.id,
|
||||
level: 2,
|
||||
name: `${INDUSTRY_PREFIX}服饰`,
|
||||
sortOrder: 10,
|
||||
},
|
||||
});
|
||||
industryId = leaf.id;
|
||||
|
||||
const created = await createApp();
|
||||
app = created.app;
|
||||
await app.init();
|
||||
const loginBody = await login(`${ACCOUNT_PREFIX}admin`);
|
||||
adminToken = loginBody.accessToken;
|
||||
adminRefreshToken = loginBody.refreshToken;
|
||||
limitedToken = (await login(`${ACCOUNT_PREFIX}limited`)).accessToken;
|
||||
customerId = await createCustomer(`${CUSTOMER_PREFIX}花间集`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtures();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('A5 审计 · 采集(七类动作 + 失败请求 + 前后值)', () => {
|
||||
it('登录 / 登出留痕:actor、result、IP、UA、trace_id 完整', async () => {
|
||||
const logins = await auditRows({ actionCode: 'identity.auth.login', actorId: adminUserId });
|
||||
expect(logins.length).toBeGreaterThanOrEqual(1);
|
||||
const login = logins[0]!;
|
||||
expect(login.result).toBe('success');
|
||||
expect(login.actor_name).toBe('A5 审计集成admin');
|
||||
expect(login.target_type).toBe('user');
|
||||
expect(login.target_id).toBe(adminUserId);
|
||||
|
||||
const logout = await call<null>('POST', '/auth/logout', limitedToken, {
|
||||
refreshToken: adminRefreshToken,
|
||||
});
|
||||
expect(logout.status).toBe(204);
|
||||
const logouts = await auditRows({ actionCode: 'identity.auth.logout', actorId: adminUserId });
|
||||
expect(logouts.length).toBeGreaterThanOrEqual(1);
|
||||
expect(logouts[0]?.target_id).toBe(adminUserId);
|
||||
|
||||
const raw = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
expect(raw?.ip).toBeTruthy();
|
||||
expect(raw?.userAgent).toBeTruthy();
|
||||
expect(raw?.traceId).toBeTruthy();
|
||||
expect(raw?.httpMethod).toBe('POST');
|
||||
expect(raw?.httpPath).toContain('/auth/login');
|
||||
// 绝不记录密码
|
||||
expect(JSON.stringify(raw?.afterJson)).not.toContain(PASSWORD);
|
||||
});
|
||||
|
||||
it('权限变更 / 客户变更 / 字典变更 / 配置变更 / 审计查看 均落库并可 diff', async () => {
|
||||
// 权限:新建角色
|
||||
const createdRole = await call<{ role: { id: string; name: string } }>(
|
||||
'POST',
|
||||
'/roles',
|
||||
adminToken,
|
||||
{
|
||||
name: `${ROLE_PREFIX}审计采样`,
|
||||
description: 'A5 审计采样',
|
||||
permissions: [Permission.USER_VIEW],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
);
|
||||
expect(createdRole.status).toBe(201);
|
||||
createdRoleIds.push(createdRole.body.role.id);
|
||||
|
||||
// 客户:修改备注
|
||||
const updated = await call<CustomerBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||
remark: 'A5 审计集成测试:备注已更新',
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
|
||||
// 字典:新建类型
|
||||
const dict = await call<{ code: string }>('POST', '/dicts', adminToken, {
|
||||
code: dictTypeCode,
|
||||
name: 'A5 审计演示字典',
|
||||
group: 'content',
|
||||
kind: 'list',
|
||||
description: 'A5 审计集成测试',
|
||||
});
|
||||
expect(dict.status).toBe(201);
|
||||
createdDictCodes.push(dictTypeCode);
|
||||
|
||||
// 配置:保存 audit.logs_platform_url
|
||||
const config = await call<{ items: unknown[] }>('PUT', '/system/configs', adminToken, {
|
||||
changes: [{ key: 'audit.logs_platform_url', value: LOGS_URL }],
|
||||
});
|
||||
expect(config.status).toBe(200);
|
||||
|
||||
// 审计查看:列表
|
||||
const list = await call<AuditListBody>('GET', '/audit-logs?limit=5', adminToken);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.items.length).toBeGreaterThan(0);
|
||||
|
||||
const expectedActions = [
|
||||
'identity.role.create',
|
||||
'client.customer.update',
|
||||
'catalog.dict_type.create',
|
||||
'platform.system_config.update',
|
||||
'audit.log.view',
|
||||
];
|
||||
for (const actionCode of expectedActions) {
|
||||
const rows = await auditRows({ actionCode });
|
||||
expect(rows.length, `动作 ${actionCode} 应落库`).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// 客户变更详情:before/after 可 diff
|
||||
const detail = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'client.customer.update', targetId: customerId },
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
expect(detail).not.toBeNull();
|
||||
const detailId = detail?.id ?? '';
|
||||
expect(detailId).not.toBe('');
|
||||
const detailResponse = await call<AuditDetailBody>(
|
||||
'GET',
|
||||
`/audit-logs/${detailId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(detailResponse.status).toBe(200);
|
||||
const remarkDiff = detailResponse.body.diff.find((row) => row.path === 'remark');
|
||||
expect(remarkDiff).toMatchObject({
|
||||
before: 'A5 审计集成测试客户',
|
||||
after: 'A5 审计集成测试:备注已更新',
|
||||
kind: 'changed',
|
||||
});
|
||||
expect(detailResponse.body.customer_id).toBe(customerId);
|
||||
expect(detailResponse.body.trace_id).toBeTruthy();
|
||||
|
||||
// 查看详情本身留痕:target_id = 被查看审计 id
|
||||
const viewRows = await prisma.auditLog.findMany({
|
||||
where: { actionCode: 'audit.log.view', targetId: detailId },
|
||||
});
|
||||
expect(viewRows.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 恢复配置,避免影响其他测试(同样会留审计)
|
||||
await call<{ items: unknown[] }>('PUT', '/system/configs', adminToken, {
|
||||
changes: [{ key: 'audit.logs_platform_url', value: '' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('失败请求也落库:result=failure、error_code/error_message,不吞原错误', async () => {
|
||||
const before = new Date();
|
||||
const failed = await call<ErrorBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||
name: '',
|
||||
});
|
||||
expect(failed.status).toBe(400);
|
||||
expect(failed.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
|
||||
const rows = await auditRows({
|
||||
actionCode: 'client.customer.update',
|
||||
result: 'failure',
|
||||
targetId: customerId,
|
||||
occurredAt: { gte: before },
|
||||
});
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const raw = await prisma.auditLog.findFirst({
|
||||
where: {
|
||||
actionCode: 'client.customer.update',
|
||||
result: 'failure',
|
||||
targetId: customerId,
|
||||
occurredAt: { gte: before },
|
||||
},
|
||||
});
|
||||
expect(raw?.errorCode).toBe(String(ErrorCode.PARAM_INVALID));
|
||||
expect(raw?.errorMessage?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A5 审计 · 检索(筛选 / 游标 / 只读)', () => {
|
||||
it('动作目录:返回中文名、分类与服务端筛选数据', async () => {
|
||||
const actions = await call<{
|
||||
items: { code: string; label: string; category: string; target_type: string | null }[];
|
||||
categories: { key: string; label: string }[];
|
||||
}>('GET', '/audit-logs/actions', adminToken);
|
||||
expect(actions.status).toBe(200);
|
||||
expect(actions.body.items.length).toBeGreaterThanOrEqual(50);
|
||||
expect(actions.body.items.find((item) => item.code === 'audit.export')?.label).toBe(
|
||||
'导出审计日志',
|
||||
);
|
||||
expect(actions.body.categories).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('筛选生效:action_code / category / result / customer_id / actor / 时间窗', async () => {
|
||||
const byAction = await call<AuditListBody>(
|
||||
'GET',
|
||||
'/audit-logs?action_code=client.customer.update&limit=50',
|
||||
adminToken,
|
||||
);
|
||||
expect(byAction.status).toBe(200);
|
||||
expect(byAction.body.items.every((item) => item.action_code === 'client.customer.update')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const byCategory = await call<AuditListBody>('GET', '/audit-logs?category=client', adminToken);
|
||||
expect(byCategory.body.items.every((item) => item.category === 'client')).toBe(true);
|
||||
|
||||
const byResult = await call<AuditListBody>('GET', '/audit-logs?result=failure', adminToken);
|
||||
expect(byResult.body.items.every((item) => item.result === 'failure')).toBe(true);
|
||||
|
||||
const byCustomer = await call<AuditListBody>(
|
||||
'GET',
|
||||
`/audit-logs?customer_id=${customerId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(byCustomer.body.items.every((item) => item.customer_id === customerId)).toBe(true);
|
||||
|
||||
const byActor = await call<AuditListBody>(
|
||||
'GET',
|
||||
`/audit-logs?actor=${encodeURIComponent('A5 审计集成admin')}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(byActor.body.items.every((item) => item.actor_name === 'A5 审计集成admin')).toBe(true);
|
||||
|
||||
const from = encodeURIComponent(testStartedAt.toISOString());
|
||||
const byTime = await call<AuditListBody>(`GET`, `/audit-logs?from=${from}`, adminToken);
|
||||
expect(byTime.body.items.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
byTime.body.items.every(
|
||||
(item) => new Date(item.occurred_at).getTime() >= testStartedAt.getTime(),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const missing = await call<AuditListBody>(
|
||||
'GET',
|
||||
'/audit-logs?customer_id=01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||
adminToken,
|
||||
);
|
||||
expect(missing.status).toBe(200);
|
||||
expect(missing.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('列表只返回摘要(无 before/after/changes),详情才有 diff 与 UA', async () => {
|
||||
const list = await call<AuditListBody>(
|
||||
'GET',
|
||||
'/audit-logs?action_code=client.customer.update&result=success&limit=1',
|
||||
adminToken,
|
||||
);
|
||||
const item = list.body.items[0];
|
||||
expect(item).toBeDefined();
|
||||
expect(item).not.toHaveProperty('before');
|
||||
expect(item).not.toHaveProperty('after');
|
||||
expect(item?.change_count).toBeGreaterThan(0);
|
||||
|
||||
const detail = await call<AuditDetailBody>('GET', `/audit-logs/${item!.id}`, adminToken);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.user_agent).toBeTruthy();
|
||||
expect(Array.isArray(detail.body.diff)).toBe(true);
|
||||
|
||||
const missing = await call<ErrorBody>(
|
||||
'GET',
|
||||
'/audit-logs/01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||
adminToken,
|
||||
);
|
||||
expect(missing.status).toBe(404);
|
||||
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('游标分页稳定:同毫秒写入不重不漏,排序 occurred_at DESC, id DESC', async () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const updated = await call<CustomerBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||
remark: `A5 游标分页 ${String(index)}`,
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
}
|
||||
const baseQuery = `/audit-logs?action_code=client.customer.update&target_id=${customerId}&result=success&limit=2`;
|
||||
const seen: AuditItemBody[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < 20; page += 1) {
|
||||
const url: string =
|
||||
cursor === null ? baseQuery : `${baseQuery}&cursor=${encodeURIComponent(cursor)}`;
|
||||
const result = await call<AuditListBody>('GET', url, adminToken);
|
||||
expect(result.status).toBe(200);
|
||||
seen.push(...result.body.items);
|
||||
cursor = result.body.next_cursor;
|
||||
if (cursor === null) break;
|
||||
}
|
||||
expect(seen.length).toBeGreaterThanOrEqual(5);
|
||||
expect(new Set(seen.map((item) => item.id)).size).toBe(seen.length);
|
||||
for (let index = 1; index < seen.length; index += 1) {
|
||||
const previous = seen[index - 1]!;
|
||||
const current = seen[index]!;
|
||||
const delta =
|
||||
new Date(previous.occurred_at).getTime() - new Date(current.occurred_at).getTime();
|
||||
if (delta === 0) {
|
||||
expect(previous.id > current.id).toBe(true);
|
||||
} else {
|
||||
expect(delta).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
// 非法游标
|
||||
const invalid = await call<ErrorBody>('GET', '/audit-logs?cursor=bad-cursor', adminToken);
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('特殊权限:无 audit:read / audit:export 分别 40303,且不泄露数据', async () => {
|
||||
const read = await call<ErrorBody>('GET', '/audit-logs', limitedToken);
|
||||
expect(read.status).toBe(403);
|
||||
expect(read.body.code).toBe(ErrorCode.SPECIAL_PERMISSION_REQUIRED);
|
||||
|
||||
const exportDenied = await call<ErrorBody>('POST', '/audit-logs/export', limitedToken, {
|
||||
format: 'csv',
|
||||
filters: {},
|
||||
});
|
||||
expect(exportDenied.status).toBe(403);
|
||||
expect(exportDenied.body.code).toBe(ErrorCode.SPECIAL_PERMISSION_REQUIRED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A5 审计 · append-only 与分区', () => {
|
||||
it('DB 层拒绝 UPDATE / DELETE / TRUNCATE,数据保持不变', async () => {
|
||||
const target = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||
});
|
||||
expect(target).not.toBeNull();
|
||||
const id = target?.id ?? '';
|
||||
const occurredAt = target?.occurredAt ?? new Date();
|
||||
const countBefore = await prisma.auditLog.count();
|
||||
|
||||
await expect(
|
||||
prisma.$executeRawUnsafe(
|
||||
`UPDATE audit_logs SET result = 'failure' WHERE id = '${id}' AND occurred_at = '${occurredAt.toISOString()}'`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
prisma.$executeRawUnsafe(
|
||||
`DELETE FROM audit_logs WHERE id = '${id}' AND occurred_at = '${occurredAt.toISOString()}'`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(prisma.$executeRawUnsafe('TRUNCATE audit_logs')).rejects.toThrow();
|
||||
|
||||
expect(await prisma.auditLog.count()).toBe(countBefore);
|
||||
const still = await prisma.auditLog.findFirst({ where: { id } });
|
||||
expect(still?.result).toBe('success');
|
||||
});
|
||||
|
||||
it('lingxi_app 角色:审计表只能 SELECT / INSERT,UPDATE 被权限拒绝', async () => {
|
||||
const target = await prisma.auditLog.findFirst({
|
||||
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||
});
|
||||
expect(target).not.toBeNull();
|
||||
await expect(
|
||||
prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe('SET LOCAL ROLE lingxi_app');
|
||||
const current = await tx.$queryRawUnsafe<{ current_user: string }[]>(
|
||||
'SELECT current_user AS current_user',
|
||||
);
|
||||
expect(current[0]?.current_user).toBe('lingxi_app');
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO audit_logs (id, occurred_at, action_code, category, result) VALUES ('01HZZZZZZZZZZZZZZZZZZZZZZYY', now(), 'audit.log.view', 'sensitive', 'success')`,
|
||||
);
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE audit_logs SET result = 'failure' WHERE id = '${target?.id ?? ''}'`,
|
||||
);
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('分区函数幂等,当前月分区存在', async () => {
|
||||
const first = await prisma.$queryRawUnsafe<{ created: number }[]>(
|
||||
'SELECT audit_logs_ensure_partitions(13) AS created',
|
||||
);
|
||||
const second = await prisma.$queryRawUnsafe<{ created: number }[]>(
|
||||
'SELECT audit_logs_ensure_partitions(13) AS created',
|
||||
);
|
||||
expect(typeof first[0]?.created).toBe('number');
|
||||
expect(second[0]?.created).toBe(0);
|
||||
|
||||
const now = new Date();
|
||||
const name = `audit_logs_${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
const partitions = await prisma.$queryRawUnsafe<{ name: string | null }[]>(
|
||||
`SELECT to_regclass('${name}')::text AS name`,
|
||||
);
|
||||
expect(partitions[0]?.name).toBe(name);
|
||||
|
||||
await expect(
|
||||
prisma.$queryRawUnsafe('SELECT audit_logs_ensure_partitions(999)'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('A5 审计 · 导出全链路(异步 + 自身留痕)', () => {
|
||||
it('创建 → 轮询 succeeded → 签名下载 CSV → 下载计数 + audit.export.download', async () => {
|
||||
const exportAuditBefore = await prisma.auditLog.count({
|
||||
where: { actionCode: 'audit.export' },
|
||||
});
|
||||
const created = await call<{ id: string; status: string }>(
|
||||
'POST',
|
||||
'/audit-logs/export',
|
||||
adminToken,
|
||||
{
|
||||
format: 'csv',
|
||||
filters: { action_code: 'client.customer.update', target_id: customerId },
|
||||
},
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.status).toBe('queued');
|
||||
createdExportJobIds.push(created.body.id);
|
||||
|
||||
const job = await waitForExport(created.body.id, adminToken);
|
||||
expect(job.status).toBe('succeeded');
|
||||
expect(job.progress).toBe(100);
|
||||
expect(job.row_count).toBeGreaterThan(0);
|
||||
expect(job.truncated).toBe(false);
|
||||
expect(job.download_token).toBeTruthy();
|
||||
|
||||
// 导出自身留痕(创建 + 完成)
|
||||
const exportAuditAfter = await prisma.auditLog.count({ where: { actionCode: 'audit.export' } });
|
||||
expect(exportAuditAfter).toBeGreaterThan(exportAuditBefore);
|
||||
|
||||
// 导出历史与本人任务
|
||||
const jobs = await call<{ items: ExportJobBody[] }>('GET', '/audit-logs/exports', adminToken);
|
||||
expect(jobs.status).toBe(200);
|
||||
expect(jobs.body.items.some((item) => item.id === created.body.id)).toBe(true);
|
||||
|
||||
// 签名下载
|
||||
const token = job.download_token ?? '';
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/audit-logs/exports/${created.body.id}/download?token=${encodeURIComponent(token)}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/csv');
|
||||
expect(response.headers['content-disposition']).toContain('attachment');
|
||||
expect(response.body.startsWith('\uFEFF')).toBe(true);
|
||||
expect(response.body).toContain('occurred_at,actor,account,action_code,action_label');
|
||||
expect(response.body).toContain('client.customer.update');
|
||||
|
||||
// 下载计数 + 下载审计
|
||||
const afterDownload = await call<ExportJobBody>(
|
||||
'GET',
|
||||
`/audit-logs/exports/${created.body.id}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(afterDownload.body.download_count).toBe(1);
|
||||
const downloadAudit = await prisma.auditLog.findMany({
|
||||
where: { actionCode: 'audit.export.download', targetId: created.body.id },
|
||||
});
|
||||
expect(downloadAudit.length).toBe(1);
|
||||
expect(downloadAudit[0]?.actorId).toBe(adminUserId);
|
||||
|
||||
// 篡改 token / 他人任务不可见
|
||||
const tampered = await app.inject({
|
||||
method: 'GET',
|
||||
url: `${API}/audit-logs/exports/${created.body.id}/download?token=${encodeURIComponent(`${token}x`)}`,
|
||||
});
|
||||
expect(tampered.statusCode).toBe(422);
|
||||
const othersJob = await call<ErrorBody>(
|
||||
'GET',
|
||||
`/audit-logs/exports/${created.body.id}`,
|
||||
limitedToken,
|
||||
);
|
||||
expect([403, 404]).toContain(othersJob.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AuditExportJob, AuditLog } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
countAuditChanges,
|
||||
toAuditActionCatalog,
|
||||
toAuditDetail,
|
||||
toAuditExportJob,
|
||||
toAuditListItem,
|
||||
} from './audit.mapper';
|
||||
|
||||
function logRow(overrides: Partial<AuditLog> = {}): AuditLog {
|
||||
return {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
occurredAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||
actorId: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||
actorName: '江岚',
|
||||
actorAccount: 'jiang.lan',
|
||||
actionCode: 'client.customer.update',
|
||||
category: 'client',
|
||||
targetType: 'customer',
|
||||
targetId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||
targetName: '花间集',
|
||||
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||
customerName: '花间集',
|
||||
result: 'success',
|
||||
httpMethod: 'PATCH',
|
||||
httpPath: '/api/v1/customers/1',
|
||||
traceId: 'trace-1',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'vitest',
|
||||
beforeJson: { name: '旧名', status: 'ACTIVE' },
|
||||
afterJson: { name: '花间集', status: 'ACTIVE' },
|
||||
changesJson: null,
|
||||
reason: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
durationMs: 12,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jobRow(overrides: Partial<AuditExportJob> = {}): AuditExportJob {
|
||||
return {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZW',
|
||||
status: 'running',
|
||||
format: 'csv',
|
||||
filtersJson: { result: 'failure' },
|
||||
requestedBy: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||
requestedAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||
startedAt: new Date('2026-11-04T08:00:01.000Z'),
|
||||
finishedAt: null,
|
||||
totalRows: 100,
|
||||
rowCount: null,
|
||||
processedRows: 50,
|
||||
truncated: false,
|
||||
filePath: null,
|
||||
fileSize: null,
|
||||
errorMessage: null,
|
||||
expiresAt: null,
|
||||
downloadCount: 0,
|
||||
lastDownloadedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('审计映射(A5-BE-03)', () => {
|
||||
it('列表摘要:中文名 / 分类 / 变更字段数 / 不返回前后值', () => {
|
||||
const item = toAuditListItem(logRow());
|
||||
expect(item).toMatchObject({
|
||||
action_code: 'client.customer.update',
|
||||
action_label: '编辑客户',
|
||||
category: 'client',
|
||||
category_label: '客户与行业',
|
||||
target_name: '花间集',
|
||||
result: 'success',
|
||||
change_count: 1,
|
||||
has_before: true,
|
||||
has_after: true,
|
||||
});
|
||||
expect('before' in item).toBe(false);
|
||||
expect('diff' in item).toBe(false);
|
||||
});
|
||||
|
||||
it('detail:包含 before / after / changes / diff / trace / ip / ua', () => {
|
||||
const detail = toAuditDetail(logRow());
|
||||
expect(detail.before).toEqual({ name: '旧名', status: 'ACTIVE' });
|
||||
expect(detail.after).toEqual({ name: '花间集', status: 'ACTIVE' });
|
||||
expect(detail.diff).toEqual([
|
||||
{ path: 'name', label: null, before: '旧名', after: '花间集', kind: 'changed' },
|
||||
]);
|
||||
expect(detail.trace_id).toBe('trace-1');
|
||||
expect(detail.ip).toBe('127.0.0.1');
|
||||
expect(detail.user_agent).toBe('vitest');
|
||||
});
|
||||
|
||||
it('未知动作走兜底 label,result 非法值按 success', () => {
|
||||
const item = toAuditListItem(logRow({ actionCode: 'unknown.action', category: 'other' }));
|
||||
expect(item.action_label).toBe('unknown.action');
|
||||
expect(item.category_label).toBe('其他');
|
||||
expect(toAuditListItem(logRow({ result: 'weird' })).result).toBe('success');
|
||||
});
|
||||
|
||||
it('变更字段数:无 diff 时按 changes 的 added / removed 计数', () => {
|
||||
expect(countAuditChanges(null, null, null)).toBe(0);
|
||||
expect(
|
||||
countAuditChanges(null, null, {
|
||||
permissions: { added: ['a', 'b'], removed: ['c'] },
|
||||
dataScope: 'all',
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('动作目录按分类顺序输出并含中文标签', () => {
|
||||
const items = toAuditActionCatalog();
|
||||
expect(items.length).toBeGreaterThanOrEqual(50);
|
||||
const login = items.find((item) => item.code === 'identity.auth.login');
|
||||
expect(login).toMatchObject({ label: '登录', category: 'auth', sensitive: true });
|
||||
const categories = items.map((item) => item.category);
|
||||
expect(categories[0]).toBe('auth');
|
||||
expect(categories.at(-1)).toBe('sensitive');
|
||||
});
|
||||
|
||||
it('导出任务:running 进度按 processed / total 计算,succeeded 为 100', () => {
|
||||
expect(toAuditExportJob(jobRow()).progress).toBe(50);
|
||||
expect(toAuditExportJob(jobRow({ status: 'queued', processedRows: null })).progress).toBe(0);
|
||||
expect(toAuditExportJob(jobRow({ status: 'succeeded' })).progress).toBe(100);
|
||||
expect(toAuditExportJob(jobRow({ status: 'failed' })).progress).toBe(100);
|
||||
expect(toAuditExportJob(jobRow())).toMatchObject({
|
||||
filters: { result: 'failure' },
|
||||
total_rows: 100,
|
||||
processed_rows: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 审计实体 → 共享契约映射(A5-BE-03)
|
||||
*
|
||||
* - 列表只给摘要与变更字段数;详情才返回 before / after / changes 与 diff;
|
||||
* - 中文名 / 分类来自 `@lingxi/shared` 的 `AUDIT_ACTION_CATALOG`,表里只存 code。
|
||||
*/
|
||||
import {
|
||||
AUDIT_ACTION_CATALOG,
|
||||
AUDIT_CATEGORIES,
|
||||
type AuditActionCatalogItem,
|
||||
auditActionMeta,
|
||||
auditCategoryLabel,
|
||||
type AuditExportJob,
|
||||
type AuditLogDetail,
|
||||
type AuditLogFilter,
|
||||
auditLogFilterSchema,
|
||||
type AuditLogItem,
|
||||
buildAuditDiff,
|
||||
} from '@lingxi/shared';
|
||||
import type { AuditExportJob as AuditExportJobRow, AuditLog } from '@prisma/client';
|
||||
|
||||
/** 变更字段数:优先前后值 diff;仅有 changes 时按 added/removed 粒度估算(仅用于列表摘要) */
|
||||
// eslint-disable-next-line complexity -- 变更计数兼容 diff / changes 两种来源
|
||||
export function countAuditChanges(before: unknown, after: unknown, changes: unknown): number {
|
||||
const diff = buildAuditDiff(before, after);
|
||||
if (diff.length > 0) return diff.length;
|
||||
if (typeof changes !== 'object' || changes === null || Array.isArray(changes)) {
|
||||
return changes === null || changes === undefined ? 0 : 1;
|
||||
}
|
||||
let count = 0;
|
||||
for (const value of Object.values(changes)) {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// 运行时已用 Array.isArray 收窄,descriptor.value 的 any 不会外泄
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const added = Object.getOwnPropertyDescriptor(value, 'added')?.value;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const removed = Object.getOwnPropertyDescriptor(value, 'removed')?.value;
|
||||
if (Array.isArray(added) || Array.isArray(removed)) {
|
||||
count +=
|
||||
(Array.isArray(added) ? added.length : 0) + (Array.isArray(removed) ? removed.length : 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function toAuditListItem(row: AuditLog): AuditLogItem {
|
||||
const meta = auditActionMeta(row.actionCode);
|
||||
return {
|
||||
id: row.id,
|
||||
occurred_at: row.occurredAt.toISOString(),
|
||||
actor_id: row.actorId,
|
||||
actor_name: row.actorName,
|
||||
actor_account: row.actorAccount,
|
||||
action_code: row.actionCode,
|
||||
action_label: meta.label,
|
||||
category: row.category,
|
||||
category_label: auditCategoryLabel(row.category),
|
||||
target_type: row.targetType,
|
||||
target_id: row.targetId,
|
||||
target_name: row.targetName,
|
||||
customer_id: row.customerId,
|
||||
customer_name: row.customerName,
|
||||
result: row.result === 'failure' ? 'failure' : 'success',
|
||||
http_method: row.httpMethod,
|
||||
http_path: row.httpPath,
|
||||
trace_id: row.traceId,
|
||||
ip: row.ip,
|
||||
duration_ms: row.durationMs,
|
||||
error_code: row.errorCode,
|
||||
error_message: row.errorMessage,
|
||||
change_count: countAuditChanges(row.beforeJson, row.afterJson, row.changesJson),
|
||||
has_before: row.beforeJson !== null,
|
||||
has_after: row.afterJson !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toAuditDetail(row: AuditLog): AuditLogDetail {
|
||||
return {
|
||||
...toAuditListItem(row),
|
||||
user_agent: row.userAgent,
|
||||
reason: row.reason,
|
||||
before: row.beforeJson,
|
||||
after: row.afterJson,
|
||||
changes: row.changesJson,
|
||||
diff: buildAuditDiff(row.beforeJson, row.afterJson),
|
||||
};
|
||||
}
|
||||
|
||||
export function toAuditActionCatalog(): AuditActionCatalogItem[] {
|
||||
const categoryOrder = new Map<string, number>(
|
||||
AUDIT_CATEGORIES.map((item, index) => [item.key, index]),
|
||||
);
|
||||
return Object.values(AUDIT_ACTION_CATALOG)
|
||||
.map((meta) => ({
|
||||
code: meta.code,
|
||||
label: meta.label,
|
||||
category: meta.category,
|
||||
category_label: auditCategoryLabel(meta.category),
|
||||
target_type: meta.targetType ?? null,
|
||||
sensitive: meta.sensitive === true,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const categoryDelta =
|
||||
(categoryOrder.get(left.category) ?? 99) - (categoryOrder.get(right.category) ?? 99);
|
||||
return categoryDelta !== 0 ? categoryDelta : left.code.localeCompare(right.code);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity -- 映射字段多且包含进度分支,拆散会分散契约口径
|
||||
export function toAuditExportJob(
|
||||
row: AuditExportJobRow,
|
||||
downloadToken: string | null = null,
|
||||
): AuditExportJob {
|
||||
const parsed = auditLogFilterSchema.safeParse(row.filtersJson);
|
||||
const filters: AuditLogFilter = parsed.success ? parsed.data : {};
|
||||
const total = row.totalRows ?? null;
|
||||
const processed = row.processedRows ?? 0;
|
||||
let progress = 0;
|
||||
if (row.status === 'succeeded' || row.status === 'failed') {
|
||||
progress = 100;
|
||||
} else if (row.status === 'running' && total !== null && total > 0) {
|
||||
progress = Math.min(99, Math.floor((processed / total) * 100));
|
||||
} else if (row.status === 'running') {
|
||||
progress = processed > 0 ? 1 : 0;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
status:
|
||||
row.status === 'running' || row.status === 'succeeded' || row.status === 'failed'
|
||||
? row.status
|
||||
: 'queued',
|
||||
format: 'csv',
|
||||
filters,
|
||||
total_rows: total,
|
||||
row_count: row.rowCount,
|
||||
processed_rows: row.processedRows,
|
||||
truncated: row.truncated,
|
||||
progress,
|
||||
file_size: row.fileSize,
|
||||
error_message: row.errorMessage,
|
||||
requested_at: row.requestedAt.toISOString(),
|
||||
started_at: row.startedAt === null ? null : row.startedAt.toISOString(),
|
||||
finished_at: row.finishedAt === null ? null : row.finishedAt.toISOString(),
|
||||
expires_at: row.expiresAt === null ? null : row.expiresAt.toISOString(),
|
||||
download_count: row.downloadCount,
|
||||
last_downloaded_at: row.lastDownloadedAt === null ? null : row.lastDownloadedAt.toISOString(),
|
||||
download_token: downloadToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AUDIT_SINK } from '../../../common/security/audit-sink';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditExportService } from './audit-export.service';
|
||||
import { AuditExportRunnerService } from './audit-export-runner.service';
|
||||
import { AuditExportTokenService } from './audit-export-token.service';
|
||||
import { AuditPartitionService } from './audit-partition.service';
|
||||
import { AuditRetentionService } from './audit-retention.service';
|
||||
import { AuditWriterService } from './audit-writer.service';
|
||||
|
||||
/**
|
||||
* 审计子模块(A5-BE-01..04):
|
||||
* - 检索 / 详情 / 动作目录(A5-BE-03);
|
||||
* - 异步导出 + 签名下载 + 导出自身审计(A5-BE-03);
|
||||
* - 分区维护(A5-BE-01)与保留归档占位(A5-BE-04);
|
||||
* - 通过 `AUDIT_SINK` 端口向全局 `AuditInterceptor` 提供落库实现(依赖倒置,common 不依赖 modules)。
|
||||
*/
|
||||
@Module({
|
||||
imports: [SystemConfigModule],
|
||||
controllers: [AuditController],
|
||||
providers: [
|
||||
AuditService,
|
||||
AuditWriterService,
|
||||
AuditExportService,
|
||||
AuditExportRunnerService,
|
||||
AuditExportTokenService,
|
||||
AuditPartitionService,
|
||||
AuditRetentionService,
|
||||
{ provide: AUDIT_SINK, useExisting: AuditWriterService },
|
||||
],
|
||||
exports: [AuditWriterService, AuditPartitionService, AuditRetentionService, AUDIT_SINK],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 审计检索与详情(A5-BE-03,只读)
|
||||
*
|
||||
* ScopeContext 说明(有意为之):审计是平台级特殊权限(`audit:read`,默认关闭),
|
||||
* 跨客户可见用于合规回溯;`customer_id` 仍随请求写入并支持筛选,但不套用 `ScopeGuard`
|
||||
* 的客户范围过滤,也不存在“先查全量再过滤”(所有条件都在 SQL where 内)。
|
||||
*/
|
||||
import {
|
||||
AUDIT_CATEGORIES,
|
||||
type AuditLogActionsResponse,
|
||||
type AuditLogDetail,
|
||||
type AuditLogListQuery,
|
||||
type AuditLogListResponse,
|
||||
ErrorCode,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AppException } from '../../../common/errors/app-exception';
|
||||
import { RequestContextService } from '../../../common/security/request-context';
|
||||
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../../infra/tokens';
|
||||
import { toAuditActionCatalog, toAuditDetail, toAuditListItem } from './audit.mapper';
|
||||
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||
import { buildAuditWhere } from './audit-query';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async list(query: AuditLogListQuery): Promise<AuditLogListResponse> {
|
||||
// 审计查看本身留痕:列表 targetId = null(A5-BE-04 / LOG-14)
|
||||
this.requestContext.patchAudit({ targetType: 'audit_log' });
|
||||
|
||||
const cursor =
|
||||
query.cursor === undefined
|
||||
? null
|
||||
: (() => {
|
||||
const decoded = decodeAuditCursor(query.cursor);
|
||||
if (decoded === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '分页游标无效,请刷新后重试',
|
||||
});
|
||||
}
|
||||
return decoded;
|
||||
})();
|
||||
|
||||
const rows = await this.prisma.auditLog.findMany({
|
||||
where: buildAuditWhere(query),
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.limit + 1,
|
||||
...(cursor === null
|
||||
? {}
|
||||
: { cursor: { id_occurredAt: { id: cursor.id, occurredAt: cursor.at } }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const last = page[page.length - 1];
|
||||
return {
|
||||
items: page.map((row) => toAuditListItem(row)),
|
||||
next_cursor:
|
||||
rows.length > query.limit && last !== undefined
|
||||
? encodeAuditCursor(last.occurredAt, last.id)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
actions(): AuditLogActionsResponse {
|
||||
return {
|
||||
items: toAuditActionCatalog(),
|
||||
categories: AUDIT_CATEGORIES.map((item) => ({ key: item.key, label: item.label })),
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<AuditLogDetail> {
|
||||
// 详情留痕:targetId = 被查看的审计日志 id(由拦截器读取 params.id,双保险再 patch 一次)
|
||||
this.requestContext.patchAudit({ targetType: 'audit_log', targetId: id });
|
||||
// 复合主键 (id, occurred_at):只传 id 用 findFirst,禁止 findUnique
|
||||
const row = await this.prisma.auditLog.findFirst({ where: { id } });
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '审计日志不存在' });
|
||||
}
|
||||
return toAuditDetail(row);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user