/** * OperationLogModule * 操作日志子表 - 挂在 userModule 下,记录"某用户做了什么" * - 默认只记 mutation(add/edit/delete/clear/import) + login * - args 自动脱敏 + JSON.stringify + 截断到 2000 字符 * - 日志失败不影响主调用 * - 只暴露 list/get,不开 add/edit/delete 防止误改 */ import { IFieldConfig, IOperationLog, WithId, ILabel } from "../IShrimpType" import { Shrimp } from "./Shrimp" import { ShrimpModule } from "./ShrimpModule" import { ShrimpTableModule } from "./ShrimpTableModule" import type { ShrimpCrud } from "./ShrimpCrud" const SENSITIVE_KEY_RE = /(password|pwd|token|secret)/i const ARGS_MAX_LEN = 2000 const operationLogFields: IFieldConfig[] = [ { key: "ip", label: { t: "operationLog.ip" }, type: "string", searchable: true, editable: "readonly" }, { key: "method", label: { t: "operationLog.method" }, type: "string", searchable: true, filterable: true, editable: "readonly" }, { key: "args", label: { t: "operationLog.args" }, type: "textarea", editable: "readonly" }, { key: "success", label: { t: "operationLog.success" }, type: "boolean", filterable: true, editable: "readonly" }, { key: "errorMsg", label: { t: "operationLog.errorMsg" }, type: "string", editable: "readonly" }, { key: "createdAt", label: { t: "operationLog.createdAt" }, type: "date", editable: "readonly", sortable: true, filterable: true }, ] export class OperationLogModule extends ShrimpTableModule> { logEvents: Set = new Set(['add', 'edit', 'delete', 'clear', 'import', 'login']) constructor(parent: Shrimp | ShrimpModule, dataSource: ShrimpCrud>, fields?: IFieldConfig[], label?: ILabel) { super(parent, "operationLog", dataSource, fields || operationLogFields, label || { t: "user.operationLog" }, undefined, { icon: "Tickets", ownerKey: "operatorId", }) this.addEvent("list", this.power) this.addEvent("get", this.power) } shouldLog(path: string[]): boolean { if (path.length === 0) return false if (path.includes('operationLog')) return false const last = path[path.length - 1] return this.logEvents.has(last) } sanitizeArgs(args: any[]): string { const walk = (val: any): any => { if (val == null) return val if (Array.isArray(val)) return val.map(walk) if (typeof val === 'object') { const out: Record = {} for (const [k, v] of Object.entries(val)) { out[k] = SENSITIVE_KEY_RE.test(k) ? '***' : walk(v) } return out } return val } let str: string try { str = JSON.stringify(walk(args)) } catch { str = '[unserializable]' } return str.length > ARGS_MAX_LEN ? str.substring(0, ARGS_MAX_LEN) + '...' : str } async record(operatorId: any, ip: string, path: string[], args: any[], success: boolean, errorMsg: string = ''): Promise { try { await this.dataSource.add({ operatorId: operatorId == null ? null : String(operatorId), ip, method: path.join('.'), args: this.sanitizeArgs(args), success, errorMsg, createdAt: Date.now(), } as Omit, '_id'>) } catch (e: any) { console.warn('[OperationLog] record failed', e?.message || e) } } }