4 Komitmen 41e6c63482 ... 2c87ea2868

Pembuat SHA1 Pesan Tanggal
  alphaH 2c87ea2868 docs(开放文档): 新增商户对接 docx《支付宝收单 接口对接指南》 5 hari lalu
  alphaH d750c285b5 docs(交付): 补记 ③ —— 轮询 10 秒 + 回调上限 3 次,产物指纹换代 5 hari lalu
  alphaH 5396cb4c7d feat(汇安融): 回调失败重试上限 8 → 3 次(含首推,用户裁定) 5 hari lalu
  alphaH f2ef6b13be feat(汇安融): 订单轮询间隔 60 秒 → 10 秒(用户裁定) 5 hari lalu

+ 37 - 0
.claude/plan/2026-10-09-har-pick-dedup-retry-交付说明.md

@@ -49,6 +49,43 @@
 部署步骤不变:先跑 `db/har-callback-retry.sql` + `db/har-shop-region.sql`,再 jar + dist 同批换;
 无新 SQL、无重登、无新菜单。
 
+## ★ 2026-10-09 晚间补记 ③ —— 轮询 10 秒 + 回调上限 3 次(用户两条裁定)
+
+两个功能提交 + 本节一提交:
+
+- `f2ef6b13` 订单轮询间隔 60s → 10s(`HarOrderPollScheduler#pollPendingOrders` 的 fixedDelay / initialDelay;
+  `retryFailedCallbacks` 保持 60s / 75s 不动)。只改「发货」节奏:单笔订单两次查询的间距仍由各自
+  `next_query_time` 游标控制(30s 起步退避),对上游的查询密度不变,只减少「已到期却还没被捞走」的空等。
+  该文件此前没有任何调度间隔断言,本次补两条钉住用例(poll=10s、retry=60s,先红后绿)。
+- `5396cb4c` 回调失败重试上限 8 → **3 次(含首推 = 首推 1 + 重试 2)**:`HarProperties.callbackMaxRetries` 8→3、
+  `db/har-callback-retry.sql` 存量封顶 UPDATE 8→3(含注释与只读核对)、`HarCallbackServiceTest` 改钉
+  (schema 用例改为「提取脚本封顶值」并断言 == 默认值 == 3;`retryFailed_pinsScanQuery` 自定义值 3→5 ——
+  原值恰逢新默认值会让「写死常量」的变异不再变红、判别力归零,改 5 后变异实测红
+  `expected [5,FAILED] but was [3,FAILED]`、还原后绿)、`har-openapi.md` 两处 8→3。
+
+**现行产物(换这一套,覆盖上一条补记的指纹;jar + dist 同批)**:
+
+- jar:sha256 `46d7cad9da2f32c8ef69d142f72f784f052f41896ddb6d6da98021b7c0139a78`(143,153,266 字节,18:50:59)
+- dist.zip:sha256 `1fd3d9f94483f29315f563bf36e87982360679f50f3611dd0a9868c732100fe1`(9,373,683 字节,18:54:42;
+  281 条目 = 277 文件 + 4 目录项、0 反斜杠、testzip None)
+
+判据(各带正对照):jar 内 `javap -v` 回读 —— `pollPendingOrders` 注解 `fixedDelay=10000l, initialDelay=10000l`、
+`retryFailedCallbacks` 仍 `60000l / 75000l`;`HarProperties` 构造器 `iconst_3 → putfield callbackMaxRetries`
+(正对照:connect / read timeout 5000 / 15000 原样);jar 内嵌 `db/har-callback-retry.sql` 已是 `notify_count = 3` / `< 3`。
+dist 内文档 chunk(`index.C6vqgUS8.js`)「3 次(含首推)」在、「8 次(含首推)」0 命中、正对照「本章接口路径前缀」在;
+与工作区旧 zip(sha 前缀 `83f4536f…`,9,286,480 字节,18:13 出现 —— 非本方会话产物,内容属 18:08 构建的另行重打包)
+规范化逐条目比:唯一实质差异就是该文档 chunk(72714 → 72714 字节,前缀 35679 / 后缀 35967 相等,中段即两处 8→3)。
+
+读数:全量 **1270 / 0 / 0 / 0**(上批 1268 + 两条新钉住用例;控制台汇总与 100 份 surefire XML 求和两读一致)。
+
+部署:已跑过 `har-callback-retry.sql` 的环境**无需重跑**(列与索引已在;脚本新值 3 供生产首次执行 / 清库重跑);
+jar + dist 同批换、无重登、无新菜单。★ 若部署环境显式配过 `har.callback-max-retries`(application.yml / 启动参数),
+配置会覆盖 Java 默认值 —— 需一并改 3。乐收赢 `leshouying.downstream-notify-max-retries` 仍为 8,**未动**(不在裁定范围)。
+验收订正:§三 第 4 条(回调)的封顶数按 **3 次**观察 —— 正文 §③ / §三 里写的 8 为本轮之前的写法。
+
+push 状态订正:写本节时 `origin/java` = `41e6c634`(此前提交**已全部 push**,含 18:11 的 application.yml 提交);
+本轮 3 个提交(`f2ef6b13`、`5396cb4c`、本节)待你 push。
+
 ## 需求原文与裁定
 
 ### ① 开放 API 挑店改版(C)

