2026-07-29 线上:FRIDAY 推 patient_settlement 反复 SocketTimeoutException。
查下来是一条链,三处都得补。
【事故链】
① manifest 的退费识别只认两种表达:status=4、status=3 且 receivable_this<0。
FRIDAY 还有第三种 —— 应收不动、只把钱退回去(status=3 / 应收 0 或 6000 / 实收 -500~-6000),
测试服 57,939 条结算里 4 行。它们漏进 payment_rows → payment_record 的
amount_cents<0 撞 zod nonnegative。
② 当时 FactWriter.bulkWrite 是**整批 zod 校验、一条不合格整批抛错**,ParserPipeline
捕获后降级 per-entry —— 而 per-entry 是**每 fact 一个 $transaction**。
单批 200 行:bulkWrite 3 秒 → 逐行事务 63.5 秒(实测 199 次事务累计 79 秒;
对照组:空事务 2.7ms、那条 findFirst 4.1ms —— 数据库不慢,慢在往返次数)。
③ 63.5s 超过宿主 60s 读超时,对方收不到响应就重推同一批;而 PAC 侧回执是
status=success / failed=0,对方也看不出是自己哪一行的问题。
实测从 07-28 20:00 到 07-29 09:00,同一批 200 行推了 13 小时,一条没进。
【改动】
- data/friday/manifest.yaml:
· payment_rows 加 net_receipts_this>=0(负实收不算消费)
· 新增 _refund_neg_receipt(status∈{1,3} 且 应收>=0 且 实收<0)并入 refund_full_rows。
与既有两条零重叠(全库无 status=4、无应收<0),refund 的 amount 本就取 net_receipts_this。
- fact-writer.service.ts:bulkWrite 由「整批抛」改为「隔离违规条目」,
返回 { results, rejected };新增 FactReject / BulkWriteOutcome。
违规条目只带定位信息(type / subjectId / transactionId / 字段级 issues),
**不带 content 原文** —— 回执会进对方日志,患者姓名金额不该顺着外流。
- parser-pipeline.service.ts:消费 rejected 计入 factsFailed + factRejects;
外层 catch 从此只兜真正的批级故障(DB 异常/事务超时),不再因一行脏数据降级。
- cold-import.service.ts / push.schema.ts / push-receiver.service.ts:
factsFailed + factRejects 一路透到 push 回执(封顶 10 条,同种错整批同因)。
- channel-push.mdx §9.1:补 factsFailed / factRejects 字段说明与实例,
讲清 failed(行没进) vs factsFailed(行进了但派生不出事实)的区别。
【验证】新增 3 个回归用例锁住隔离语义(好行照常写 / 全违规不抛错 / 正常路径不受影响,
并断言回执不含 content 原文);650 tests / 41 suites 全过;typecheck 通过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>