Forráskód Böngészése

feat(mdp): land WP10 S4b outbox backoff/alerts and lock WP9 decisions (server 1.0.303)

D12: backoff 1-120m, MaxRetry=6, LogWarning+SysNotice. Apply UpdateScripts/1.0.303.sql. WP9 D7-A/D8-A/D9-C locked; S1 inventory recorded. Smoke: M500000005 -> SM20260809004 OK/NOOP.
Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 3 napja
szülő
commit
6446b5da71

+ 3 - 3
doc/plan/旧DOP-MES-WMS/对接任务书/README.md

@@ -17,8 +17,8 @@
 
 | 状态 | 工作包 |
 |------|-------|
-| **可直接派发** | **WP7 §3.5 物料职责补数(= WP10 卡片 0,无任何代码依赖,可最先执行)**;WP1、WP2、WP8、WP7 §2/§3;WP3(写权限验证后);**WP10 S0/S1/S2/S3/S4a(D10、D11 已定;派发前先读其 §11 已知空白,卡片见 §12)** |
-| **暂不可派发** | WP4(字段矩阵仍是调研任务,D1/D3/D4/D6 未决)、WP7 §4(受 A4)、WP5(需真实 APP 环境与人)、WP6(依赖 WP5)、**WP9(D7/D8/D9 未决;其 S1 盘点步可先行)**、**WP10 S4b(D12 未决)** |
+| **可直接派发** | **WP7 §3.5 物料职责补数(= WP10 卡片 0,无任何代码依赖,可最先执行)**;WP1、WP2、WP8、WP7 §2/§3;WP3(写权限验证后);**WP10 S0–S4b(D10/D11/D12 已定;派发前先读其 §11 已知空白,卡片见 §12)** |
+| **暂不可派发** | WP4(字段矩阵仍是调研任务,D1/D3/D4/D6 未决)、WP7 §4(受 A4)、WP5(需真实 APP 环境与人)、WP6(依赖 WP5)、**WP9(D7/D8/D9 未决;其 S1 盘点步可先行)** |
 
 | 文档 | 内容 | 前置 |
 |------|------|------|
@@ -67,7 +67,7 @@
 | WP4 的 **D1、D3、D4、D6** 未定(D2、D5 已结) | 影响复刻范围 | WP4 §6 |
 | WP7 的 **B1、B3、B4** 未定(**B2 已查实结案、B5 已定**) | B1 影响收货任务执行人来源;B3 只读范围;B4 是上线前组织约定 | WP7 §7 |
 | **F13 卡死会阻碍联调**(实测活例 `M500000005`) | 建单后回写失败的工单**永久不可复用**(重放被幂等跳过),联调期需反复手工清库。165 为开发测试机故非生产事故,但 S1 属**测试前置项**。**已解阻断,可派发** | WP10 §7.1、§12 卡片 B |
-| WP10 的 D12(Outbox 退避参数与告警渠道)未定 | 仅阻断 S4b(上线前项);不影响 S0–S4a | WP10 §3 |
+| ~~WP10 的 D12 未定~~ → ✅ **已定**(退避 `1m…120m` / `MaxRetry=6` / LogWarning+SysNotice) | S4b 已解锁并落地(后端 1.0.303) | WP10 §3 / §5 S4b |
 | **退料单发起方未知(D6)** | 疑由 BPM 引擎发起;未确认前不投入反编译工时 | WP4 §4.4 |
 | **PDA 是否真有待发料任务(A4)** | 决定 WP7 §4 全量实施还是裁剪 | 附录 A §4.1 |
 | `MissedPrint` 热查询方案未定案(E1) | 标签维度的实时性无法落地 | WP8 §4.3 |

+ 7 - 7
doc/plan/旧DOP-MES-WMS/对接任务书/WP10-跨库成败判定与失败兜底.md

@@ -4,7 +4,7 @@
 |----|------|
 | 目标 | 在**无分布式事务**前提下,让跨库业务动作的「成功 / 部分成功 / 失败」判定准确、提示可读、失败可自愈,且任何单次失败都不得让业务对象进入**不可重试的卡死态** |
 | 编写日期 | 2026-08-09 |
