Răsfoiți Sursa

feat(汇安融): 回调失败重试上限 8 → 3 次(含首推,用户裁定)

「回调失败重试只重试 3 次,不能一直重试」(用户 2026-10-09 裁定)。
原上限 8 与乐收赢默认对齐;本通道收紧到 3(= 首推 1 + 重试 2),
重试节奏不变(仍是每分钟一轮)。乐收赢 leshouying.downstream-notify-max-retries
仍为 8,未动(不在本次裁定范围)。

- HarProperties.callbackMaxRetries 默认 8 → 3(含首次;javadoc 记裁定与语义)
- db/har-callback-retry.sql:存量封顶 UPDATE 8 → 3 + 注释与只读核对同步
  (脚本封顶值与代码默认值的同步由 HarCallbackServiceTest 钉死)
- HarCallbackServiceTest:
  - schema 用例改为「提取脚本封顶值」→ 断言 == HarProperties 默认值、== 3
    (先红后绿;两条断言分别防「脚本/代码漂移」与「数值回退」)
  - retryFailed_pinsScanQuery 自定义上限 3 → 5:原值恰逢新默认值,
    「写死默认常量」不再变红(判别力归零);改 5 后变异实测红
    (expected [5,FAILED] but was [3,FAILED]),还原后绿
- har-openapi.md:两处「最多 8 次(含首推)」→ 3 次

部署注意:部署环境若在 application.yml / 启动参数显式配过
har.callback-max-retries,配置会覆盖 Java 默认值 —— 需一并改 3。
已跑过 har-callback-retry.sql 的环境无需重跑(列与索引已在);
脚本新值 3 供生产首次执行与清库重跑。

测试:聚焦 18/18 绿;全量 1270/0/0/0(见交付说明补记 ③)。
alphaH 5 zile în urmă
părinte
comite
5396cb4c7d

+ 2 - 2
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -200,7 +200,7 @@ Content-Type: application/x-www-form-urlencoded
 
 1. **应答**:处理成功后返回 **HTTP 2xx**。返回非 2xx 视为投递失败。
 2. **去重**:同一笔订单可能收到多次通知。请用 `out_biz_no` + `status` 做幂等。
-3. **快速应答**:先应答再异步处理。平台**首推只发一次**,失败(你方返回非 2xx、超时或连接异常)由平台自动重试:约每分钟一轮,累计尝试最多 **8 次(含首推)**;始终失败则停止,该单 `notify_status` 停在 `FAILED`。
+3. **快速应答**:先应答再异步处理。平台**首推只发一次**,失败(你方返回非 2xx、超时或连接异常)由平台自动重试:约每分钟一轮,累计尝试最多 **3 次(含首推)**;始终失败则停止,该单 `notify_status` 停在 `FAILED`。
 
 > ⚠️ **推送的触发不止「上游通知到达」这一种。** 平台在**本地订单状态收敛后**推送:既包括收到上游通知,也包括平台自己的查询任务发现的状态变化(上游通知丢了的那条腿)。但重试有上限(见上)—— 超过上限就不会再推。**请仍给长期停在 `"0"` 的订单做主动查询兜底**,别把回调当作唯一渠道。
 
@@ -231,7 +231,7 @@ Content-Type: application/x-www-form-urlencoded
 |---|---|
 | `PENDING` | 尚未发起过推送(订单刚生成 / 状态刚变化,推送还没回写结果) |
 | `SUCCESS` | 你方已成功接收 |
-| `FAILED` | 推送失败(你方返回非 2xx、超时异常,或你还没配置回调地址)。平台会自动重试,累计最多 8 次(含首推)后停止;补配地址后未达上限的订单仍会被重推 |
+| `FAILED` | 推送失败(你方返回非 2xx、超时异常,或你还没配置回调地址)。平台会自动重试,累计最多 3 次(含首推)后停止;补配地址后未达上限的订单仍会被重推 |
 
 ---
 

+ 8 - 3
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java

