一次请求结束了,图片任务却可能还没有结束。要判断卡在哪里,需要关联 ID 和阶段状态;把提示词、原图地址和异常对象全部打印出来,并不能补上这条链路。
图片猫是我自己的图片处理项目,项目入口是 www.piccat.cn。开发 AI 改图和直传处理链路时,日志需要同时服务于排障、任务恢复和模型用量核对。这篇文章围绕当前仓库的 NestJS、Prisma 与 Vue 实现,讨论哪些字段该留下,哪些内容应留在受控的业务存储里。
1 先分清一次请求和一个任务
提交、轮询、刷新恢复会形成多个 HTTP 请求;主模型重试和备用模型又会形成多次模型尝试。因此,HTTP 状态码、模型请求结果和业务任务终态,必须分别解释。

图 1 按当前代码职责整理的关联示意图 虚线部分是建议补充的关联关系
|-----------|--------------|-----------------|
| 标识符 | 用途 | 不能用来证明什么 |
| requestId | 定位一次 HTTP 请求 | 不等于任务或模型调用 ID |
| taskId | 关联任务状态和模型记录 | 拥有 ID 不等于拥有查询权限 |
| 幂等键 | 识别重复的业务提交 | 不承担每次重试的流水编号 |
| attemptNo | 标记任务内模型尝试顺序 | 第 2 次尝试不一定切了备用 |
下文区分"已有实现"和"建议补强"。现有三类记录具备部分关联能力,但还不能描述为完整的跨服务分布式追踪。
2 已有实现如何把请求和任务记录下来
请求入口先生成可检索的 ID
LoggerMiddleware 校验客户端的 x-request-id,不符合格式就生成 UUID,再把 ID 写入请求对象和响应头。sanitizeRequestId 只接受 8 到 64 位的字母、数字、下划线和连字符,避免换行等内容混入 ID。
项目代码摘录 webServer/src/common/logger.middleware.ts
const requestId = sanitizeRequestId(req.headers'x-request-id') || randomUUID();
(req as any).authRequestId = requestId;
res.setHeader('X-Request-Id', requestId);
响应触发 finish 时,中间件记录方法、脱敏后的 URL、状态码和请求耗时,按 2xx/3xx、4xx、5xx 分配日志级别。这个 result 反映 HTTP 响应结果。连接提前断开时,不能只依赖 finish 事件证明任务已经结束。
任务表保存业务事实
ProcessingTask 有独立的 status、billingStatus、failureCode、completedAt 等字段。BillingService.createProcessingTask 按"userId + idempotencyKey"查询或创建任务,数据库还有对应唯一约束。复用旧任务前会校验业务输入,不能只看幂等键相同就无条件放行。
任务创建或复用后,BillingService 调用上下文服务的 setTaskId。后续模型用量记录从这个上下文取 taskId,避免每层函数都增加一个参数。
项目代码摘录 webServer/src/user-activity/user-activity-context.service.ts
setTaskId(taskId: string) {
const context = this.storage.getStore();
if (context) context.taskId = taskId;
}
上下文使用 AsyncLocalStorage。UserActivityInterceptor 在 RxJS 实际订阅期间运行上下文;只在 intercept 返回前调用一次 run,可能覆盖不到之后才执行的异步代码。异步上下文仍只限本进程,跨队列或另一个服务时需要显式传递标识。
目前还缺一条直接的关联记录
HTTP 中间件把 ID 存为 req.authRequestId,但它生成的日志行没有 taskId;当前 UserActivityRequestContext 类型也没有 requestId。因此,"查到 HTTP 请求就能直接跳到任务"仍有缺口。
我倾向于先补一个最小事件:任务创建成功时同时记录 requestId 和 taskId。任务继续用 taskId 串联模型与存储阶段。这样能先补齐排障入口,无需立刻引入完整的追踪平台。这个关联事件属于建议方案。
3 已有脱敏规则保留了哪些信息
日志里最容易混入的内容,是请求体中的用户创作内容。现有中间件把 password、token、authorization、base64、image、blob 等已列出的键替换为"已脱敏";提示词类键则保留长度。
项目代码摘录 webServer/src/common/logger.middleware.ts 省略键名集合和递归遍历
const normalizedKey = key.toLowerCase();
const compactKey = normalizedKey.replace(/_-/g, '');
if (sensitiveKeys.has(normalizedKey)) return '已脱敏';
if (privateContentKeys.has(compactKey)) {
return typeof value === 'string'
? `已脱敏,长度=${value.length}`
: '已脱敏';
}
if (identityKeys.has(normalizedKey) && typeof value === 'string') {
return `哈希:${hashAuthIdentity(value)}`;
}
privateContentKeys 包含 prompt、imageprompt、negativeprompt、systemprompt、texteditextrainstruction、textedits。compactKey 会去掉下划线和连字符,因此 image_prompt 与 imagePrompt 能落到同一条规则。注意,sensitiveKeys 的匹配走 normalizedKey,并没有复用 compactKey。
账号、邮箱、用户名和手机号使用 HMAC-SHA256 后截取 12 位十六进制。它便于关联同一输入,但只是去标识化,并非匿名化:密钥管理、短摘要碰撞和可关联性仍要考虑。生产环境应独立配置日志哈希密钥,避免依赖开发默认值。
长度限制有用 但不能代替脱敏
普通字符串最多保留 160 字符,数组最多取 10 项,对象深度也有限制;最终参数字符串还会截到 300 字符。这些规则减少日志体积,但前 160 字符照样可能含敏感内容。prompt.length 统计的也是 JavaScript 字符串长度,不是 token 数。
|---------|---------------------|----------------|
| 位置 | 当前处理 | 仍需注意 |
| URL 查询值 | 替换值,保留最多 20 个键名 | 路径和键名仍需避免承载隐私 |
| 请求体 URL | 未命中键名时按普通字符串截断 | 签名 URL 不能靠截断保护 |
| 上游错误详情 | 公共 model-http 有独立清洗 | 其他日志出口不会自动复用 |
| 游客身份 | 记录完整 IP 和游客或爬虫标记 | 仍属于需要权限控制的数据 |
所以,准确的说法是"HTTP 请求体对列出的字段做了脱敏",而不是"项目已经不会记录敏感信息"。尤其不能把不认识的字段默认视作安全字段。
4 模型调用成功与生成成功要分开解释
ModelUsageService 包装 globalThis.fetch,根据 POST 请求、地址或模型网关上下文识别调用。记录包含 taskId、provider、model、durationMs、attemptNo、fallbackUsed、errorCode 和 estimatedCostCny。GET 轮询与图片下载不按新建模型调用记录。
项目代码摘录 webServer/src/model-usage/model-usage.service.ts 省略后续写入与活动事件
const durationMs = Date.now() - startedAt;
const status = response.ok ? 'succeeded' : 'failed';
这段代码的 succeeded 由 response.ok 决定。异步模型接口可能用 HTTP 200 返回"已受理",之后轮询才失败;也可能返回的图片不符合比例或像素要求。因此,用量表里的 succeeded 不能直接作为用户任务成功率。这里的 durationMs 也只是这次 fetch 返回响应的耗时,并非完整生成耗时。
保留 unknown 才能表达不确定性
fetch 抛出异常时,用量记录标记 unknown,错误码为 NETWORK_ERROR 或 ABORTED。业务上不能从"客户端没有拿到结果"推导出"供应商没有执行",也不能据此认定供应商没有产生费用。当前 estimatedCostCny 是按目录或任务快照估算的成本,不是供应商账单。
统一模型网关还有独立的 attempts 轨迹。FeatureModelGatewayService 在 model.execute 和结果校验之后才标记该次尝试成功,失败时记录错误码、耗时和可获得的上游诊断。支持 stopOnUncertainFailure 的执行路径遇到不确定失败会停止自动重复调用;这不是所有接口都自动具备的保证。
重试次数和备用线路是两个维度
用量记录优先采用上下文里的 aiAttemptNo 和 aiFallbackUsed。显式的 false 会保留下来,所以同一主模型的第 2 次重试可以是 attemptNo=2、fallbackUsed=false。只有缺少显式标记时,当前实现才用 attemptNo>1 兜底推断;这仍是需要警惕的统计边界。
模型网关的诊断先经过 model-http 的清洗:替换已知 API key 和请求文本、URL、data URL、Bearer 值、长编码,并处理控制字符和长度。但部分文本回显、未覆盖的供应商路径仍需检查。不能在捕获异常时又把原始 error 或 response.data 整体输出。
模型用量写入目前是异步尽力记录,异常只告警,避免统计故障中断用户任务。代价是进程退出或数据库故障可能留下缺口;需要可靠成本核对时,应增加持久化事件投递与对账,而不是把每条用量事件视作绝对完整。
5 建议为关键阶段使用固定字段白名单
现有 HTTP 日志是键值文本,模型网关已有 JSON 事件。下一步可以先统一关键任务事件的字段,不把整个 req.body、任务对象或 Error 实例展开。下面只演示安全构造日志对象的方式,尚未接入项目。
教学简化代码 建议改进方向 JavaScript 日志对象构造函数
function taskLog(meta) {
const row = { event: 'image_task_state', schemaVersion: 1 };
const idRule = /^A-Za-z0-9._:-{1,128}$/;
for (const key of 'requestId', 'taskId') {
if (typeof metakey === 'string' && idRule.test(metakey)) {
rowkey = metakey;
}
}
const stages = 'accepted', 'model', 'persist', 'finished';
const states = 'running', 'succeeded', 'failed', 'unknown';
row.stage = stages.includes(meta.stage) ? meta.stage : 'unknown';
row.status = states.includes(meta.status) ? meta.status : 'unknown';
if (Number.isSafeInteger(meta.durationMs) && meta.durationMs >= 0) {
row.durationMs = meta.durationMs;
}
if (typeof meta.fallbackUsed === 'boolean') {
row.fallbackUsed = meta.fallbackUsed;
}
return row;
}
调用方再把返回对象交给 logger 序列化为一行 JSON。示例有意只允许 ID、阶段、状态、耗时和备用标记;未知字段与嵌套对象全部丢弃。它要求 meta 为普通元数据对象,省略了 logger、上下文传播、错误码字典和持久化投递,不能当作完整任务服务直接使用。
白名单也有前提:requestId、taskId 必须来自已验证的请求上下文和服务端任务,不能把用户输入塞进允许字段。正则只限制格式,无法证明内容来源安全。provider、model、failureCode 若需加入,应从服务端配置或固定错误码字典取值。
将原始内容和排障元数据分开存放
业务恢复确实可能需要 prompt。AI 改图入口在 enhance.controller.ts 的 initialResult 中保存了最多 1000 字符的提示词;用户任务接口也会返回这段业务数据。它不是脱敏摘要,更不是因为日志脱敏就不存在了。
这类内容应留在有归属校验、访问控制和保留期的业务存储中。通用日志只保留任务 ID 和必要元数据;需要分析内容时,走受控的诊断流程。prompt 的哈希也不应默认添加,因为可猜测文本仍可能被枚举关联。
6 用一条失败时间线检查记录是否说得清
假设一次图片生成返回 HTTP 200,但结果校验发现输出尺寸不符合要求,业务最终失败。这是根据现有分层职责构造的教学案例,不代表某次线上故障,也不包含真实任务 ID、耗时或费用。
|---------------|---------------------|-----------------|
| 阶段 | 应如何解读记录 | 排障时查看什么 |
| 提交并建任务 | HTTP 接口与业务任务分别产生 ID | 建议关联事件中的两个 ID |
| 模型返回 HTTP 200 | 用量表可能记录 succeeded | 只说明这次 HTTP 请求成功 |
| 结果校验失败 | 模型尝试或业务阶段记录失败 | 错误码与具体校验阶段 |
| 补偿与状态收敛 | 按业务路径退款或释放权益 | 任务计费状态与账本结果 |
| 页面再次打开 | 查询任务终态并恢复错误提示 | 当前用户归属和任务终态 |
排查时先按 taskId 查业务终态,再看同一任务的模型尝试和用量记录。HTTP 200 与业务失败可以同时成立;看到"执行了退款分支"也不能当作退款已核验,仍需核对任务 billingStatus 和对应账本。
文件删除不会自动清除已经写出的日志