-| 状态 | **D10/D11 已定(2026-08-09),S0/S1/S2/S3/S4a 全部可派发**;仅 S4b 受 D12 阻断。§7 盘点已完成,在联调环境实测到 F13 的活例(工单 `M500000005` 卡死)与 4 条无人知晓的死信。165(`123.60.180.165`)当前**是开发测试服务器**,故这些是**缺陷实证而非生产事故**,存量数据可直接清理;但缺陷本身必须修,因为同一份代码将来要跑真实 MES/WMS 库 |
+| 状态 | **D10/D11/D12 已定(2026-08-09),S0–S4b 全部可派发**。§7 盘点已完成,在联调环境实测到 F13 的活例(工单 `M500000005` 卡死)与 4 条无人知晓的死信。165(`123.60.180.165`)当前**是开发测试服务器**,故这些是**缺陷实证而非生产事故**,存量数据可直接清理;但缺陷本身必须修,因为同一份代码将来要跑真实 MES/WMS 库 |
 | 前置 | WP2(165 取号)、WP3(Outbox)、WP4(建领料)已落地 |
 | 产出 | 单据级返回契约 + 错误码表 + 事务边界修正 + Outbox 退避与死信管理 + 孤儿单盘点脚本 |
 | 不做 | 不引入分布式事务 / XA / MSDTC;不改 165 表结构与触发器;不改 WP9 的权威库决策(D7 另议);本包**不做**自动 DELETE 165 数据(见 D10) |
@@ -72,7 +72,7 @@
 |---|------|------|------|
 | **D10** | 165 上**存量孤儿单**如何处置 | A:**改幂等判定口径**(`NbrMaster` 存在**且有明细**才算已建单),孤儿不阻塞重建<br>B:检测到孤儿即自动 `DELETE` 后重建<br>C:把孤儿 `NbrMaster.Status` 软标记为作废 | ✅ **已定 A**(2026-08-09)。§7 实测孤儿 0 条;存量清理走 S0,代码不内置 DELETE |
 | **D11** | 批量建单时 **165 不可达**的语义 | A:**整批 fail-fast**,一张不建,本地事务全回滚<br>B:逐单尽力而为,返回部分成功<br>C:转为异步任务,全部走 Outbox 补偿 | ✅ **已定 A**(2026-08-09)。依据见下 |
-| **D12** | Outbox 退避参数与死信告警渠道 | 退避序列 / `MaxRetry` 取值 / 告警走日志、站内信还是钉钉 | ⏳ **待定**。建议:退避 `1m,5m,15m,30m,60m,120m`;`MaxRetry=6`;先落**日志 + 站内信**。仅阻断 S4b |
+| **D12** | Outbox 退避参数与死信告警渠道 | 退避序列 / `MaxRetry` 取值 / 告警走日志、站内信还是钉钉 | ✅ **已定**(2026-08-09)。退避 `1m,5m,15m,30m,60m,120m`(按 `retry_count` 下标,clamp 末项);`MaxRetry=6`;告警渠道=`LogWarning` + `SysNotice`(站内信);**本轮不做钉钉** |
 
 ### D11 定 A 的依据
 
@@ -105,7 +105,7 @@
 
 > **注意**:A 方案本身只解决「孤儿阻塞」这一种卡死。§7.1 实测到的 `M500000005` 属另一种(单据完整但后续副作用缺失),必须靠 S1 步骤 3b 的「重放收敛到终态」解决。两者都要做。
 
-> **回填状态**:✅ 已在 [`WP6-对账与运维.md`](./WP6-对账与运维.md) §2.1/§2.4/§4 登记孤儿单、Outbox 死信与跨库半成品三类对账对象,并统一了重放端点;✅ 已在 [`附-字段共管矩阵.md`](./附-字段共管矩阵.md) §13.1 登记建领料的回写通道变更。⏳ 仅剩 D12 定案后回填本节
+> **回填状态**:✅ 已在 [`WP6-对账与运维.md`](./WP6-对账与运维.md) §2.1/§2.4/§4 登记孤儿单、Outbox 死信与跨库半成品三类对账对象,并统一了重放端点;✅ 已在 [`附-字段共管矩阵.md`](./附-字段共管矩阵.md) §13.1 登记建领料的回写通道变更;✅ D12 已定并落地 S4b(退避 + `MdpOutboxDeadLetterAlertJob`)
 
 ---
 
