feat(api): A5 审计采集、检索与导出
AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
This commit is contained in:
1 parent
6215f204ce
commit
6bdda6ec62
44 files changed
+3845
-127
No files matched your search
@@ -0,0 +1,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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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<string, NormalizedJson> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>): ExecutionContext {
|
||||||
|
const handler: Record<string, unknown> = {};
|
||||||
|
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<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
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<unknown>): CallHandler {
|
||||||
|
return { handle: fn };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sinkOf(): { write: ReturnType<typeof vi.fn> } {
|
||||||
|
return { write: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function interceptorOf(
|
||||||
|
requestContext: RequestContextService,
|
||||||
|
sink?: { write: ReturnType<typeof vi.fn> },
|
||||||
|
): 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(['客户一', '客户二']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { auditActionMeta } from '@lingxi/shared';
|
||||||
import {
|
import {
|
||||||
type CallHandler,
|
type CallHandler,
|
||||||
type ExecutionContext,
|
type ExecutionContext,
|
||||||
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
type NestInterceptor,
|
type NestInterceptor,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { defer, type Observable } from 'rxjs';
|
import { from, Observable, throwError } from 'rxjs';
|
||||||
import { tap } 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 { AUDIT_ACTION_KEY } from './decorators';
|
||||||
import {
|
import {
|
||||||
type AuditDetail,
|
type AuditDetail,
|
||||||
@@ -24,35 +30,68 @@ interface AuditParams {
|
|||||||
|
|
||||||
interface AuditPayload {
|
interface AuditPayload {
|
||||||
action: string;
|
action: string;
|
||||||
|
actionLabel: string;
|
||||||
|
category: string;
|
||||||
result: 'success' | 'failure';
|
result: 'success' | 'failure';
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
|
actorName: string | null;
|
||||||
account: string | null;
|
account: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
targetType: string | null;
|
targetType: string | null;
|
||||||
targetId: string | null;
|
targetId: string | null;
|
||||||
|
targetName: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string | null;
|
||||||
before?: unknown;
|
before?: unknown;
|
||||||
after?: unknown;
|
after?: unknown;
|
||||||
changes?: unknown;
|
changes?: unknown;
|
||||||
affectedUserCount?: number;
|
affectedUserCount?: number;
|
||||||
affectedUserIds?: string[];
|
affectedUserIds?: string[];
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
httpMethod: string;
|
||||||
|
httpPath: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
durationMs: number;
|
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 {
|
function actionOf(context: ExecutionContext): string | null {
|
||||||
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
|
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
|
||||||
return typeof action === 'string' ? action : null;
|
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 日志并(若装配了 AUDIT_SINK)写入 `audit_logs`;
|
||||||
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
|
* - 落库在响应返回前 await,但**写入失败只记 Pino error,不影响主请求**;
|
||||||
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
|
* - 业务 Service 通过 `RequestContextService.patchAudit` 补充前后值 / 对象 / 客户维度;
|
||||||
|
* - 依赖倒置:本拦截器在 common 层,不 import 任何 modules;AUDIT_SINK 由 PlatformModule 提供,
|
||||||
|
* 单测未提供时自动退化为仅 Pino。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditInterceptor implements NestInterceptor {
|
export class AuditInterceptor implements NestInterceptor {
|
||||||
@@ -60,6 +99,7 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
@Optional() @Inject(AUDIT_SINK) private readonly sink?: AuditSink,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
@@ -70,57 +110,131 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
const action = actionOf(context);
|
const action = actionOf(context);
|
||||||
const targetId = request.params?.id ?? null;
|
const targetId = request.params?.id ?? null;
|
||||||
|
|
||||||
return defer(() =>
|
// 必须在 ALS 上下文内订阅:若先返回 Observable 再订阅,handler / patchAudit 会丢失 store。
|
||||||
|
return new Observable<unknown>((subscriber) =>
|
||||||
this.requestContext.run(ctx, () => {
|
this.requestContext.run(ctx, () => {
|
||||||
if (action === null) {
|
if (action === null) {
|
||||||
return next.handle();
|
return next.handle().subscribe(subscriber);
|
||||||
}
|
}
|
||||||
return next.handle().pipe(
|
return next
|
||||||
tap({
|
.handle()
|
||||||
next: () => {
|
.pipe(
|
||||||
this.emit(ctx, action, 'success', targetId);
|
concatMap((value: unknown) =>
|
||||||
},
|
from(this.record(ctx, action, 'success', targetId)).pipe(map(() => value)),
|
||||||
error: (error: unknown) => {
|
),
|
||||||
this.emit(ctx, action, 'failure', targetId, error);
|
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,
|
ctx: RequestContext,
|
||||||
action: string,
|
action: string,
|
||||||
result: AuditPayload['result'],
|
result: AuditPayload['result'],
|
||||||
defaultTargetId: string | null,
|
defaultTargetId: string | null,
|
||||||
error?: unknown,
|
error?: unknown,
|
||||||
): void {
|
): Promise<void> {
|
||||||
const detail: AuditDetail = ctx.audit ?? {};
|
try {
|
||||||
const payload: AuditPayload = {
|
const detail: AuditDetail = ctx.audit ?? {};
|
||||||
action,
|
const meta = auditActionMeta(action);
|
||||||
result,
|
const before = sanitizeAuditValue(detail.before);
|
||||||
actorId: ctx.userId,
|
const after = sanitizeAuditValue(detail.after);
|
||||||
account: ctx.account,
|
const changes = sanitizeAuditValue(detail.changes);
|
||||||
ip: ctx.ip,
|
const errorInfo = error === undefined ? null : errorInfoOf(error);
|
||||||
userAgent: ctx.userAgent,
|
const httpPath = truncate(ctx.url, MAX_PATH) ?? '';
|
||||||
targetType: detail.targetType ?? null,
|
|
||||||
targetId: detail.targetId ?? defaultTargetId,
|
const payload: AuditPayload = {
|
||||||
...(detail.before === undefined ? {} : { before: detail.before }),
|
action,
|
||||||
...(detail.after === undefined ? {} : { after: detail.after }),
|
actionLabel: meta.label,
|
||||||
...(detail.changes === undefined ? {} : { changes: detail.changes }),
|
category: meta.category,
|
||||||
...(detail.affectedUserCount === undefined
|
result,
|
||||||
? {}
|
actorId: ctx.userId,
|
||||||
: { affectedUserCount: detail.affectedUserCount }),
|
actorName: ctx.actorName,
|
||||||
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
|
account: ctx.account,
|
||||||
...(detail.reason === undefined ? {} : { reason: detail.reason }),
|
ip: ctx.ip,
|
||||||
traceId: ctx.traceId,
|
userAgent: truncate(ctx.userAgent, MAX_UA),
|
||||||
durationMs: Date.now() - ctx.startedAt,
|
targetType: detail.targetType ?? meta.targetType ?? null,
|
||||||
...(error === undefined
|
targetId: detail.targetId ?? defaultTargetId,
|
||||||
? {}
|
targetName: detail.targetName ?? null,
|
||||||
: { error: error instanceof Error ? error.message : '未知错误' }),
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface JwtRefreshPayload {
|
|||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId: string;
|
userId: string;
|
||||||
account: string;
|
account: string;
|
||||||
|
/** 用户姓名(审计 actor_name;登录 / 刷新返回的 me 同源) */
|
||||||
|
name: string;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export class PermissionService {
|
|||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
account: user.account,
|
account: user.account,
|
||||||
|
name: user.name,
|
||||||
status,
|
status,
|
||||||
roles: roleEntities.map((role) => toRoleSummary(role)),
|
roles: roleEntities.map((role) => toRoleSummary(role)),
|
||||||
permissions,
|
permissions,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import type { ScopeContext } from './scope/scope-context';
|
|||||||
export interface AuditDetail {
|
export interface AuditDetail {
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
|
/** 对象展示名(客户名 / 用户姓名 / 字典类型名 / 配置项名等),审计表 target_name */
|
||||||
|
targetName?: string;
|
||||||
|
/** 客户维度(R-04):客户相关动作写入 audit_logs.customer_id / customer_name */
|
||||||
|
customerId?: string;
|
||||||
|
customerName?: string;
|
||||||
before?: unknown;
|
before?: unknown;
|
||||||
after?: unknown;
|
after?: unknown;
|
||||||
affectedUserCount?: number;
|
affectedUserCount?: number;
|
||||||
@@ -30,6 +35,8 @@ export interface RequestContext {
|
|||||||
traceId: string;
|
traceId: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
account: string | null;
|
account: string | null;
|
||||||
|
/** 操作人姓名(auth 解析时一并带入,供审计表 actor_name) */
|
||||||
|
actorName: string | null;
|
||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
scope: ScopeContext | null;
|
scope: ScopeContext | null;
|
||||||
audit: AuditDetail | null;
|
audit: AuditDetail | null;
|
||||||
@@ -53,6 +60,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
|||||||
traceId: traceIdOf(request),
|
traceId: traceIdOf(request),
|
||||||
userId: auth?.userId ?? null,
|
userId: auth?.userId ?? null,
|
||||||
account: auth?.account ?? null,
|
account: auth?.account ?? null,
|
||||||
|
actorName: auth?.name ?? null,
|
||||||
permissions: auth?.permissions ?? null,
|
permissions: auth?.permissions ?? null,
|
||||||
scope: request.scope ?? null,
|
scope: request.scope ?? null,
|
||||||
audit: null,
|
audit: null,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
|
|||||||
return {
|
return {
|
||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
account: 'chen.mo',
|
account: 'chen.mo',
|
||||||
|
name: '陈默',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: ['user:view'],
|
permissions: ['user:view'],
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_type',
|
targetType: 'dict_type',
|
||||||
targetId: id,
|
targetId: id,
|
||||||
|
targetName: input.name,
|
||||||
before: null,
|
before: null,
|
||||||
after: summary,
|
after: summary,
|
||||||
});
|
});
|
||||||
@@ -224,6 +225,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_type',
|
targetType: 'dict_type',
|
||||||
targetId: existing.id,
|
targetId: existing.id,
|
||||||
|
targetName: input.name ?? existing.name,
|
||||||
before: typeRowAudit(existing),
|
before: typeRowAudit(existing),
|
||||||
after: {
|
after: {
|
||||||
code: summary.code,
|
code: summary.code,
|
||||||
@@ -255,6 +257,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_type',
|
targetType: 'dict_type',
|
||||||
targetId: existing.id,
|
targetId: existing.id,
|
||||||
|
targetName: existing.name,
|
||||||
before: typeRowAudit(existing),
|
before: typeRowAudit(existing),
|
||||||
after: {
|
after: {
|
||||||
code: summary.code,
|
code: summary.code,
|
||||||
@@ -285,6 +288,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_type',
|
targetType: 'dict_type',
|
||||||
targetId: existing.id,
|
targetId: existing.id,
|
||||||
|
targetName: existing.name,
|
||||||
before: typeRowAudit(existing),
|
before: typeRowAudit(existing),
|
||||||
after: null,
|
after: null,
|
||||||
});
|
});
|
||||||
@@ -435,6 +439,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_item',
|
targetType: 'dict_item',
|
||||||
targetId: id,
|
targetId: id,
|
||||||
|
targetName: input.name,
|
||||||
before: null,
|
before: null,
|
||||||
after: itemAudit(row),
|
after: itemAudit(row),
|
||||||
});
|
});
|
||||||
@@ -485,6 +490,7 @@ export class DictsService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'dict_item',
|
targetType: 'dict_item',
|
||||||
targetId: row.id,
|
targetId: row.id,
|
||||||
|
targetName: input.name ?? existing.name,
|
||||||
before: itemAudit(existing),
|
before: itemAudit(existing),
|
||||||
after: itemAudit(row),
|
after: itemAudit(row),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
@@ -55,6 +56,7 @@ export class CustomerFieldsService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -128,7 +130,7 @@ export class CustomerFieldsService {
|
|||||||
}
|
}
|
||||||
const customer = await this.prisma.customer.findFirst({
|
const customer = await this.prisma.customer.findFirst({
|
||||||
where: { id: customerId, deletedAt: null },
|
where: { id: customerId, deletedAt: null },
|
||||||
select: { id: true, status: true },
|
select: { id: true, name: true, status: true },
|
||||||
});
|
});
|
||||||
if (customer === null) {
|
if (customer === null) {
|
||||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
@@ -173,6 +175,21 @@ export class CustomerFieldsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: FIELD_INCLUDE,
|
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);
|
return toCustomerField(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +202,7 @@ export class CustomerFieldsService {
|
|||||||
const scope = requireScopeContext(scopeInput);
|
const scope = requireScopeContext(scopeInput);
|
||||||
const existing = await this.prisma.customerField.findUnique({
|
const existing = await this.prisma.customerField.findUnique({
|
||||||
where: { id },
|
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
|
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
|
||||||
if (existing === null || existing.customer.deletedAt !== null) {
|
if (existing === null || existing.customer.deletedAt !== null) {
|
||||||
@@ -222,6 +239,19 @@ export class CustomerFieldsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: FIELD_INCLUDE,
|
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);
|
return toCustomerField(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +46,10 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
|
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerGroupsService {
|
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<ListResponse<CustomerGroupSummary>> {
|
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
|
||||||
const rows = await this.prisma.customerGroup.findMany({
|
const rows = await this.prisma.customerGroup.findMany({
|
||||||
@@ -96,11 +100,21 @@ export class CustomerGroupsService {
|
|||||||
if (input.customerIds !== undefined && input.customerIds.length > 0) {
|
if (input.customerIds !== undefined && input.customerIds.length > 0) {
|
||||||
await this.addMembers(id, input.customerIds);
|
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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||||
await this.findByIdOrThrow(id);
|
const existing = await this.findByIdOrThrow(id);
|
||||||
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
|
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
|
||||||
await this.prisma.customerGroup.update({
|
await this.prisma.customerGroup.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -109,11 +123,21 @@ export class CustomerGroupsService {
|
|||||||
...(input.description === undefined ? {} : { description: input.description }),
|
...(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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findByIdOrThrow(id);
|
const existing = await this.findByIdOrThrow(id);
|
||||||
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
|
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
|
||||||
if (referenced > 0) {
|
if (referenced > 0) {
|
||||||
throw new AppException({
|
throw new AppException({
|
||||||
@@ -122,14 +146,32 @@ export class CustomerGroupsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.prisma.customerGroup.delete({ where: { id } });
|
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(
|
async updateMembers(
|
||||||
id: string,
|
id: string,
|
||||||
input: UpdateCustomerGroupMembersInput,
|
input: UpdateCustomerGroupMembersInput,
|
||||||
): Promise<CustomerGroupDetail> {
|
): Promise<CustomerGroupDetail> {
|
||||||
await this.findByIdOrThrow(id);
|
const existing = await this.findByIdOrThrow(id);
|
||||||
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
|
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([
|
await this.prisma.$transaction([
|
||||||
this.prisma.customerGroupMember.deleteMany({
|
this.prisma.customerGroupMember.deleteMany({
|
||||||
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
|
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.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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
@@ -63,6 +64,7 @@ export class CustomersService {
|
|||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
||||||
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -155,6 +157,23 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
throw error;
|
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);
|
return this.detail(id, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +236,33 @@ export class CustomersService {
|
|||||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
...(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);
|
return this.detail(id, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +286,22 @@ export class CustomersService {
|
|||||||
const target =
|
const target =
|
||||||
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
|
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
|
||||||
await this.prisma.customer.update({ where: { id }, data: { status: target } });
|
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);
|
return this.detail(id, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +328,15 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
await this.ensureOwner(input.ownerUserId);
|
await this.ensureOwner(input.ownerUserId);
|
||||||
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: 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);
|
return this.detail(id, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +363,16 @@ export class CustomersService {
|
|||||||
},
|
},
|
||||||
data: { ownerUserId: toUserId },
|
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 };
|
return { transferred: result.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +382,7 @@ export class CustomersService {
|
|||||||
scopeInput: ScopeContext | null,
|
scopeInput: ScopeContext | null,
|
||||||
): Promise<CustomerDetail> {
|
): Promise<CustomerDetail> {
|
||||||
const scope = requireScopeContext(scopeInput);
|
const scope = requireScopeContext(scopeInput);
|
||||||
await this.findRow(id);
|
const existing = await this.findRow(id);
|
||||||
this.assertVisible(scope, id);
|
this.assertVisible(scope, id);
|
||||||
if (input.groupIds.length > 0) {
|
if (input.groupIds.length > 0) {
|
||||||
const found = await this.prisma.customerGroup.findMany({
|
const found = await this.prisma.customerGroup.findMany({
|
||||||
@@ -312,12 +393,27 @@ export class CustomersService {
|
|||||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
|
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([
|
await this.prisma.$transaction([
|
||||||
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
|
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
|
||||||
this.prisma.customerGroupMember.createMany({
|
this.prisma.customerGroupMember.createMany({
|
||||||
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
|
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);
|
return this.detail(id, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +430,28 @@ export class CustomersService {
|
|||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
industryId: string;
|
industryId: string;
|
||||||
name: 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({
|
const row = await this.prisma.customer.findFirst({
|
||||||
where: { id, deletedAt: null },
|
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) {
|
if (row === null) {
|
||||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import { type IndustryRow, toIndustryNode } from './client.mapper';
|
import { type IndustryRow, toIndustryNode } from './client.mapper';
|
||||||
@@ -28,7 +29,10 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IndustriesService {
|
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<ListResponse<IndustryTreeItem>> {
|
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
|
||||||
const rows = await this.prisma.industry.findMany({
|
const rows = await this.prisma.industry.findMany({
|
||||||
@@ -116,6 +120,7 @@ export class IndustriesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity -- 两级行业校验 + 唯一约束映射集中保证事务前校验完整
|
||||||
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
|
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||||
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
|
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
|
||||||
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
|
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
|
||||||
@@ -149,6 +154,18 @@ export class IndustriesService {
|
|||||||
}
|
}
|
||||||
throw error;
|
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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +182,18 @@ export class IndustriesService {
|
|||||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
...(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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +226,18 @@ export class IndustriesService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
|
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);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
|
const existing = await this.findOrThrow(id);
|
||||||
const references = await this.references(id);
|
const references = await this.references(id);
|
||||||
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
|
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
|
||||||
throw new AppException({
|
throw new AppException({
|
||||||
@@ -220,6 +257,17 @@ export class IndustriesService {
|
|||||||
}
|
}
|
||||||
throw error;
|
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) */
|
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@Audit('identity.auth.login')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '账号密码登录',
|
summary: '账号密码登录',
|
||||||
description: '返回 accessToken / refreshToken / expiresIn / me',
|
description: '返回 accessToken / refreshToken / expiresIn / me',
|
||||||
@@ -61,6 +62,7 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
|
@Audit('identity.auth.logout')
|
||||||
@ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' })
|
@ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' })
|
||||||
async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise<void> {
|
async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise<void> {
|
||||||
await this.auth.logout(input);
|
await this.auth.logout(input);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
|
|||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { PermissionService } from '../../common/security/permission.service';
|
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 type { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||||
import { TokenService } from '../../common/security/token.service';
|
import { TokenService } from '../../common/security/token.service';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
@@ -119,6 +120,7 @@ function harness(): Harness {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
const requestContext = { patch: vi.fn(), patchAudit: vi.fn() };
|
||||||
const service = new AuthService(
|
const service = new AuthService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
testConfig,
|
testConfig,
|
||||||
@@ -128,6 +130,7 @@ function harness(): Harness {
|
|||||||
permissions as unknown as PermissionService,
|
permissions as unknown as PermissionService,
|
||||||
scopes as unknown as ScopeResolver,
|
scopes as unknown as ScopeResolver,
|
||||||
me as unknown as MeService,
|
me as unknown as MeService,
|
||||||
|
requestContext as unknown as RequestContextService,
|
||||||
);
|
);
|
||||||
return { service, prisma, failures, passwords, permissions, me };
|
return { service, prisma, failures, passwords, permissions, me };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
||||||
import { PermissionService } from '../../common/security/permission.service';
|
import { PermissionService } from '../../common/security/permission.service';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||||
import { TokenService } from '../../common/security/token.service';
|
import { TokenService } from '../../common/security/token.service';
|
||||||
import { APP_CONFIG } from '../../config/config.module';
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
@@ -36,40 +37,55 @@ export class AuthService {
|
|||||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||||
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
|
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
|
||||||
@Inject(MeService) private readonly me: MeService,
|
@Inject(MeService) private readonly me: MeService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(input: LoginInput, meta: AuthRequestMeta): Promise<LoginResponse> {
|
async login(input: LoginInput, meta: AuthRequestMeta): Promise<LoginResponse> {
|
||||||
const account = input.account.trim().toLowerCase();
|
const account = input.account.trim().toLowerCase();
|
||||||
const status = await this.failures.status(account);
|
// 登录失败也要能记录“尝试账号”(actor_account),成功后再补 userId / 姓名
|
||||||
if (status.locked) {
|
this.requestContext.patch({ account });
|
||||||
throw this.lockedError();
|
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<TokenPair> {
|
async refresh(input: RefreshInput): Promise<TokenPair> {
|
||||||
@@ -100,11 +116,24 @@ export class AuthService {
|
|||||||
|
|
||||||
async logout(input: LogoutInput, userId?: string): Promise<void> {
|
async logout(input: LogoutInput, userId?: string): Promise<void> {
|
||||||
if (input.refreshToken !== undefined) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (userId !== undefined) {
|
if (userId !== undefined) {
|
||||||
await this.revokeAllForUser(userId);
|
await this.revokeAllForUser(userId);
|
||||||
|
this.requestContext.patchAudit({ targetType: 'user', targetId: userId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +158,14 @@ export class AuthService {
|
|||||||
data: { passwordHash, mustChangePassword: false },
|
data: { passwordHash, mustChangePassword: false },
|
||||||
});
|
});
|
||||||
await this.revokeAllForUser(userId);
|
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<User | null> {
|
private async findByAccount(account: string): Promise<User | null> {
|
||||||
@@ -199,18 +236,27 @@ export class AuthService {
|
|||||||
return pair;
|
return pair;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async revokeByToken(token: string): Promise<void> {
|
private async revokeByToken(token: string): Promise<{
|
||||||
|
userId: string;
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
} | null> {
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = this.tokens.verifyRefresh(token);
|
payload = this.tokens.verifyRefresh(token);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
|
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
|
||||||
if (record === null) {
|
if (record === null) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
await this.revokeFamily(record.familyId);
|
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<void> {
|
private async revokeFamily(familyId: string): Promise<void> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
|
||||||
@@ -51,7 +52,10 @@ function mapDepartmentError(error: unknown): AppException {
|
|||||||
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
|
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DepartmentsService {
|
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<DepartmentTreeNode[]> {
|
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
|
||||||
const [departments, counts, lineage] = await Promise.all([
|
const [departments, counts, lineage] = await Promise.all([
|
||||||
@@ -133,6 +137,18 @@ export class DepartmentsService {
|
|||||||
},
|
},
|
||||||
select: { id: true },
|
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;
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapDepartmentError(error);
|
throw mapDepartmentError(error);
|
||||||
@@ -164,6 +180,21 @@ export class DepartmentsService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapDepartmentError(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'] }> {
|
async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
|
||||||
@@ -173,6 +204,13 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
await this.assertNoActiveMembers(existing);
|
await this.assertNoActiveMembers(existing);
|
||||||
await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } });
|
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' };
|
return { id, status: 'disabled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,17 +221,34 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
await this.assertParentActiveForEnable(existing.parentId);
|
await this.assertParentActiveForEnable(existing.parentId);
|
||||||
await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } });
|
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' };
|
return { id, status: 'active' };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrThrow(id: string): Promise<{
|
private async findOrThrow(id: string): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
leaderUserId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}> {
|
}> {
|
||||||
const department = await this.prisma.department.findUnique({
|
const department = await this.prisma.department.findUnique({
|
||||||
where: { id },
|
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) {
|
if (department === null) {
|
||||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ describe('LoginFailureService', () => {
|
|||||||
|
|
||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
||||||
|
const requestContext = { patch: vi.fn(), patchAudit: vi.fn() };
|
||||||
|
|
||||||
const systemConfig = {
|
const systemConfig = {
|
||||||
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
@@ -223,6 +224,7 @@ describe('UsersService', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
passwords as unknown as PasswordService,
|
passwords as unknown as PasswordService,
|
||||||
systemConfig,
|
systemConfig,
|
||||||
|
requestContext as unknown as RequestContextService,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +299,12 @@ describe('UsersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('DepartmentsService', () => {
|
describe('DepartmentsService', () => {
|
||||||
|
const requestContext = { patch: vi.fn(), patchAudit: vi.fn() };
|
||||||
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
|
function serviceOf(prisma: Record<string, unknown>): 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<string, unknown> = {}): Record<string, unknown> {
|
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export class RolesService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: id,
|
targetId: id,
|
||||||
|
targetName: existing.name,
|
||||||
before: { name: existing.name, code: existing.code, status: existing.status },
|
before: { name: existing.name, code: existing.code, status: existing.status },
|
||||||
after: null,
|
after: null,
|
||||||
affectedUserCount: 0,
|
affectedUserCount: 0,
|
||||||
@@ -442,6 +443,7 @@ export class RolesService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: role.id,
|
targetId: role.id,
|
||||||
|
targetName: role.name,
|
||||||
before: null,
|
before: null,
|
||||||
after: this.auditSnapshot(role),
|
after: this.auditSnapshot(role),
|
||||||
affectedUserCount: 0,
|
affectedUserCount: 0,
|
||||||
@@ -514,6 +516,7 @@ export class RolesService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'role',
|
targetType: 'role',
|
||||||
targetId: args.role.id,
|
targetId: args.role.id,
|
||||||
|
targetName: args.role.name,
|
||||||
before: {
|
before: {
|
||||||
name: args.existing.name,
|
name: args.existing.name,
|
||||||
description: args.existing.description,
|
description: args.existing.description,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
@@ -39,6 +40,7 @@ export class UsersService {
|
|||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||||
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
||||||
@@ -96,9 +98,23 @@ export class UsersService {
|
|||||||
throw this.mapUniqueError(error);
|
throw this.mapUniqueError(error);
|
||||||
}
|
}
|
||||||
await this.warnIfSuperAdminExceeded(input.roleIds);
|
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);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-lines-per-function, complexity -- 资料 / 角色 / 部门三段更新共用同一事务与审计口径
|
||||||
async update(id: string, input: UpdateUserInput, permissions: string[]): Promise<UserDetail> {
|
async update(id: string, input: UpdateUserInput, permissions: string[]): Promise<UserDetail> {
|
||||||
const existing = await this.findOrThrow(id);
|
const existing = await this.findOrThrow(id);
|
||||||
if (input.departmentId !== undefined) {
|
if (input.departmentId !== undefined) {
|
||||||
@@ -134,6 +150,28 @@ export class UsersService {
|
|||||||
if (input.roleIds !== undefined) {
|
if (input.roleIds !== undefined) {
|
||||||
await this.warnIfSuperAdminExceeded(input.roleIds);
|
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);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +205,14 @@ export class UsersService {
|
|||||||
data: { revokedAt: new Date() },
|
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);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +225,13 @@ export class UsersService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'ACTIVE', disabledAt: null },
|
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);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||||
|
|
||||||
|
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||||
|
|
||||||
|
describe('审计游标编解码(A5-BE-03)', () => {
|
||||||
|
it('编码 → 解码保持 occurred_at 与 id,且为 base64url', () => {
|
||||||
|
const at = new Date('2026-11-04T08:00:00.123Z');
|
||||||
|
const cursor = encodeAuditCursor(at, ID);
|
||||||
|
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
const decoded = decodeAuditCursor(cursor);
|
||||||
|
expect(decoded?.id).toBe(ID);
|
||||||
|
expect(decoded?.at.toISOString()).toBe(at.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('接受 ISO 字符串并保持毫秒精度(同毫秒分页稳定)', () => {
|
||||||
|
const decoded = decodeAuditCursor(encodeAuditCursor('2026-11-04T08:00:00.001Z', ID));
|
||||||
|
expect(decoded?.at.toISOString()).toBe('2026-11-04T08:00:00.001Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法游标返回 null(非 base64 / 缺分隔符 / 非法时间 / 非法 ULID)', () => {
|
||||||
|
expect(decodeAuditCursor('')).toBeNull();
|
||||||
|
expect(decodeAuditCursor('不是base64')).toBeNull();
|
||||||
|
expect(decodeAuditCursor(Buffer.from('no-separator').toString('base64url'))).toBeNull();
|
||||||
|
expect(decodeAuditCursor(Buffer.from(`not-a-time|${ID}`).toString('base64url'))).toBeNull();
|
||||||
|
expect(
|
||||||
|
decodeAuditCursor(Buffer.from('2026-11-04T08:00:00.000Z|lower-id').toString('base64url')),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 游标分页编解码(A5-BE-03)
|
||||||
|
*
|
||||||
|
* 格式:`base64url(<ISO occurred_at>|<ULID id>)`,对应稳定排序 `occurred_at DESC, id DESC`。
|
||||||
|
* 同一毫秒内按 id 二次排序,保证翻页不重不漏;非法游标返回 null,由 Service 映射为 40001。
|
||||||
|
*/
|
||||||
|
import { isIsoDateString, ULID_PATTERN } from '@lingxi/shared';
|
||||||
|
|
||||||
|
export interface AuditCursor {
|
||||||
|
at: Date;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeAuditCursor(at: Date | string, id: string): string {
|
||||||
|
const iso = at instanceof Date ? at.toISOString() : at;
|
||||||
|
return Buffer.from(`${iso}|${id}`, 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeAuditCursor(value: string): AuditCursor | null {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = Buffer.from(value, 'base64url').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const separator = decoded.lastIndexOf('|');
|
||||||
|
if (separator <= 0) return null;
|
||||||
|
const iso = decoded.slice(0, separator);
|
||||||
|
const id = decoded.slice(separator + 1);
|
||||||
|
if (!isIsoDateString(iso) || !ULID_PATTERN.test(id)) return null;
|
||||||
|
const at = new Date(iso);
|
||||||
|
if (Number.isNaN(at.getTime())) return null;
|
||||||
|
return { at, id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { AuditLog } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIT_EXPORT_BOM,
|
||||||
|
AUDIT_EXPORT_COLUMNS,
|
||||||
|
auditExportHeader,
|
||||||
|
auditLogToCsvLine,
|
||||||
|
csvEscape,
|
||||||
|
} from './audit-export-csv';
|
||||||
|
|
||||||
|
function logRow(overrides: Partial<AuditLog> = {}): AuditLog {
|
||||||
|
return {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
occurredAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||||
|
actorId: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||||
|
actorName: '江岚',
|
||||||
|
actorAccount: 'jiang.lan',
|
||||||
|
actionCode: 'client.customer.update',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
targetId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
targetName: '花间集',
|
||||||
|
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
customerName: '花间集',
|
||||||
|
result: 'success',
|
||||||
|
httpMethod: 'PATCH',
|
||||||
|
httpPath: '/api/v1/customers/1',
|
||||||
|
traceId: 'trace-1',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'vitest',
|
||||||
|
beforeJson: { name: '旧名' },
|
||||||
|
afterJson: { name: '花间集' },
|
||||||
|
changesJson: null,
|
||||||
|
reason: null,
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: null,
|
||||||
|
durationMs: 12,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('审计导出 CSV(A5-BE-03)', () => {
|
||||||
|
it('BOM 与表头字段固定;行尾 CRLF', () => {
|
||||||
|
expect(AUDIT_EXPORT_BOM).toBe('\uFEFF');
|
||||||
|
const header = auditExportHeader();
|
||||||
|
expect(header).toBe(
|
||||||
|
'occurred_at,actor,account,action_code,action_label,category,target_type,target_id,target_name,customer_id,customer_name,result,ip,trace_id,before,after\r\n',
|
||||||
|
);
|
||||||
|
expect(AUDIT_EXPORT_COLUMNS).toContain('action_label');
|
||||||
|
expect(AUDIT_EXPORT_COLUMNS).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RFC 4180 转义:逗号 / 引号 / 换行 / null / 对象', () => {
|
||||||
|
expect(csvEscape('纯文本')).toBe('纯文本');
|
||||||
|
expect(csvEscape('a,b')).toBe('"a,b"');
|
||||||
|
expect(csvEscape('say "hi"')).toBe('"say ""hi"""');
|
||||||
|
expect(csvEscape('line1\nline2')).toBe('"line1\nline2"');
|
||||||
|
expect(csvEscape(null)).toBe('');
|
||||||
|
expect(csvEscape(undefined)).toBe('');
|
||||||
|
expect(csvEscape({ name: '花间集' })).toBe('"{""name"":""花间集""}"');
|
||||||
|
expect(csvEscape(3)).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内容包含标签与前后值,顺序与表头一致', () => {
|
||||||
|
const line = auditLogToCsvLine(logRow(), '编辑客户');
|
||||||
|
expect(line.endsWith('\r\n')).toBe(true);
|
||||||
|
expect(
|
||||||
|
line.startsWith(
|
||||||
|
'2026-11-04T08:00:00.000Z,江岚,jiang.lan,client.customer.update,编辑客户,client,customer,',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(line).toContain('"{""name"":""旧名""}"');
|
||||||
|
expect(line.split('\r\n')[0]?.split('","').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('含逗号字段被正确加引号(不会串列)', () => {
|
||||||
|
const line = auditLogToCsvLine(
|
||||||
|
logRow({ targetName: '花间集, 二店', afterJson: null }),
|
||||||
|
'编辑客户',
|
||||||
|
);
|
||||||
|
expect(line).toContain('"花间集, 二店"');
|
||||||
|
// after 为空时输出空列
|
||||||
|
expect(line.endsWith(',\r\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 审计导出 CSV 工具(A5-BE-03)
|
||||||
|
*
|
||||||
|
* - UTF-8 BOM(Excel 直开不乱码);
|
||||||
|
* - RFC 4180 转义:含逗号 / 引号 / 换行的字段加引号,内部引号双写;
|
||||||
|
* - 对象字段(before / after)序列化为 JSON 字符串。
|
||||||
|
*/
|
||||||
|
import type { AuditLog } from '@prisma/client';
|
||||||
|
|
||||||
|
export const AUDIT_EXPORT_BOM = '\uFEFF';
|
||||||
|
|
||||||
|
export const AUDIT_EXPORT_COLUMNS = [
|
||||||
|
'occurred_at',
|
||||||
|
'actor',
|
||||||
|
'account',
|
||||||
|
'action_code',
|
||||||
|
'action_label',
|
||||||
|
'category',
|
||||||
|
'target_type',
|
||||||
|
'target_id',
|
||||||
|
'target_name',
|
||||||
|
'customer_id',
|
||||||
|
'customer_name',
|
||||||
|
'result',
|
||||||
|
'ip',
|
||||||
|
'trace_id',
|
||||||
|
'before',
|
||||||
|
'after',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function cell(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvEscape(value: unknown): string {
|
||||||
|
const text = cell(value);
|
||||||
|
if (/[",\r\n]/.test(text)) {
|
||||||
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditExportHeader(): string {
|
||||||
|
return `${AUDIT_EXPORT_COLUMNS.join(',')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditLogToCsvLine(row: AuditLog, actionLabel: string): string {
|
||||||
|
const values: readonly unknown[] = [
|
||||||
|
row.occurredAt.toISOString(),
|
||||||
|
row.actorName ?? '',
|
||||||
|
row.actorAccount ?? '',
|
||||||
|
row.actionCode,
|
||||||
|
actionLabel,
|
||||||
|
row.category,
|
||||||
|
row.targetType ?? '',
|
||||||
|
row.targetId ?? '',
|
||||||
|
row.targetName ?? '',
|
||||||
|
row.customerId ?? '',
|
||||||
|
row.customerName ?? '',
|
||||||
|
row.result,
|
||||||
|
row.ip ?? '',
|
||||||
|
row.traceId ?? '',
|
||||||
|
row.beforeJson,
|
||||||
|
row.afterJson,
|
||||||
|
];
|
||||||
|
return `${values.map((value) => csvEscape(value)).join(',')}\r\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* 审计异步导出执行器(A5-BE-03)
|
||||||
|
*
|
||||||
|
* - `POST /audit-logs/export` 只创建 queued 任务并立即返回,后台 `setImmediate` 执行
|
||||||
|
* (不新增依赖;A6 任务中心协议就绪后替换为 BullMQ 任务);
|
||||||
|
* - 每批 500 行游标遍历(`occurred_at DESC, id DESC`),行数上限读 `audit.export_max_rows`;
|
||||||
|
* - 超出上限截断并在任务上标 `truncated=true`,`total_rows` 保留命中总数;
|
||||||
|
* - 导出成功 / 失败都写 `audit.export` 审计(自身留痕,PRD R-05);
|
||||||
|
* - 启动时把卡在 queued/running 超过 10 分钟的任务标记 failed(服务重启中断)。
|
||||||
|
*/
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { createWriteStream, type WriteStream } from 'node:fs';
|
||||||
|
import { mkdir, stat } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { auditActionMeta, auditLogFilterSchema, ErrorCode } from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { AuditExportJob, AuditLog } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
import { AUDIT_EXPORT_BOM, auditExportHeader, auditLogToCsvLine } from './audit-export-csv';
|
||||||
|
import { buildAuditWhere } from './audit-query';
|
||||||
|
import { AuditWriterService } from './audit-writer.service';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 500;
|
||||||
|
const INTERRUPTED_AFTER_MS = 10 * 60 * 1000;
|
||||||
|
const INTERRUPTED_MESSAGE = '服务重启中断,请重新导出';
|
||||||
|
|
||||||
|
export function auditExportDirectory(): string {
|
||||||
|
return resolve(process.cwd(), '.data', 'audit-exports');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditExportFilePath(jobId: string): string {
|
||||||
|
return resolve(auditExportDirectory(), `audit-export-${jobId}.csv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeChunk(stream: WriteStream, text: string): Promise<void> {
|
||||||
|
if (!stream.write(text)) {
|
||||||
|
await once(stream, 'drain');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishStream(stream: WriteStream): Promise<void> {
|
||||||
|
stream.end();
|
||||||
|
await once(stream, 'finish');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditExportRunnerService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger('AuditExport');
|
||||||
|
private readonly running = new Set<string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SystemConfigService) private readonly configs: SystemConfigService,
|
||||||
|
@Inject(AuditWriterService) private readonly writer: AuditWriterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const interrupted = await this.prisma.auditExportJob.updateMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['queued', 'running'] },
|
||||||
|
requestedAt: { lt: new Date(Date.now() - INTERRUPTED_AFTER_MS) },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
errorMessage: INTERRUPTED_MESSAGE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (interrupted.count > 0) {
|
||||||
|
this.logger.warn(`已将 ${String(interrupted.count)} 个中断的审计导出任务标记为失败`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`审计导出启动恢复失败:${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(jobId: string): void {
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.run(jobId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行导出;不抛出异常(失败写任务状态 + 失败审计) */
|
||||||
|
async run(jobId: string): Promise<void> {
|
||||||
|
if (this.running.has(jobId)) return;
|
||||||
|
this.running.add(jobId);
|
||||||
|
try {
|
||||||
|
const job = await this.prisma.auditExportJob.findUnique({ where: { id: jobId } });
|
||||||
|
if (job?.status !== 'queued') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.execute(job);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`审计导出执行失败 job=${jobId}:${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.running.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- 导出流程(取数 / 写文件 / 状态机 / 审计)集中保证顺序
|
||||||
|
private async execute(job: AuditExportJob): Promise<void> {
|
||||||
|
await this.prisma.auditExportJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: { status: 'running', startedAt: new Date(), processedRows: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedFilters = auditLogFilterSchema.safeParse(job.filtersJson);
|
||||||
|
const filters = parsedFilters.success ? parsedFilters.data : {};
|
||||||
|
const where = buildAuditWhere(filters);
|
||||||
|
const maxRows = await this.configs.getNumber('audit.export_max_rows', 50000);
|
||||||
|
const totalRows = await this.prisma.auditLog.count({ where });
|
||||||
|
const targetRows = Math.min(totalRows, maxRows);
|
||||||
|
|
||||||
|
const directory = auditExportDirectory();
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
const filePath = auditExportFilePath(job.id);
|
||||||
|
const stream = createWriteStream(filePath, { encoding: 'utf8' });
|
||||||
|
let written = 0;
|
||||||
|
let cursor: { id: string; occurredAt: Date } | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeChunk(stream, `${AUDIT_EXPORT_BOM}${auditExportHeader()}`);
|
||||||
|
while (written < targetRows) {
|
||||||
|
const take = Math.min(BATCH_SIZE, targetRows - written);
|
||||||
|
const batch: AuditLog[] = await this.prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take,
|
||||||
|
...(cursor === null
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
cursor: { id_occurredAt: { id: cursor.id, occurredAt: cursor.occurredAt } },
|
||||||
|
skip: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
for (const row of batch) {
|
||||||
|
await writeChunk(stream, auditLogToCsvLine(row, auditActionMeta(row.actionCode).label));
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
const last = batch[batch.length - 1];
|
||||||
|
if (last !== undefined) {
|
||||||
|
cursor = { id: last.id, occurredAt: last.occurredAt };
|
||||||
|
}
|
||||||
|
await this.prisma.auditExportJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: { processedRows: written },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await finishStream(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
const ttl = await this.configs.getNumber('platform.export.signed_url_ttl_seconds', 900);
|
||||||
|
const truncated = totalRows > maxRows;
|
||||||
|
await this.prisma.auditExportJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: 'succeeded',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
totalRows,
|
||||||
|
rowCount: written,
|
||||||
|
processedRows: written,
|
||||||
|
truncated,
|
||||||
|
filePath,
|
||||||
|
fileSize: fileStat.size,
|
||||||
|
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.writer.recordDirect({
|
||||||
|
actionCode: 'audit.export',
|
||||||
|
result: 'success',
|
||||||
|
actorId: job.requestedBy,
|
||||||
|
targetType: 'audit_export',
|
||||||
|
targetId: job.id,
|
||||||
|
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||||
|
after: { format: job.format, filters, rowCount: written, totalRows, truncated },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '未知错误';
|
||||||
|
await this.prisma.auditExportJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
errorMessage: message.slice(0, 500),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.writer.recordDirect({
|
||||||
|
actionCode: 'audit.export',
|
||||||
|
result: 'failure',
|
||||||
|
actorId: job.requestedBy,
|
||||||
|
targetType: 'audit_export',
|
||||||
|
targetId: job.id,
|
||||||
|
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||||
|
errorCode: String(ErrorCode.INTERNAL_ERROR),
|
||||||
|
errorMessage: message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 审计导出下载签名(A5-BE-03)
|
||||||
|
*
|
||||||
|
* - HMAC-SHA256,密钥由 `JWT_ACCESS_SECRET` 派生(不新增环境变量 / 运行时依赖);
|
||||||
|
* - token 载荷含 jobId + exp(秒),base64url 编码,签名做时序安全比较;
|
||||||
|
* - 校验失败 / 过期返回 null,由 Service 映射为 42201。
|
||||||
|
*/
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../../config/env';
|
||||||
|
|
||||||
|
const DERIVE_LABEL = 'lingxi.audit-export.download.v1';
|
||||||
|
|
||||||
|
export interface AuditExportTokenPayload {
|
||||||
|
jobId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditExportTokenService {
|
||||||
|
private readonly key: Buffer;
|
||||||
|
|
||||||
|
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||||
|
this.key = createHmac('sha256', config.jwtAccessSecret).update(DERIVE_LABEL).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(jobId: string, expiresAt: Date): string {
|
||||||
|
const exp = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
const payload = `${jobId}.${String(exp)}`;
|
||||||
|
const signature = createHmac('sha256', this.key).update(payload).digest('base64url');
|
||||||
|
return `${Buffer.from(payload, 'utf8').toString('base64url')}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(token: string): AuditExportTokenPayload | null {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const encoded = parts[0];
|
||||||
|
const signature = parts[1];
|
||||||
|
if (encoded === undefined || signature === undefined || encoded.length === 0) return null;
|
||||||
|
|
||||||
|
let payload: string;
|
||||||
|
try {
|
||||||
|
payload = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expected = createHmac('sha256', this.key).update(payload).digest('base64url');
|
||||||
|
const actualBuffer = Buffer.from(signature, 'utf8');
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||||
|
if (
|
||||||
|
actualBuffer.length !== expectedBuffer.length ||
|
||||||
|
!timingSafeEqual(actualBuffer, expectedBuffer)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = payload.lastIndexOf('.');
|
||||||
|
if (separator <= 0) return null;
|
||||||
|
const jobId = payload.slice(0, separator);
|
||||||
|
const exp = Number(payload.slice(separator + 1));
|
||||||
|
if (!Number.isInteger(exp) || exp <= 0) return null;
|
||||||
|
const expiresAt = new Date(exp * 1000);
|
||||||
|
if (expiresAt.getTime() <= Date.now()) return null;
|
||||||
|
return { jobId, expiresAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { AppConfig } from '../../../config/env';
|
||||||
|
import { testConfig } from '../../../testing/test-app';
|
||||||
|
import { AuditExportTokenService } from './audit-export-token.service';
|
||||||
|
|
||||||
|
const JOB_ID = '01HZZZZZZZZZZZZZZZZZZZZZZW';
|
||||||
|
const config: AppConfig = { ...testConfig };
|
||||||
|
|
||||||
|
describe('审计导出下载签名(A5-BE-03)', () => {
|
||||||
|
const tokens = new AuditExportTokenService(config);
|
||||||
|
|
||||||
|
it('签名 → 校验通过,jobId 与过期时间一致', () => {
|
||||||
|
const expiresAt = new Date(Date.now() + 60_000);
|
||||||
|
const token = tokens.sign(JOB_ID, expiresAt);
|
||||||
|
const payload = tokens.verify(token);
|
||||||
|
expect(payload?.jobId).toBe(JOB_ID);
|
||||||
|
expect(payload?.expiresAt.getTime()).toBe(Math.floor(expiresAt.getTime() / 1000) * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已过期返回 null', () => {
|
||||||
|
const token = tokens.sign(JOB_ID, new Date(Date.now() - 1000));
|
||||||
|
expect(tokens.verify(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('篡改载荷 / 签名 / 缺少分段均返回 null', () => {
|
||||||
|
const token = tokens.sign(JOB_ID, new Date(Date.now() + 60_000));
|
||||||
|
const [encoded, signature] = token.split('.');
|
||||||
|
expect(encoded).toBeDefined();
|
||||||
|
expect(signature).toBeDefined();
|
||||||
|
const tamperedPayload = Buffer.from(`${JOB_ID}.9999999999`, 'utf8').toString('base64url');
|
||||||
|
expect(tokens.verify(`${tamperedPayload}.${signature ?? ''}`)).toBeNull();
|
||||||
|
expect(tokens.verify(`${encoded ?? ''}.${'a'.repeat(signature?.length ?? 0)}`)).toBeNull();
|
||||||
|
expect(tokens.verify(encoded ?? '')).toBeNull();
|
||||||
|
expect(tokens.verify(`${encoded ?? ''}.${signature ?? ''}.extra`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同密钥签发的 token 不可互认', () => {
|
||||||
|
const other = new AuditExportTokenService({
|
||||||
|
...config,
|
||||||
|
jwtAccessSecret: 'another-secret-at-least-32-characters!!',
|
||||||
|
});
|
||||||
|
const token = other.sign(JOB_ID, new Date(Date.now() + 60_000));
|
||||||
|
expect(tokens.verify(token)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 审计导出(A5-BE-03):创建异步任务、查询进度、签名下载。
|
||||||
|
*
|
||||||
|
* - 创建:queued 任务立即返回,`AuditExportRunnerService` 后台执行;
|
||||||
|
* - 下载:`@Public()` + HMAC token(jobId + exp),校验任务状态 / 过期 / 文件存在;
|
||||||
|
* - 导出与下载都直接写审计(创建由 @Audit 拦截器记录,完成 / 下载由 Runner / 本服务直写)。
|
||||||
|
*/
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AuditExportCreateParsed,
|
||||||
|
type AuditExportCreateResponse,
|
||||||
|
type AuditExportJob,
|
||||||
|
type AuditExportListQuery,
|
||||||
|
type AuditExportListResponse,
|
||||||
|
ErrorCode,
|
||||||
|
ulid,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { AuditExportJob as AuditExportJobRow } from '@prisma/client';
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { toPrismaJsonValue } from '../../../common/security/audit-sanitize';
|
||||||
|
import type { AuthContext } from '../../../common/security/auth.types';
|
||||||
|
import { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { toAuditExportJob } from './audit.mapper';
|
||||||
|
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||||
|
import { AuditExportRunnerService } from './audit-export-runner.service';
|
||||||
|
import { AuditExportTokenService } from './audit-export-token.service';
|
||||||
|
import { AuditWriterService } from './audit-writer.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditExportService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(AuditExportRunnerService) private readonly runner: AuditExportRunnerService,
|
||||||
|
@Inject(AuditExportTokenService) private readonly tokens: AuditExportTokenService,
|
||||||
|
@Inject(AuditWriterService) private readonly writer: AuditWriterService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
input: AuditExportCreateParsed,
|
||||||
|
actor: AuthContext,
|
||||||
|
): Promise<AuditExportCreateResponse> {
|
||||||
|
const id = ulid();
|
||||||
|
await this.prisma.auditExportJob.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
status: 'queued',
|
||||||
|
format: input.format,
|
||||||
|
filtersJson: toPrismaJsonValue(input.filters),
|
||||||
|
requestedBy: actor.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'audit_export',
|
||||||
|
targetId: id,
|
||||||
|
targetName: `审计导出 ${input.format.toUpperCase()}`,
|
||||||
|
after: { format: input.format, filters: input.filters },
|
||||||
|
});
|
||||||
|
this.runner.schedule(id);
|
||||||
|
return { id, status: 'queued' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listExports(query: AuditExportListQuery, userId: string): Promise<AuditExportListResponse> {
|
||||||
|
const cursor =
|
||||||
|
query.cursor === undefined
|
||||||
|
? null
|
||||||
|
: (() => {
|
||||||
|
const decoded = decodeAuditCursor(query.cursor);
|
||||||
|
if (decoded === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '分页游标无效,请刷新后重试',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
})();
|
||||||
|
const rows = await this.prisma.auditExportJob.findMany({
|
||||||
|
where: { requestedBy: userId },
|
||||||
|
orderBy: [{ requestedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit + 1,
|
||||||
|
...(cursor === null ? {} : { cursor: { id: cursor.id }, skip: 1 }),
|
||||||
|
});
|
||||||
|
const page = rows.slice(0, query.limit);
|
||||||
|
const last = page[page.length - 1];
|
||||||
|
return {
|
||||||
|
items: page.map((row) => this.mapJob(row)),
|
||||||
|
next_cursor:
|
||||||
|
rows.length > query.limit && last !== undefined
|
||||||
|
? encodeAuditCursor(last.requestedAt, last.id)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExport(id: string, userId: string): Promise<AuditExportJob> {
|
||||||
|
const row = await this.prisma.auditExportJob.findFirst({
|
||||||
|
where: { id, requestedBy: userId },
|
||||||
|
});
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '导出任务不存在' });
|
||||||
|
}
|
||||||
|
return this.mapJob(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已完成任务返回短时效签名 token(过期任务不返回) */
|
||||||
|
private mapJob(row: AuditExportJobRow): AuditExportJob {
|
||||||
|
const token =
|
||||||
|
row.status === 'succeeded' && row.expiresAt !== null && row.filePath !== null
|
||||||
|
? this.tokens.sign(row.id, row.expiresAt)
|
||||||
|
: null;
|
||||||
|
return toAuditExportJob(row, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 签名下载:token 校验 → 任务 / 过期 / 文件校验 → 计数 + 审计 → 流式返回 */
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- 下载校验链集中保证顺序与错误码一致
|
||||||
|
async download(id: string, token: string, reply: FastifyReply): Promise<void> {
|
||||||
|
const payload = this.tokens.verify(token);
|
||||||
|
if (payload?.jobId !== id) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '下载链接无效或已过期,请重新导出',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const job = await this.prisma.auditExportJob.findUnique({ where: { id } });
|
||||||
|
if (job === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '导出任务不存在' });
|
||||||
|
}
|
||||||
|
if (job.status !== 'succeeded' || job.filePath === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '导出任务尚未完成或已失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((job.expiresAt?.getTime() ?? 0) <= Date.now()) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '下载链接已过期,请重新导出',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileSize: number;
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(job.filePath);
|
||||||
|
fileSize = fileStat.size;
|
||||||
|
} catch {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.NOT_FOUND,
|
||||||
|
message: '导出文件不存在或已被清理',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.auditExportJob.update({
|
||||||
|
where: { id },
|
||||||
|
data: { downloadCount: { increment: 1 }, lastDownloadedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.writer.recordDirect({
|
||||||
|
actionCode: 'audit.export.download',
|
||||||
|
result: 'success',
|
||||||
|
actorId: job.requestedBy,
|
||||||
|
targetType: 'audit_export',
|
||||||
|
targetId: job.id,
|
||||||
|
targetName: `审计导出 ${job.format.toUpperCase()}`,
|
||||||
|
after: { downloadCount: updated.downloadCount, fileSize },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
reply.header('Content-Disposition', `attachment; filename="audit-export-${job.id}.csv"`);
|
||||||
|
reply.header('Content-Length', String(fileSize));
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
reply.send(createReadStream(job.filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 审计分区维护(A5-BE-01)
|
||||||
|
*
|
||||||
|
* `audit_logs` 按月 RANGE 分区,写入落在哪个月依赖分区存在:
|
||||||
|
* - 应用启动时立即 ensure(并作为「缺失分区」兜底);
|
||||||
|
* - 进程内每 24h ensure 一次(`setInterval(...).unref()`,不阻止进程退出);
|
||||||
|
* - 迁移本身也会调用 ensure 创建当月 ~ +13 月分区。
|
||||||
|
*
|
||||||
|
* 不使用 default 分区(避免后续 ATTACH / 归档冲突);写入范围由 ensure 保证。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
type OnApplicationShutdown,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
|
||||||
|
/** 预创建月数:当月 ~ +13 月(覆盖 12 个月保留期 + 1 个月缓冲) */
|
||||||
|
export const AUDIT_PARTITION_MONTHS_AHEAD = 13;
|
||||||
|
const DAILY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditPartitionService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger('AuditPartition');
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.ensure(AUDIT_PARTITION_MONTHS_AHEAD);
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.ensure(AUDIT_PARTITION_MONTHS_AHEAD);
|
||||||
|
}, DAILY_MS);
|
||||||
|
// 不阻止进程退出(对齐 Node 定时器约定)
|
||||||
|
this.timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplicationShutdown(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等创建分区;返回本次新建数量。失败只记日志,不阻断启动。 */
|
||||||
|
async ensure(monthsAhead = AUDIT_PARTITION_MONTHS_AHEAD): Promise<number> {
|
||||||
|
try {
|
||||||
|
const rows = await this.prisma.$queryRaw<
|
||||||
|
{ created: number }[]
|
||||||
|
>`SELECT audit_logs_ensure_partitions(${monthsAhead}::int) AS created`;
|
||||||
|
const created = rows[0]?.created ?? 0;
|
||||||
|
if (created > 0) {
|
||||||
|
this.logger.log(`已创建 ${String(created)} 个审计月分区(+${String(monthsAhead)} 月)`);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`审计分区创建失败:${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 审计查询条件 → Prisma where(A5-BE-03)
|
||||||
|
*
|
||||||
|
* 所有过滤在 DB 完成(禁止先查全量再过滤);客户维度只作为筛选条件,
|
||||||
|
* 审计是平台级特殊权限、跨客户可见(见 AuditService ScopeContext 注释)。
|
||||||
|
*/
|
||||||
|
import type { AuditLogFilter } from '@lingxi/shared';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
function contains(value: string): Prisma.StringFilter {
|
||||||
|
return { contains: value, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuditWhere(filter: AuditLogFilter): Prisma.AuditLogWhereInput {
|
||||||
|
const and: Prisma.AuditLogWhereInput[] = [];
|
||||||
|
|
||||||
|
if (filter.actor !== undefined) {
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ actorId: filter.actor },
|
||||||
|
{ actorName: contains(filter.actor) },
|
||||||
|
{ actorAccount: contains(filter.actor) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filter.keyword !== undefined) {
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ targetName: contains(filter.keyword) },
|
||||||
|
{ targetId: contains(filter.keyword) },
|
||||||
|
{ httpPath: contains(filter.keyword) },
|
||||||
|
{ reason: contains(filter.keyword) },
|
||||||
|
{ actorName: contains(filter.keyword) },
|
||||||
|
{ actorAccount: contains(filter.keyword) },
|
||||||
|
{ traceId: contains(filter.keyword) },
|
||||||
|
{ ip: contains(filter.keyword) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurredAt: Prisma.DateTimeFilter = {};
|
||||||
|
if (filter.from !== undefined) occurredAt.gte = new Date(filter.from);
|
||||||
|
if (filter.to !== undefined) occurredAt.lte = new Date(filter.to);
|
||||||
|
if (occurredAt.gte !== undefined || occurredAt.lte !== undefined) {
|
||||||
|
and.push({ occurredAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(and.length > 0 ? { AND: and } : {}),
|
||||||
|
...(filter.customer_id === undefined ? {} : { customerId: filter.customer_id }),
|
||||||
|
...(filter.action_code === undefined ? {} : { actionCode: filter.action_code }),
|
||||||
|
...(filter.category === undefined ? {} : { category: filter.category }),
|
||||||
|
...(filter.target_type === undefined ? {} : { targetType: filter.target_type }),
|
||||||
|
...(filter.target_id === undefined ? {} : { targetId: filter.target_id }),
|
||||||
|
...(filter.result === undefined ? {} : { result: filter.result }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 审计保留与归档(A5-BE-04 占位)
|
||||||
|
*
|
||||||
|
* 口径(PRD Q-08 / QT-10,最终保留期仍待产品确认):
|
||||||
|
* - `audit.retention_months` 默认 12、下限 12(不得低于 12 个月);
|
||||||
|
* - `audit_logs` 是 append-only:**本服务不执行 UPDATE / DELETE**,只提供归档骨架;
|
||||||
|
* - 真正的归档(冷存储搬迁)依赖对象存储与任务中心,A5 只登记占位;
|
||||||
|
* - 归档动作本身也应写审计(后续批次接 `audit.retention.archive`)。
|
||||||
|
*
|
||||||
|
* 当前实现只读取配置并返回计划,不会修改任何审计数据。
|
||||||
|
*/
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
|
||||||
|
export interface AuditArchivePlan {
|
||||||
|
/** 计划保留月数(来自 system_configs,下限 12) */
|
||||||
|
retentionMonths: number;
|
||||||
|
/** 该时间之前的审计可归档(只读搬迁,不删除) */
|
||||||
|
before: Date;
|
||||||
|
/** 当前仅占位:实际搬迁未启用 */
|
||||||
|
supported: false;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditRetentionService {
|
||||||
|
private readonly logger = new Logger('AuditRetention');
|
||||||
|
|
||||||
|
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||||
|
|
||||||
|
/** 计算归档窗口;`before` 由调用方传入用于日志 / 任务参数 */
|
||||||
|
async archiveBefore(before: Date): Promise<AuditArchivePlan> {
|
||||||
|
const retentionMonths = await this.configs.getNumber('audit.retention_months', 12);
|
||||||
|
this.logger.warn(
|
||||||
|
`审计归档为 A5 占位:before=${before.toISOString()} retentionMonths=${String(retentionMonths)}(未执行任何删除 / 更新)`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
retentionMonths: Math.max(12, retentionMonths),
|
||||||
|
before,
|
||||||
|
supported: false,
|
||||||
|
message:
|
||||||
|
'审计日志 append-only,归档仅做只读冷存储搬迁、不删除数据;待对象存储与任务中心就绪后接入(QT-10 保留期最终值待确认)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划归档窗口:now - retentionMonths;供后台任务 / 运维查询 */
|
||||||
|
async plan(): Promise<AuditArchivePlan> {
|
||||||
|
const retentionMonths = await this.configs.getNumber('audit.retention_months', 12);
|
||||||
|
const months = Math.max(12, retentionMonths);
|
||||||
|
const before = new Date();
|
||||||
|
before.setMonth(before.getMonth() - months);
|
||||||
|
return this.archiveBefore(before);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* 审计落库实现(A5-BE-02)
|
||||||
|
*
|
||||||
|
* - 实现 common 层 `AuditSink` 端口,由 AuditModule 提供(PlatformModule 全局导出);
|
||||||
|
* - 写入失败只记 Pino error,绝不向上抛(审计失败不得影响业务请求);
|
||||||
|
* - 落库前统一脱敏(password / token / secret 等键剔除);
|
||||||
|
* - 直接写入(导出完成 / 下载等无 HTTP 审计上下文的场景)复用 `recordDirect`。
|
||||||
|
*/
|
||||||
|
import { auditActionMeta, type AuditResult, ulid } from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { sanitizeAuditValue, toPrismaJson } from '../../../common/security/audit-sanitize';
|
||||||
|
import type { AuditSink, AuditWriteInput } from '../../../common/security/audit-sink';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
|
||||||
|
export interface DirectAuditInput {
|
||||||
|
actionCode: string;
|
||||||
|
result: AuditResult;
|
||||||
|
actorId?: string | null;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
targetName?: string | null;
|
||||||
|
customerId?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
changes?: unknown;
|
||||||
|
reason?: string | null;
|
||||||
|
httpMethod?: string | null;
|
||||||
|
httpPath?: string | null;
|
||||||
|
traceId?: string | null;
|
||||||
|
ip?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 DB 列长度截断,避免超长字段导致整条审计写入失败(审计宁可截断也不能丢) */
|
||||||
|
function cut(value: string | null, max: number): string | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
return value.length > max ? value.slice(0, max) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedActor {
|
||||||
|
actorName: string | null;
|
||||||
|
actorAccount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditWriterService implements AuditSink {
|
||||||
|
private readonly logger = new Logger('Audit');
|
||||||
|
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async write(input: AuditWriteInput): Promise<void> {
|
||||||
|
await this.persist({
|
||||||
|
occurredAt: input.occurredAt,
|
||||||
|
actorId: input.actorId,
|
||||||
|
actorName: input.actorName,
|
||||||
|
actorAccount: input.actorAccount,
|
||||||
|
actionCode: input.actionCode,
|
||||||
|
category: input.category,
|
||||||
|
targetType: input.targetType,
|
||||||
|
targetId: input.targetId,
|
||||||
|
targetName: input.targetName,
|
||||||
|
customerId: input.customerId,
|
||||||
|
customerName: input.customerName,
|
||||||
|
result: input.result,
|
||||||
|
httpMethod: input.httpMethod,
|
||||||
|
httpPath: input.httpPath,
|
||||||
|
traceId: input.traceId,
|
||||||
|
ip: input.ip,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
before: input.before,
|
||||||
|
after: input.after,
|
||||||
|
changes: input.changes,
|
||||||
|
reason: input.reason,
|
||||||
|
errorCode: input.errorCode,
|
||||||
|
errorMessage: input.errorMessage,
|
||||||
|
durationMs: input.durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无请求上下文的直接审计(导出完成 / 导出下载 / 启动恢复等) */
|
||||||
|
// eslint-disable-next-line complexity -- 直接审计需为所有可选字段兜底默认值
|
||||||
|
async recordDirect(input: DirectAuditInput): Promise<void> {
|
||||||
|
const actor = await this.resolveActor(input.actorId ?? null);
|
||||||
|
const meta = auditActionMeta(input.actionCode);
|
||||||
|
await this.persist({
|
||||||
|
occurredAt: new Date(),
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
actorName: actor.actorName,
|
||||||
|
actorAccount: actor.actorAccount,
|
||||||
|
actionCode: input.actionCode,
|
||||||
|
category: meta.category,
|
||||||
|
targetType: input.targetType ?? meta.targetType ?? null,
|
||||||
|
targetId: input.targetId ?? null,
|
||||||
|
targetName: input.targetName ?? null,
|
||||||
|
customerId: input.customerId ?? null,
|
||||||
|
customerName: input.customerName ?? null,
|
||||||
|
result: input.result,
|
||||||
|
httpMethod: input.httpMethod ?? null,
|
||||||
|
httpPath: input.httpPath ?? null,
|
||||||
|
traceId: input.traceId ?? null,
|
||||||
|
ip: input.ip ?? null,
|
||||||
|
userAgent: input.userAgent ?? null,
|
||||||
|
before: input.before,
|
||||||
|
after: input.after,
|
||||||
|
changes: input.changes,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
errorCode: input.errorCode ?? null,
|
||||||
|
errorMessage: input.errorMessage ?? null,
|
||||||
|
durationMs: input.durationMs ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析操作人姓名 / 账号(审计冗余字段,避免列表再 join) */
|
||||||
|
async resolveActor(userId: string | null): Promise<ResolvedActor> {
|
||||||
|
if (userId === null) return { actorName: null, actorAccount: null };
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { name: true, account: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
actorName: user?.name ?? null,
|
||||||
|
actorAccount: user?.account ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { actorName: null, actorAccount: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(row: InternalAuditRow): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
occurredAt: row.occurredAt,
|
||||||
|
actorId: cut(row.actorId, 26),
|
||||||
|
actorName: cut(row.actorName, 64),
|
||||||
|
actorAccount: cut(row.actorAccount, 64),
|
||||||
|
actionCode: cut(row.actionCode, 64) ?? 'unknown',
|
||||||
|
category: cut(row.category, 32) ?? 'other',
|
||||||
|
targetType: cut(row.targetType, 64),
|
||||||
|
targetId: cut(row.targetId, 128),
|
||||||
|
targetName: cut(row.targetName, 160),
|
||||||
|
customerId: cut(row.customerId, 26),
|
||||||
|
customerName: cut(row.customerName, 128),
|
||||||
|
result: row.result,
|
||||||
|
httpMethod: cut(row.httpMethod, 8),
|
||||||
|
httpPath: cut(row.httpPath, 255),
|
||||||
|
traceId: cut(row.traceId, 64),
|
||||||
|
ip: cut(row.ip, 64),
|
||||||
|
userAgent: cut(row.userAgent, 255),
|
||||||
|
beforeJson:
|
||||||
|
row.before === undefined ? Prisma.DbNull : toPrismaJson(sanitizeAuditValue(row.before)),
|
||||||
|
afterJson:
|
||||||
|
row.after === undefined ? Prisma.DbNull : toPrismaJson(sanitizeAuditValue(row.after)),
|
||||||
|
changesJson:
|
||||||
|
row.changes === undefined
|
||||||
|
? Prisma.DbNull
|
||||||
|
: toPrismaJson(sanitizeAuditValue(row.changes)),
|
||||||
|
reason: cut(row.reason, 255),
|
||||||
|
errorCode: cut(row.errorCode, 32),
|
||||||
|
errorMessage: cut(row.errorMessage, 500),
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 审计失败只影响日志,不改变业务结果(A5-BE-02)
|
||||||
|
this.logger.error(
|
||||||
|
`审计落库失败 action=${row.actionCode} result=${row.result} error=${
|
||||||
|
error instanceof Error ? error.message : '未知错误'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalAuditRow {
|
||||||
|
occurredAt: Date;
|
||||||
|
actorId: string | null;
|
||||||
|
actorName: string | null;
|
||||||
|
actorAccount: string | null;
|
||||||
|
actionCode: string;
|
||||||
|
category: string;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
targetName: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
result: AuditResult;
|
||||||
|
httpMethod: string | null;
|
||||||
|
httpPath: string | null;
|
||||||
|
traceId: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
changes?: unknown;
|
||||||
|
reason: string | null;
|
||||||
|
errorCode: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
type AuditExportCreateParsed,
|
||||||
|
type AuditExportCreateResponse,
|
||||||
|
auditExportCreateResponseSchema,
|
||||||
|
auditExportCreateSchema,
|
||||||
|
type AuditExportJob,
|
||||||
|
type AuditExportListQuery,
|
||||||
|
auditExportListQuerySchema,
|
||||||
|
type AuditExportListResponse,
|
||||||
|
type AuditLogActionsResponse,
|
||||||
|
type AuditLogDetail,
|
||||||
|
type AuditLogListQuery,
|
||||||
|
auditLogListQuerySchema,
|
||||||
|
type AuditLogListResponse,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Body, Controller, Get, Inject, Param, Post, Query, Res } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../../common/security/auth.types';
|
||||||
|
import {
|
||||||
|
Audit,
|
||||||
|
CurrentUser,
|
||||||
|
Public,
|
||||||
|
RequirePermissions,
|
||||||
|
} from '../../../common/security/decorators';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditExportService } from './audit-export.service';
|
||||||
|
|
||||||
|
const downloadQuerySchema = z.object({ token: z.string().min(1).max(4096) });
|
||||||
|
type DownloadQuery = z.infer<typeof downloadQuerySchema>;
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志(A5-BE-03 / 开发计划 §7.5):
|
||||||
|
* - 检索 / 详情:特殊权限 `audit:read`(缺失走 40303);
|
||||||
|
* - 导出 / 导出历史 / 下载:特殊权限 `audit:export`,下载走短时效签名链接(公开端点自行校验 token);
|
||||||
|
* - 只读,没有任何修改入口。
|
||||||
|
*/
|
||||||
|
@ApiTags('platform-audit')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('audit-logs')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(
|
||||||
|
@Inject(AuditService) private readonly audit: AuditService,
|
||||||
|
@Inject(AuditExportService) private readonly exports: AuditExportService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.AUDIT_READ)
|
||||||
|
@Audit('audit.log.view')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC)',
|
||||||
|
})
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(auditLogListQuerySchema)) query: AuditLogListQuery,
|
||||||
|
): Promise<AuditLogListResponse> {
|
||||||
|
return this.audit.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('actions')
|
||||||
|
@RequirePermissions(Permission.AUDIT_READ)
|
||||||
|
@ApiOperation({ summary: '审计动作目录(中文名 / 分类 / 对象类型,供筛选下拉)' })
|
||||||
|
actions(): AuditLogActionsResponse {
|
||||||
|
return this.audit.actions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('export')
|
||||||
|
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||||
|
@Audit('audit.export')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '创建审计导出任务(异步,立即返回 queued;导出本身写审计)',
|
||||||
|
})
|
||||||
|
async createExport(
|
||||||
|
@Body(new ZodValidationPipe(auditExportCreateSchema)) input: AuditExportCreateParsed,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<AuditExportCreateResponse> {
|
||||||
|
const result = await this.exports.create(input, requireAuth(auth));
|
||||||
|
return auditExportCreateResponseSchema.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('exports')
|
||||||
|
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||||
|
@ApiOperation({ summary: '我的审计导出任务(按发起时间倒序)' })
|
||||||
|
listExports(
|
||||||
|
@Query(new ZodValidationPipe(auditExportListQuerySchema)) query: AuditExportListQuery,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<AuditExportListResponse> {
|
||||||
|
return this.exports.listExports(query, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('exports/:id')
|
||||||
|
@RequirePermissions(Permission.AUDIT_EXPORT)
|
||||||
|
@ApiOperation({ summary: '导出任务进度(status / progress / rowCount / fileSize / expiresAt)' })
|
||||||
|
getExport(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<AuditExportJob> {
|
||||||
|
return this.exports.getExport(id, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('exports/:id/download')
|
||||||
|
@ApiOperation({ summary: '下载审计导出 CSV(短时效 HMAC 签名链接,无需登录)' })
|
||||||
|
async download(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query(new ZodValidationPipe(downloadQuerySchema)) query: DownloadQuery,
|
||||||
|
@Res() reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.exports.download(id, query.token, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.AUDIT_READ)
|
||||||
|
@Audit('audit.log.view')
|
||||||
|
@ApiOperation({ summary: '审计日志详情(before / after / changes / diff / trace / ip / ua)' })
|
||||||
|
detail(@Param('id') id: string): Promise<AuditLogDetail> {
|
||||||
|
return this.audit.detail(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../../config/load-dotenv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A5 审计集成用例(真实 PG / Redis,进程内 Fastify inject)
|
||||||
|
*
|
||||||
|
* 覆盖:七类动作落库(登录 / 登出、权限、客户、字典、配置、审计查看、导出)、
|
||||||
|
* 失败请求落库、before/after diff、游标分页稳定、筛选、append-only(UPDATE / DELETE / TRUNCATE
|
||||||
|
* 与 lingxi_app 权限)、分区幂等、导出全链路(创建 → 轮询 → 下载 → 自身审计)、特殊权限 40303。
|
||||||
|
*
|
||||||
|
* 说明:`audit_logs` 是 append-only,**afterAll 不删除审计数据**(删除即违反 DB 约束);
|
||||||
|
* 通过独立账号 / 客户 / 字典前缀与动作筛选隔离,重复执行不互相干扰。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const ACCOUNT_PREFIX = 'ita5.';
|
||||||
|
const ROLE_PREFIX = 'ITA5-角色-';
|
||||||
|
const INDUSTRY_PREFIX = 'ITA5-';
|
||||||
|
const CUSTOMER_PREFIX = 'ITA5-客户-';
|
||||||
|
const DICT_PREFIX = 'ita5_';
|
||||||
|
const PREFIX_ROLE_CODE = 'ita5_role_';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
const LOGS_URL = 'https://logs.lingxi.test/grafana';
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBody {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginBody {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditItemBody {
|
||||||
|
id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
actor_id: string | null;
|
||||||
|
actor_name: string | null;
|
||||||
|
actor_account: string | null;
|
||||||
|
action_code: string;
|
||||||
|
action_label: string;
|
||||||
|
category: string;
|
||||||
|
target_type: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
target_name: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
result: 'success' | 'failure';
|
||||||
|
change_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditListBody {
|
||||||
|
items: AuditItemBody[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditDetailBody extends AuditItemBody {
|
||||||
|
before: unknown;
|
||||||
|
after: unknown;
|
||||||
|
changes: unknown;
|
||||||
|
diff: { path: string; label: string | null; before: unknown; after: unknown; kind: string }[];
|
||||||
|
trace_id: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportJobBody {
|
||||||
|
id: string;
|
||||||
|
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||||
|
progress: number;
|
||||||
|
total_rows: number | null;
|
||||||
|
row_count: number | null;
|
||||||
|
truncated: boolean;
|
||||||
|
file_size: number | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
download_count: number;
|
||||||
|
download_token: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerBody {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let adminToken = '';
|
||||||
|
let adminRefreshToken = '';
|
||||||
|
let adminUserId = '';
|
||||||
|
let limitedToken = '';
|
||||||
|
let industryId = '';
|
||||||
|
let customerId = '';
|
||||||
|
const dictTypeCode = `${DICT_PREFIX}audit_demo`;
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
const createdDictCodes: string[] = [];
|
||||||
|
const createdExportJobIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `136${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(
|
||||||
|
suffix: string,
|
||||||
|
permissions: string[],
|
||||||
|
special: string[] = [],
|
||||||
|
): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX_ROLE_CODE}${suffix}`,
|
||||||
|
name: `${ROLE_PREFIX}${suffix}`,
|
||||||
|
description: 'A5 审计集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: {
|
||||||
|
create: [...permissions, ...special].map((permissionCode) => ({ permissionCode })),
|
||||||
|
},
|
||||||
|
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<string> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A5 审计集成${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<LoginBody> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<LoginBody>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
headers: { authorization: `Bearer ${token}`, 'user-agent': 'ita5-vitest' },
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExport(jobId: string, token: string): Promise<ExportJobBody> {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const result = await call<ExportJobBody>('GET', `/audit-logs/exports/${jobId}`, token);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
if (result.body.status === 'succeeded' || result.body.status === 'failed') {
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error('审计导出任务超时未完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomer(name: string): Promise<string> {
|
||||||
|
const created = await call<CustomerBody>('POST', '/customers', adminToken, {
|
||||||
|
industryId,
|
||||||
|
name,
|
||||||
|
shortName: name.slice(-6),
|
||||||
|
ownerUserId: adminUserId,
|
||||||
|
remark: 'A5 审计集成测试客户',
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
return created.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditRows(where: Record<string, unknown>): Promise<AuditItemBody[]> {
|
||||||
|
const rows = await prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
occurred_at: row.occurredAt.toISOString(),
|
||||||
|
actor_id: row.actorId,
|
||||||
|
actor_name: row.actorName,
|
||||||
|
actor_account: row.actorAccount,
|
||||||
|
action_code: row.actionCode,
|
||||||
|
action_label: '',
|
||||||
|
category: row.category,
|
||||||
|
target_type: row.targetType,
|
||||||
|
target_id: row.targetId,
|
||||||
|
target_name: row.targetName,
|
||||||
|
customer_id: row.customerId,
|
||||||
|
customer_name: row.customerName,
|
||||||
|
result: row.result === 'failure' ? 'failure' : 'success',
|
||||||
|
change_count: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
await prisma.auditExportJob.deleteMany({ where: { requestedBy: { in: createdUserIds } } });
|
||||||
|
await prisma.customerField.deleteMany({
|
||||||
|
where: { customer: { name: { startsWith: CUSTOMER_PREFIX } } },
|
||||||
|
});
|
||||||
|
await prisma.customerGroupMember.deleteMany({
|
||||||
|
where: { customer: { name: { startsWith: CUSTOMER_PREFIX } } },
|
||||||
|
});
|
||||||
|
await prisma.customer.deleteMany({ where: { name: { startsWith: CUSTOMER_PREFIX } } });
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
if (createdDictCodes.length > 0) {
|
||||||
|
await prisma.dictType.deleteMany({ where: { code: { in: createdDictCodes } } });
|
||||||
|
}
|
||||||
|
await prisma.industry.deleteMany({ where: { name: { startsWith: INDUSTRY_PREFIX } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let testStartedAt = new Date();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
createdRoleIds.length = 0;
|
||||||
|
createdUserIds.length = 0;
|
||||||
|
createdDictCodes.length = 0;
|
||||||
|
createdExportJobIds.length = 0;
|
||||||
|
testStartedAt = new Date();
|
||||||
|
|
||||||
|
const adminRole = await createRole(
|
||||||
|
'admin',
|
||||||
|
[
|
||||||
|
Permission.CUSTOMER_READ,
|
||||||
|
Permission.CUSTOMER_MANAGE,
|
||||||
|
Permission.ROLE_VIEW,
|
||||||
|
Permission.ROLE_MANAGE,
|
||||||
|
Permission.DICT_MANAGE,
|
||||||
|
Permission.SYSTEM_CONFIGURE,
|
||||||
|
Permission.USER_VIEW,
|
||||||
|
],
|
||||||
|
[Permission.AUDIT_READ, Permission.AUDIT_EXPORT],
|
||||||
|
);
|
||||||
|
adminUserId = await createUser('admin', adminRole);
|
||||||
|
const limitedRole = await createRole('limited', [Permission.CUSTOMER_READ]);
|
||||||
|
await createUser('limited', limitedRole);
|
||||||
|
|
||||||
|
const root = await prisma.industry.create({
|
||||||
|
data: { id: ulid(), parentId: null, level: 1, name: `${INDUSTRY_PREFIX}电商`, sortOrder: 10 },
|
||||||
|
});
|
||||||
|
const leaf = await prisma.industry.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
parentId: root.id,
|
||||||
|
level: 2,
|
||||||
|
name: `${INDUSTRY_PREFIX}服饰`,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
industryId = leaf.id;
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
const loginBody = await login(`${ACCOUNT_PREFIX}admin`);
|
||||||
|
adminToken = loginBody.accessToken;
|
||||||
|
adminRefreshToken = loginBody.refreshToken;
|
||||||
|
limitedToken = (await login(`${ACCOUNT_PREFIX}limited`)).accessToken;
|
||||||
|
customerId = await createCustomer(`${CUSTOMER_PREFIX}花间集`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A5 审计 · 采集(七类动作 + 失败请求 + 前后值)', () => {
|
||||||
|
it('登录 / 登出留痕:actor、result、IP、UA、trace_id 完整', async () => {
|
||||||
|
const logins = await auditRows({ actionCode: 'identity.auth.login', actorId: adminUserId });
|
||||||
|
expect(logins.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const login = logins[0]!;
|
||||||
|
expect(login.result).toBe('success');
|
||||||
|
expect(login.actor_name).toBe('A5 审计集成admin');
|
||||||
|
expect(login.target_type).toBe('user');
|
||||||
|
expect(login.target_id).toBe(adminUserId);
|
||||||
|
|
||||||
|
const logout = await call<null>('POST', '/auth/logout', limitedToken, {
|
||||||
|
refreshToken: adminRefreshToken,
|
||||||
|
});
|
||||||
|
expect(logout.status).toBe(204);
|
||||||
|
const logouts = await auditRows({ actionCode: 'identity.auth.logout', actorId: adminUserId });
|
||||||
|
expect(logouts.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logouts[0]?.target_id).toBe(adminUserId);
|
||||||
|
|
||||||
|
const raw = await prisma.auditLog.findFirst({
|
||||||
|
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||||
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
expect(raw?.ip).toBeTruthy();
|
||||||
|
expect(raw?.userAgent).toBeTruthy();
|
||||||
|
expect(raw?.traceId).toBeTruthy();
|
||||||
|
expect(raw?.httpMethod).toBe('POST');
|
||||||
|
expect(raw?.httpPath).toContain('/auth/login');
|
||||||
|
// 绝不记录密码
|
||||||
|
expect(JSON.stringify(raw?.afterJson)).not.toContain(PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限变更 / 客户变更 / 字典变更 / 配置变更 / 审计查看 均落库并可 diff', async () => {
|
||||||
|
// 权限:新建角色
|
||||||
|
const createdRole = await call<{ role: { id: string; name: string } }>(
|
||||||
|
'POST',
|
||||||
|
'/roles',
|
||||||
|
adminToken,
|
||||||
|
{
|
||||||
|
name: `${ROLE_PREFIX}审计采样`,
|
||||||
|
description: 'A5 审计采样',
|
||||||
|
permissions: [Permission.USER_VIEW],
|
||||||
|
dataScope: { type: 'all' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(createdRole.status).toBe(201);
|
||||||
|
createdRoleIds.push(createdRole.body.role.id);
|
||||||
|
|
||||||
|
// 客户:修改备注
|
||||||
|
const updated = await call<CustomerBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||||
|
remark: 'A5 审计集成测试:备注已更新',
|
||||||
|
});
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
|
||||||
|
// 字典:新建类型
|
||||||
|
const dict = await call<{ code: string }>('POST', '/dicts', adminToken, {
|
||||||
|
code: dictTypeCode,
|
||||||
|
name: 'A5 审计演示字典',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: 'A5 审计集成测试',
|
||||||
|
});
|
||||||
|
expect(dict.status).toBe(201);
|
||||||
|
createdDictCodes.push(dictTypeCode);
|
||||||
|
|
||||||
|
// 配置:保存 audit.logs_platform_url
|
||||||
|
const config = await call<{ items: unknown[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: 'audit.logs_platform_url', value: LOGS_URL }],
|
||||||
|
});
|
||||||
|
expect(config.status).toBe(200);
|
||||||
|
|
||||||
|
// 审计查看:列表
|
||||||
|
const list = await call<AuditListBody>('GET', '/audit-logs?limit=5', adminToken);
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body.items.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const expectedActions = [
|
||||||
|
'identity.role.create',
|
||||||
|
'client.customer.update',
|
||||||
|
'catalog.dict_type.create',
|
||||||
|
'platform.system_config.update',
|
||||||
|
'audit.log.view',
|
||||||
|
];
|
||||||
|
for (const actionCode of expectedActions) {
|
||||||
|
const rows = await auditRows({ actionCode });
|
||||||
|
expect(rows.length, `动作 ${actionCode} 应落库`).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户变更详情:before/after 可 diff
|
||||||
|
const detail = await prisma.auditLog.findFirst({
|
||||||
|
where: { actionCode: 'client.customer.update', targetId: customerId },
|
||||||
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
expect(detail).not.toBeNull();
|
||||||
|
const detailId = detail?.id ?? '';
|
||||||
|
expect(detailId).not.toBe('');
|
||||||
|
const detailResponse = await call<AuditDetailBody>(
|
||||||
|
'GET',
|
||||||
|
`/audit-logs/${detailId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(detailResponse.status).toBe(200);
|
||||||
|
const remarkDiff = detailResponse.body.diff.find((row) => row.path === 'remark');
|
||||||
|
expect(remarkDiff).toMatchObject({
|
||||||
|
before: 'A5 审计集成测试客户',
|
||||||
|
after: 'A5 审计集成测试:备注已更新',
|
||||||
|
kind: 'changed',
|
||||||
|
});
|
||||||
|
expect(detailResponse.body.customer_id).toBe(customerId);
|
||||||
|
expect(detailResponse.body.trace_id).toBeTruthy();
|
||||||
|
|
||||||
|
// 查看详情本身留痕:target_id = 被查看审计 id
|
||||||
|
const viewRows = await prisma.auditLog.findMany({
|
||||||
|
where: { actionCode: 'audit.log.view', targetId: detailId },
|
||||||
|
});
|
||||||
|
expect(viewRows.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// 恢复配置,避免影响其他测试(同样会留审计)
|
||||||
|
await call<{ items: unknown[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: 'audit.logs_platform_url', value: '' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败请求也落库:result=failure、error_code/error_message,不吞原错误', async () => {
|
||||||
|
const before = new Date();
|
||||||
|
const failed = await call<ErrorBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||||
|
name: '',
|
||||||
|
});
|
||||||
|
expect(failed.status).toBe(400);
|
||||||
|
expect(failed.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const rows = await auditRows({
|
||||||
|
actionCode: 'client.customer.update',
|
||||||
|
result: 'failure',
|
||||||
|
targetId: customerId,
|
||||||
|
occurredAt: { gte: before },
|
||||||
|
});
|
||||||
|
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const raw = await prisma.auditLog.findFirst({
|
||||||
|
where: {
|
||||||
|
actionCode: 'client.customer.update',
|
||||||
|
result: 'failure',
|
||||||
|
targetId: customerId,
|
||||||
|
occurredAt: { gte: before },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(raw?.errorCode).toBe(String(ErrorCode.PARAM_INVALID));
|
||||||
|
expect(raw?.errorMessage?.length ?? 0).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A5 审计 · 检索(筛选 / 游标 / 只读)', () => {
|
||||||
|
it('动作目录:返回中文名、分类与服务端筛选数据', async () => {
|
||||||
|
const actions = await call<{
|
||||||
|
items: { code: string; label: string; category: string; target_type: string | null }[];
|
||||||
|
categories: { key: string; label: string }[];
|
||||||
|
}>('GET', '/audit-logs/actions', adminToken);
|
||||||
|
expect(actions.status).toBe(200);
|
||||||
|
expect(actions.body.items.length).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(actions.body.items.find((item) => item.code === 'audit.export')?.label).toBe(
|
||||||
|
'导出审计日志',
|
||||||
|
);
|
||||||
|
expect(actions.body.categories).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('筛选生效:action_code / category / result / customer_id / actor / 时间窗', async () => {
|
||||||
|
const byAction = await call<AuditListBody>(
|
||||||
|
'GET',
|
||||||
|
'/audit-logs?action_code=client.customer.update&limit=50',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(byAction.status).toBe(200);
|
||||||
|
expect(byAction.body.items.every((item) => item.action_code === 'client.customer.update')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byCategory = await call<AuditListBody>('GET', '/audit-logs?category=client', adminToken);
|
||||||
|
expect(byCategory.body.items.every((item) => item.category === 'client')).toBe(true);
|
||||||
|
|
||||||
|
const byResult = await call<AuditListBody>('GET', '/audit-logs?result=failure', adminToken);
|
||||||
|
expect(byResult.body.items.every((item) => item.result === 'failure')).toBe(true);
|
||||||
|
|
||||||
|
const byCustomer = await call<AuditListBody>(
|
||||||
|
'GET',
|
||||||
|
`/audit-logs?customer_id=${customerId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(byCustomer.body.items.every((item) => item.customer_id === customerId)).toBe(true);
|
||||||
|
|
||||||
|
const byActor = await call<AuditListBody>(
|
||||||
|
'GET',
|
||||||
|
`/audit-logs?actor=${encodeURIComponent('A5 审计集成admin')}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(byActor.body.items.every((item) => item.actor_name === 'A5 审计集成admin')).toBe(true);
|
||||||
|
|
||||||
|
const from = encodeURIComponent(testStartedAt.toISOString());
|
||||||
|
const byTime = await call<AuditListBody>(`GET`, `/audit-logs?from=${from}`, adminToken);
|
||||||
|
expect(byTime.body.items.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
byTime.body.items.every(
|
||||||
|
(item) => new Date(item.occurred_at).getTime() >= testStartedAt.getTime(),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const missing = await call<AuditListBody>(
|
||||||
|
'GET',
|
||||||
|
'/audit-logs?customer_id=01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(200);
|
||||||
|
expect(missing.body.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('列表只返回摘要(无 before/after/changes),详情才有 diff 与 UA', async () => {
|
||||||
|
const list = await call<AuditListBody>(
|
||||||
|
'GET',
|
||||||
|
'/audit-logs?action_code=client.customer.update&result=success&limit=1',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
const item = list.body.items[0];
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item).not.toHaveProperty('before');
|
||||||
|
expect(item).not.toHaveProperty('after');
|
||||||
|
expect(item?.change_count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const detail = await call<AuditDetailBody>('GET', `/audit-logs/${item!.id}`, adminToken);
|
||||||
|
expect(detail.status).toBe(200);
|
||||||
|
expect(detail.body.user_agent).toBeTruthy();
|
||||||
|
expect(Array.isArray(detail.body.diff)).toBe(true);
|
||||||
|
|
||||||
|
const missing = await call<ErrorBody>(
|
||||||
|
'GET',
|
||||||
|
'/audit-logs/01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('游标分页稳定:同毫秒写入不重不漏,排序 occurred_at DESC, id DESC', async () => {
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
const updated = await call<CustomerBody>('PATCH', `/customers/${customerId}`, adminToken, {
|
||||||
|
remark: `A5 游标分页 ${String(index)}`,
|
||||||
|
});
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
}
|
||||||
|
const baseQuery = `/audit-logs?action_code=client.customer.update&target_id=${customerId}&result=success&limit=2`;
|
||||||
|
const seen: AuditItemBody[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
for (let page = 0; page < 20; page += 1) {
|
||||||
|
const url: string =
|
||||||
|
cursor === null ? baseQuery : `${baseQuery}&cursor=${encodeURIComponent(cursor)}`;
|
||||||
|
const result = await call<AuditListBody>('GET', url, adminToken);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
seen.push(...result.body.items);
|
||||||
|
cursor = result.body.next_cursor;
|
||||||
|
if (cursor === null) break;
|
||||||
|
}
|
||||||
|
expect(seen.length).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(new Set(seen.map((item) => item.id)).size).toBe(seen.length);
|
||||||
|
for (let index = 1; index < seen.length; index += 1) {
|
||||||
|
const previous = seen[index - 1]!;
|
||||||
|
const current = seen[index]!;
|
||||||
|
const delta =
|
||||||
|
new Date(previous.occurred_at).getTime() - new Date(current.occurred_at).getTime();
|
||||||
|
if (delta === 0) {
|
||||||
|
expect(previous.id > current.id).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(delta).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 非法游标
|
||||||
|
const invalid = await call<ErrorBody>('GET', '/audit-logs?cursor=bad-cursor', adminToken);
|
||||||
|
expect(invalid.status).toBe(400);
|
||||||
|
expect(invalid.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('特殊权限:无 audit:read / audit:export 分别 40303,且不泄露数据', async () => {
|
||||||
|
const read = await call<ErrorBody>('GET', '/audit-logs', limitedToken);
|
||||||
|
expect(read.status).toBe(403);
|
||||||
|
expect(read.body.code).toBe(ErrorCode.SPECIAL_PERMISSION_REQUIRED);
|
||||||
|
|
||||||
|
const exportDenied = await call<ErrorBody>('POST', '/audit-logs/export', limitedToken, {
|
||||||
|
format: 'csv',
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
expect(exportDenied.status).toBe(403);
|
||||||
|
expect(exportDenied.body.code).toBe(ErrorCode.SPECIAL_PERMISSION_REQUIRED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A5 审计 · append-only 与分区', () => {
|
||||||
|
it('DB 层拒绝 UPDATE / DELETE / TRUNCATE,数据保持不变', async () => {
|
||||||
|
const target = await prisma.auditLog.findFirst({
|
||||||
|
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||||
|
});
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
const id = target?.id ?? '';
|
||||||
|
const occurredAt = target?.occurredAt ?? new Date();
|
||||||
|
const countBefore = await prisma.auditLog.count();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE audit_logs SET result = 'failure' WHERE id = '${id}' AND occurred_at = '${occurredAt.toISOString()}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
prisma.$executeRawUnsafe(
|
||||||
|
`DELETE FROM audit_logs WHERE id = '${id}' AND occurred_at = '${occurredAt.toISOString()}'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(prisma.$executeRawUnsafe('TRUNCATE audit_logs')).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await prisma.auditLog.count()).toBe(countBefore);
|
||||||
|
const still = await prisma.auditLog.findFirst({ where: { id } });
|
||||||
|
expect(still?.result).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lingxi_app 角色:审计表只能 SELECT / INSERT,UPDATE 被权限拒绝', async () => {
|
||||||
|
const target = await prisma.auditLog.findFirst({
|
||||||
|
where: { actionCode: 'identity.auth.login', actorId: adminUserId },
|
||||||
|
});
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
await expect(
|
||||||
|
prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRawUnsafe('SET LOCAL ROLE lingxi_app');
|
||||||
|
const current = await tx.$queryRawUnsafe<{ current_user: string }[]>(
|
||||||
|
'SELECT current_user AS current_user',
|
||||||
|
);
|
||||||
|
expect(current[0]?.current_user).toBe('lingxi_app');
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`INSERT INTO audit_logs (id, occurred_at, action_code, category, result) VALUES ('01HZZZZZZZZZZZZZZZZZZZZZZYY', now(), 'audit.log.view', 'sensitive', 'success')`,
|
||||||
|
);
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`UPDATE audit_logs SET result = 'failure' WHERE id = '${target?.id ?? ''}'`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分区函数幂等,当前月分区存在', async () => {
|
||||||
|
const first = await prisma.$queryRawUnsafe<{ created: number }[]>(
|
||||||
|
'SELECT audit_logs_ensure_partitions(13) AS created',
|
||||||
|
);
|
||||||
|
const second = await prisma.$queryRawUnsafe<{ created: number }[]>(
|
||||||
|
'SELECT audit_logs_ensure_partitions(13) AS created',
|
||||||
|
);
|
||||||
|
expect(typeof first[0]?.created).toBe('number');
|
||||||
|
expect(second[0]?.created).toBe(0);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const name = `audit_logs_${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const partitions = await prisma.$queryRawUnsafe<{ name: string | null }[]>(
|
||||||
|
`SELECT to_regclass('${name}')::text AS name`,
|
||||||
|
);
|
||||||
|
expect(partitions[0]?.name).toBe(name);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
prisma.$queryRawUnsafe('SELECT audit_logs_ensure_partitions(999)'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A5 审计 · 导出全链路(异步 + 自身留痕)', () => {
|
||||||
|
it('创建 → 轮询 succeeded → 签名下载 CSV → 下载计数 + audit.export.download', async () => {
|
||||||
|
const exportAuditBefore = await prisma.auditLog.count({
|
||||||
|
where: { actionCode: 'audit.export' },
|
||||||
|
});
|
||||||
|
const created = await call<{ id: string; status: string }>(
|
||||||
|
'POST',
|
||||||
|
'/audit-logs/export',
|
||||||
|
adminToken,
|
||||||
|
{
|
||||||
|
format: 'csv',
|
||||||
|
filters: { action_code: 'client.customer.update', target_id: customerId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect(created.body.status).toBe('queued');
|
||||||
|
createdExportJobIds.push(created.body.id);
|
||||||
|
|
||||||
|
const job = await waitForExport(created.body.id, adminToken);
|
||||||
|
expect(job.status).toBe('succeeded');
|
||||||
|
expect(job.progress).toBe(100);
|
||||||
|
expect(job.row_count).toBeGreaterThan(0);
|
||||||
|
expect(job.truncated).toBe(false);
|
||||||
|
expect(job.download_token).toBeTruthy();
|
||||||
|
|
||||||
|
// 导出自身留痕(创建 + 完成)
|
||||||
|
const exportAuditAfter = await prisma.auditLog.count({ where: { actionCode: 'audit.export' } });
|
||||||
|
expect(exportAuditAfter).toBeGreaterThan(exportAuditBefore);
|
||||||
|
|
||||||
|
// 导出历史与本人任务
|
||||||
|
const jobs = await call<{ items: ExportJobBody[] }>('GET', '/audit-logs/exports', adminToken);
|
||||||
|
expect(jobs.status).toBe(200);
|
||||||
|
expect(jobs.body.items.some((item) => item.id === created.body.id)).toBe(true);
|
||||||
|
|
||||||
|
// 签名下载
|
||||||
|
const token = job.download_token ?? '';
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${API}/audit-logs/exports/${created.body.id}/download?token=${encodeURIComponent(token)}`,
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/csv');
|
||||||
|
expect(response.headers['content-disposition']).toContain('attachment');
|
||||||
|
expect(response.body.startsWith('\uFEFF')).toBe(true);
|
||||||
|
expect(response.body).toContain('occurred_at,actor,account,action_code,action_label');
|
||||||
|
expect(response.body).toContain('client.customer.update');
|
||||||
|
|
||||||
|
// 下载计数 + 下载审计
|
||||||
|
const afterDownload = await call<ExportJobBody>(
|
||||||
|
'GET',
|
||||||
|
`/audit-logs/exports/${created.body.id}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(afterDownload.body.download_count).toBe(1);
|
||||||
|
const downloadAudit = await prisma.auditLog.findMany({
|
||||||
|
where: { actionCode: 'audit.export.download', targetId: created.body.id },
|
||||||
|
});
|
||||||
|
expect(downloadAudit.length).toBe(1);
|
||||||
|
expect(downloadAudit[0]?.actorId).toBe(adminUserId);
|
||||||
|
|
||||||
|
// 篡改 token / 他人任务不可见
|
||||||
|
const tampered = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${API}/audit-logs/exports/${created.body.id}/download?token=${encodeURIComponent(`${token}x`)}`,
|
||||||
|
});
|
||||||
|
expect(tampered.statusCode).toBe(422);
|
||||||
|
const othersJob = await call<ErrorBody>(
|
||||||
|
'GET',
|
||||||
|
`/audit-logs/exports/${created.body.id}`,
|
||||||
|
limitedToken,
|
||||||
|
);
|
||||||
|
expect([403, 404]).toContain(othersJob.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { AuditExportJob, AuditLog } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
countAuditChanges,
|
||||||
|
toAuditActionCatalog,
|
||||||
|
toAuditDetail,
|
||||||
|
toAuditExportJob,
|
||||||
|
toAuditListItem,
|
||||||
|
} from './audit.mapper';
|
||||||
|
|
||||||
|
function logRow(overrides: Partial<AuditLog> = {}): AuditLog {
|
||||||
|
return {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
occurredAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||||
|
actorId: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||||
|
actorName: '江岚',
|
||||||
|
actorAccount: 'jiang.lan',
|
||||||
|
actionCode: 'client.customer.update',
|
||||||
|
category: 'client',
|
||||||
|
targetType: 'customer',
|
||||||
|
targetId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
targetName: '花间集',
|
||||||
|
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZX',
|
||||||
|
customerName: '花间集',
|
||||||
|
result: 'success',
|
||||||
|
httpMethod: 'PATCH',
|
||||||
|
httpPath: '/api/v1/customers/1',
|
||||||
|
traceId: 'trace-1',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'vitest',
|
||||||
|
beforeJson: { name: '旧名', status: 'ACTIVE' },
|
||||||
|
afterJson: { name: '花间集', status: 'ACTIVE' },
|
||||||
|
changesJson: null,
|
||||||
|
reason: null,
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: null,
|
||||||
|
durationMs: 12,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobRow(overrides: Partial<AuditExportJob> = {}): AuditExportJob {
|
||||||
|
return {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZW',
|
||||||
|
status: 'running',
|
||||||
|
format: 'csv',
|
||||||
|
filtersJson: { result: 'failure' },
|
||||||
|
requestedBy: '01HZZZZZZZZZZZZZZZZZZZZZZY',
|
||||||
|
requestedAt: new Date('2026-11-04T08:00:00.000Z'),
|
||||||
|
startedAt: new Date('2026-11-04T08:00:01.000Z'),
|
||||||
|
finishedAt: null,
|
||||||
|
totalRows: 100,
|
||||||
|
rowCount: null,
|
||||||
|
processedRows: 50,
|
||||||
|
truncated: false,
|
||||||
|
filePath: null,
|
||||||
|
fileSize: null,
|
||||||
|
errorMessage: null,
|
||||||
|
expiresAt: null,
|
||||||
|
downloadCount: 0,
|
||||||
|
lastDownloadedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('审计映射(A5-BE-03)', () => {
|
||||||
|
it('列表摘要:中文名 / 分类 / 变更字段数 / 不返回前后值', () => {
|
||||||
|
const item = toAuditListItem(logRow());
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
action_code: 'client.customer.update',
|
||||||
|
action_label: '编辑客户',
|
||||||
|
category: 'client',
|
||||||
|
category_label: '客户与行业',
|
||||||
|
target_name: '花间集',
|
||||||
|
result: 'success',
|
||||||
|
change_count: 1,
|
||||||
|
has_before: true,
|
||||||
|
has_after: true,
|
||||||
|
});
|
||||||
|
expect('before' in item).toBe(false);
|
||||||
|
expect('diff' in item).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detail:包含 before / after / changes / diff / trace / ip / ua', () => {
|
||||||
|
const detail = toAuditDetail(logRow());
|
||||||
|
expect(detail.before).toEqual({ name: '旧名', status: 'ACTIVE' });
|
||||||
|
expect(detail.after).toEqual({ name: '花间集', status: 'ACTIVE' });
|
||||||
|
expect(detail.diff).toEqual([
|
||||||
|
{ path: 'name', label: null, before: '旧名', after: '花间集', kind: 'changed' },
|
||||||
|
]);
|
||||||
|
expect(detail.trace_id).toBe('trace-1');
|
||||||
|
expect(detail.ip).toBe('127.0.0.1');
|
||||||
|
expect(detail.user_agent).toBe('vitest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知动作走兜底 label,result 非法值按 success', () => {
|
||||||
|
const item = toAuditListItem(logRow({ actionCode: 'unknown.action', category: 'other' }));
|
||||||
|
expect(item.action_label).toBe('unknown.action');
|
||||||
|
expect(item.category_label).toBe('其他');
|
||||||
|
expect(toAuditListItem(logRow({ result: 'weird' })).result).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变更字段数:无 diff 时按 changes 的 added / removed 计数', () => {
|
||||||
|
expect(countAuditChanges(null, null, null)).toBe(0);
|
||||||
|
expect(
|
||||||
|
countAuditChanges(null, null, {
|
||||||
|
permissions: { added: ['a', 'b'], removed: ['c'] },
|
||||||
|
dataScope: 'all',
|
||||||
|
}),
|
||||||
|
).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('动作目录按分类顺序输出并含中文标签', () => {
|
||||||
|
const items = toAuditActionCatalog();
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(50);
|
||||||
|
const login = items.find((item) => item.code === 'identity.auth.login');
|
||||||
|
expect(login).toMatchObject({ label: '登录', category: 'auth', sensitive: true });
|
||||||
|
const categories = items.map((item) => item.category);
|
||||||
|
expect(categories[0]).toBe('auth');
|
||||||
|
expect(categories.at(-1)).toBe('sensitive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('导出任务:running 进度按 processed / total 计算,succeeded 为 100', () => {
|
||||||
|
expect(toAuditExportJob(jobRow()).progress).toBe(50);
|
||||||
|
expect(toAuditExportJob(jobRow({ status: 'queued', processedRows: null })).progress).toBe(0);
|
||||||
|
expect(toAuditExportJob(jobRow({ status: 'succeeded' })).progress).toBe(100);
|
||||||
|
expect(toAuditExportJob(jobRow({ status: 'failed' })).progress).toBe(100);
|
||||||
|
expect(toAuditExportJob(jobRow())).toMatchObject({
|
||||||
|
filters: { result: 'failure' },
|
||||||
|
total_rows: 100,
|
||||||
|
processed_rows: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 审计实体 → 共享契约映射(A5-BE-03)
|
||||||
|
*
|
||||||
|
* - 列表只给摘要与变更字段数;详情才返回 before / after / changes 与 diff;
|
||||||
|
* - 中文名 / 分类来自 `@lingxi/shared` 的 `AUDIT_ACTION_CATALOG`,表里只存 code。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AUDIT_ACTION_CATALOG,
|
||||||
|
AUDIT_CATEGORIES,
|
||||||
|
type AuditActionCatalogItem,
|
||||||
|
auditActionMeta,
|
||||||
|
auditCategoryLabel,
|
||||||
|
type AuditExportJob,
|
||||||
|
type AuditLogDetail,
|
||||||
|
type AuditLogFilter,
|
||||||
|
auditLogFilterSchema,
|
||||||
|
type AuditLogItem,
|
||||||
|
buildAuditDiff,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { AuditExportJob as AuditExportJobRow, AuditLog } from '@prisma/client';
|
||||||
|
|
||||||
|
/** 变更字段数:优先前后值 diff;仅有 changes 时按 added/removed 粒度估算(仅用于列表摘要) */
|
||||||
|
// eslint-disable-next-line complexity -- 变更计数兼容 diff / changes 两种来源
|
||||||
|
export function countAuditChanges(before: unknown, after: unknown, changes: unknown): number {
|
||||||
|
const diff = buildAuditDiff(before, after);
|
||||||
|
if (diff.length > 0) return diff.length;
|
||||||
|
if (typeof changes !== 'object' || changes === null || Array.isArray(changes)) {
|
||||||
|
return changes === null || changes === undefined ? 0 : 1;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
for (const value of Object.values(changes)) {
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
// 运行时已用 Array.isArray 收窄,descriptor.value 的 any 不会外泄
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const added = Object.getOwnPropertyDescriptor(value, 'added')?.value;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const removed = Object.getOwnPropertyDescriptor(value, 'removed')?.value;
|
||||||
|
if (Array.isArray(added) || Array.isArray(removed)) {
|
||||||
|
count +=
|
||||||
|
(Array.isArray(added) ? added.length : 0) + (Array.isArray(removed) ? removed.length : 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuditListItem(row: AuditLog): AuditLogItem {
|
||||||
|
const meta = auditActionMeta(row.actionCode);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
occurred_at: row.occurredAt.toISOString(),
|
||||||
|
actor_id: row.actorId,
|
||||||
|
actor_name: row.actorName,
|
||||||
|
actor_account: row.actorAccount,
|
||||||
|
action_code: row.actionCode,
|
||||||
|
action_label: meta.label,
|
||||||
|
category: row.category,
|
||||||
|
category_label: auditCategoryLabel(row.category),
|
||||||
|
target_type: row.targetType,
|
||||||
|
target_id: row.targetId,
|
||||||
|
target_name: row.targetName,
|
||||||
|
customer_id: row.customerId,
|
||||||
|
customer_name: row.customerName,
|
||||||
|
result: row.result === 'failure' ? 'failure' : 'success',
|
||||||
|
http_method: row.httpMethod,
|
||||||
|
http_path: row.httpPath,
|
||||||
|
trace_id: row.traceId,
|
||||||
|
ip: row.ip,
|
||||||
|
duration_ms: row.durationMs,
|
||||||
|
error_code: row.errorCode,
|
||||||
|
error_message: row.errorMessage,
|
||||||
|
change_count: countAuditChanges(row.beforeJson, row.afterJson, row.changesJson),
|
||||||
|
has_before: row.beforeJson !== null,
|
||||||
|
has_after: row.afterJson !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuditDetail(row: AuditLog): AuditLogDetail {
|
||||||
|
return {
|
||||||
|
...toAuditListItem(row),
|
||||||
|
user_agent: row.userAgent,
|
||||||
|
reason: row.reason,
|
||||||
|
before: row.beforeJson,
|
||||||
|
after: row.afterJson,
|
||||||
|
changes: row.changesJson,
|
||||||
|
diff: buildAuditDiff(row.beforeJson, row.afterJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuditActionCatalog(): AuditActionCatalogItem[] {
|
||||||
|
const categoryOrder = new Map<string, number>(
|
||||||
|
AUDIT_CATEGORIES.map((item, index) => [item.key, index]),
|
||||||
|
);
|
||||||
|
return Object.values(AUDIT_ACTION_CATALOG)
|
||||||
|
.map((meta) => ({
|
||||||
|
code: meta.code,
|
||||||
|
label: meta.label,
|
||||||
|
category: meta.category,
|
||||||
|
category_label: auditCategoryLabel(meta.category),
|
||||||
|
target_type: meta.targetType ?? null,
|
||||||
|
sensitive: meta.sensitive === true,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const categoryDelta =
|
||||||
|
(categoryOrder.get(left.category) ?? 99) - (categoryOrder.get(right.category) ?? 99);
|
||||||
|
return categoryDelta !== 0 ? categoryDelta : left.code.localeCompare(right.code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity -- 映射字段多且包含进度分支,拆散会分散契约口径
|
||||||
|
export function toAuditExportJob(
|
||||||
|
row: AuditExportJobRow,
|
||||||
|
downloadToken: string | null = null,
|
||||||
|
): AuditExportJob {
|
||||||
|
const parsed = auditLogFilterSchema.safeParse(row.filtersJson);
|
||||||
|
const filters: AuditLogFilter = parsed.success ? parsed.data : {};
|
||||||
|
const total = row.totalRows ?? null;
|
||||||
|
const processed = row.processedRows ?? 0;
|
||||||
|
let progress = 0;
|
||||||
|
if (row.status === 'succeeded' || row.status === 'failed') {
|
||||||
|
progress = 100;
|
||||||
|
} else if (row.status === 'running' && total !== null && total > 0) {
|
||||||
|
progress = Math.min(99, Math.floor((processed / total) * 100));
|
||||||
|
} else if (row.status === 'running') {
|
||||||
|
progress = processed > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status:
|
||||||
|
row.status === 'running' || row.status === 'succeeded' || row.status === 'failed'
|
||||||
|
? row.status
|
||||||
|
: 'queued',
|
||||||
|
format: 'csv',
|
||||||
|
filters,
|
||||||
|
total_rows: total,
|
||||||
|
row_count: row.rowCount,
|
||||||
|
processed_rows: row.processedRows,
|
||||||
|
truncated: row.truncated,
|
||||||
|
progress,
|
||||||
|
file_size: row.fileSize,
|
||||||
|
error_message: row.errorMessage,
|
||||||
|
requested_at: row.requestedAt.toISOString(),
|
||||||
|
started_at: row.startedAt === null ? null : row.startedAt.toISOString(),
|
||||||
|
finished_at: row.finishedAt === null ? null : row.finishedAt.toISOString(),
|
||||||
|
expires_at: row.expiresAt === null ? null : row.expiresAt.toISOString(),
|
||||||
|
download_count: row.downloadCount,
|
||||||
|
last_downloaded_at: row.lastDownloadedAt === null ? null : row.lastDownloadedAt.toISOString(),
|
||||||
|
download_token: downloadToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AUDIT_SINK } from '../../../common/security/audit-sink';
|
||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditExportService } from './audit-export.service';
|
||||||
|
import { AuditExportRunnerService } from './audit-export-runner.service';
|
||||||
|
import { AuditExportTokenService } from './audit-export-token.service';
|
||||||
|
import { AuditPartitionService } from './audit-partition.service';
|
||||||
|
import { AuditRetentionService } from './audit-retention.service';
|
||||||
|
import { AuditWriterService } from './audit-writer.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计子模块(A5-BE-01..04):
|
||||||
|
* - 检索 / 详情 / 动作目录(A5-BE-03);
|
||||||
|
* - 异步导出 + 签名下载 + 导出自身审计(A5-BE-03);
|
||||||
|
* - 分区维护(A5-BE-01)与保留归档占位(A5-BE-04);
|
||||||
|
* - 通过 `AUDIT_SINK` 端口向全局 `AuditInterceptor` 提供落库实现(依赖倒置,common 不依赖 modules)。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [SystemConfigModule],
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [
|
||||||
|
AuditService,
|
||||||
|
AuditWriterService,
|
||||||
|
AuditExportService,
|
||||||
|
AuditExportRunnerService,
|
||||||
|
AuditExportTokenService,
|
||||||
|
AuditPartitionService,
|
||||||
|
AuditRetentionService,
|
||||||
|
{ provide: AUDIT_SINK, useExisting: AuditWriterService },
|
||||||
|
],
|
||||||
|
exports: [AuditWriterService, AuditPartitionService, AuditRetentionService, AUDIT_SINK],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 审计检索与详情(A5-BE-03,只读)
|
||||||
|
*
|
||||||
|
* ScopeContext 说明(有意为之):审计是平台级特殊权限(`audit:read`,默认关闭),
|
||||||
|
* 跨客户可见用于合规回溯;`customer_id` 仍随请求写入并支持筛选,但不套用 `ScopeGuard`
|
||||||
|
* 的客户范围过滤,也不存在“先查全量再过滤”(所有条件都在 SQL where 内)。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AUDIT_CATEGORIES,
|
||||||
|
type AuditLogActionsResponse,
|
||||||
|
type AuditLogDetail,
|
||||||
|
type AuditLogListQuery,
|
||||||
|
type AuditLogListResponse,
|
||||||
|
ErrorCode,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { toAuditActionCatalog, toAuditDetail, toAuditListItem } from './audit.mapper';
|
||||||
|
import { decodeAuditCursor, encodeAuditCursor } from './audit-cursor';
|
||||||
|
import { buildAuditWhere } from './audit-query';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: AuditLogListQuery): Promise<AuditLogListResponse> {
|
||||||
|
// 审计查看本身留痕:列表 targetId = null(A5-BE-04 / LOG-14)
|
||||||
|
this.requestContext.patchAudit({ targetType: 'audit_log' });
|
||||||
|
|
||||||
|
const cursor =
|
||||||
|
query.cursor === undefined
|
||||||
|
? null
|
||||||
|
: (() => {
|
||||||
|
const decoded = decodeAuditCursor(query.cursor);
|
||||||
|
if (decoded === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '分页游标无效,请刷新后重试',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rows = await this.prisma.auditLog.findMany({
|
||||||
|
where: buildAuditWhere(query),
|
||||||
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit + 1,
|
||||||
|
...(cursor === null
|
||||||
|
? {}
|
||||||
|
: { cursor: { id_occurredAt: { id: cursor.id, occurredAt: cursor.at } }, skip: 1 }),
|
||||||
|
});
|
||||||
|
const page = rows.slice(0, query.limit);
|
||||||
|
const last = page[page.length - 1];
|
||||||
|
return {
|
||||||
|
items: page.map((row) => toAuditListItem(row)),
|
||||||
|
next_cursor:
|
||||||
|
rows.length > query.limit && last !== undefined
|
||||||
|
? encodeAuditCursor(last.occurredAt, last.id)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
actions(): AuditLogActionsResponse {
|
||||||
|
return {
|
||||||
|
items: toAuditActionCatalog(),
|
||||||
|
categories: AUDIT_CATEGORIES.map((item) => ({ key: item.key, label: item.label })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<AuditLogDetail> {
|
||||||
|
// 详情留痕:targetId = 被查看的审计日志 id(由拦截器读取 params.id,双保险再 patch 一次)
|
||||||
|
this.requestContext.patchAudit({ targetType: 'audit_log', targetId: id });
|
||||||
|
// 复合主键 (id, occurred_at):只传 id 用 findFirst,禁止 findUnique
|
||||||
|
const row = await this.prisma.auditLog.findFirst({ where: { id } });
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '审计日志不存在' });
|
||||||
|
}
|
||||||
|
return toAuditDetail(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { NamingService } from './naming/naming.service';
|
import { NamingService } from './naming/naming.service';
|
||||||
import { SystemConfigController } from './system-config/system-config.controller';
|
import { SystemConfigModule } from './system-config/system-config.module';
|
||||||
import { SystemConfigService } from './system-config/system-config.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块
|
* platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块
|
||||||
* (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client /
|
* (技术架构 §3)。@Global 导出 SystemConfigService / NamingService / 审计写入端口,
|
||||||
* 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。
|
* 供 identity / client / 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import
|
||||||
|
* (R-26 阈值同源;A5 审计落库依赖倒置)。
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule],
|
imports: [HealthModule, SystemConfigModule, AuditModule],
|
||||||
controllers: [SystemConfigController],
|
providers: [NamingService],
|
||||||
providers: [SystemConfigService, NamingService],
|
exports: [HealthModule, SystemConfigModule, AuditModule, NamingService],
|
||||||
exports: [HealthModule, SystemConfigService, NamingService],
|
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
@@ -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 {}
|
||||||
@@ -194,6 +194,10 @@ export class SystemConfigService {
|
|||||||
this.requestContext.patchAudit({
|
this.requestContext.patchAudit({
|
||||||
targetType: 'system_config',
|
targetType: 'system_config',
|
||||||
targetId: snapshots.map((snapshot) => snapshot.key).join(','),
|
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])),
|
before: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.oldValue])),
|
||||||
after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])),
|
after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])),
|
||||||
changes: snapshots,
|
changes: snapshots,
|
||||||
|
|||||||
+251
-13
@@ -716,6 +716,26 @@ export interface paths {
|
|||||||
patch: operations['DictsController_updateItem'];
|
patch: operations['DictsController_updateItem'];
|
||||||
trace?: never;
|
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': {
|
'/api/v1/system/configs': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -768,18 +788,117 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
'/health': {
|
'/api/v1/audit-logs': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/** 审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC) */
|
||||||
* 健康检查
|
get: operations['AuditController_list'];
|
||||||
* @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。
|
put?: never;
|
||||||
*/
|
post?: never;
|
||||||
get: operations['HealthController_check'];
|
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;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: 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: {
|
SystemConfigController_list: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1921,7 +2067,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
HealthController_check: {
|
AuditController_list: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -1930,17 +2076,109 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
/** @description 全部依赖健康 */
|
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content?: never;
|
||||||
'application/json': components['schemas']['HealthResponseDto'];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
/** @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: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
+156
-16
@@ -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": {
|
"/api/v1/system/configs": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "SystemConfigController_list",
|
"operationId": "SystemConfigController_list",
|
||||||
@@ -1172,29 +1198,143 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/health": {
|
"/api/v1/audit-logs": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
"operationId": "AuditController_list",
|
||||||
"operationId": "HealthController_check",
|
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "全部依赖健康",
|
"description": ""
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HealthResponseDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"description": "依赖不可用(错误码 50002),details 内附各依赖检查结果"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": "健康检查",
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "审计日志列表(多条件筛选 + 游标分页,稳定排序 occurred_at DESC, id DESC)",
|
||||||
"tags": [
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user