TEMPAT SAMPAH
docs/支付宝收单开放接口对接指南.docx


+ 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;

+ 8 - 1
java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java

@@ -35,7 +35,14 @@ public class HarOrderPollScheduler {
         this.callbackService = callbackService;
     }
 
-    @Scheduled(fixedDelay = 60 * 1000, initialDelay = 60 * 1000)
+    /**
+     * 轮询间隔 10 秒(2026-10-09 用户裁定,原 60 秒 —— 退款后状态翻页实测要等约 60 秒)。
+     *
+     * <p>调度器只是「发货」节奏:单笔订单两次查询之间的间距由各自的 {@code next_query_time}
+     * 游标控制(30s 起步退避),收紧本间隔**不会**增加对上游的查询密度,只减少
+     * 「已到期却还没被捞走」的空等。首轮启动延迟同步收 10 秒。
+     */
+    @Scheduled(fixedDelay = 10 * 1000, initialDelay = 10 * 1000)
     public void pollPendingOrders() {
         if (!harProperties.isEnabled()) {
             return;

+ 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(后者是存量规模,记下来即可)

+ 35 - 0
java/src/test/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollSchedulerTest.java

@@ -10,10 +10,13 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.scheduling.annotation.Scheduled;
 
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
@@ -131,4 +134,36 @@ class HarOrderPollSchedulerTest {
 
         assertDoesNotThrow(scheduler::retryFailedCallbacks);
     }
+
+    // ==================== 调度节奏(2026-10-09 用户裁定) ====================
+
+    /**
+     * 轮询间隔是**用户可感知的延迟承诺**:支付/退款状态从上游发生变化到本地可见的等待
+     * ≈ 单笔查询游标(首档 30 秒)+ 本间隔(实测痛点:退款点完等了约 60 秒状态才翻)。
+     * 2026-10-09 用户裁定从 60 秒收紧到 10 秒 —— 改回大值会静默拉长等待,
+     * 套件除本用例不会有任何红点。
+     */
+    @Test
+    void poll_scheduledEveryTenSeconds() throws Exception {
+        Scheduled scheduled = HarOrderPollScheduler.class
+                .getMethod("pollPendingOrders").getAnnotation(Scheduled.class);
+        assertNotNull(scheduled, "pollPendingOrders 必须挂 @Scheduled —— 摘掉它轮询就整个停了");
+        assertEquals(10 * 1000, scheduled.fixedDelay(),
+                "轮询间隔必须是 10 秒(2026-10-09 用户裁定,原 60 秒)");
+        assertEquals(10 * 1000, scheduled.initialDelay(),
+                "首轮启动延迟同步收 10 秒(原 60 秒)—— 重启后的第一笔也不该再等 60 秒");
+    }
+
+    /**
+     * 2026-10-09 的裁定**只动回调重试上限**(8→3,见 {@code HarProperties} 与
+     * har-callback-retry.sql):重试节奏仍是每分钟一轮。别顺手改这一条 ——
+     * 重试腿打的是商户地址,节奏与上限是两回事。
+     */
+    @Test
+    void retryCallbacks_scheduledEverySixtySeconds() throws Exception {
+        Scheduled scheduled = HarOrderPollScheduler.class
+                .getMethod("retryFailedCallbacks").getAnnotation(Scheduled.class);
+        assertNotNull(scheduled, "retryFailedCallbacks 必须挂 @Scheduled");
+        assertEquals(60 * 1000, scheduled.fixedDelay(), "回调重试节奏未在本次裁定内,保持每分钟一轮");
+    }
 }

+ 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'.*"),
                 "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
     }