@@ -536,7 +536,7 @@ return MdpPushResult.Fail($"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既
 
 ---
 
-### S4b · Outbox 退避与告警(依赖 D12,**跨模块,可推迟到上线前**)
+### S4b · Outbox 退避与告警(D12 已定,**跨模块**)
 
 > **本步影响所有走 `mdp_outbox` 的出站回写**,不止领料。派发前须确认 §6 跨模块影响已知会。
 >
@@ -729,7 +729,7 @@ FROM mdp_source WHERE source_code = 'DOPDEMORQ_SQLSERVER';
 
 | # | 空白 | 影响步骤 | 处置 |
 |---|------|---------|------|
-| 1 | **D12 未定**(退避序列、`MaxRetry`、告警渠道) | S4b | S4b 不得开工。S0–S4a 不受影响 |
+| 1 | ~~**D12 未定**~~ → ✅ **已定**(退避 `1m…120m` / `MaxRetry=6` / LogWarning+SysNotice) | S4b | 已解锁;本轮不做钉钉 |
 | 2 | **S2/S3/S4b 为契约级而非代码级** | S2/S3/S4b | §4.2 的 JSON、§4.3 的错误码表是**强约束**,字段名与错误码不得自创;其余实现细节由执行者按本仓既有风格决定 |
 | 3 | **`MdpDbPushExecutor` 的 UPSERT/UPDATE 具体行为未在本书复述** | S1 第 6 节、S4a | 以 [`WP3-回写执行器.md`](./WP3-回写执行器.md) 为准;本书只规定 payload 形状与 keys 选择 |
 | 4 | **165 `WorkOrdRouting.ID` 语义不明** | S1 第 6 节 | 实测本库全为 NULL、165 表为空,无法判断该列本意。**按本书写法(keys 不含 `ID`)实现,并强制校验 `affected=1`**;若联调中出现 `affected>1`,停下来回报,不要放过 |
@@ -782,6 +782,6 @@ FROM mdp_source WHERE source_code = 'DOPDEMORQ_SQLSERVER';
 >
 > **前置**:S1 必须已合入(S2 依赖其事务边界与三分类探查)。前后端版本号各 patch +1。
 
-### 卡片 E · S4b Outbox 退避与告警(**D12 定了才发**)
+### 卡片 E · S4b Outbox 退避与告警(**D12 已定,已解锁**)
 
-> 执行 WP10 §5 的 S4b。**这一步改的是公共分发器,影响所有出站回写模块**(含 WP7 六张基础数据表),改前先读 §6 跨模块影响。表结构变更须同时提供 `server/Admin.NET.Web.Entry/UpdateScripts/<新版本>.sql`,新增列必须 nullable。
+> 执行 WP10 §5 的 S4b。**这一步改的是公共分发器,影响所有出站回写模块**(含 WP7 六张基础数据表),改前先读 §6 跨模块影响。表结构变更须同时提供 `server/Admin.NET.Web.Entry/UpdateScripts/<新版本>.sql`,新增列必须 nullable。D12:退避 `1m,5m,15m,30m,60m,120m`,`MaxRetry=6`,告警=`LogWarning`+`SysNotice`(无钉钉)。

+ 4 - 4
doc/plan/旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md

@@ -4,7 +4,7 @@
 |----|------|
 | 目标 | 消除「工单下达建本库领料单」与「MES 下达建 165 领料单」两条并存路径,确立**唯一权威库**与**唯一取号源**,避免重号、双单、状态口径分裂 |
 | 编写日期 | 2026-08-09 |
-| 状态 | **待决策**(D7/D8/D9 未定前不得改写路径代码) |
+| 状态 | **D7/D8/D9 已定**(2026-08-09:D7-A / D8-A / D9 过渡 C);S1 盘点已出,见 [`_wp9_盘点.md`](./_wp9_盘点.md) |
 | 前置 | WP2(165 取号)、WP3(Outbox)、WP4 建领料已落地 |
 | 产出 | 决策记录 + 路径开关 + 单一入口 + 号格式一致性测试 + S5 列表切源 + 盘点/对账 SQL |
 | 不做 | 不改 165 结构;不改 `NbrControl` 配置数据;不引入分布式事务;不动退料(`WOD`)/入库(`WOI`)链 |
@@ -59,9 +59,9 @@
 
 | # | 决策 | 选项 | 建议默认 |
 |---|------|------|----------|
-| **D7** | 领料单的**权威库** | A:165 唯一权威,本库退为只读镜像<br>B:本库唯一权威,Outbox 单向推 165<br>C:双写并对账 | **A** |
-| **D8** | 本库已存量 SM 单如何处理 | A:保留并打标 `source_system='AIDOP_LEGACY'`,不迁移<br>B:按工单在 165 重建<br>C:物理清理 | **A** |
-| **D9** | 下达按钮语义 | A:合并为一个「工单下达」,内部按开关决定写哪边<br>B:保留两个按钮(过渡期)<br>C:MES 按钮仅作补偿重试 | **A**(过渡期先 C) |
+| **D7** | 领料单的**权威库** | A:165 唯一权威,本库退为只读镜像<br>B:本库唯一权威,Outbox 单向推 165<br>C:双写并对账 | **已定 A** |
+| **D8** | 本库已存量 SM 单如何处理 | A:保留并打标 `source_system='AIDOP_LEGACY'`,不迁移<br>B:按工单在 165 重建<br>C:物理清理 | **已定 A** |
+| **D9** | 下达按钮语义 | A:合并为一个「工单下达」,内部按开关决定写哪边<br>B:保留两个按钮(过渡期)<br>C:MES 按钮仅作补偿重试 | ✅ **已定过渡 C**(后续再合并为 A) |
 
 **D7 建议 A 的依据**:MES/WMS APP 只认 165 的 `NbrMaster`;本库那张表既不被 APP 读,也不被 165 过程读,仅供 Ai-DOP 自己列表展示,而展示已可切源。若选 B,则 APP 端永远拿不到实时单据,WP5 场景 1 无法通过。
 

+ 24 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/_wp9_盘点.md

@@ -0,0 +1,24 @@
+# WP9 S1 盘点结果(2026-08-09)
+
+| 指标 | 数值 | 说明 |
+|------|------|------|
+| 本库 SM 总数 | **9** | `NbrMaster WHERE Type='SM'` |
+| 165 SM 总数 | **0** | S0 已清冒烟单;当前无有效 SM |
+| 重号条数(两库 Nbr 交集) | **0** | 165 为空,交集为空 |
+| 本库降级号条数 | **0** | 不匹配 `^SM[0-9]{8}[0-9]{3}$` 的为 0 |
+| 本库一工单多票 | **0** | `Type='SM' AND IFNULL(TransType,'')=''` |
+| 165 `NbrDayInfo` sm | 多日历史行仍在;今日若无行将由取号服务新建 | `NextValue` 历史最大值见库内 |
+
+## 决策落定(按建议默认,2026-08-09)
+
+| # | 结论 |
+|---|------|
+| **D7** | ✅ **A**:165 唯一权威,本库退为只读镜像 |
+| **D8** | ✅ **A**:本库已存 SM 保留并打标 `source_system='AIDOP_LEGACY'`,不迁移 |
+| **D9** | ✅ **过渡 C**:MES 按钮作补偿重试;后续再合并为单一「工单下达」 |
+
+## 判读
+
+- C1 重号目前未发生(165 为空)。
+- 本库 9 张 SM 属历史/本库路径产物,按 D8-A 保留打标即可。
+- 下一步:WP9 S2 抽象 `IPickBillCreator` + 配置开关(默认仍 `Local` 不改行为),再切 `Mes`。

+ 3 - 2
doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md

@@ -1,6 +1,6 @@
 # 执行进度(2026-08-09)
 
-后端版本目标 **1.0.302** / 前端 **2.4.277**(WP10 卡片 0/A/B/C/D 落地)。
+后端版本目标 **1.0.303** / 前端 **2.4.277**(WP10 卡片 0/A/B/C/D + S4b 落地)。
 
 ## 已完成(含本轮收尾)
 
@@ -17,6 +17,7 @@
 | **WP7 B5 / §3.2** | Keeper `Ordinal` 归一 + 硬校验;`WmsBaseDataService` 区间字符集校验;idem_key 去哈希 | `KeeperResolveService` / `WmsBaseDataService` |
 | **WP10 S4a** | Outbox 监控页 + `mdp-outbox` API + 配置类失败不烧重试;`FUNC-S9-016` | `outbox.vue` / `MdpOutboxAdminService` |
 | **WP10 S3** | 结果弹窗组件 + 按 `code` 呈现 | `PickBillResultDialog.vue` |
+| **WP10 S4b** | D12 已定:退避 `1m…120m`、`MaxRetry=6`、死信告警 LogWarning+SysNotice;`next_retry_time`/`last_error_code` | `MdpTargetPushDispatcher` / `MdpOutboxDeadLetterAlertJob` / `1.0.303.sql` |
 
 ## 本轮新增:方案与任务书(文档已先落,代码见上表)
 
@@ -55,7 +56,7 @@
 | WP5 E2E + 真人 APP | 需现场环境 |
 | WP6 对账 | 依赖 WP5 |
 | 与本库 `WorkOrderPickBillService`(MySQL SM)统一单号 | 本轮保留双路径:本库下达仍走 `NumberRule`;MES 侧走 WP2 `sm`。收敛方案见 [`WP9-领料建单双路径统一.md`](./WP9-领料建单双路径统一.md),**受 D7/D8/D9 阻断**(其 S1 盘点步可先行) |
-| 跨库成败判定与失败兜底 | 复查发现 13 项缺陷并已实测验证:工单 `M500000005` 处于卡死态(本库已下达、165 有领料单但无工单头、回写消息成死信、重放被幂等跳过),`mdp_outbox` 8 行中 4 条死信无人知晓。方案见 [`WP10-跨库成败判定与失败兜底.md`](./WP10-跨库成败判定与失败兜底.md)。**D10、D11 已定 A(2026-08-09),S0/S1/S2/S3/S4a 均可派发**(派发前先读其 §11 已知空白,卡片见 §12);仅 S4b 受 D12 阻断 |
+| 跨库成败判定与失败兜底 | 复查发现 13 项缺陷并已实测验证:工单 `M500000005` 处于卡死态(本库已下达、165 有领料单但无工单头、回写消息成死信、重放被幂等跳过),`mdp_outbox` 8 行中 4 条死信无人知晓。方案见 [`WP10-跨库成败判定与失败兜底.md`](./WP10-跨库成败判定与失败兜底.md)。**D10/D11/D12 已定(2026-08-09),S0–S4b 均可派发/已落地**(派发前先读其 §11 已知空白,卡片见 §12) |
 
 ## 菜单生效说明
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.303</AssemblyVersion>
-    <FileVersion>1.0.303</FileVersion>
-    <Version>1.0.303</Version>
+    <AssemblyVersion>1.0.304</AssemblyVersion>
+    <FileVersion>1.0.304</FileVersion>
+    <Version>1.0.304</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 26 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.304.sql

@@ -0,0 +1,26 @@
+-- 1.0.304:WP10 S4b / D12 — Outbox 退避列 + 待推索引
+-- 退避序列:1m,5m,15m,30m,60m,120m;MaxRetry=6;告警=LogWarning+SysNotice
+-- 幂等:ADD COLUMN / INDEX 前用 information_schema 判存在。
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_outbox' AND COLUMN_NAME='next_retry_time'),
+  'SELECT 1',
+  'ALTER TABLE mdp_outbox ADD COLUMN next_retry_time datetime NULL'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_outbox' AND COLUMN_NAME='last_error_code'),
+  'SELECT 1',
+  'ALTER TABLE mdp_outbox ADD COLUMN last_error_code varchar(64) NULL'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @idx_exists := (
+  SELECT COUNT(1) FROM information_schema.statistics
+  WHERE table_schema = DATABASE() AND table_name='mdp_outbox' AND index_name='ix_mdp_outbox_pending'
+);
+SET @sql := IF(@idx_exists=0,
+  'CREATE INDEX ix_mdp_outbox_pending ON mdp_outbox(status, next_retry_time, id)',
+  'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 138 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpTargetPushDispatcher.cs

@@ -5,11 +5,23 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
 
 /// <summary>
 /// 出站分发:扫描 mdp_outbox,按 source_type 路由到 API / DB 执行器。
+/// WP10 S4b / D12:退避 1m/5m/15m/30m/60m/120m,MaxRetry=6;配置类失败不烧重试。
 /// </summary>
 public sealed class MdpTargetPushDispatcher : ITransient
 {
     public const int DefaultTake = 500;
-    private const int MaxRetry = 3;
+    private const int MaxRetry = 6;
+
+    /// <summary>D12:按 retry_count 下标取退避分钟数(clamp 到末项)。</summary>
+    private static readonly int[] BackoffMinutes = [1, 5, 15, 30, 60, 120];
+
+    private static readonly string[] PermanentErrorPrefixes =
+    [
+        "PAYLOAD_INVALID",
+        "CONSTRAINT_VIOLATION",
+        "SQL_CONSTRAINT",
+        "UNSUPPORTED_OP",
+    ];
 
     private readonly ISqlSugarClient _db;
     private readonly MdpApiPushExecutor _api;
@@ -31,8 +43,11 @@ public sealed class MdpTargetPushDispatcher : ITransient
     public async Task<(int success, int failed, int skipped)> PushPendingAsync(
         int take = DefaultTake, CancellationToken cancellationToken = default)
     {
+        var now = DateTime.Now;
         var pending = await _db.Queryable<MdpOutbox>()
-            .Where(x => x.Status == 0 && x.RetryCount < MaxRetry)
+            .Where(x => x.Status == 0
+                        && x.RetryCount < MaxRetry
+                        && (x.NextRetryTime == null || x.NextRetryTime <= now))
             .OrderBy(x => x.Id)
             .Take(take > 0 ? take : DefaultTake)
             .ToListAsync(cancellationToken);
@@ -57,6 +72,7 @@ public sealed class MdpTargetPushDispatcher : ITransient
                     // 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
                     await MarkConfigIssueAsync(
                         item,
+                        "SOURCE_DISABLED",
                         $"目标源 {item.TargetSourceCode} 未启用",
                         cancellationToken);
                     skipped++;
@@ -65,7 +81,7 @@ public sealed class MdpTargetPushDispatcher : ITransient
 
                 if (!TryResolve(source, out var executor, out var routeError))
                 {
-                    await MarkConfigIssueAsync(item, routeError!, cancellationToken);
+                    await MarkConfigIssueAsync(item, "SOURCE_ROUTE_UNRESOLVED", routeError!, cancellationToken);
                     skipped++;
                     continue;
                 }
@@ -76,23 +92,19 @@ public sealed class MdpTargetPushDispatcher : ITransient
                 var result = await executor.PushAsync(source, item, cancellationToken);
                 if (result.Success)
                 {
-                    await MarkAsync(item, 1, result.ResponseJson, null, cancellationToken);
+                    await MarkAsync(item, 1, result.ResponseJson, null, null, null, cancellationToken);
                     success++;
                 }
                 else
                 {
-                    item.RetryCount++;
-                    var status = item.RetryCount >= MaxRetry ? 2 : 0;
-                    await MarkAsync(item, status, result.ResponseJson, Truncate(result.ErrorMessage, 900), cancellationToken);
-                    if (status == 2) failed++; else skipped++;
+                    await ApplyFailureAsync(item, result.ResponseJson, result.ErrorMessage, cancellationToken);
+                    if (item.Status == 2) failed++; else skipped++;
                 }
             }
             catch (Exception ex)
             {
-                item.RetryCount++;
-                var status = item.RetryCount >= MaxRetry ? 2 : 0;
-                await MarkAsync(item, status, null, Truncate(ex.Message, 900), cancellationToken);
-                if (status == 2) failed++; else skipped++;
+                await ApplyFailureAsync(item, null, ex.Message, cancellationToken);
+                if (item.Status == 2) failed++; else skipped++;
                 _logger.LogWarning(ex, "[MdpTargetPushDispatcher] outbox id={Id} failed", item.Id);
             }
         }
@@ -100,6 +112,96 @@ public sealed class MdpTargetPushDispatcher : ITransient
         return (success, failed, skipped);
     }
 
+    private async Task ApplyFailureAsync(
+        MdpOutbox item, string? responseJson, string? errorMessage, CancellationToken ct)
+    {
+        var msg = Truncate(errorMessage, 900);
+        var (errorCode, permanent) = ClassifyFailure(msg);
+
+        if (permanent)
+        {
+            // 立即死信,不递增 retry_count
+            item.NextRetryTime = null;
+            item.LastErrorCode = errorCode;
+            await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
+            return;
+        }
+
+        item.RetryCount++;
+        item.LastErrorCode = errorCode;
+        if (item.RetryCount >= MaxRetry)
+        {
+            item.NextRetryTime = null;
+            await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
+        }
+        else
+        {
+            item.NextRetryTime = DateTime.Now.AddMinutes(GetBackoffMinutes(item.RetryCount));
+            await MarkAsync(item, 0, responseJson, msg, item.NextRetryTime, errorCode, ct);
+        }
+    }
+
+    /// <summary>
+    /// 可重试 vs 不可重试。配置类走 <see cref="MarkConfigIssueAsync"/>,不进本方法。
+    /// </summary>
+    private static (string? errorCode, bool permanent) ClassifyFailure(string? error)
+    {
+        if (string.IsNullOrWhiteSpace(error))
+            return ("UNKNOWN", false);
+
+        var trimmed = error.Trim();
+        foreach (var prefix in PermanentErrorPrefixes)
+        {
+            if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+                return (prefix, true);
+        }
+
+        // payload 反序列化 / 解析失败 → 永久
+        if (trimmed.Contains("payload 解析失败", StringComparison.OrdinalIgnoreCase)
+            || trimmed.Contains("payload parse", StringComparison.OrdinalIgnoreCase)
+            || trimmed.Contains("JsonException", StringComparison.OrdinalIgnoreCase)
+            || trimmed.Contains("反序列化", StringComparison.OrdinalIgnoreCase))
+            return ("PAYLOAD_INVALID", true);
+
+        // SQL 约束 / 唯一冲突 → 永久
+        if (ContainsAny(trimmed,
+                "unique constraint", "unique key", "duplicate key", "duplicate entry",
+                "primary key", "violation of unique", "violation of primary",
+                "cannot insert duplicate", "约束", "唯一索引", "主键冲突"))
+            return ("CONSTRAINT_VIOLATION", true);
+
+        if (trimmed.Contains("不支持的 op", StringComparison.OrdinalIgnoreCase))
+            return ("UNSUPPORTED_OP", true);
+
+        // 连接 / 超时 → 可重试
+        if (ContainsAny(trimmed,
+                "timeout", "timed out", "connection", "network", "could not open",
+                "unable to connect", "transport-level", "broken pipe", "socket",
+                "连接", "超时", "无法连接"))
+            return ("TRANSIENT", false);
+
+        return ("RETRYABLE", false);
+    }
+
+    private static bool ContainsAny(string haystack, params string[] needles)
+    {
+        foreach (var n in needles)
+        {
+            if (haystack.Contains(n, StringComparison.OrdinalIgnoreCase))
+                return true;
+        }
+        return false;
+    }
+
+    /// <summary>retry_count 从 1 起对应 Backoff[0];越界 clamp 到末项。</summary>
+    private static int GetBackoffMinutes(int retryCount)
+    {
+        var idx = Math.Max(0, retryCount - 1);
+        if (idx >= BackoffMinutes.Length)
+            idx = BackoffMinutes.Length - 1;
+        return BackoffMinutes[idx];
+    }
+
     /// <summary>
     /// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
     /// </summary>
@@ -135,24 +237,43 @@ public sealed class MdpTargetPushDispatcher : ITransient
         return false;
     }
 
