1
0

2 کامیت‌ها 7d7b0470d6 ... c36aadcf64

نویسنده SHA1 پیام تاریخ
  alphaH c36aadcf64 docs(汇安融开放文档): 逐单 notify_url 进 10.1/10.4/11,对接指南 docx 同步 1 روز پیش
  alphaH 8dcff1063a feat(汇安融下单): 开放 API 支持逐单 notify_url,全生命周期回调只推它 1 روز پیش

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


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

@@ -31,6 +31,9 @@ POST /payment/openapi/alipay/order/create
 | `out_biz_no` | string | 否  | 你自己的订单号,≤64 字符,原样存下供你查询 |
 | `ip` | string | 是  | 顾客用户端ip |
 | `pay_timeout` | int | 否  | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) |
+| `notify_url` | string | 否  | **本单专属回调地址**,≤512 字符。传了 ⇒ 这笔订单**整个生命周期**的回调全推它;不传 ⇒ 按 10.4 的默认地址 |
+
+> ℹ️ **`notify_url` 是「本单专属」的回调地址(2026-10-14 新增)。** 传了它,这笔订单**每一次回调都只推这个地址**(支付成功、退款各态、超时关闭……一切状态变化),不再看你 API Key 上配的 `return_url`。地址在下单时记到订单上,**下单之后再改 Key / 租户配置都不影响这一单**;不传(或传空白)则完全沿用旧口径(见 10.4 的三级优先级)。
 
 **响应**(B 型,`data` 内)
 
@@ -147,10 +150,16 @@ POST /payment/openapi/alipay/refund
 
 订单状态变化时,平台会向你推送一条回调。
 
-**回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配时平台不会丢下这笔** —— 记一次推送失败并继续重试(见下),你补配地址后未达上限的订单仍会收到;但尽快补配,超过上限就不会再推了,届时只能靠主动查询(10.2)拿结果。
+**回调地址按下单时的快照取,三级优先级(2026-10-14 起)**:
+
+1. **下单时传了 `notify_url`(10.1)⇒ 这笔订单整个生命周期只推它。** 地址在下单时冻结在这笔订单上,之后你改 Key 配置、改租户配置都不影响这一单;
+2. 没传 ⇒ 你这把 API Key(下单用的那把)上配置的 `return_url` —— 这级是**推送时现读**的,不是下单时快照:推的时候 Key 上配的是什么就推什么;
+3. Key 上也没配 ⇒ 该租户的默认回调地址。
+
+**三级全空时平台不会丢下这笔** —— 记一次推送失败并继续重试(见下),你补配地址后未达上限的订单仍会收到;但尽快补配,超过上限就不会再推了,届时只能靠主动查询(10.2)拿结果。
 
 ```
-POST <你的 return_url>
+POST <你的回调地址>
 Content-Type: application/x-www-form-urlencoded
 ```
 
@@ -242,6 +251,7 @@ Content-Type: application/x-www-form-urlencoded
 - [ ] 拿到了 `pay_url` 并能渲染成二维码
 - [ ] 按 `trade_status` 的**数字字符串**分支处理(`"1"` 才是钱到账)
 - [ ] `trade_status = "16"`(超时关闭)按未收款终态处理:这一单不会再有支付,不用等、不用主动查
+- [ ] 回调地址按需选:单笔指定用下单的 `notify_url`,全局兜底配 API Key 的 `return_url`(三级优先级见 10.4)
 - [ ] 回调接口能处理表单编码,并把 `content` **再解析一次** JSON
 - [ ] 回调按 `out_biz_no` 做幂等
 - [ ] 对长期停在 `"0"` 的订单有主动查询兜底

+ 13 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java

@@ -98,4 +98,17 @@ public class HarOrderCreateDTO {
 
     /** 用户付款码。有值即走被扫收款 */
     private String authCode;
+
+    /**
+     * 本单专属回调地址(选填,2026-10-14)。**传了 ⇒ 这笔订单整个生命周期的回调全部走它**
+     * (支付成功、退款各态、超时关闭……一切状态变化),不再看 API Key / 租户上的默认地址;
+     * 不传(或空白)⇒ 沿用旧口径:Key 上的 return_url → 租户默认地址。
+     *
+     * <p>下单时**快照**进 {@code pay_har_order.notify_url} —— 之后改 Key 配置或平台配置
+     * 都不影响这一单的推送去向(与乐收赢 {@code LsyQrCreateDTO#notifyUrl} 同一语义)。
+     * 只校验长度、不校验格式(同 LSY 口径):地址不可达只是推送失败 ⇒ 记 FAILED 进重试,
+     * 不会静默丢。签名按整个请求体算,本字段自动被覆盖,无需另签。
+     */
+    @Size(max = 512, message = "notify_url 不能超过 512 位")
+    private String notifyUrl;
 }

