feat(api): A5 审计采集、检索与导出

AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:26:42 +08:00
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);
}
}