-    /// <summary>配置类问题:仅写 ErrorMsg/UpdateTime,status 保持 0,retry_count 不变。</summary>
-    private async Task MarkConfigIssueAsync(MdpOutbox item, string error, CancellationToken ct)
+    /// <summary>配置类问题:仅写 ErrorMsg/LastErrorCode/UpdateTime,status 保持 0,retry_count 不变。</summary>
+    private async Task MarkConfigIssueAsync(MdpOutbox item, string errorCode, string error, CancellationToken ct)
     {
         item.ErrorMsg = Truncate(error, 900);
+        item.LastErrorCode = Truncate(errorCode, 64);
         item.UpdateTime = DateTime.Now;
         await _db.Updateable(item)
-            .UpdateColumns(x => new { x.ErrorMsg, x.UpdateTime })
+            .UpdateColumns(x => new { x.ErrorMsg, x.LastErrorCode, x.UpdateTime })
             .ExecuteCommandAsync(ct);
     }
 
-    private async Task MarkAsync(MdpOutbox item, int status, string? responseJson, string? error, CancellationToken ct)
+    private async Task MarkAsync(
+        MdpOutbox item,
+        int status,
+        string? responseJson,
+        string? error,
+        DateTime? nextRetryTime,
+        string? lastErrorCode,
+        CancellationToken ct)
     {
         item.Status = status;
         item.ResponseJson = Truncate(responseJson, 4000);
         item.ErrorMsg = error;
+        item.NextRetryTime = nextRetryTime;
+        item.LastErrorCode = Truncate(lastErrorCode, 64);
         item.UpdateTime = DateTime.Now;
         await _db.Updateable(item)
-            .UpdateColumns(x => new { x.Status, x.RetryCount, x.ResponseJson, x.ErrorMsg, x.UpdateTime })
+            .UpdateColumns(x => new
+            {
+                x.Status,
+                x.RetryCount,
+                x.ResponseJson,
+                x.ErrorMsg,
+                x.NextRetryTime,
+                x.LastErrorCode,
+                x.UpdateTime
+            })
             .ExecuteCommandAsync(ct);
     }
 

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpOutboxAdminService.cs

