From 6bdda6ec622d093146c5aed6840b7a23fe121b97 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 16:26:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20A5=20=E5=AE=A1=E8=AE=A1=E9=87=87?= =?UTF-8?q?=E9=9B=86=E3=80=81=E6=A3=80=E7=B4=A2=E4=B8=8E=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。 --- .../common/security/audit-sanitize.spec.ts | 53 ++ .../api/src/common/security/audit-sanitize.ts | 66 ++ apps/api/src/common/security/audit-sink.ts | 44 + .../common/security/audit.interceptor.spec.ts | 204 +++++ .../src/common/security/audit.interceptor.ts | 204 +++-- apps/api/src/common/security/auth.types.ts | 2 + .../src/common/security/permission.service.ts | 1 + .../src/common/security/request-context.ts | 8 + apps/api/src/common/security/security.spec.ts | 1 + apps/api/src/modules/catalog/dicts.service.ts | 6 + .../modules/client/customer-fields.service.ts | 34 +- .../modules/client/customer-groups.service.ts | 57 +- .../src/modules/client/customers.service.ts | 118 ++- .../src/modules/client/industries.service.ts | 50 +- .../src/modules/identity/auth.controller.ts | 2 + .../src/modules/identity/auth.service.spec.ts | 3 + apps/api/src/modules/identity/auth.service.ts | 112 ++- .../modules/identity/departments.service.ts | 59 +- .../identity/identity.services.spec.ts | 8 +- .../api/src/modules/identity/roles.service.ts | 3 + .../api/src/modules/identity/users.service.ts | 53 ++ .../platform/audit/audit-cursor.spec.ts | 31 + .../modules/platform/audit/audit-cursor.ts | 34 + .../platform/audit/audit-export-csv.spec.ts | 86 ++ .../platform/audit/audit-export-csv.ts | 71 ++ .../audit/audit-export-runner.service.ts | 213 +++++ .../audit/audit-export-token.service.ts | 69 ++ .../platform/audit/audit-export-token.spec.ts | 46 ++ .../platform/audit/audit-export.service.ts | 178 ++++ .../platform/audit/audit-partition.service.ts | 65 ++ .../src/modules/platform/audit/audit-query.ts | 57 ++ .../platform/audit/audit-retention.service.ts | 55 ++ .../platform/audit/audit-writer.service.ts | 209 +++++ .../platform/audit/audit.controller.ts | 131 +++ .../modules/platform/audit/audit.int.spec.ts | 758 ++++++++++++++++++ .../platform/audit/audit.mapper.spec.ts | 135 ++++ .../modules/platform/audit/audit.mapper.ts | 152 ++++ .../modules/platform/audit/audit.module.ts | 36 + .../modules/platform/audit/audit.service.ts | 87 ++ .../src/modules/platform/platform.module.ts | 16 +- .../system-config/system-config.module.ts | 15 + .../system-config/system-config.service.ts | 4 + apps/web/src/api/generated/openapi.d.ts | 264 +++++- openapi/lingxi.openapi.json | 172 +++- 44 files changed, 3845 insertions(+), 127 deletions(-) create mode 100644 apps/api/src/common/security/audit-sanitize.spec.ts create mode 100644 apps/api/src/common/security/audit-sanitize.ts create mode 100644 apps/api/src/common/security/audit-sink.ts create mode 100644 apps/api/src/common/security/audit.interceptor.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit-cursor.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit-cursor.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export-csv.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export-csv.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export-runner.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export-token.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export-token.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit-export.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit-partition.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit-query.ts create mode 100644 apps/api/src/modules/platform/audit/audit-retention.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit-writer.service.ts create mode 100644 apps/api/src/modules/platform/audit/audit.controller.ts create mode 100644 apps/api/src/modules/platform/audit/audit.int.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit.mapper.spec.ts create mode 100644 apps/api/src/modules/platform/audit/audit.mapper.ts create mode 100644 apps/api/src/modules/platform/audit/audit.module.ts create mode 100644 apps/api/src/modules/platform/audit/audit.service.ts create mode 100644 apps/api/src/modules/platform/system-config/system-config.module.ts diff --git a/apps/api/src/common/security/audit-sanitize.spec.ts b/apps/api/src/common/security/audit-sanitize.spec.ts new file mode 100644 index 0000000..b2410f6 --- /dev/null +++ b/apps/api/src/common/security/audit-sanitize.spec.ts @@ -0,0 +1,53 @@ +import { Prisma } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { sanitizeAuditValue, toPrismaJson, toPrismaJsonValue } from './audit-sanitize'; + +describe('审计脱敏与 JSON 归一(A5-BE-02 / LOG-03)', () => { + it('递归剔除密码 / token / secret / apiKey 等键', () => { + const sanitized = sanitizeAuditValue({ + name: '花间集', + password: 'p@ss', + nested: { + accessToken: 'token-value', + apiKey: 'ak', + secret: 's', + authorization: 'Bearer x', + keep: 'ok', + }, + list: [{ password: 'x', value: 1 }], + }); + expect(sanitized).toEqual({ + name: '花间集', + password: '[redacted]', + nested: { + accessToken: '[redacted]', + apiKey: '[redacted]', + secret: '[redacted]', + authorization: '[redacted]', + keep: 'ok', + }, + list: [{ password: '[redacted]', value: 1 }], + }); + }); + + it('深度截断避免超深对象', () => { + const deep = { + a: { b: { c: { d: { e: { f: { g: { h: { i: 'too-deep' } } } } } } } }, + }; + const sanitized = sanitizeAuditValue(deep); + expect(JSON.stringify(sanitized)).toContain('[truncated]'); + }); + + it('toPrismaJson:顶层 null → DbNull,对象内 null 保留,非有限数字转 null', () => { + expect(toPrismaJson(null)).toBe(Prisma.DbNull); + expect(toPrismaJson(undefined)).toBe(Prisma.DbNull); + expect(toPrismaJson({ a: 1, b: null, c: Number.NaN })).toEqual({ a: 1, b: null, c: null }); + expect(toPrismaJson([1, 'x', false])).toEqual([1, 'x', false]); + }); + + it('toPrismaJsonValue:非空 JSON 列顶层 null → JsonNull', () => { + expect(toPrismaJsonValue(null)).toBe(Prisma.JsonNull); + expect(toPrismaJsonValue({ result: 'success' })).toEqual({ result: 'success' }); + }); +}); diff --git a/apps/api/src/common/security/audit-sanitize.ts b/apps/api/src/common/security/audit-sanitize.ts new file mode 100644 index 0000000..91a3078 --- /dev/null +++ b/apps/api/src/common/security/audit-sanitize.ts @@ -0,0 +1,66 @@ +/** + * 审计 JSON 脱敏与 JSON 值归一(A5-BE-02 / 工程规范 08 LOG-03) + * + * - 脱敏:递归删除 password / token / secret / apiKey / authorization / cookie 等键, + * 统一替换为 `[redacted]`,避免审计表与 Pino 日志出现密钥; + * - 归一:把 unknown 递归转换为 Prisma 可写的 JSON 值,避免 any / as 绕过类型检查。 + */ +import { Prisma } from '@prisma/client'; + +/** 命中即脱敏的键名(大小写不敏感,包含匹配) */ +const SENSITIVE_KEY_PATTERN = + /(password|passwd|pwd|token|secret|api[_-]?key|apikey|authorization|cookie|credential|private[_-]?key)/i; + +const REDACTED = '[redacted]'; +const MAX_DEPTH = 8; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** 递归脱敏:数组逐项处理,对象按键名剔除敏感值;超过深度直接截断为占位符 */ +export function sanitizeAuditValue(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return value; + if (depth >= MAX_DEPTH) return '[truncated]'; + if (Array.isArray(value)) { + return value.map((item) => sanitizeAuditValue(item, depth + 1)); + } + if (!isPlainObject(value)) { + return value; + } + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : sanitizeAuditValue(item, depth + 1); + } + return result; +} + +type NormalizedJson = Prisma.InputJsonValue | null; + +function normalizeJson(value: unknown): NormalizedJson { + if (value === null || value === undefined) return null; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (Array.isArray(value)) return value.map((item) => normalizeJson(item)); + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item === undefined) continue; + result[key] = normalizeJson(item); + } + return result; + } + return '[unserializable]'; +} + +/** Prisma 可写的 JSON:顶层 null 用 DbNull 表示 SQL NULL,对象内 null 保持 JSON null */ +export function toPrismaJson(value: unknown): Prisma.InputJsonValue | typeof Prisma.DbNull { + const normalized = normalizeJson(value); + return normalized ?? Prisma.DbNull; +} + +/** 非空 JSON 列(如 audit_export_jobs.filters_json):顶层 null 用 JsonNull */ +export function toPrismaJsonValue(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { + const normalized = normalizeJson(value); + return normalized ?? Prisma.JsonNull; +} diff --git a/apps/api/src/common/security/audit-sink.ts b/apps/api/src/common/security/audit-sink.ts new file mode 100644 index 0000000..f55f87b --- /dev/null +++ b/apps/api/src/common/security/audit-sink.ts @@ -0,0 +1,44 @@ +/** + * 审计落库端口(依赖倒置,A5-BE-02) + * + * common 层只定义端口,不感知 platform / audit 模块;实现由 `PlatformModule`(@Global) + * 提供。`AuditInterceptor` 通过 `@Optional() @Inject(AUDIT_SINK)` 注入: + * - 生产 / 集成环境:写入 `audit_logs` 表; + * - 单测或未装配环境:退化为仅 Pino 结构化日志,请求不受影响。 + */ +import type { AuditResult } from '@lingxi/shared'; + +export const AUDIT_SINK = Symbol('AUDIT_SINK'); + +export interface AuditWriteInput { + 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; + ip: string | null; + userAgent: string | null; + /** 已脱敏的 JSON 值(password / token / secret 等键已剔除) */ + before: unknown; + after: unknown; + changes: unknown; + reason: string | null; + errorCode: string | null; + errorMessage: string | null; + durationMs: number | null; +} + +export interface AuditSink { + /** 写入审计;实现方必须自行吞掉异常(调用方不因审计失败而失败) */ + write(input: AuditWriteInput): Promise; +} diff --git a/apps/api/src/common/security/audit.interceptor.spec.ts b/apps/api/src/common/security/audit.interceptor.spec.ts new file mode 100644 index 0000000..844fb94 --- /dev/null +++ b/apps/api/src/common/security/audit.interceptor.spec.ts @@ -0,0 +1,204 @@ +import 'reflect-metadata'; + +import { ErrorCode } from '@lingxi/shared'; +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { lastValueFrom, type Observable, of, throwError } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { AppException } from '../errors/app-exception'; +import { AuditInterceptor } from './audit.interceptor'; +import type { AuditWriteInput } from './audit-sink'; +import { AUDIT_ACTION_KEY } from './decorators'; +import { RequestContextService } from './request-context'; + +function contextOf(action: string | null, request: Record): ExecutionContext { + const handler: Record = {}; + if (action !== null) { + Reflect.defineMetadata(AUDIT_ACTION_KEY, action, handler); + } + return { + getHandler: () => handler, + getClass: () => ({ name: 'TestController' }), + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +function requestFixture(overrides: Record = {}): Record { + return { + id: 'trace-1', + ip: '127.0.0.1', + headers: { 'user-agent': 'vitest' }, + method: 'PATCH', + url: '/api/v1/customers/c1?x=1', + params: { id: 'c1' }, + auth: { + userId: 'u1', + account: 'chen.mo', + name: '陈默', + status: 'active', + roles: [], + permissions: [], + dataScopes: [], + departmentIds: [], + isSuperAdmin: false, + mustChangePassword: false, + }, + ...overrides, + }; +} + +function handlerOf(fn: () => Observable): CallHandler { + return { handle: fn }; +} + +function sinkOf(): { write: ReturnType } { + return { write: vi.fn().mockResolvedValue(undefined) }; +} + +function interceptorOf( + requestContext: RequestContextService, + sink?: { write: ReturnType }, +): AuditInterceptor { + return new AuditInterceptor(requestContext, sink); +} + +describe('AuditInterceptor · 审计落库(A5-BE-02)', () => { + it('成功请求:写入 actor / target / 请求信息,并在响应前 await 落库', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + const interceptor = interceptorOf(requestContext, sink); + const next = handlerOf(() => { + // 模拟业务 Service patchAudit;验证 ALS 上下文在 handler 内可用 + requestContext.patchAudit({ + targetType: 'customer', + targetName: '花间集', + customerId: 'c1', + customerName: '花间集', + before: { name: '旧名', password: 'secret' }, + after: { name: '花间集' }, + reason: '客户更名', + }); + return of({ ok: true }); + }); + + await expect( + lastValueFrom( + interceptor.intercept(contextOf('client.customer.update', requestFixture()), next), + ), + ).resolves.toEqual({ ok: true }); + + expect(sink.write).toHaveBeenCalledTimes(1); + const written = sink.write.mock.calls[0]?.[0] as AuditWriteInput; + expect(written).toMatchObject({ + actionCode: 'client.customer.update', + category: 'client', + result: 'success', + actorId: 'u1', + actorName: '陈默', + actorAccount: 'chen.mo', + targetType: 'customer', + targetId: 'c1', + targetName: '花间集', + customerId: 'c1', + customerName: '花间集', + httpMethod: 'PATCH', + traceId: 'trace-1', + reason: '客户更名', + }); + // 脱敏应剔除 password,但此处写入的是原始 detail;Writer 负责脱敏,拦截器只透传 + expect(written.before).toMatchObject({ name: '旧名' }); + expect(written.errorCode).toBeNull(); + }); + + it('失败请求:result=failure 且带错误码,原错误不被吞掉', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + const interceptor = interceptorOf(requestContext, sink); + const error = new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '状态不允许', + }); + const next = handlerOf(() => throwError(() => error)); + + await expect( + lastValueFrom( + interceptor.intercept(contextOf('client.customer.status', requestFixture()), next), + ), + ).rejects.toBe(error); + + expect(sink.write).toHaveBeenCalledTimes(1); + expect(sink.write.mock.calls[0]?.[0]).toMatchObject({ + actionCode: 'client.customer.status', + result: 'failure', + errorCode: String(ErrorCode.BUSINESS_RULE_VIOLATION), + errorMessage: '状态不允许', + }); + }); + + it('sink 缺失时退化为仅 Pino,不影响请求', async () => { + const requestContext = new RequestContextService(); + const interceptor = interceptorOf(requestContext); + await expect( + lastValueFrom( + interceptor.intercept( + contextOf('client.customer.create', requestFixture()), + handlerOf(() => of('ok')), + ), + ), + ).resolves.toBe('ok'); + }); + + it('审计写入异常不影响主请求(只记 error 日志)', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + sink.write.mockRejectedValueOnce(new Error('db down')); + const interceptor = interceptorOf(requestContext, sink); + await expect( + lastValueFrom( + interceptor.intercept( + contextOf('client.customer.update', requestFixture()), + handlerOf(() => of('ok')), + ), + ), + ).resolves.toBe('ok'); + }); + + it('未标注 @Audit 的请求不写审计', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + const interceptor = interceptorOf(requestContext, sink); + await lastValueFrom( + interceptor.intercept( + contextOf(null, requestFixture()), + handlerOf(() => of('ok')), + ), + ); + expect(sink.write).not.toHaveBeenCalled(); + }); + + it('同一毫秒内多次请求:上下文互不串扰(ALS 隔离)', async () => { + const requestContext = new RequestContextService(); + const sink = sinkOf(); + const interceptor = interceptorOf(requestContext, sink); + const makeNext = (name: string): CallHandler => + handlerOf(() => { + requestContext.patchAudit({ targetName: name }); + return of(name); + }); + const first = lastValueFrom( + interceptor.intercept( + contextOf('client.customer.update', requestFixture({ params: { id: 'c1' } })), + makeNext('客户一'), + ), + ); + const second = lastValueFrom( + interceptor.intercept( + contextOf('client.customer.update', requestFixture({ params: { id: 'c2' } })), + makeNext('客户二'), + ), + ); + await expect(Promise.all([first, second])).resolves.toEqual(['客户一', '客户二']); + const targets = sink.write.mock.calls.map((call) => (call[0] as AuditWriteInput).targetName); + expect(targets).toEqual(['客户一', '客户二']); + }); +}); diff --git a/apps/api/src/common/security/audit.interceptor.ts b/apps/api/src/common/security/audit.interceptor.ts index a378c86..a6ec5e0 100644 --- a/apps/api/src/common/security/audit.interceptor.ts +++ b/apps/api/src/common/security/audit.interceptor.ts @@ -1,14 +1,20 @@ +import { auditActionMeta } from '@lingxi/shared'; import { type CallHandler, type ExecutionContext, + HttpException, Inject, Injectable, Logger, type NestInterceptor, + Optional, } from '@nestjs/common'; -import { defer, type Observable } from 'rxjs'; -import { tap } from 'rxjs'; +import { from, Observable, throwError } from 'rxjs'; +import { catchError, concatMap, map, mergeMap } from 'rxjs/operators'; +import { AppException } from '../errors/app-exception'; +import { sanitizeAuditValue } from './audit-sanitize'; +import { AUDIT_SINK, type AuditSink, type AuditWriteInput } from './audit-sink'; import { AUDIT_ACTION_KEY } from './decorators'; import { type AuditDetail, @@ -24,35 +30,68 @@ interface AuditParams { interface AuditPayload { action: string; + actionLabel: string; + category: string; result: 'success' | 'failure'; actorId: string | null; + actorName: string | null; account: string | null; ip: string | null; userAgent: string | null; targetType: string | null; targetId: string | null; + targetName: string | null; + customerId: string | null; + customerName: string | null; before?: unknown; after?: unknown; changes?: unknown; affectedUserCount?: number; affectedUserIds?: string[]; reason?: string; + httpMethod: string; + httpPath: string; traceId: string; durationMs: number; - error?: string; + errorCode?: string; + errorMessage?: string; } +const MAX_ERROR_MESSAGE = 500; +const MAX_PATH = 255; +const MAX_UA = 255; + function actionOf(context: ExecutionContext): string | null { const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown; return typeof action === 'string' ? action : null; } +function errorInfoOf(error: unknown): { code: string | null; message: string } { + if (error instanceof AppException) { + return { code: String(error.code), message: error.message }; + } + if (error instanceof HttpException) { + return { code: String(error.getStatus()), message: error.message }; + } + if (error instanceof Error) { + return { code: null, message: error.message }; + } + return { code: null, message: '未知错误' }; +} + +function truncate(value: string | null, max: number): string | null { + if (value === null) return null; + return value.length > max ? value.slice(0, max) : value; +} + /** - * 全局请求上下文 + @Audit 结构化日志。 + * 全局请求上下文 + @Audit 审计(A5-BE-02)。 * - * A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入 - * 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA / - * trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。 + * - 请求成功 / 失败都输出一条结构化 Pino 日志并(若装配了 AUDIT_SINK)写入 `audit_logs`; + * - 落库在响应返回前 await,但**写入失败只记 Pino error,不影响主请求**; + * - 业务 Service 通过 `RequestContextService.patchAudit` 补充前后值 / 对象 / 客户维度; + * - 依赖倒置:本拦截器在 common 层,不 import 任何 modules;AUDIT_SINK 由 PlatformModule 提供, + * 单测未提供时自动退化为仅 Pino。 */ @Injectable() export class AuditInterceptor implements NestInterceptor { @@ -60,6 +99,7 @@ export class AuditInterceptor implements NestInterceptor { constructor( @Inject(RequestContextService) private readonly requestContext: RequestContextService, + @Optional() @Inject(AUDIT_SINK) private readonly sink?: AuditSink, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { @@ -70,57 +110,131 @@ export class AuditInterceptor implements NestInterceptor { const action = actionOf(context); const targetId = request.params?.id ?? null; - return defer(() => + // 必须在 ALS 上下文内订阅:若先返回 Observable 再订阅,handler / patchAudit 会丢失 store。 + return new Observable((subscriber) => this.requestContext.run(ctx, () => { if (action === null) { - return next.handle(); + return next.handle().subscribe(subscriber); } - return next.handle().pipe( - tap({ - next: () => { - this.emit(ctx, action, 'success', targetId); - }, - error: (error: unknown) => { - this.emit(ctx, action, 'failure', targetId, error); - }, - }), - ); + return next + .handle() + .pipe( + concatMap((value: unknown) => + from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)), + ), + catchError((error: unknown) => + // record 内部已吞掉异常;这里再兜底一次,保证原错误原样抛回 + from( + this.record(ctx, action, 'failure', targetId, error).catch(() => undefined), + ).pipe(mergeMap(() => throwError(() => error))), + ), + ) + .subscribe(subscriber); }), ); } - private emit( + /** 记录审计:任何异常都在此吞掉,仅输出 error 日志,绝不改变主请求结果 */ + // eslint-disable-next-line max-lines-per-function, complexity -- payload 字段映射集中一处,拆散会丢失字段完整性 + private async record( ctx: RequestContext, action: string, result: AuditPayload['result'], defaultTargetId: string | null, error?: unknown, - ): void { - const detail: AuditDetail = ctx.audit ?? {}; - const payload: AuditPayload = { - action, - result, - actorId: ctx.userId, - account: ctx.account, - ip: ctx.ip, - userAgent: ctx.userAgent, - targetType: detail.targetType ?? null, - targetId: detail.targetId ?? defaultTargetId, - ...(detail.before === undefined ? {} : { before: detail.before }), - ...(detail.after === undefined ? {} : { after: detail.after }), - ...(detail.changes === undefined ? {} : { changes: detail.changes }), - ...(detail.affectedUserCount === undefined - ? {} - : { affectedUserCount: detail.affectedUserCount }), - ...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }), - ...(detail.reason === undefined ? {} : { reason: detail.reason }), - traceId: ctx.traceId, - durationMs: Date.now() - ctx.startedAt, - ...(error === undefined - ? {} - : { error: error instanceof Error ? error.message : '未知错误' }), + ): Promise { + try { + const detail: AuditDetail = ctx.audit ?? {}; + const meta = auditActionMeta(action); + const before = sanitizeAuditValue(detail.before); + const after = sanitizeAuditValue(detail.after); + const changes = sanitizeAuditValue(detail.changes); + const errorInfo = error === undefined ? null : errorInfoOf(error); + const httpPath = truncate(ctx.url, MAX_PATH) ?? ''; + + const payload: AuditPayload = { + action, + actionLabel: meta.label, + category: meta.category, + result, + actorId: ctx.userId, + actorName: ctx.actorName, + account: ctx.account, + ip: ctx.ip, + userAgent: truncate(ctx.userAgent, MAX_UA), + targetType: detail.targetType ?? meta.targetType ?? null, + targetId: detail.targetId ?? defaultTargetId, + targetName: detail.targetName ?? null, + customerId: detail.customerId ?? null, + customerName: detail.customerName ?? null, + ...(before === undefined ? {} : { before }), + ...(after === undefined ? {} : { after }), + ...(changes === undefined ? {} : { changes }), + ...(detail.affectedUserCount === undefined + ? {} + : { affectedUserCount: detail.affectedUserCount }), + ...(detail.affectedUserIds === undefined + ? {} + : { affectedUserIds: detail.affectedUserIds }), + ...(detail.reason === undefined ? {} : { reason: detail.reason }), + httpMethod: ctx.method, + httpPath, + traceId: ctx.traceId, + durationMs: Date.now() - ctx.startedAt, + ...(errorInfo === null + ? {} + : { + errorCode: errorInfo.code ?? undefined, + errorMessage: errorInfo.message.slice(0, MAX_ERROR_MESSAGE), + }), + }; + this.logger.log(JSON.stringify(payload)); + + if (this.sink === undefined) { + return; + } + await this.sink.write(this.toWriteInput(payload, meta.category, detail)); + } catch (writeError) { + // LOG-03:日志不带敏感值;仅记录错误类型与 action,避免审计写失败影响主请求 + this.logger.error( + `审计写入失败 action=${action} result=${result} error=${ + writeError instanceof Error ? writeError.message : '未知错误' + }`, + ); + } + } + + private toWriteInput( + payload: AuditPayload, + category: string, + detail: AuditDetail, + ): AuditWriteInput { + return { + occurredAt: new Date(), + actorId: payload.actorId, + actorName: payload.actorName, + actorAccount: payload.account, + actionCode: payload.action, + category, + targetType: payload.targetType, + targetId: payload.targetId, + targetName: payload.targetName, + customerId: payload.customerId, + customerName: payload.customerName, + result: payload.result, + httpMethod: payload.httpMethod, + httpPath: payload.httpPath, + traceId: payload.traceId, + ip: payload.ip, + userAgent: payload.userAgent, + before: detail.before, + after: detail.after, + changes: detail.changes, + reason: detail.reason ?? null, + errorCode: payload.errorCode ?? null, + errorMessage: payload.errorMessage ?? null, + durationMs: payload.durationMs, }; - this.logger.log(JSON.stringify(payload)); } } diff --git a/apps/api/src/common/security/auth.types.ts b/apps/api/src/common/security/auth.types.ts index 37d8242..91b4cd7 100644 --- a/apps/api/src/common/security/auth.types.ts +++ b/apps/api/src/common/security/auth.types.ts @@ -21,6 +21,8 @@ export interface JwtRefreshPayload { export interface AuthContext { userId: string; account: string; + /** 用户姓名(审计 actor_name;登录 / 刷新返回的 me 同源) */ + name: string; status: UserStatus; roles: RoleSummary[]; permissions: string[]; diff --git a/apps/api/src/common/security/permission.service.ts b/apps/api/src/common/security/permission.service.ts index 9dce164..6c6920d 100644 --- a/apps/api/src/common/security/permission.service.ts +++ b/apps/api/src/common/security/permission.service.ts @@ -122,6 +122,7 @@ export class PermissionService { return { userId: user.id, account: user.account, + name: user.name, status, roles: roleEntities.map((role) => toRoleSummary(role)), permissions, diff --git a/apps/api/src/common/security/request-context.ts b/apps/api/src/common/security/request-context.ts index cb27b43..2f69ffe 100644 --- a/apps/api/src/common/security/request-context.ts +++ b/apps/api/src/common/security/request-context.ts @@ -16,6 +16,11 @@ import type { ScopeContext } from './scope/scope-context'; export interface AuditDetail { targetType?: string; targetId?: string; + /** 对象展示名(客户名 / 用户姓名 / 字典类型名 / 配置项名等),审计表 target_name */ + targetName?: string; + /** 客户维度(R-04):客户相关动作写入 audit_logs.customer_id / customer_name */ + customerId?: string; + customerName?: string; before?: unknown; after?: unknown; affectedUserCount?: number; @@ -30,6 +35,8 @@ export interface RequestContext { traceId: string; userId: string | null; account: string | null; + /** 操作人姓名(auth 解析时一并带入,供审计表 actor_name) */ + actorName: string | null; permissions: string[] | null; scope: ScopeContext | null; audit: AuditDetail | null; @@ -53,6 +60,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext { traceId: traceIdOf(request), userId: auth?.userId ?? null, account: auth?.account ?? null, + actorName: auth?.name ?? null, permissions: auth?.permissions ?? null, scope: request.scope ?? null, audit: null, diff --git a/apps/api/src/common/security/security.spec.ts b/apps/api/src/common/security/security.spec.ts index 207021d..c710ef2 100644 --- a/apps/api/src/common/security/security.spec.ts +++ b/apps/api/src/common/security/security.spec.ts @@ -40,6 +40,7 @@ function authFixture(overrides: Partial = {}): AuthContext { return { userId: 'u1', account: 'chen.mo', + name: '陈默', status: 'active', roles: [], permissions: ['user:view'], diff --git a/apps/api/src/modules/catalog/dicts.service.ts b/apps/api/src/modules/catalog/dicts.service.ts index 863d74c..dfc8959 100644 --- a/apps/api/src/modules/catalog/dicts.service.ts +++ b/apps/api/src/modules/catalog/dicts.service.ts @@ -197,6 +197,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_type', targetId: id, + targetName: input.name, before: null, after: summary, }); @@ -224,6 +225,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_type', targetId: existing.id, + targetName: input.name ?? existing.name, before: typeRowAudit(existing), after: { code: summary.code, @@ -255,6 +257,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_type', targetId: existing.id, + targetName: existing.name, before: typeRowAudit(existing), after: { code: summary.code, @@ -285,6 +288,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_type', targetId: existing.id, + targetName: existing.name, before: typeRowAudit(existing), after: null, }); @@ -435,6 +439,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_item', targetId: id, + targetName: input.name, before: null, after: itemAudit(row), }); @@ -485,6 +490,7 @@ export class DictsService { this.requestContext.patchAudit({ targetType: 'dict_item', targetId: row.id, + targetName: input.name ?? existing.name, before: itemAudit(existing), after: itemAudit(row), }); diff --git a/apps/api/src/modules/client/customer-fields.service.ts b/apps/api/src/modules/client/customer-fields.service.ts index fe59b90..3c7f37b 100644 --- a/apps/api/src/modules/client/customer-fields.service.ts +++ b/apps/api/src/modules/client/customer-fields.service.ts @@ -14,6 +14,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; +import { RequestContextService } from '../../common/security/request-context'; import type { ScopeContext } from '../../common/security/scope/scope-context'; import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; @@ -55,6 +56,7 @@ export class CustomerFieldsService { constructor( @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, ) {} async list( @@ -128,7 +130,7 @@ export class CustomerFieldsService { } const customer = await this.prisma.customer.findFirst({ where: { id: customerId, deletedAt: null }, - select: { id: true, status: true }, + select: { id: true, name: true, status: true }, }); if (customer === null) { throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); @@ -173,6 +175,21 @@ export class CustomerFieldsService { where: { id }, include: FIELD_INCLUDE, }); + this.requestContext.patchAudit({ + targetType: 'customer_field', + targetId: id, + targetName: input.name, + customerId, + customerName: customer.name, + after: { + name: input.name, + code: input.code, + type: input.type, + purpose: input.purpose, + entersGeneration: input.entersGeneration, + status: input.entersGeneration ? 'pending' : 'active', + }, + }); return toCustomerField(row); } @@ -185,7 +202,7 @@ export class CustomerFieldsService { const scope = requireScopeContext(scopeInput); const existing = await this.prisma.customerField.findUnique({ where: { id }, - include: { customer: { select: { id: true, deletedAt: true } } }, + include: { customer: { select: { id: true, name: true, deletedAt: true } } }, }); // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404 if (existing === null || existing.customer.deletedAt !== null) { @@ -222,6 +239,19 @@ export class CustomerFieldsService { where: { id }, include: FIELD_INCLUDE, }); + this.requestContext.patchAudit({ + targetType: 'customer_field', + targetId: id, + targetName: existing.name, + customerId: existing.customerId, + customerName: existing.customer.name, + before: { status: 'pending', approverUserId: null }, + after: { + status: input.decision === 'approve' ? 'active' : 'rejected', + approverUserId: actorId, + reason: input.decision === 'approve' ? null : (input.reason ?? ''), + }, + }); return toCustomerField(row); } diff --git a/apps/api/src/modules/client/customer-groups.service.ts b/apps/api/src/modules/client/customer-groups.service.ts index c708b5a..387e35d 100644 --- a/apps/api/src/modules/client/customer-groups.service.ts +++ b/apps/api/src/modules/client/customer-groups.service.ts @@ -13,6 +13,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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 { @@ -45,7 +46,10 @@ function isUniqueViolation(error: unknown): boolean { /** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */ @Injectable() export class CustomerGroupsService { - constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, + ) {} async list(query: CustomerGroupListQuery): Promise> { const rows = await this.prisma.customerGroup.findMany({ @@ -96,11 +100,21 @@ export class CustomerGroupsService { if (input.customerIds !== undefined && input.customerIds.length > 0) { await this.addMembers(id, input.customerIds); } + this.requestContext.patchAudit({ + targetType: 'customer_group', + targetId: id, + targetName: input.name, + after: { + name: input.name, + description: input.description ?? '', + customerIds: input.customerIds ?? [], + }, + }); return this.detail(id); } async update(id: string, input: UpdateCustomerGroupInput): Promise { - await this.findByIdOrThrow(id); + const existing = await this.findByIdOrThrow(id); if (input.name !== undefined) await this.assertNameUnique(input.name, id); await this.prisma.customerGroup.update({ where: { id }, @@ -109,11 +123,21 @@ export class CustomerGroupsService { ...(input.description === undefined ? {} : { description: input.description }), }, }); + this.requestContext.patchAudit({ + targetType: 'customer_group', + targetId: id, + targetName: input.name ?? existing.name, + before: { name: existing.name, description: existing.description }, + after: { + name: input.name ?? existing.name, + description: input.description ?? existing.description, + }, + }); return this.detail(id); } async remove(id: string): Promise { - await this.findByIdOrThrow(id); + const existing = await this.findByIdOrThrow(id); const referenced = await this.prisma.dataScope.count({ where: { targetId: id } }); if (referenced > 0) { throw new AppException({ @@ -122,14 +146,32 @@ export class CustomerGroupsService { }); } await this.prisma.customerGroup.delete({ where: { id } }); + this.requestContext.patchAudit({ + targetType: 'customer_group', + targetId: id, + targetName: existing.name, + before: { name: existing.name, description: existing.description }, + }); } async updateMembers( id: string, input: UpdateCustomerGroupMembersInput, ): Promise { - await this.findByIdOrThrow(id); + const existing = await this.findByIdOrThrow(id); if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds); + const current = await this.prisma.customerGroupMember.findMany({ + where: { groupId: id }, + select: { customerId: true }, + }); + const beforeMemberIds = current.map((item) => item.customerId).sort(); + const removed = new Set(input.removeCustomerIds); + const afterMemberIds = [ + ...new Set([ + ...beforeMemberIds.filter((customerId) => !removed.has(customerId)), + ...input.addCustomerIds, + ]), + ].sort(); await this.prisma.$transaction([ this.prisma.customerGroupMember.deleteMany({ where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } }, @@ -143,6 +185,13 @@ export class CustomerGroupsService { }), this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }), ]); + this.requestContext.patchAudit({ + targetType: 'customer_group', + targetId: id, + targetName: existing.name, + before: { customerIds: beforeMemberIds }, + after: { customerIds: afterMemberIds }, + }); return this.detail(id); } diff --git a/apps/api/src/modules/client/customers.service.ts b/apps/api/src/modules/client/customers.service.ts index 5e5df94..fde3f68 100644 --- a/apps/api/src/modules/client/customers.service.ts +++ b/apps/api/src/modules/client/customers.service.ts @@ -18,6 +18,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; +import { RequestContextService } from '../../common/security/request-context'; import type { ScopeContext } from '../../common/security/scope/scope-context'; import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; @@ -63,6 +64,7 @@ export class CustomersService { @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(IndustriesService) private readonly industries: IndustriesService, @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, ) {} async list( @@ -155,6 +157,23 @@ export class CustomersService { } throw error; } + this.requestContext.patchAudit({ + targetType: 'customer', + targetId: id, + targetName: input.name, + customerId: id, + customerName: input.name, + after: { + name: input.name, + shortName: input.shortName, + industryId: input.industryId, + ownerUserId: input.ownerUserId, + contactName: input.contactName ?? null, + contactPhone: input.contactPhone ?? null, + baseFields, + remark: input.remark ?? null, + }, + }); return this.detail(id, scope); } @@ -217,6 +236,33 @@ export class CustomersService { ...(input.remark === undefined ? {} : { remark: input.remark }), }, }); + this.requestContext.patchAudit({ + targetType: 'customer', + targetId: id, + targetName: input.name ?? existing.name, + customerId: id, + customerName: input.name ?? existing.name, + before: { + name: existing.name, + shortName: existing.shortName, + ownerUserId: existing.ownerUserId, + contactName: existing.contactName, + contactPhone: existing.contactPhone, + preferences: existing.preferences, + baseFields: existing.customFields, + remark: existing.remark, + }, + after: { + name: input.name ?? existing.name, + shortName: input.shortName ?? existing.shortName, + ownerUserId: input.ownerUserId ?? existing.ownerUserId, + contactName: input.contactName ?? existing.contactName, + contactPhone: input.contactPhone ?? existing.contactPhone, + preferences: input.preferences ?? existing.preferences, + baseFields: input.baseFields ?? existing.customFields, + remark: input.remark ?? existing.remark, + }, + }); return this.detail(id, scope); } @@ -240,6 +286,22 @@ export class CustomersService { const target = input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED'; await this.prisma.customer.update({ where: { id }, data: { status: target } }); + this.requestContext.patchAudit({ + targetType: 'customer', + targetId: id, + targetName: existing.name, + customerId: id, + customerName: existing.name, + before: { + status: + existing.status === 'ACTIVE' + ? 'active' + : existing.status === 'PAUSED' + ? 'paused' + : 'ended', + }, + after: { status: input.status }, + }); return this.detail(id, scope); } @@ -266,6 +328,15 @@ export class CustomersService { } await this.ensureOwner(input.ownerUserId); await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } }); + this.requestContext.patchAudit({ + targetType: 'customer', + targetId: id, + targetName: existing.name, + customerId: id, + customerName: existing.name, + before: { ownerUserId: existing.ownerUserId }, + after: { ownerUserId: input.ownerUserId }, + }); return this.detail(id, scope); } @@ -292,6 +363,16 @@ export class CustomersService { }, data: { ownerUserId: toUserId }, }); + this.requestContext.patchAudit({ + targetType: 'customer', + targetName: `客户转交(${String(result.count)} 个)`, + changes: { + fromUserId, + toUserId, + transferredCount: result.count, + customerIds: customerIds === undefined ? null : [...customerIds], + }, + }); return { transferred: result.count }; } @@ -301,7 +382,7 @@ export class CustomersService { scopeInput: ScopeContext | null, ): Promise { const scope = requireScopeContext(scopeInput); - await this.findRow(id); + const existing = await this.findRow(id); this.assertVisible(scope, id); if (input.groupIds.length > 0) { const found = await this.prisma.customerGroup.findMany({ @@ -312,12 +393,27 @@ export class CustomersService { throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' }); } } + const currentMemberships = await this.prisma.customerGroupMember.findMany({ + where: { customerId: id }, + select: { groupId: true }, + }); + const beforeGroupIds = currentMemberships.map((item) => item.groupId).sort(); + const afterGroupIds = [...new Set(input.groupIds)].sort(); await this.prisma.$transaction([ this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }), this.prisma.customerGroupMember.createMany({ data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })), }), ]); + this.requestContext.patchAudit({ + targetType: 'customer', + targetId: id, + targetName: existing.name, + customerId: id, + customerName: existing.name, + before: { groupIds: beforeGroupIds }, + after: { groupIds: afterGroupIds }, + }); return this.detail(id, scope); } @@ -334,10 +430,28 @@ export class CustomersService { ownerUserId: string; industryId: string; name: string; + shortName: string; + contactName: string | null; + contactPhone: string | null; + preferences: Prisma.JsonValue; + customFields: Prisma.JsonValue; + remark: string | null; }> { const row = await this.prisma.customer.findFirst({ where: { id, deletedAt: null }, - select: { id: true, status: true, ownerUserId: true, industryId: true, name: true }, + select: { + id: true, + status: true, + ownerUserId: true, + industryId: true, + name: true, + shortName: true, + contactName: true, + contactPhone: true, + preferences: true, + customFields: true, + remark: true, + }, }); if (row === null) { throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); diff --git a/apps/api/src/modules/client/industries.service.ts b/apps/api/src/modules/client/industries.service.ts index e7c6063..3aa5b97 100644 --- a/apps/api/src/modules/client/industries.service.ts +++ b/apps/api/src/modules/client/industries.service.ts @@ -14,6 +14,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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 { type IndustryRow, toIndustryNode } from './client.mapper'; @@ -28,7 +29,10 @@ function isUniqueViolation(error: unknown): boolean { */ @Injectable() export class IndustriesService { - constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, + ) {} async list(query: IndustryListQuery): Promise> { const rows = await this.prisma.industry.findMany({ @@ -116,6 +120,7 @@ export class IndustriesService { }; } + // eslint-disable-next-line complexity -- 两级行业校验 + 唯一约束映射集中保证事务前校验完整 async create(input: CreateIndustryInput): Promise { const level = input.parentId === undefined || input.parentId === null ? 1 : 2; if (level === 2 && input.parentId !== undefined && input.parentId !== null) { @@ -149,6 +154,18 @@ export class IndustriesService { } throw error; } + this.requestContext.patchAudit({ + targetType: 'industry', + targetId: id, + targetName: input.name, + after: { + name: input.name, + parentId: level === 1 ? null : (input.parentId ?? null), + level, + sortOrder: input.sortOrder, + remark: input.remark, + }, + }); return this.detail(id); } @@ -165,6 +182,18 @@ export class IndustriesService { ...(input.remark === undefined ? {} : { remark: input.remark }), }, }); + const before = { name: existing.name, sortOrder: existing.sortOrder, remark: existing.remark }; + this.requestContext.patchAudit({ + targetType: 'industry', + targetId: id, + targetName: input.name ?? existing.name, + before, + after: { + name: input.name ?? existing.name, + sortOrder: input.sortOrder ?? existing.sortOrder, + remark: input.remark ?? existing.remark, + }, + }); return this.detail(id); } @@ -197,10 +226,18 @@ export class IndustriesService { where: { id }, data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' }, }); + this.requestContext.patchAudit({ + targetType: 'industry', + targetId: id, + targetName: existing.name, + before: { status: existing.status === 'ACTIVE' ? 'active' : 'disabled' }, + after: { status: input.status }, + }); return this.detail(id); } async remove(id: string): Promise { + const existing = await this.findOrThrow(id); const references = await this.references(id); if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) { throw new AppException({ @@ -220,6 +257,17 @@ export class IndustriesService { } throw error; } + this.requestContext.patchAudit({ + targetType: 'industry', + targetId: id, + targetName: existing.name, + before: { + name: existing.name, + parentId: existing.parentId, + level: existing.level, + status: existing.status === 'ACTIVE' ? 'active' : 'disabled', + }, + }); } /** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */ diff --git a/apps/api/src/modules/identity/auth.controller.ts b/apps/api/src/modules/identity/auth.controller.ts index ca5b249..209c8dd 100644 --- a/apps/api/src/modules/identity/auth.controller.ts +++ b/apps/api/src/modules/identity/auth.controller.ts @@ -37,6 +37,7 @@ export class AuthController { @Public() @Post('login') @HttpCode(200) + @Audit('identity.auth.login') @ApiOperation({ summary: '账号密码登录', description: '返回 accessToken / refreshToken / expiresIn / me', @@ -61,6 +62,7 @@ export class AuthController { @Public() @Post('logout') @HttpCode(204) + @Audit('identity.auth.logout') @ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' }) async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise { await this.auth.logout(input); diff --git a/apps/api/src/modules/identity/auth.service.spec.ts b/apps/api/src/modules/identity/auth.service.spec.ts index 69c2b85..c4fc646 100644 --- a/apps/api/src/modules/identity/auth.service.spec.ts +++ b/apps/api/src/modules/identity/auth.service.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest'; import { AppException } from '../../common/errors/app-exception'; import type { PermissionService } from '../../common/security/permission.service'; +import type { RequestContextService } from '../../common/security/request-context'; import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service'; import { TokenService } from '../../common/security/token.service'; import type { PrismaService } from '../../infra/prisma/prisma.service'; @@ -119,6 +120,7 @@ function harness(): Harness { mustChangePassword: false, }), }; + const requestContext = { patch: vi.fn(), patchAudit: vi.fn() }; const service = new AuthService( prisma as unknown as PrismaService, testConfig, @@ -128,6 +130,7 @@ function harness(): Harness { permissions as unknown as PermissionService, scopes as unknown as ScopeResolver, me as unknown as MeService, + requestContext as unknown as RequestContextService, ); return { service, prisma, failures, passwords, permissions, me }; } diff --git a/apps/api/src/modules/identity/auth.service.ts b/apps/api/src/modules/identity/auth.service.ts index 4f44cf8..c4265e8 100644 --- a/apps/api/src/modules/identity/auth.service.ts +++ b/apps/api/src/modules/identity/auth.service.ts @@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; import type { AuthRequestMeta } from '../../common/security/auth.types'; import { PermissionService } from '../../common/security/permission.service'; +import { RequestContextService } from '../../common/security/request-context'; import { ScopeResolver } from '../../common/security/scope/scope-resolver.service'; import { TokenService } from '../../common/security/token.service'; import { APP_CONFIG } from '../../config/config.module'; @@ -36,40 +37,55 @@ export class AuthService { @Inject(PermissionService) private readonly permissions: PermissionService, @Inject(ScopeResolver) private readonly scopes: ScopeResolver, @Inject(MeService) private readonly me: MeService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, ) {} async login(input: LoginInput, meta: AuthRequestMeta): Promise { const account = input.account.trim().toLowerCase(); - const status = await this.failures.status(account); - if (status.locked) { - throw this.lockedError(); + // 登录失败也要能记录“尝试账号”(actor_account),成功后再补 userId / 姓名 + this.requestContext.patch({ account }); + try { + const status = await this.failures.status(account); + if (status.locked) { + throw this.lockedError(); + } + const user = await this.findByAccount(account); + if (user === null) { + await this.failures.record(account); + throw this.badCredentials(); + } + const matched = await this.passwords.verify(user.passwordHash, input.password); + if (!matched) { + await this.recordFailure(account); + throw this.badCredentials(); + } + if (user.status === 'DISABLED') { + throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' }); + } + await this.failures.clear(account); + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date(), lastLoginIp: meta.ip }, + }); + const auth = await this.permissions.resolveAuth(user.id); + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' }); + } + const scope = await this.scopes.resolve(auth); + const { pair } = await this.createTokenPair(user.id, null, meta); + const me = await this.me.getMe(user.id, auth.permissions, scope); + this.requestContext.patch({ userId: user.id, account: user.account, actorName: user.name }); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: user.id, + targetName: user.name, + after: { account: user.account, name: user.name }, + }); + return { ...pair, me }; + } catch (error) { + this.requestContext.patchAudit({ targetType: 'user', targetName: account }); + throw error; } - const user = await this.findByAccount(account); - if (user === null) { - await this.failures.record(account); - throw this.badCredentials(); - } - const matched = await this.passwords.verify(user.passwordHash, input.password); - if (!matched) { - await this.recordFailure(account); - throw this.badCredentials(); - } - if (user.status === 'DISABLED') { - throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' }); - } - await this.failures.clear(account); - await this.prisma.user.update({ - where: { id: user.id }, - data: { lastLoginAt: new Date(), lastLoginIp: meta.ip }, - }); - const auth = await this.permissions.resolveAuth(user.id); - if (auth === null) { - throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' }); - } - const scope = await this.scopes.resolve(auth); - const { pair } = await this.createTokenPair(user.id, null, meta); - const me = await this.me.getMe(user.id, auth.permissions, scope); - return { ...pair, me }; } async refresh(input: RefreshInput): Promise { @@ -100,11 +116,24 @@ export class AuthService { async logout(input: LogoutInput, userId?: string): Promise { if (input.refreshToken !== undefined) { - await this.revokeByToken(input.refreshToken); + const actor = await this.revokeByToken(input.refreshToken); + if (actor !== null) { + this.requestContext.patch({ + userId: actor.userId, + account: actor.account, + actorName: actor.name, + }); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: actor.userId, + targetName: actor.name, + }); + } return; } if (userId !== undefined) { await this.revokeAllForUser(userId); + this.requestContext.patchAudit({ targetType: 'user', targetId: userId }); } } @@ -129,6 +158,14 @@ export class AuthService { data: { passwordHash, mustChangePassword: false }, }); await this.revokeAllForUser(userId); + // 绝不记录密码:只记录“强制改密已完成”这一事实 + this.requestContext.patchAudit({ + targetType: 'user', + targetId: userId, + targetName: user.name, + before: { mustChangePassword: user.mustChangePassword }, + after: { mustChangePassword: false }, + }); } private async findByAccount(account: string): Promise { @@ -199,18 +236,27 @@ export class AuthService { return pair; } - private async revokeByToken(token: string): Promise { + private async revokeByToken(token: string): Promise<{ + userId: string; + account: string; + name: string; + } | null> { let payload; try { payload = this.tokens.verifyRefresh(token); } catch { - return; + return null; } const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } }); if (record === null) { - return; + return null; } await this.revokeFamily(record.familyId); + const user = await this.prisma.user.findUnique({ + where: { id: record.userId }, + select: { id: true, account: true, name: true }, + }); + return user === null ? null : { userId: user.id, account: user.account, name: user.name }; } private async revokeFamily(familyId: string): Promise { diff --git a/apps/api/src/modules/identity/departments.service.ts b/apps/api/src/modules/identity/departments.service.ts index 30a1be2..dd821a5 100644 --- a/apps/api/src/modules/identity/departments.service.ts +++ b/apps/api/src/modules/identity/departments.service.ts @@ -9,6 +9,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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'; @@ -51,7 +52,10 @@ function mapDepartmentError(error: unknown): AppException { /** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */ @Injectable() export class DepartmentsService { - constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, + ) {} async tree(includeDisabled: boolean): Promise { const [departments, counts, lineage] = await Promise.all([ @@ -133,6 +137,18 @@ export class DepartmentsService { }, select: { id: true }, }); + this.requestContext.patchAudit({ + targetType: 'department', + targetId: created.id, + targetName: input.name, + after: { + name: input.name, + parentId: input.parentId, + leaderUserId: input.leaderUserId ?? null, + sortOrder: input.sortOrder ?? 0, + status: 'active', + }, + }); return created; } catch (error) { throw mapDepartmentError(error); @@ -164,6 +180,21 @@ export class DepartmentsService { } catch (error) { throw mapDepartmentError(error); } + this.requestContext.patchAudit({ + targetType: 'department', + targetId: id, + targetName: input.name ?? existing.name, + before: { + name: existing.name, + sortOrder: existing.sortOrder, + leaderUserId: existing.leaderUserId, + }, + after: { + name: input.name ?? existing.name, + sortOrder: input.sortOrder ?? existing.sortOrder, + leaderUserId: input.leaderUserId === undefined ? existing.leaderUserId : input.leaderUserId, + }, + }); } async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> { @@ -173,6 +204,13 @@ export class DepartmentsService { } await this.assertNoActiveMembers(existing); await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } }); + this.requestContext.patchAudit({ + targetType: 'department', + targetId: id, + targetName: existing.name, + before: { status: 'active' }, + after: { status: 'disabled' }, + }); return { id, status: 'disabled' }; } @@ -183,17 +221,34 @@ export class DepartmentsService { } await this.assertParentActiveForEnable(existing.parentId); await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } }); + this.requestContext.patchAudit({ + targetType: 'department', + targetId: id, + targetName: existing.name, + before: { status: 'disabled' }, + after: { status: 'active' }, + }); return { id, status: 'active' }; } private async findOrThrow(id: string): Promise<{ id: string; parentId: string | null; + name: string; + sortOrder: number; + leaderUserId: string | null; status: string; }> { const department = await this.prisma.department.findUnique({ where: { id }, - select: { id: true, parentId: true, status: true }, + select: { + id: true, + parentId: true, + name: true, + sortOrder: true, + leaderUserId: true, + status: true, + }, }); if (department === null) { throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' }); diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts index 1695ca1..ac7e4c2 100644 --- a/apps/api/src/modules/identity/identity.services.spec.ts +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -213,6 +213,7 @@ describe('LoginFailureService', () => { describe('UsersService', () => { const passwords = { hash: vi.fn().mockResolvedValue('hashed') }; + const requestContext = { patch: vi.fn(), patchAudit: vi.fn() }; const systemConfig = { getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)), @@ -223,6 +224,7 @@ describe('UsersService', () => { prisma as unknown as PrismaService, passwords as unknown as PasswordService, systemConfig, + requestContext as unknown as RequestContextService, ); } @@ -297,8 +299,12 @@ describe('UsersService', () => { }); describe('DepartmentsService', () => { + const requestContext = { patch: vi.fn(), patchAudit: vi.fn() }; function serviceOf(prisma: Record): DepartmentsService { - return new DepartmentsService(prisma as unknown as PrismaService); + return new DepartmentsService( + prisma as unknown as PrismaService, + requestContext as unknown as RequestContextService, + ); } function departmentRow(overrides: Record = {}): Record { diff --git a/apps/api/src/modules/identity/roles.service.ts b/apps/api/src/modules/identity/roles.service.ts index dd645f1..4a7e354 100644 --- a/apps/api/src/modules/identity/roles.service.ts +++ b/apps/api/src/modules/identity/roles.service.ts @@ -392,6 +392,7 @@ export class RolesService { this.requestContext.patchAudit({ targetType: 'role', targetId: id, + targetName: existing.name, before: { name: existing.name, code: existing.code, status: existing.status }, after: null, affectedUserCount: 0, @@ -442,6 +443,7 @@ export class RolesService { this.requestContext.patchAudit({ targetType: 'role', targetId: role.id, + targetName: role.name, before: null, after: this.auditSnapshot(role), affectedUserCount: 0, @@ -514,6 +516,7 @@ export class RolesService { this.requestContext.patchAudit({ targetType: 'role', targetId: args.role.id, + targetName: args.role.name, before: { name: args.existing.name, description: args.existing.description, diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts index af407fc..41e9b6a 100644 --- a/apps/api/src/modules/identity/users.service.ts +++ b/apps/api/src/modules/identity/users.service.ts @@ -15,6 +15,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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 { SystemConfigService } from '../platform/system-config/system-config.service'; @@ -39,6 +40,7 @@ export class UsersService { @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PasswordService) private readonly passwords: PasswordService, @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, + @Inject(RequestContextService) private readonly requestContext: RequestContextService, ) {} async list(query: UserListQuery): Promise> { @@ -96,9 +98,23 @@ export class UsersService { throw this.mapUniqueError(error); } await this.warnIfSuperAdminExceeded(input.roleIds); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: id, + targetName: input.name, + after: { + name: input.name, + account, + departmentId: input.departmentId, + roleIds: input.roleIds, + status: 'active', + mustChangePassword: true, + }, + }); return this.detail(id, permissions); } + // eslint-disable-next-line max-lines-per-function, complexity -- 资料 / 角色 / 部门三段更新共用同一事务与审计口径 async update(id: string, input: UpdateUserInput, permissions: string[]): Promise { const existing = await this.findOrThrow(id); if (input.departmentId !== undefined) { @@ -134,6 +150,28 @@ export class UsersService { if (input.roleIds !== undefined) { await this.warnIfSuperAdminExceeded(input.roleIds); } + const beforeRoleIds = existing.roles.map((item) => item.roleId).sort(); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: id, + targetName: input.name ?? existing.name, + before: { + name: existing.name, + phone: existing.phone, + departmentId: existing.departmentId, + roleIds: beforeRoleIds, + remark: existing.remark, + }, + after: { + name: input.name ?? existing.name, + phone: input.phone ?? existing.phone, + departmentId: input.departmentId ?? existing.departmentId, + roleIds: [...(input.roleIds ?? beforeRoleIds)].sort(), + // null 表示清空备注,不能回退为旧值,故不用 ?? + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + remark: input.remark === undefined ? existing.remark : input.remark, + }, + }); return this.detail(id, permissions); } @@ -167,6 +205,14 @@ export class UsersService { data: { revokedAt: new Date() }, }), ]); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: id, + targetName: existing.name, + before: { status: 'active', disabledAt: null }, + after: { status: 'disabled', disabledAt: new Date().toISOString() }, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); return this.detail(id, permissions); } @@ -179,6 +225,13 @@ export class UsersService { where: { id }, data: { status: 'ACTIVE', disabledAt: null }, }); + this.requestContext.patchAudit({ + targetType: 'user', + targetId: id, + targetName: existing.name, + before: { status: 'disabled', disabledAt: null }, + after: { status: 'active', disabledAt: null }, + }); return this.detail(id, permissions); } diff --git a/apps/api/src/modules/platform/audit/audit-cursor.spec.ts b/apps/api/src/modules/platform/audit/audit-cursor.spec.ts new file mode 100644 index 0000000..d169989 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-cursor.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/modules/platform/audit/audit-cursor.ts b/apps/api/src/modules/platform/audit/audit-cursor.ts new file mode 100644 index 0000000..28b56df --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-cursor.ts @@ -0,0 +1,34 @@ +/** + * 游标分页编解码(A5-BE-03) + * + * 格式:`base64url(|)`,对应稳定排序 `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 }; +} diff --git a/apps/api/src/modules/platform/audit/audit-export-csv.spec.ts b/apps/api/src/modules/platform/audit/audit-export-csv.spec.ts new file mode 100644 index 0000000..13c77a5 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export-csv.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/modules/platform/audit/audit-export-csv.ts b/apps/api/src/modules/platform/audit/audit-export-csv.ts new file mode 100644 index 0000000..c43eec7 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export-csv.ts @@ -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`; +} diff --git a/apps/api/src/modules/platform/audit/audit-export-runner.service.ts b/apps/api/src/modules/platform/audit/audit-export-runner.service.ts new file mode 100644 index 0000000..dee4613 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export-runner.service.ts @@ -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 { + if (!stream.write(text)) { + await once(stream, 'drain'); + } +} + +async function finishStream(stream: WriteStream): Promise { + stream.end(); + await once(stream, 'finish'); +} + +@Injectable() +export class AuditExportRunnerService implements OnModuleInit { + private readonly logger = new Logger('AuditExport'); + private readonly running = new Set(); + + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(SystemConfigService) private readonly configs: SystemConfigService, + @Inject(AuditWriterService) private readonly writer: AuditWriterService, + ) {} + + async onModuleInit(): Promise { + 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 { + 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 { + 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; + } + } +} diff --git a/apps/api/src/modules/platform/audit/audit-export-token.service.ts b/apps/api/src/modules/platform/audit/audit-export-token.service.ts new file mode 100644 index 0000000..a36bd64 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export-token.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/modules/platform/audit/audit-export-token.spec.ts b/apps/api/src/modules/platform/audit/audit-export-token.spec.ts new file mode 100644 index 0000000..2d3e9dc --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export-token.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/modules/platform/audit/audit-export.service.ts b/apps/api/src/modules/platform/audit/audit-export.service.ts new file mode 100644 index 0000000..d7350de --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-export.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/apps/api/src/modules/platform/audit/audit-partition.service.ts b/apps/api/src/modules/platform/audit/audit-partition.service.ts new file mode 100644 index 0000000..c69109b --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-partition.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/apps/api/src/modules/platform/audit/audit-query.ts b/apps/api/src/modules/platform/audit/audit-query.ts new file mode 100644 index 0000000..3b63620 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-query.ts @@ -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 }), + }; +} diff --git a/apps/api/src/modules/platform/audit/audit-retention.service.ts b/apps/api/src/modules/platform/audit/audit-retention.service.ts new file mode 100644 index 0000000..3824f29 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-retention.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/api/src/modules/platform/audit/audit-writer.service.ts b/apps/api/src/modules/platform/audit/audit-writer.service.ts new file mode 100644 index 0000000..b7a522a --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit-writer.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/modules/platform/audit/audit.controller.ts b/apps/api/src/modules/platform/audit/audit.controller.ts new file mode 100644 index 0000000..e6be15a --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.controller.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.audit.detail(id); + } +} diff --git a/apps/api/src/modules/platform/audit/audit.int.spec.ts b/apps/api/src/modules/platform/audit/audit.int.spec.ts new file mode 100644 index 0000000..99f75d6 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.int.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); +} + +async function call( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}`, 'user-agent': 'ita5-vitest' }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const body = response.body.length === 0 ? (null as T) : response.json(); + return { status: response.statusCode, body }; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForExport(jobId: string, token: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const result = await call('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 { + const created = await call('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): Promise { + 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 { + 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('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('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('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( + '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('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( + '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('GET', '/audit-logs?category=client', adminToken); + expect(byCategory.body.items.every((item) => item.category === 'client')).toBe(true); + + const byResult = await call('GET', '/audit-logs?result=failure', adminToken); + expect(byResult.body.items.every((item) => item.result === 'failure')).toBe(true); + + const byCustomer = await call( + 'GET', + `/audit-logs?customer_id=${customerId}`, + adminToken, + ); + expect(byCustomer.body.items.every((item) => item.customer_id === customerId)).toBe(true); + + const byActor = await call( + '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(`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( + '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( + '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('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( + '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('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('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('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('GET', '/audit-logs', limitedToken); + expect(read.status).toBe(403); + expect(read.body.code).toBe(ErrorCode.SPECIAL_PERMISSION_REQUIRED); + + const exportDenied = await call('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( + '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( + 'GET', + `/audit-logs/exports/${created.body.id}`, + limitedToken, + ); + expect([403, 404]).toContain(othersJob.status); + }); +}); diff --git a/apps/api/src/modules/platform/audit/audit.mapper.spec.ts b/apps/api/src/modules/platform/audit/audit.mapper.spec.ts new file mode 100644 index 0000000..c4f0f0a --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.mapper.spec.ts @@ -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 { + 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 { + 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, + }); + }); +}); diff --git a/apps/api/src/modules/platform/audit/audit.mapper.ts b/apps/api/src/modules/platform/audit/audit.mapper.ts new file mode 100644 index 0000000..03b9867 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.mapper.ts @@ -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( + 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, + }; +} diff --git a/apps/api/src/modules/platform/audit/audit.module.ts b/apps/api/src/modules/platform/audit/audit.module.ts new file mode 100644 index 0000000..a151f76 --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.module.ts @@ -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 {} diff --git a/apps/api/src/modules/platform/audit/audit.service.ts b/apps/api/src/modules/platform/audit/audit.service.ts new file mode 100644 index 0000000..bc6d8aa --- /dev/null +++ b/apps/api/src/modules/platform/audit/audit.service.ts @@ -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 { + // 审计查看本身留痕:列表 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 { + // 详情留痕: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); + } +} diff --git a/apps/api/src/modules/platform/platform.module.ts b/apps/api/src/modules/platform/platform.module.ts index 3cd1826..8dbd3a5 100644 --- a/apps/api/src/modules/platform/platform.module.ts +++ b/apps/api/src/modules/platform/platform.module.ts @@ -1,20 +1,20 @@ import { Global, Module } from '@nestjs/common'; +import { AuditModule } from './audit/audit.module'; import { HealthModule } from './health/health.module'; import { NamingService } from './naming/naming.service'; -import { SystemConfigController } from './system-config/system-config.controller'; -import { SystemConfigService } from './system-config/system-config.service'; +import { SystemConfigModule } from './system-config/system-config.module'; /** * platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块 - * (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client / - * 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。 + * (技术架构 §3)。@Global 导出 SystemConfigService / NamingService / 审计写入端口, + * 供 identity / client / 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import + * (R-26 阈值同源;A5 审计落库依赖倒置)。 */ @Global() @Module({ - imports: [HealthModule], - controllers: [SystemConfigController], - providers: [SystemConfigService, NamingService], - exports: [HealthModule, SystemConfigService, NamingService], + imports: [HealthModule, SystemConfigModule, AuditModule], + providers: [NamingService], + exports: [HealthModule, SystemConfigModule, AuditModule, NamingService], }) export class PlatformModule {} diff --git a/apps/api/src/modules/platform/system-config/system-config.module.ts b/apps/api/src/modules/platform/system-config/system-config.module.ts new file mode 100644 index 0000000..e383096 --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { SystemConfigController } from './system-config.controller'; +import { SystemConfigService } from './system-config.service'; + +/** + * 系统配置子模块(A4-BE-02 / PRD R-26):阈值 / 上限的唯一读取入口。 + * 由 PlatformModule 导入并再导出,保持其他模块通过 @Global PlatformModule 注入 SystemConfigService。 + */ +@Module({ + controllers: [SystemConfigController], + providers: [SystemConfigService], + exports: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/apps/api/src/modules/platform/system-config/system-config.service.ts b/apps/api/src/modules/platform/system-config/system-config.service.ts index a321479..efc72cf 100644 --- a/apps/api/src/modules/platform/system-config/system-config.service.ts +++ b/apps/api/src/modules/platform/system-config/system-config.service.ts @@ -194,6 +194,10 @@ export class SystemConfigService { this.requestContext.patchAudit({ targetType: 'system_config', targetId: snapshots.map((snapshot) => snapshot.key).join(','), + targetName: snapshots + .map((snapshot) => systemConfigDefinition(snapshot.key)?.label ?? snapshot.key) + .join('、') + .slice(0, 160), before: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.oldValue])), after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])), changes: snapshots, diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 7971adb..6a7263f 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -716,6 +716,26 @@ export interface paths { patch: operations['DictsController_updateItem']; trace?: never; }; + '/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 健康检查 + * @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。 + */ + get: operations['HealthController_check']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/system/configs': { parameters: { query?: never; @@ -768,18 +788,117 @@ export interface paths { patch?: never; trace?: never; }; - '/health': { + '/api/v1/audit-logs': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * 健康检查 - * @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。 - */ - get: operations['HealthController_check']; + /** 审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC) */ + get: operations['AuditController_list']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/actions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 审计动作目录(中文名 / 分类 / 对象类型,供筛选下拉) */ + get: operations['AuditController_actions']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/export': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 创建审计导出任务(异步,立即返回 queued;导出本身写审计) */ + post: operations['AuditController_createExport']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/exports': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 我的审计导出任务(按发起时间倒序) */ + get: operations['AuditController_listExports']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/exports/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 导出任务进度(status / progress / rowCount / fileSize / expiresAt) */ + get: operations['AuditController_getExport']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/exports/{id}/download': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 下载审计导出 CSV(短时效 HMAC 签名链接,无需登录) */ + get: operations['AuditController_download']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/audit-logs/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 审计日志详情(before / after / changes / diff / trace / ip / ua) */ + get: operations['AuditController_detail']; put?: never; post?: never; delete?: never; @@ -1853,6 +1972,33 @@ export interface operations { }; }; }; + HealthController_check: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 全部依赖健康 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HealthResponseDto']; + }; + }; + /** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SystemConfigController_list: { parameters: { query?: never; @@ -1921,7 +2067,7 @@ export interface operations { }; }; }; - HealthController_check: { + AuditController_list: { parameters: { query?: never; header?: never; @@ -1930,17 +2076,109 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 全部依赖健康 */ 200: { headers: { [name: string]: unknown; }; - content: { - 'application/json': components['schemas']['HealthResponseDto']; - }; + content?: never; }; - /** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */ - 503: { + }; + }; + AuditController_actions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuditController_createExport: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuditController_listExports: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuditController_getExport: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuditController_download: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuditController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; diff --git a/openapi/lingxi.openapi.json b/openapi/lingxi.openapi.json index 7d9bfa9..b901eb9 100644 --- a/openapi/lingxi.openapi.json +++ b/openapi/lingxi.openapi.json @@ -1094,6 +1094,32 @@ ] } }, + "/health": { + "get": { + "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。", + "operationId": "HealthController_check", + "parameters": [], + "responses": { + "200": { + "description": "全部依赖健康", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponseDto" + } + } + } + }, + "503": { + "description": "依赖不可用(错误码 50002),details 内附各依赖检查结果" + } + }, + "summary": "健康检查", + "tags": [ + "platform" + ] + } + }, "/api/v1/system/configs": { "get": { "operationId": "SystemConfigController_list", @@ -1172,29 +1198,143 @@ ] } }, - "/health": { + "/api/v1/audit-logs": { "get": { - "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。", - "operationId": "HealthController_check", + "operationId": "AuditController_list", "parameters": [], "responses": { "200": { - "description": "全部依赖健康", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponseDto" - } - } - } - }, - "503": { - "description": "依赖不可用(错误码 50002),details 内附各依赖检查结果" + "description": "" } }, - "summary": "健康检查", + "security": [ + { + "bearer": [] + } + ], + "summary": "审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC)", "tags": [ - "platform" + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/actions": { + "get": { + "operationId": "AuditController_actions", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "审计动作目录(中文名 / 分类 / 对象类型,供筛选下拉)", + "tags": [ + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/export": { + "post": { + "operationId": "AuditController_createExport", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "创建审计导出任务(异步,立即返回 queued;导出本身写审计)", + "tags": [ + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/exports": { + "get": { + "operationId": "AuditController_listExports", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "我的审计导出任务(按发起时间倒序)", + "tags": [ + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/exports/{id}": { + "get": { + "operationId": "AuditController_getExport", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "导出任务进度(status / progress / rowCount / fileSize / expiresAt)", + "tags": [ + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/exports/{id}/download": { + "get": { + "operationId": "AuditController_download", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "下载审计导出 CSV(短时效 HMAC 签名链接,无需登录)", + "tags": [ + "platform-audit" + ] + } + }, + "/api/v1/audit-logs/{id}": { + "get": { + "operationId": "AuditController_detail", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "审计日志详情(before / after / changes / diff / trace / ip / ua)", + "tags": [ + "platform-audit" ] } }