+ 12 - 2
java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java

@@ -28,11 +28,21 @@ public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
 
     /**
      * 下单时使用的租户 apiKey。
-     * 回调时用它反查该 Key 上配置的 return_url —— 与 OpenapiService.resolveReturnUrl
-     * 的取址优先级一致(api_key.return_url → open_conf.return_url)。
+     * 回调时用它反查该 Key 上配置的 return_url —— **没有逐单快照时的回退腿**
+     * (2026-10-14 起取址优先级:{@link #notifyUrl} 快照 → 本 Key 的 return_url
+     * → 租户 open_conf 默认;三级解析见 HarCallbackService)。
      */
     private String apiKey;
 
+    /**
+     * 本单专属回调地址快照(下单时从请求的 notify_url 冻结进来,2026-10-14)。
+     *
+     * <p>回调取址的**第一优先级**:非空即用,不再看 Key / 租户默认地址 ——
+     * 「整个生命周期走这个地址」靠这一格成立(商户之后改 Key 配置也不漂移,
+     * 同 {@code pay_lsy_order.notify_url})。空白存 NULL、不是空串。
+     */
+    private String notifyUrl;
+
     /** 上游返回的 order_no */
     private String upstreamOrderNo;
 

+ 24 - 8
java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java

@@ -10,6 +10,7 @@ import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
 import com.payment.platform.module.payment.har.enums.HarNotifyStatus;
 import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import com.payment.platform.module.payment.har.util.HarLogSanitizer;
 import com.payment.platform.module.payment.openapi.entity.OpenConfEntity;
 import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
 import com.payment.platform.module.payment.stream.OrderEventPublisher;
@@ -42,8 +43,9 @@ import java.util.concurrent.TimeUnit;
  * 报文格式与 OpenapiService.sendCallback 完全一致(form POST,
  * 字段 notify_id / timestamp / content),这样企业商户只需要实现一套验签与解析逻辑。
  *
- * 回调地址解析也沿用 OpenapiService.resolveReturnUrl 的优先级:
- * sys_tenant_api_key.return_url → open_conf.return_url。
+ * 回调地址三级解析(2026-10-14 起):**订单快照 notify_url** → sys_tenant_api_key.return_url
+ * → open_conf.return_url。后两级沿用 OpenapiService.resolveReturnUrl 的次序;快照非空时
+ * 整个生命周期只推它(下单时冻结,商户事后改 Key/租户配置不漂移)。
  *
  * 三点刻意的设计:
  *