@@ -62,10 +62,15 @@ public class HarProperties {
     /**
      * 下游回调最大尝试次数(含首次)。
      *
-     * <p>推送失败 / 缺地址落 FAILED 后由调度器按此上限重试
-     * (对齐乐收赢 {@code leshouying.downstream-notify-max-retries} 的默认 8)。
+     * <p>2026-10-09 用户裁定:**3**(原 8 —— 与乐收赢
+     * {@code leshouying.downstream-notify-max-retries} 的默认一致)。本通道收紧:
+     * 重试 3 次仍失败即永久停在 FAILED,不再持续打商户地址;乐收赢那条**未动**(仍是 8)。
+     *
+     * <p>语义:含首次 ⇒ 3 = 首推 1 + 重试 2。判定在 {@code HarCallbackService#retryFailed}
+     * (notify_count &lt; 本值才捞);交付脚本里的存量封顶值须与默认值同步(见
+     * db/har-callback-retry.sql,用例 HarCallbackServiceTest 把两者钉在一起)。
      */
-    private int callbackMaxRetries = 8;
+    private int callbackMaxRetries = 3;
 
     /** 连接超时(毫秒) */
     private int connectTimeoutMs = 5000;

+ 5 - 5
java/src/main/resources/db/har-callback-retry.sql

@@ -7,7 +7,7 @@
 -- 本次交付补齐「下游回调失败自动重试」(对齐乐收赢同款):
 --   HarCallbackService#writeNotifyResult 每次回调尝试都写 notify_count+1 / last_notify_time;
 --   HarOrderPollScheduler#retryFailedCallbacks 每 60 秒扫一遍
---       notify_status='FAILED' 且 notify_count < har.callback-max-retries(默认 8)
+--       notify_status='FAILED' 且 notify_count < har.callback-max-retries(默认 3)
 --       按 last_notify_time 升序(饿最久的先推)、单轮 LIMIT 50,重新推送。
 -- 这两列此前没有(pay_har_order 建于 V1.8__create_pay_har_tables.sql,当时只有
 -- notify_status),而实体 HarOrderEntity 已映射 notifyCount / lastNotifyTime。
@@ -24,8 +24,8 @@
 -- 这不是本次要的行为(要补推的是**部署之后**才失败的推送),所以下面把
 -- 执行那一刻的存量 FAILED 行一次性封顶到上限,让它们不进重试扫描。
 -- 新产生的失败(含存量单在部署后因状态收敛新触发的推送失败)从 1 开始正常重试。
--- ★ 封顶值取默认上限 8。若生产把 har.callback-max-retries 配得比 8 **大**,
---   把下面 UPDATE 与被封顶行的值改成那个数(配得比 8 小不用改,8 同样够不到)。
+-- ★ 封顶值取默认上限 3。若生产把 har.callback-max-retries 配得比 3 **大**,
+--   把下面 UPDATE 与被封顶行的值改成那个数(配得比 3 小不用改,3 同样够不到)。
 --
 -- ── 执行 ─────────────────────────────────────────────────────────────
 ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0;
@@ -38,7 +38,7 @@ COMMENT ON COLUMN pay_har_order.last_notify_time IS
 
 -- 存量 FAILED 行封顶(只动 count=0 的:新 jar 写出的 FAILED 行 count 必然 ≥1,
 -- 所以本句重跑也不会把正常重试中的单踢出扫描)
-UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0;
+UPDATE pay_har_order SET notify_count = 3 WHERE notify_status = 'FAILED' AND notify_count = 0;
 
 -- 重试扫描的唯一热路径是 notify_status='FAILED' AND notify_count < 上限
 -- ORDER BY last_notify_time LIMIT 50。FAILED 只是全部订单里极小的一部分,
@@ -60,7 +60,7 @@ CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry
 -- 期望:一行
 --
 -- ③ ★ 存量是否已全部封顶(这条同时量出了「原本会被补推的存量单数」)
--- SELECT count(*) FILTER (WHERE notify_count < 8) AS 还会进扫描的存量_FAILED,
+-- SELECT count(*) FILTER (WHERE notify_count < 3) AS 还会进扫描的存量_FAILED,
 --        count(*)                                AS 存量_FAILED_总数
 --   FROM pay_har_order WHERE notify_status = 'FAILED';
 -- 期望:前者 0(后者是存量规模,记下来即可)

+ 19 - 7
java/src/test/java/com/payment/platform/module/payment/har/service/HarCallbackServiceTest.java

@@ -187,13 +187,15 @@ class HarCallbackServiceTest {
      *
      * 【为什么五个要素全钉】状态字面量、计数上界、排序方向、LIMIT 任一写错,症状都是
      * 「失败回调永远不被重试而套件全绿」—— 没有别的任务会断言这个查询。
-     * 【取值为什么定 3 而不是默认 8】钉住上界来自 HarProperties 而不是写死的数字。
+     * 【取值为什么定 5 而不是默认 3】钉住上界来自 HarProperties 而不是写死的数字 ——
+     * 自定义值必须**≠ 默认值**:定成默认值的话,「写死默认常量」的回归照样能过(本用例
+     * 原定 3、恰逢 2026-10-09 默认从 8 收紧到 3,判别力归零,故改为 5)。
      * 【断言为什么分两半】MyBatis-Plus 的 .eq()/.lt() 不把值写进 SQL 文本(塞进
      * paramNameValuePairs)⇒ 结构看 getSqlSegment(),取值看参数表,取值用**恰好相等**。
      */
     @Test
     void retryFailed_pinsScanQuery() {
-        props.setCallbackMaxRetries(3);
+        props.setCallbackMaxRetries(5);
         when(orderMapper.selectList(any())).thenReturn(List.of());
 
         service.retryFailed();
@@ -203,7 +205,7 @@ class HarCallbackServiceTest {
         LambdaQueryWrapper<?> w = scan.getValue();
         String sql = w.getSqlSegment();
 
-        assertEquals(Set.of(HarNotifyStatus.FAILED, 3), new HashSet<>(w.getParamNameValuePairs().values()),
+        assertEquals(Set.of(HarNotifyStatus.FAILED, 5), new HashSet<>(w.getParamNameValuePairs().values()),
                 "取值必须恰好是 {FAILED, 上限},实测: " + w.getParamNameValuePairs());
         assertTrue(sql.contains("notify_status = #{"),
                 "必须按 notify_status 等值过滤,实测: " + sql);
@@ -272,8 +274,9 @@ class HarCallbackServiceTest {
      *
      * <p>封顶那句(存量不补推)是**用户裁定**的落地处:执行那一刻已有的 FAILED 单
      * 不能因为新列默认 0 被当成「没试满」全部补推。删掉它、或把上限值改得与
-     * {@code har.callback-max-retries} 不一致(默认 8,生产配得比 8 大时脚本要同步改),
-     * 部署时就会把存量积压重推一轮 —— 而套件除了本用例没有任何红点。
+     * {@code har.callback-max-retries} 的默认值不一致(现为 3,2026-10-09 用户裁定
+     * 从 8 收紧;生产把运行时上限配得比脚本封顶值大时脚本要同步改),部署时就会
+     * 把存量积压重推一轮 —— 而套件除了本用例没有任何红点。
      */
     @Test
     void schemaScript_addsRetryColumnsAndSealsExistingFailedRows() throws Exception {
@@ -285,9 +288,18 @@ class HarCallbackServiceTest {
                 "notify_count 列不见了 —— 每次回调尝试都写它(SQL 侧自增),缺列即每次写库抛错");
         assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp.*"),
                 "last_notify_time 列不见了 —— 重试扫描的排序键,缺列即重试扫描每分钟抛一次错");
-        assertTrue(text.matches("(?s).*UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0.*"),
+        Matcher seal = Pattern.compile(
+                "UPDATE pay_har_order SET notify_count = (\\d+) WHERE notify_status = 'FAILED' AND notify_count = 0")
+                .matcher(text);
+        assertTrue(seal.find(),
                 "存量 FAILED 行的封顶句不见了/被改了 —— 「存量不补推」是 2026-10-09 的用户裁定:"
-                        + "新列默认 0 会让存量失败单立刻被当成「没试满」全部重推。值必须 = har.callback-max-retries(默认 8)");
+                        + "新列默认 0 会让存量失败单立刻被当成「没试满」全部重推");
+        int sealedTo = Integer.parseInt(seal.group(1));
+        assertEquals(new HarProperties().getCallbackMaxRetries(), sealedTo,
+                "封顶值必须 = har.callback-max-retries 的默认值 —— 脚本与代码必须同步改:"
+                        + "脚本值 < 运行时上限 ⇒ 存量被当成「没试满」补推;改代码忘改脚本的漂移由本断言拦下");
+        assertEquals(3, sealedTo,
+                "2026-10-09 用户裁定:回调失败最多尝试 3 次(含首推)—— 封顶值随默认值从 8 收紧到 3");
         assertTrue(text.matches("(?s).*CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry\\s+ON pay_har_order \\(last_notify_time\\) WHERE notify_status = 'FAILED'.*"),
                 "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
     }