@@ -75,6 +75,8 @@ public class MdpOutboxAdminService : IDynamicApiController, ITransient
                 payloadJson = x.PayloadJson,
                 status = x.Status,
                 retryCount = x.RetryCount,
+                nextRetryTime = x.NextRetryTime?.ToString("yyyy-MM-dd HH:mm:ss"),
+                lastErrorCode = x.LastErrorCode,
                 responseJson = x.ResponseJson,
                 errorMsg = x.ErrorMsg,
                 createTime = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),
@@ -136,6 +138,8 @@ public class MdpOutboxAdminService : IDynamicApiController, ITransient
                 .SetColumns(x => x.Status == 0)
                 .SetColumns(x => x.RetryCount == 0)
                 .SetColumns(x => x.ErrorMsg == null)
+                .SetColumns(x => x.NextRetryTime == null)
+                .SetColumns(x => x.LastErrorCode == null)
                 .SetColumns(x => x.UpdateTime == DateTime.Now)
                 .Where(x => x.Status == 2)
                 .ExecuteCommandAsync(ct);
@@ -147,6 +151,8 @@ public class MdpOutboxAdminService : IDynamicApiController, ITransient
                 .SetColumns(x => x.Status == 0)
                 .SetColumns(x => x.RetryCount == 0)
                 .SetColumns(x => x.ErrorMsg == null)