@@ -120,9 +122,13 @@ public class HarCallbackService {
         if (order == null) {
             return;
         }
-        String returnUrl = resolveReturnUrl(order.getTenantId(), order.getApiKey());
+        // 逐单快照第一优先级(2026-10-14):传过 notify_url 的订单,整个生命周期只推它,
+        // Key/租户配置不再看(快照非空即短路,见 resolveReturnUrl 的注释)。
+        String returnUrl = StrUtil.isNotBlank(order.getNotifyUrl())
+                ? order.getNotifyUrl()
+                : resolveReturnUrl(order.getTenantId(), order.getApiKey());
         if (StrUtil.isBlank(returnUrl)) {
-            log.warn("[汇安融回调] 租户未配置回调地址,记 FAILED 待重试: tenant_id={}, order_no={}",
+            log.warn("[汇安融回调] 快照/Key/租户皆无回调地址,记 FAILED 待重试: tenant_id={}, order_no={}",
                     order.getTenantId(), order.getOrderNo());
             writeNotifyResult(order, false);
             return;
@@ -172,10 +178,13 @@ public class HarCallbackService {
     }
 
     /**
-     * 回调地址解析 — 与 OpenapiService.resolveReturnUrl 同一优先级。
+     * 回调地址解析(**回退腿**)— 快照为空时用,与 OpenapiService.resolveReturnUrl 同一优先级。
      *
      * 先按 apiKey 精确定位(企业可能配了多个 Key,各自指向不同的接收地址),
      * 再退化到按租户取 open_conf 的默认地址。
+     *
+     * <p>2026-10-14 起取址完整序为:订单快照({@link HarOrderEntity#getNotifyUrl()},
+     * 在 {@link #notifyEnterprise} 顶部判)→ 本方法 → null;快照非空时本方法不会被调用。
      */
     private String resolveReturnUrl(Long tenantId, String apiKey) {
         if (StrUtil.isNotBlank(apiKey)) {
@@ -225,6 +234,13 @@ public class HarCallbackService {
     /**
      * 单次推送 —— 线程内不再退避重试(重试见 {@link #retryFailed})。
      *
+     * <p>url 与异常文本打日志前都过 {@link HarLogSanitizer#flatten}:2026-10-14 起
+     * 地址可由**下单调用方**直接写进 notify_url(此前只来自管理端配置的 return_url),
+     * 一个已认证的 API Key 持有人就能往里塞换行符、在自己的日志里伪造行(CWE-117);
+     * 而失败分支打出的 {@code e.getMessage()} 里,Spring 会把 url 原样拼进去,
+     * UnknownHttpStatusCodeException 还会带上**对端服务器**的 reason phrase ——
+     * 两个实参都不可信(同 LsyCallbackService#push 对同样两个实参的处置)。
+     *
      * @return true = 商户侧返回 2xx;任何非 2xx / 异常都算一次失败尝试,记 FAILED 等调度器再试
      */
     private boolean pushOnce(String url, HarOrderEntity order) {
@@ -240,15 +256,15 @@ public class HarCallbackService {
             ResponseEntity<String> response = restTemplate.postForEntity(
                     url, new HttpEntity<>(formData, headers), String.class);
             if (response.getStatusCode().is2xxSuccessful()) {
-                log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
+                log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, HarLogSanitizer.flatten(url));
                 return true;
             }
             log.warn("[汇安融回调] 返回非 2xx,记 FAILED 待调度器重试: notify_id={}, status={}, url={}",
-                    notifyId, response.getStatusCode(), url);
+                    notifyId, response.getStatusCode(), HarLogSanitizer.flatten(url));
             return false;
         } catch (Exception e) {
             log.warn("[汇安融回调] 推送异常,记 FAILED 待调度器重试: notify_id={}, url={}, error={}",
-                    notifyId, url, e.getMessage());
+                    notifyId, HarLogSanitizer.flatten(url), HarLogSanitizer.flatten(e.getMessage()));
             return false;
         }
     }

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -480,6 +480,9 @@ public class HarOrderService {
         entity.setOrderNo(orderNo);
         entity.setOutBizNo(dto.getOutBizNo());
         entity.setApiKey(apiKey);
+        // 逐单回调地址(2026-10-14)快照:空白 ⇒ NULL,不是空串(口径同上面企业号那格:
+        // isBlank ⇒ null,非空白原样,不 trim)。取址层只认非空白值(HarCallbackService)。
+        entity.setNotifyUrl(StrUtil.isBlank(dto.getNotifyUrl()) ? null : dto.getNotifyUrl());
         entity.setMerchantCode(merchant.getMerchantCode());
         entity.setShopCode(dto.getShopCode());
         entity.setThirdShopCode(thirdShopCode);

+ 63 - 0
java/src/main/java/com/payment/platform/module/payment/har/util/HarLogSanitizer.java

@@ -0,0 +1,63 @@
+package com.payment.platform.module.payment.har.util;
+
+/**
+ * 日志安全工具 —— 压平行终止符,**仅供日志**(CWE-117 日志伪造)。
+ *
+ * <p>【这是该字符集在本仓库的**第三份**拷贝】另两份在乐收赢侧:
+ * {@code LeshouyingApiException.sanitize}(**入站**侧,且额外带 512 长度封顶)与
+ * {@code LsyLogSanitizer.flatten}(通用的出站 / 汇点侧,不封顶长度)。
+ * 本类 = 第二份的平行副本,2026-10-14 随逐单回调地址(notify_url)上线而补:
+ * 地址自此由**下单调用方**直接可控(此前只来自管理端配置的 return_url),
+ * 而 {@code HarCallbackService#pushOnce} 的失败分支不仅打 url,还把
+ * {@code e.getMessage()} 打出来 —— Spring 的 ResourceAccessException 会把 url
+ * 拼进 message,UnknownHttpStatusCodeException 还会带上**对端服务器**的
+ * reason phrase,所以两个实参都必须先摊平。
+ *
+ * <p>【为什么是拷贝而不是 import 乐收赢那份】本仓通道间工具各自独立:
+ * har 与 leshouying 主代码零互相 import(先例 {@code HarAmountFloor} /
+ * {@code LsyAmountFloor}:同一口径的小工具宁可各通道各持一份,也不让两个
+ * 通道在编译期互相绑定)。代价是字符集可能漂移 —— 这条由
+ * {@code HarLogSanitizerTest} 顶上:它对 {@code LsyLogSanitizer.flatten}
+ * 逐字比对(本仓库**测试侧**唯一一处跨通道引用,故意的)。
+ *
+ * <p>【为什么是这几个字符】同 LsyLogSanitizer 的论证:\r \n 是伪造能否成立的
+ * 全部关键;U+2028 / U+2029 是一对行/段分隔符,只堵前一个等于只堵一半;
+ * U+0085、VT(U+000B)、FF(U+000C) 在部分查看器 / 日志检索界面里同样表现为换行。
+ * **故意不动 \t**:正常报文里的合法内容,压掉只毁可读性、换来零安全收益。
+ *
+ * <p>【写法警告】上面这几个字符**必须写成转义形式**(反斜杠 + u + 恰好四位
+ * 十六进制),不要贴真字符 —— 它们在多数编辑器里不可见,被当成普通空格顺手
+ * 删掉就静默失去这层防护(本仓库已踩过一次;2026-10-14 本批落盘时 U+2028/U+2029
+ * 又真的被折成了真字符,靠逐码点探针抓回、改写转义 —— 写完记得探,别信肉眼)。
+ * 反过来,**绝不能**把 U+000A / U+000D 也写成同样的转义形式:javac 在词法
+ * 分析**之前**就还原它们(JLS §3.3),会直接把源代码行拆断、整模块编译失败。
+ *
+ * <p>【不封顶长度】只用于短标量(url、异常 message)。谁要拿它处理整段报文,
+ * 请先回来看一眼长度上界(同 LsyLogSanitizer 的口径)。
+ */
+public final class HarLogSanitizer {
+
+    /** 工具类不可实例化。 */
+    private HarLogSanitizer() {
+    }
+
+    /**
+     * 把任意值摊平成一个不含行终止符的字符串,**专供拼日志用**。
+     *
+     * 入参为 null 时返回 null(不做 {@code String.valueOf} 的 "null" 转换),
+     * 以便调用方保持「字段缺失」与「字段值是字面量 null」的区别。
+     * 对象上的原始字段一律**不被修改** —— 本方法只产出用于打日志的副本。
+     */
+    public static String flatten(Object v) {
+        if (v == null) {
+            return null;
+        }
+        return String.valueOf(v).replace('\r', ' ')
+                .replace('\n', ' ')
+                .replace('\u2028', ' ')
+                .replace('\u2029', ' ')
+                .replace('\u0085', ' ')
+                .replace('\u000B', ' ')
+                .replace('\u000C', ' ');
+    }
+}

+ 10 - 5
java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyLogSanitizer.java

@@ -3,13 +3,18 @@ package com.payment.platform.module.payment.leshouying.util;
 /**
  * 日志安全工具 —— 压平行终止符,**仅供日志**(CWE-117 日志伪造)。
  *
- * 【本字符集在仓库里有**两处**拷贝,改动其一必须同步另一处】
+ * 【本字符集在仓库里有**三处**拷贝,改动其一必须同步其余两处】
  *   1. {@code LeshouyingApiException.sanitize} —— **入站**侧,且额外带 512 长度封顶;
- *   2. 本方法 —— 通用的出站 / 汇点侧,**不封顶长度**。
- *   (第 3 项不在此列:调用方 {@code LsyOrderService} 的未知码 log.error 位点**不是拷贝**,
+ *   2. 本方法 —— 通用的出站 / 汇点侧,**不封顶长度**;
+ *   3. {@code HarLogSanitizer.flatten}(har 通道)—— 2026-10-14 随逐单回调地址
+ *      (notify_url)补的平行副本,字符集与本方法逐字一致(通道间工具各自独立、
+ *      主代码零跨通道 import,故是副本而非引用)。
+ *   (不在此列:调用方 {@code LsyOrderService} 的未知码 log.error 位点**不是拷贝**,
  *    它调用本方法;此处仅作记录,列出来是为了知道谁在依赖这套字符集。)
- * 两份拷贝的字符集必须逐字一致:它们面对的是同一类不可信文本(上游报文原文 / 下游租户请求体),
- * 只堵其中一个位点,换个字段或换个位点就绕过。
+ * 三份拷贝的字符集必须逐字一致:它们面对的是同一类不可信文本(上游报文原文 / 下游租户请求体 /
+ * 逐单回调地址),只堵其中一个位点,换个字段或换个位点就绕过。
+ * 执行面:(1,2)由 {@code LsyLogSanitizerTest} 对拍、(2,3)由 {@code HarLogSanitizerTest}
+ * 对拍(测试侧唯一的跨通道引用)—— 传递覆盖全部三对。
  *
  * 【为什么是这几个字符】
  * \r \n 是必须压的(CWE-117 的伪造成立与否就看这两个);U+2028 / U+2029 / U+0085

+ 26 - 0
java/src/main/resources/db/har-order-notify-url.sql

@@ -0,0 +1,26 @@
+-- pay_har_order 新增 notify_url 列(2026-10-14,逐单回调地址)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 开放 API 下单新增选填参数 notify_url(≤512):传了 ⇒ 该笔订单**整个生命周期**的回调
+-- 全部推到它(支付、退款、超时关闭……一切状态变化),实现方式就是下单时把地址快照进这一列。
+-- 不传/空白 ⇒ 存 NULL,取址落回旧口径(API Key 的 return_url → 租户默认地址)。
+-- 列宽 512 与 DTO 的 @Size(max=512) 同值(列短于校验上限会「校验通过、insert 炸」)。
+--
+-- ★ 【部署顺序:先跑本脚本、再换新 jar】新代码的 insert 会带上这一列;不先建列,
+--   每笔下单(热路径)直接抛「column does not exist」。
+-- ★ 存量行全部为 NULL ⇒ 行为与升级前完全一致(没有快照就按旧的 Key/租户两级解析)。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_url varchar(512);
+
+COMMENT ON COLUMN pay_har_order.notify_url IS
+    '逐单回调地址快照:下单时从请求 notify_url 冻结;非空即整个生命周期只推它,空白存 NULL 走 Key→租户两级回退';
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- SELECT column_name, data_type, character_maximum_length, is_nullable
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_order' AND column_name = 'notify_url';
+-- 期望:notify_url | character varying | 512 | YES

+ 12 - 2
java/src/test/java/com/payment/platform/module/payment/har/dto/HarOrderCreateJsonContractTest.java

@@ -42,16 +42,17 @@ class HarOrderCreateJsonContractTest {
         }
     }
 
-    /** 对外键集 —— 与开放文档 10.2 请求表的每一行逐一对得上(IP 与省码两个地区入参) */
+    /** 对外键集 —— 与开放文档 10.2 请求表的每一行逐一对得上(地区入参 + 逐单回调地址) */
     @Test
     void orderCreateRequestFields() {
         HarOrderCreateDTO d = new HarOrderCreateDTO();
         d.setProvinceCode("320000");
         d.setIp("114.114.114.114");
+        d.setNotifyUrl("https://m.example.com/har/callback");
 
         assertEquals(Set.of("enterprise_id", "merchant_code", "out_biz_no", "price", "title",
                         "trade_type", "biz", "stage_num", "percent", "third_shop_code", "shop_code",
-                        "pay_timeout", "extra_params", "auth_code", "province_code", "ip"),
+                        "pay_timeout", "extra_params", "auth_code", "province_code", "ip", "notify_url"),
                 keysOf(d));
     }
 
@@ -65,6 +66,15 @@ class HarOrderCreateJsonContractTest {
         assertEquals("114.114.114.114", d.getIp());
     }
 
+    /** 调用方按文档传 {@code notify_url} ⇒ 必须真的绑进 DTO(逐单回调地址,2026-10-14) */
+    @Test
+    void notifyUrlBindsFromTheSnakeCaseNameTheDocQuotes() throws Exception {
+        HarOrderCreateDTO d = mapper.readValue(
+                "{\"notify_url\":\"https://m.example.com/har/callback\"}", HarOrderCreateDTO.class);
+
+        assertEquals("https://m.example.com/har/callback", d.getNotifyUrl());
+    }
+
     /**
      * 反方向:写成 camelCase(照直觉)**不会静默通过** —— 生产那支是裸
      * {@code new ObjectMapper()},FAIL_ON_UNKNOWN_PROPERTIES 为 true,绑不上直接报错。

+ 98 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarCallbackServiceTest.java

@@ -53,6 +53,10 @@ import static org.mockito.Mockito.*;
  *    一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
  * ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
  *    计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
+ * ④ 回调地址的三级取址序:订单快照 → Key → 租户默认(2026-10-14 加逐单回调地址)——
+ *    快照被取址层忽略、或优先级写反时,套件其余全绿,只有
+ *    {@link HarCallbackServiceTest#notifyEnterprise_snapshotUrlWinsOverKeyConfig} 与
+ *    {@code retryFailed_scannedOrderWithSnapshotUrl_pushesToSnapshot} 会红。
  *
  * 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
  * 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
@@ -108,6 +112,18 @@ class HarCallbackServiceTest {
         when(apiKeyMapper.selectOne(any())).thenReturn(key);
     }
 
+    /**
+     * 同上,但**宽松**(lenient)—— 专用作「订单快照存在时」的反例对照组。
+     * 正确实现读快照即短路、根本走不到 Key,严格的 Mockito 会把这条存根判成
+     * UnnecessaryStubbing 而误红;可一旦取址优先级写反(先 Key 后快照),
+     * 这条存根就会被读到、把推送引到 key 地址上,让快照断言变红。
+     */
+    private void stubKeyWithUrlAsCounterControl(String url) {
+        TenantApiKeyEntity key = new TenantApiKeyEntity();
+        key.setReturnUrl(url);
+        lenient().when(apiKeyMapper.selectOne(any())).thenReturn(key);
+    }
+
     private void stubPushOk() {
         when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
                 .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
@@ -215,6 +231,50 @@ class HarCallbackServiceTest {
         verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
     }
 
+    /**
+     * 逐单回调地址(2026-10-14):订单上有快照 ⇒ **只推它**,Key 上配的地址靠边站。
+     *
+     * 【为什么是主断言】「传了回调地址 ⇒ 整个生命周期走这个地址」的承重点:字段、落库、
+     * 提示词都齐了但取址层没读快照(或先读 Key)时,套件其余全绿 —— 只有这条会红。
+     * Key 上的地址是反例对照组(宽松存根,理由见 helper)。
+     */
+    @Test
+    void notifyEnterprise_snapshotUrlWinsOverKeyConfig() {
+        stubKeyWithUrlAsCounterControl("https://key.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        HarOrderEntity o = order("K1");
+        o.setNotifyUrl("https://snapshot.example.com/cb");
+        service.notifyEnterprise(o);
+
+        verify(restTemplate, timeout(3000)).postForEntity(
+                eq("https://snapshot.example.com/cb"), any(HttpEntity.class), eq(String.class));
+        verify(restTemplate, never()).postForEntity(
+                eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * 快照是空白(脏值)⇒ 落回 Key 配置 —— 取址层只认**非空白**。
+     *
+     * 空白当地址用会把推送推到一个空白 URL 上(HTTP 客户端解析必炸),等于把本来
+     * 有地址可推的单弄成永远 FAILED。本用例对「isNotEmpty」式写法是红的(空白被
+     * 当成快照 ⇒ 推到 "   " 上);正常路径上它是绿的 —— 咬合由变异验证出示。
+     */
+    @Test
+    void notifyEnterprise_blankSnapshotFallsBackToKeyConfig() {
+        stubKeyWithUrl("https://key.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        HarOrderEntity o = order("K1");
+        o.setNotifyUrl("   ");
+        service.notifyEnterprise(o);
+
+        verify(restTemplate, timeout(3000)).postForEntity(
+                eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
+    }
+
     /**
      * {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
      *
@@ -268,6 +328,24 @@ class HarCallbackServiceTest {
         verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
     }
 
+    /** 重试腿同样认快照:这一单当时传了逐单地址,重推必须推回那个地址(Key 是反例对照组) */
+    @Test
+    void retryFailed_scannedOrderWithSnapshotUrl_pushesToSnapshot() {
+        HarOrderEntity o = order("K1");
+        o.setNotifyUrl("https://snapshot.example.com/cb");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        stubKeyWithUrlAsCounterControl("https://key.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.retryFailed();
+
+        verify(restTemplate, timeout(3000)).postForEntity(
+                eq("https://snapshot.example.com/cb"), any(HttpEntity.class), eq(String.class));
+        verify(restTemplate, never()).postForEntity(
+                eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
+    }
+
     /**
      * 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
      *
@@ -337,6 +415,26 @@ class HarCallbackServiceTest {
                 "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
     }
 
+    /**
+     * 逐单回调地址那一列只存在于**交付给用户的建表脚本**里(同上一条的理由:本工程
+     * 未接 Flyway,脚本由用户手工执行)。部署顺序是**先跑脚本再换新 jar** —— 反过来的话
+     * 新代码 insert 带上未知列,每笔下单(热路径)直接抛「column does not exist」,全停级。
+     *
+     * <p>列长必须与 DTO 的 {@code @Size(max=512)} 同值:列短于校验上限时,超长值
+     * 过了 DTO 校验、在 insert 处被 DB 拒收 —— 报错点离校验点很远,排查要来回看两处。
+     */
+    @Test
+    void schemaScript_addsNotifyUrlColumn() throws Exception {
+        Path sql = Path.of("src/main/resources/db/har-order-notify-url.sql");
+        assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
+        String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
+
+        assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_url varchar\\(512\\).*"),
+                "notify_url 列不见了/长度不是 512 —— 与 HarOrderCreateDTO.notifyUrl 的 @Size(max=512) 必须同步");
+        assertTrue(text.contains("COMMENT ON COLUMN pay_har_order.notify_url"),
+                "列注释不见了 —— 「下单时快照、空白存 NULL」这层语义在这一格上没有别处可读");
+    }
+
     /**
      * 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
      *

+ 37 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -212,6 +212,43 @@ class HarOrderServiceTest {
         assertEquals("KEY1", captor.getValue().getApiKey());
     }
 
+    /**
+     * 逐单回调地址(2026-10-14)随下单**快照**进订单行 —— 商户之后改 Key 配置
+     * 或平台配置都不影响这一单的推送去向(同 {@code pay_lsy_order.notify_url} 的语义)。
+     */
+    @Test
+    void create_snapshotsNotifyUrlOntoOrder() {
+        HarOrderCreateDTO dto = dto();
+        dto.setNotifyUrl("https://m.example.com/har/callback");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        assertEquals("https://m.example.com/har/callback", captor.getValue().getNotifyUrl());
+    }
+
+    /**
+     * 空白 notify_url 存 NULL 而不是空串(口径同 {@code enterpriseId} 的
+     * {@code StrUtil.isBlank ? null : 原样}):DB 里这一格只有「没传」与「真地址」
+     * 两种形态,取址层不必再区分空串。
+     */
+    @Test
+    void create_blankNotifyUrl_storesNull_notEmptyString() {
+        HarOrderCreateDTO dto = dto();
+        dto.setNotifyUrl("   ");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        assertNull(captor.getValue().getNotifyUrl());
+    }
+
     /** 有付款码即被扫,走 pay 而非 precreate */
     @Test
     void create_withAuthCode_usesPayInsteadOfPrecreate() {

+ 91 - 0
java/src/test/java/com/payment/platform/module/payment/har/util/HarLogSanitizerTest.java

@@ -0,0 +1,91 @@
+package com.payment.platform.module.payment.har.util;
+
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 压平行终止符的 HAR 副本({@link HarLogSanitizer})必须与乐收赢那份
+ * ({@code LsyLogSanitizer.flatten})**逐字一致**,且两者都逐字等于
+ * 「7 个字符各换成空格、其余原样」的规范。
+ *
+ * <p>【为什么要有这条跨通道断言】2026-10-14 给逐单回调地址(notify_url)补
+ * 日志摊平时,HAR 没有 import 乐收赢那份,而是按通道间工具各自独立的先例
+ * 复制了第三份(见 {@code HarLogSanitizer} 类 javadoc)。复制带来的代价就是
+ * 这条断言:乐收赢那边改字符集、HAR 不动时,两边各自的测试都会绿,
+ * 只有这里会红。这是本仓库**测试侧**唯一一处跨通道引用(主代码里 har 与
+ * leshouying 零互相 import)—— 故意的:一致性必须被执行,不能只是注释里的
+ * 口号({@code LsyLogSanitizerTest} 的 javadoc 就是为同一个理由而写)。
+ *
+ * <p>★ 写法警告(与三份实现同一份):下面的行终止符**必须写成转义形式**
+ * (反斜杠 + u + 恰好四位十六进制),不要贴真字符 —— 它们不可见,被顺手
+ * 删掉不会有任何人看出来。反过来 U+000A / U+000D **绝不能**写成转义形式:
+ * javac 在词法分析**之前**就还原它们(JLS §3.3),会直接把源码行拆断、
+ * 整个模块编译失败。
+ */
+class HarLogSanitizerTest {
+
+    /** 必须被压平的 7 个字符:CR、LF、U+2028、U+2029、U+0085、VT(U+000B)、FF(U+000C)。 */
+    private static final String LINE_TERMINATORS = "\r\n\u2028\u2029\u0085\u000B\u000C";
+
+    @Test
+    void harCopy_agreesWithLsyCopy_andFlattensExactlyTheSameSevenCharacters() {
+        // 探针 = 7 个待压字符各自夹在普通字符之间 + 一个 \t(故意保留的字符)。
+        String probe = "a\rb\nc\u2028d\u2029e\u0085f\u000Bg\u000Ch\ti";
+
+        // ── 夹具自检:探针必须真的“脏” ──
+        // 没有这一条,探针被改坏(转义被当成占位符删掉,本仓库已踩过一次)时,
+        // 下面「压平了」的断言会平凡通过。
+        for (char c : LINE_TERMINATORS.toCharArray()) {
+            assertTrue(probe.indexOf(c) >= 0, "探针里必须含 " + codePoint(c)
+                    + " —— 否则「压平了」这条断言在探针被改坏时照样绿");
+        }
+
+        String out = HarLogSanitizer.flatten(probe);
+        assertNotEquals(probe, out, "flatten 对探针原样返回 —— 它一个字符都没压");
+
+        // 规范:7 个字符各换成空格,其余原样(\t 必须还在)
+        String expected = probe;
+        for (char c : LINE_TERMINATORS.toCharArray()) {
+            expected = expected.replace(c, ' ');
+        }
+        assertTrue(expected.contains("\t"), "规范里必须保留 \\t —— 它是合法内容,压掉只毁可读性、换来零安全收益");
+        assertEquals(expected, out,
+                "压平结果与 7 字符规范不一致(U+0009 必须保留、其余原样);实测=" + pretty(out));
+
+        // ★ 承重断言(本类存在的理由):与乐收赢那份逐字一致。
+        //   只改其中一份 ⇒ 这里立刻变红,且报出两边各自压成了什么。
+        assertEquals(LsyLogSanitizer.flatten(probe), out,
+                "HAR 副本与 LsyLogSanitizer.flatten 的字符集不一致 ⇒ 改其一必须同步另一处"
+                        + "(见 HarLogSanitizer 类 javadoc);har=" + pretty(out)
+                        + ";lsy=" + pretty(LsyLogSanitizer.flatten(probe)));
+    }
+
+    /** null 进 null 出(不做 {@code String.valueOf} 的 "null" 转换)—— 调用方靠这个保持「字段缺失」可辨。 */
+    @Test
+    void flatten_nullYieldsNull() {
+        assertNull(HarLogSanitizer.flatten(null));
+    }
+
+    private static String codePoint(char c) {
+        return String.format("U+%04X", (int) c);
+    }
+
+    /**
+     * 断言消息里的人眼可读形式:控制字符写成 {@code U+XXXX}。
+     * ★ 没有它,红了的消息里是两条**看起来一模一样**的串(差异字符恰好不可见),
+     * 排查的人只能去猜差在哪个字符(同 LsyLogSanitizerTest 的理由)。
+     */
+    private static String pretty(String s) {
+        StringBuilder b = new StringBuilder();
+        for (char c : s.toCharArray()) {
+            if (c < 0x20 || c == 0x85 || c == 0x2028 || c == 0x2029) {
+                b.append(codePoint(c));
+            } else {
+                b.append(c);
+            }
+        }
+        return b.toString();
+    }
+}

+ 5 - 3
java/src/test/java/com/payment/platform/module/payment/leshouying/util/LsyLogSanitizerTest.java

@@ -17,8 +17,10 @@ import static org.junit.jupiter.api.Assertions.*;
 /**
  * 压平行终止符的**两份拷贝**必须逐字一致。
  *
- * <p>【为什么需要一条专门的「一致性」测试】{@code LsyLogSanitizer} 的类 javadoc 写着
- * 「本字符集在仓库里有**两处**拷贝,改动其一必须同步另一处」,但在这条测试之前,
+ * <p>【为什么需要一条专门的「一致性」测试】{@code LsyLogSanitizer} 的类 javadoc 当时写着
+ * 「本字符集在仓库里有**两处**拷贝,改动其一必须同步另一处」(2026-10-14 起为**三处**:
+ * 多了 har 通道的平行副本 {@code HarLogSanitizer},其与本方法的对拍在
+ * {@code HarLogSanitizerTest};本类管的始终是 LSY 内部这两份),但在这条测试之前,
  * **没有任何东西**在执行这句话:另一个测试类 {@code LeshouyingApiExceptionTest} 只测
  * {@code sanitize} 那一份,而 {@code flatten} 那一份此前只有零散的单侧断言。
  * 两份拷贝各自都「正确」而彼此不同时的缺陷形态是:
@@ -38,7 +40,7 @@ import static org.junit.jupiter.api.Assertions.*;
  * (后者由 {@code LeshouyingApiExceptionTest} 与那条「不许加 (String, Throwable) 构造器」的
  * 断言守着)。两条合起来才完整:本类保证两份实现不漂移,那边保证实现确实长在调用路径上。
  *
- * <p>★ 写法警告(与两个实现类同一份):下面的行终止符**必须写成转义形式**
+ * <p>★ 写法警告(与三份实现同一份):下面的行终止符**必须写成转义形式**
  * (反斜杠 + u + 恰好四位十六进制),不要贴真字符 —— 它们不可见,被顺手删掉不会有任何人看出来。
  * 反过来 U+000A / U+000D **绝不能**写成转义形式:javac 在词法分析**之前**就还原它们
  * (JLS §3.3),会直接把源码行拆断、整个模块编译失败。