图 2 仓库中的文件安全界面素材 日期与 30 天文案属于既有素材所示状态
这个界面说明了文件期限和主动删除入口,但不能据此推断日志也保存 30 天。main.ts 的 DailyRotateFile 配置是按天轮转、单文件上限 20 MB、保留 90d,并启用压缩;这只是应用配置,不包含控制台、PM2、日志平台或备份的保留策略。
FileCleanupService 删除任务文件后更新文件删除状态,默认还会移除关联操作历史;不能笼统声称所有历史记录都会留下。文件资产、任务元数据、计费账本和日志需要分别定义留存范围。若敏感内容已经落入日志,删除图片本身无法撤回这些副本。
7 明确验证范围与下一步验收
已经做过的本地验证
本地运行了 logger.middleware.spec.ts 和 model-usage.service.spec.ts 两个现有测试文件,共 25 个用例通过。前者覆盖请求体和查询值脱敏、创作内容长度、原对象不变性与非法 requestId 等;后者覆盖请求识别、任务关联、模型成本快照和主模型重试的备用标记等。
这些测试使用构造请求、模拟 fetch 和数据库对象,验证的是局部行为,没有调用真实模型供应商,也没有核验生产日志、真实退款或保留期清理。教学函数还单独检查了未知字段丢弃、非法 ID、枚举回退、异常耗时和原输入不变性;这也不等于完整隐私审计。
建议验收清单
- 关联完整性:提交任务后核对 requestId 与 taskId 的关联事件;刷新查询产生新的请求 ID,业务任务 ID 保持一致。跨进程路径另查上下文传递。
- 隐私出口:用专门的测试标记覆盖 prompt、嵌套字段、签名 URL、上游回显和异常堆栈;在文件日志、控制台、活动表、操作历史等出口检索,不能只测 sanitizeBody。
- 失败语义:覆盖"受理成功后失败"、结果校验失败、网络不确定、主模型重试和备用接管,分别核对 HTTP、attempt、业务状态与计费账本。
- 日志可靠性:模拟数据库暂时不可用和进程退出,评估用量事件缺口;核验补报是否去重、是否保留原始尝试序号。
- 权限与留存:用另一账号验证任务不可越权读取;核对应用日志、PM2、集中平台与备份的权限和保留期,实际验证文件删除及残留元数据。
此外,全局异常过滤器仍会把原始 URL、错误正文和堆栈写入 OperationHistory 的部分记录,这与 HTTP 中间件是不同出口,应纳入脱敏复核。日志设计需要按每个落点检查,不能因为有一个统一中间件就停止审视其他写入路径。
对图片任务而言,优先保证每个阶段的含义清楚、关联 ID 稳定,再控制记录内容的范围。这样既能回答"任务走到哪一步",也能减少为排障额外复制用户内容的机会。
参考资料
NestJS Logger 官方文档 日志级别和自定义 logger