+                .SetColumns(x => x.NextRetryTime == null)
+                .SetColumns(x => x.LastErrorCode == null)
                 .SetColumns(x => x.UpdateTime == DateTime.Now)
                 .Where(x => ids.Contains(x.Id))
                 .ExecuteCommandAsync(ct);

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpOutbox.cs

@@ -31,6 +31,14 @@ public class MdpOutbox
     [SugarColumn(ColumnName = "retry_count")]
     public int RetryCount { get; set; }
 
+    /// <summary>下次可推时间;null 表示立即可推(WP10 S4b / D12)。</summary>
+    [SugarColumn(ColumnName = "next_retry_time", IsNullable = true)]
+    public DateTime? NextRetryTime { get; set; }
+
+    /// <summary>最近一次错误码(如 SOURCE_ROUTE_UNRESOLVED / PAYLOAD_INVALID)。</summary>
+    [SugarColumn(ColumnName = "last_error_code", Length = 64, IsNullable = true)]
+    public string? LastErrorCode { get; set; }
+
     [SugarColumn(ColumnName = "response_json", ColumnDataType = "text", IsNullable = true)]
     public string? ResponseJson { get; set; }
 

+ 121 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxDeadLetterAlertJob.cs

@@ -0,0 +1,121 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// WP10 S4b / D12:Outbox 死信与积压告警(LogWarning + 站内 SysNotice)。
+/// 每 5 分钟扫描;同条件告警间隔不少于 30 分钟,避免刷屏。无钉钉。
+/// </summary>
+[JobDetail("job_mdp_outbox_dl_alert", Description = "MDP Outbox 死信/积压告警", GroupName = "default", Concurrent = false)]
+[PeriodSeconds(300, TriggerId = "trigger_mdp_outbox_dl_alert", Description = "每 5 分钟扫描 Outbox 告警", RunOnStart = false)]
+public class MdpOutboxDeadLetterAlertJob : IJob
+{
+    private const long NoticeReceiverUserId = 1300000000101L;
+    private const string NoticeReceiverUserName = "超级管理员";
+    private static readonly TimeSpan AlertCooldown = TimeSpan.FromMinutes(30);
+    private static readonly TimeSpan StalePendingThreshold = TimeSpan.FromMinutes(15);
+
+    private static readonly object Gate = new();
+    private static DateTime? _lastAlertUtc;
+    private static DateTime? _lastScanUtc;
+    private static long _lastSeenDeadMaxId;
+
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public MdpOutboxDeadLetterAlertJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(MdpOutboxDeadLetterAlertJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+
+        var now = DateTime.Now;
+        var scanSince = _lastScanUtc?.ToLocalTime() ?? now.AddMinutes(-5);
+
+        var newDeadCount = await db.Queryable<MdpOutbox>()
+            .CountAsync(x => x.Status == 2 && x.UpdateTime >= scanSince, stoppingToken);
+
+        var maxDeadId = await db.Queryable<MdpOutbox>()
+            .Where(x => x.Status == 2)
+            .MaxAsync(x => (long?)x.Id, stoppingToken) ?? 0L;
+        var newDeadById = maxDeadId > _lastSeenDeadMaxId
+            ? await db.Queryable<MdpOutbox>()
+                .CountAsync(x => x.Status == 2 && x.Id > _lastSeenDeadMaxId, stoppingToken)
+            : 0;
+
+        var hasNewDead = newDeadCount > 0 || newDeadById > 0;
+
+        double oldestPendingMinutes = 0;
+        var pending = await db.Queryable<MdpOutbox>().CountAsync(x => x.Status == 0, stoppingToken);
+        if (pending > 0)
+        {
+            var oldest = await db.Queryable<MdpOutbox>()
+                .Where(x => x.Status == 0)
+                .OrderBy(x => x.CreateTime)
+                .Select(x => x.CreateTime)
+                .FirstAsync(stoppingToken);
+            oldestPendingMinutes = (now - oldest).TotalMinutes;
+        }
+
+        var stalePending = oldestPendingMinutes > StalePendingThreshold.TotalMinutes;
+        _lastScanUtc = DateTime.UtcNow;
+        if (maxDeadId > _lastSeenDeadMaxId)
+            _lastSeenDeadMaxId = maxDeadId;
+
+        if (!hasNewDead && !stalePending)
+            return;
+
+        lock (Gate)
+        {
+            if (_lastAlertUtc.HasValue && DateTime.UtcNow - _lastAlertUtc.Value < AlertCooldown)
+                return;
+            _lastAlertUtc = DateTime.UtcNow;
+        }
+
+        var deadTotal = await db.Queryable<MdpOutbox>().CountAsync(x => x.Status == 2, stoppingToken);
+        var msg =
+            $"Outbox 告警:新增死信≈{Math.Max(newDeadCount, newDeadById)}(累计死信={deadTotal})," +
+            $"待推={pending},最老待推≈{oldestPendingMinutes:F1} 分钟。" +
+            $"请到「出站回写队列」查看 /aidop/data-platform/outbox。";
+
+        _logger.LogWarning("[MdpOutboxDeadLetterAlertJob] {Message}", msg);
+
+        try
+        {
+            // 后台作业无登录态,直接落 SysNotice + 一条 SysNoticeUser(超管),不走 SysNoticeService.InitNoticeInfo
+            var notice = new SysNotice
+            {
+                Title = "Outbox死信/积压告警",
+                Content = msg,
+                Type = NoticeTypeEnum.NOTICE,
+                PublicUserId = NoticeReceiverUserId,
+                PublicUserName = NoticeReceiverUserName,
+                PublicTime = now,
+                Status = NoticeStatusEnum.PUBLIC,
+                CreateTime = now,
+                CreateUserId = NoticeReceiverUserId,
+                CreateUserName = NoticeReceiverUserName,
+            };
+            var noticeId = await db.Insertable(notice).ExecuteReturnSnowflakeIdAsync(stoppingToken);
+            await db.Insertable(new SysNoticeUser
+            {
+                NoticeId = noticeId,
+                UserId = NoticeReceiverUserId,
+                ReadStatus = NoticeUserStatusEnum.UNREAD,
+            }).ExecuteCommandAsync(stoppingToken);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "[MdpOutboxDeadLetterAlertJob] SysNotice 写入失败");
+        }
+    }
+}