ソースを参照

feat(HAR): 门店与订单通道后端

上游「门店与订单」通道的完整后端:租户建门店、下单收款、查单。

- client:HarSigner 签名与验签、HarClient 发请求、HarResponseParser 解报文。
  上游来的每个报文都要过验签 —— 代码里没有跳过开关,签名不对就是拒绝。
- wire 字段名用 @JsonProperty 逐字钉住上游报文,Java 侧驼峰只是习惯,
  不做映射层:以后对账时,报文里叫什么、代码里就找得到什么。
- 状态字段不叫笼统的 status:订单是 tradeStatus(另有 notifyStatus 记我方
  通知下发进度),门店是 createStatus。一张表上两个状态维度,同名分不清是谁的。
- 控制器:管理端三个(门店 / 商户 / 订单)、上游回调一个、开放接口一个。
  开放接口挂在 /payment/openapi/har 下(shop/create、order/create、order/query),
  按 API Key 认证。
- 订单状态有两条来源:上游回调与主动轮询(HarOrderPollScheduler)。

核心安全配置随之放行与收口:

- SecurityConfig 白名单放行开放接口前缀(认证交给 TenantApiKeyAuthFilter)
  与回调地址(免登录,真实性由 RSA 验签保证)。
- TenantApiKeyAuthFilter 的 OPENAPI_PREFIXES 补上 /payment/openapi/har。
  这一条是并入时补的:HAR 那侧只把三个端点加进了逐条核对清单,漏了真正
  用来判定的前缀数组。后果不是「少一层保护」而是零鉴权 —— 白名单已经
  permitAll 掉这个前缀,filter 又不认它。
- TenantInnerInterceptor 的 CONDITIONAL_TABLES 加入 pay_har_merchant / _shop /
  _order:这三张表会被无认证场景访问(回调、轮询),但有认证上下文时不跳过。
  API Key 认证写入的 principal 是 LoginUser,所以开放接口那条路有租户过滤,
  管理端同理。

配套:pay_har_* 建表迁移,以及四个交付 SQL(菜单、租户级、一租户多商户、
商户-租户只读核对),与迁移分开 —— 它们要按顺序由人执行,不自动跑。

测试 8 个类 124 条全绿。
alphaH 3 週間 前
親
コミット
5fa9febd1f
62 ファイル変更、5918 行追加、2 行削除
  1. 4 0
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  2. 16 1
      java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java
  3. 5 1
      java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java
  4. 29 0
      java/src/main/java/com/payment/platform/module/payment/har/client/HarApiException.java
  5. 164 0
      java/src/main/java/com/payment/platform/module/payment/har/client/HarClient.java
  6. 23 0
      java/src/main/java/com/payment/platform/module/payment/har/client/HarResponse.java
  7. 120 0
      java/src/main/java/com/payment/platform/module/payment/har/client/HarResponseParser.java
  8. 190 0
      java/src/main/java/com/payment/platform/module/payment/har/client/HarSigner.java
  9. 51 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPayRequest.java
  10. 22 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPrecreateDetail.java
  11. 51 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPrecreateRequest.java
  12. 66 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarQueryDetail.java
  13. 19 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarQueryRequest.java
  14. 19 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateDetail.java
  15. 20 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateRequest.java
  16. 45 0
      java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java
  17. 68 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarMerchantController.java
  18. 43 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarNotifyController.java
  19. 70 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java
  20. 55 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarShopController.java
  21. 124 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java
  22. 20 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantQueryDTO.java
  23. 39 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantSaveDTO.java
  24. 40 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantVO.java
  25. 29 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOpenOrderQueryDTO.java
  26. 68 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java
  27. 25 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderQueryDTO.java
  28. 74 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java
  29. 42 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java
  30. 15 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopQueryDTO.java
  31. 43 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java
  32. 30 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarMerchantEntity.java
  33. 94 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java
  34. 43 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarShopEntity.java
  35. 15 0
      java/src/main/java/com/payment/platform/module/payment/har/enums/HarNotifyStatus.java
  36. 37 0
      java/src/main/java/com/payment/platform/module/payment/har/enums/HarOrderStatus.java
  37. 22 0
      java/src/main/java/com/payment/platform/module/payment/har/enums/HarShopCreateStatus.java
  38. 14 0
      java/src/main/java/com/payment/platform/module/payment/har/enums/HarTradeType.java
  39. 9 0
      java/src/main/java/com/payment/platform/module/payment/har/mapper/HarMerchantMapper.java
  40. 9 0
      java/src/main/java/com/payment/platform/module/payment/har/mapper/HarOrderMapper.java
  41. 9 0
      java/src/main/java/com/payment/platform/module/payment/har/mapper/HarShopMapper.java
  42. 64 0
      java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java
  43. 247 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java
  44. 222 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java
  45. 142 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarNotifyService.java
  46. 475 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java
  47. 207 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java
  48. 18 0
      java/src/main/resources/application.yml
  49. 102 0
      java/src/main/resources/db/har-menu.sql
  50. 49 0
      java/src/main/resources/db/har-merchant-multi-per-tenant.sql
  51. 75 0
      java/src/main/resources/db/har-merchant-tenant-readonly.sql
  52. 67 0
      java/src/main/resources/db/har-tenant-level.sql
  53. 97 0
      java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql
  54. 115 0
      java/src/test/java/com/payment/platform/module/payment/har/client/HarResponseParserTest.java
  55. 310 0
      java/src/test/java/com/payment/platform/module/payment/har/client/HarSignerTest.java
  56. 151 0
      java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java
  57. 98 0
      java/src/test/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollSchedulerTest.java
  58. 347 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java
  59. 282 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarNotifyServiceTest.java
  60. 572 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java
  61. 356 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java
  62. 41 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarTestAuth.java

+ 4 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -45,6 +45,8 @@ public class SecurityConfig {
             // 万一 filter 因路径或装配问题漏跑,请求会走到 Controller 拿明确响应,
             // 而不是被 AuthorizationFilter 丢一个空 403。
             "/payment/openapi/pay/**",
+            // 汇安融开放接口:同上,由 TenantApiKeyAuthFilter 独立认证
+            "/payment/openapi/har/**",
             "/system/auth/login",
             "/system/auth/login/mini",
             "/system/auth/login/sms",
@@ -62,6 +64,8 @@ public class SecurityConfig {
             "/system/notice/available",
             "/payment/notify/health",
             "/payment/notify/alipay",
+            // 汇安融异步通知:免登录,真实性由 RSA 验签保证(HarNotifyService)
+            "/payment/notify/har",
             // 支付宝授权回调白名单地址(当面付 app_auth_code + openid auth_code 共用,按参数分流;
             // 无登录态,租户隔离由 TenantInnerInterceptor 条件表放行)
             "/payment/aplipay/auth",

+ 16 - 1
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -80,7 +80,8 @@ import java.util.UUID;
  *
  * ———————————————————————————————————————
  * 作用范围:
- *   拦截 /payment/openapi/account/** 与 /payment/openapi/pay/**(前缀判定, 见 OPENAPI_PREFIXES)
+ *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/har/**
+ *   (前缀判定, 见 OPENAPI_PREFIXES)
  *   /payment/openapi/conf 等管理接口走 JWT 认证, 不经过此 filter
  */
 @Slf4j
@@ -105,6 +106,10 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/pay/order/close",
             "/payment/openapi/pay/refund",
             "/payment/openapi/pay/refund/query",
+            // 汇安融开放接口(门店/订单)。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
+            "/payment/openapi/har/shop/create",
+            "/payment/openapi/har/order/create",
+            "/payment/openapi/har/order/query",
     };
 
     /**
@@ -125,6 +130,16 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
     private static final String[] OPENAPI_PREFIXES = {
             "/payment/openapi/account",
             "/payment/openapi/pay",
+            // ★ 汇安融(HAR)开放接口:**这一条是 2026-09-24 并入 HAR 分支时补的**,
+            //   HAR 那一侧自己没带 —— 它只把 3 条端点加进了上面那份逐条清单,
+            //   而**本数组才是认证判定读的那个**(见本数组第 1 条 javadoc)。
+            //   不补的后果不是「少一层保护」而是**零鉴权**:SecurityConfig.WHITE_LIST 已
+            //   permitAll 掉 /payment/openapi/har/**,filter 又不认这个前缀 ⇒ 该前缀下
+            //   没有任何 filter 认证,且不会有测试变红。
+            //   HAR 的管理端在 /payment/har/**(不走 openapi)、通知在 /payment/notify/har,
+            //   都不被本前缀覆盖;这条边界由 LeshouyingAdminWiringTest 的
+            //   adminPaths_mustNotBeCoveredByApiKeyFilterPrefixes 动态守住。
+            "/payment/openapi/har",
     };
 
     private static final String AUTH_PREFIX = "ApiKey ";

+ 5 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -209,7 +209,11 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "pay_lsy_order",
             "pay_lsy_refund",
             "pay_lsy_merchant",
-            "pay_lsy_notify_log"
+            "pay_lsy_notify_log",
+            "pay_har_merchant",      // 汇安融商户配置(下单时按 enterprise_id 反查,开放API无登录态)
+            "pay_har_shop",          // 汇安融门店(开放API创建/查询无登录态)
+            "pay_har_order"          // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
+                                     //   订单号为我方生成的雪花ID,全局唯一,无跨租户风险)
     );
 
     public TenantInnerInterceptor() {

+ 29 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/HarApiException.java

@@ -0,0 +1,29 @@
+package com.payment.platform.module.payment.har.client;
+
+/**
+ * 汇安融调用异常。
+ *
+ * 上游返回非成功码、网络异常、报文解析失败、密钥配置错误均抛此异常。
+ */
+public class HarApiException extends RuntimeException {
+
+    /** 上游 response_code;本地异常(网络/解析/配置)时为 null */
+    private final String responseCode;
+
+    public HarApiException(String message) {
+        this(message, null, null);
+    }
+
+    public HarApiException(String message, Throwable cause) {
+        this(message, null, cause);
+    }
+
+    public HarApiException(String message, String responseCode, Throwable cause) {
+        super(message, cause);
+        this.responseCode = responseCode;
+    }
+
+    public String getResponseCode() {
+        return responseCode;
+    }
+}

+ 164 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/HarClient.java

@@ -0,0 +1,164 @@
+package com.payment.platform.module.payment.har.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.module.payment.har.client.dto.HarPayRequest;
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
+import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.client.dto.HarShopCreateRequest;
+import com.payment.platform.module.payment.har.config.HarProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 汇安融上游客户端。
+ *
+ * 报文格式对齐 kf-request-demo 的 KfClient:application/x-www-form-urlencoded,
+ * 五个字段 appId / method / version / sign / bizContent。
+ * 规范 1.1.1 称「整体为 JSON 格式」与 demo 的 UrlEncodedFormEntity 冲突,以 demo 为准。
+ *
+ * sign = SHA256WithRSA(bizContent 原文, 我方私钥) 的 URL-safe Base64。
+ * bizContent 只序列化一次,签名与 form 字段引用同一个串 ——
+ * 序列化两次会因字段顺序不确定导致签名与内容不一致。
+ *
+ * 用 java.net.http.HttpClient 而非项目里的 RestTemplate:签名对报文字节敏感,
+ * 这里需要精确控制 URL 编码与字符集。demo 的注释也点明了这一点
+ * (「此处不加 utf-8 服务端会出现乱码 导致签名不通过」)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HarClient {
+
+    /** 规范 4 接口声明表的 Application 列,即 method 取值 */
+    private static final String METHOD_PRECREATE = "kf.alipay.trade.precreate";
+    private static final String METHOD_PAY = "kf.alipay.trade.pay";
+    private static final String METHOD_QUERY = "kf.alipay.trade.query";
+    private static final String METHOD_SHOP_CREATE = "kf.har.shop.create";
+
+    /** 上游 context-path,见 demo Main.java:13 的 http://localhost:9090/kf-alipay-api/... */
+    private static final String CONTEXT_PATH = "/kf-alipay-api";
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final HarProperties properties;
+
+    public HarResponse precreate(HarPrecreateRequest req) {
+        return invoke(METHOD_PRECREATE, "/api/alipay/trade/precreate", req);
+    }
+
+    public HarResponse pay(HarPayRequest req) {
+        return invoke(METHOD_PAY, "/api/alipay/trade/pay", req);
+    }
+
+    public HarResponse query(HarQueryRequest req) {
+        return invoke(METHOD_QUERY, "/api/alipay/trade/query", req);
+    }
+
+    public HarResponse createShop(HarShopCreateRequest req) {
+        return invoke(METHOD_SHOP_CREATE, "/api/shop/create", req);
+    }
+
+    /**
+     * 序列化业务参数。
+     *
+     * 空值不输出 —— 上游对 null 的处理未定义,省略比传 null 安全,
+     * 也避免把没填的可选字段当成显式空值发过去。
+     */
+    public static String toBizContent(Object req) {
+        try {
+            return MAPPER.writeValueAsString(req);
+        } catch (Exception e) {
+            throw new HarApiException("业务参数序列化失败: " + e.getMessage(), e);
+        }
+    }
+
+    private HarResponse invoke(String method, String path, Object req) {
+        if (!properties.isEnabled()) {
+            throw new HarApiException("汇安融通道未启用(har.enabled=false)");
+        }
+        if (properties.getBaseUrl() == null || properties.getBaseUrl().isBlank()) {
+            throw new HarApiException("汇安融通道 baseUrl 未配置");
+        }
+
+        String bizContent = toBizContent(req);
+        String sign = HarSigner.sign(bizContent, properties.getOurPrivateKey());
+
+        Map<String, String> form = new LinkedHashMap<>();
+        form.put("appId", properties.getAppId());
+        form.put("method", method);
+        form.put("version", properties.getVersion());
+        form.put("sign", sign);
+        form.put("bizContent", bizContent);
+
+        String url = properties.getBaseUrl() + CONTEXT_PATH + path;
+        String raw = post(url, form);
+        HarResponse response = HarResponseParser.parse(raw);
+
+        log.info("[汇安融] {} 调用完成: response_code={}, msg={}",
+                method, response.responseCode(), response.msg());
+        return response;
+    }
+
+    private String post(String url, Map<String, String> form) {
+        HttpClient client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofMillis(properties.getConnectTimeoutMs()))
+                .build();
+
+        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+                .header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
+                .timeout(Duration.ofMillis(properties.getReadTimeoutMs()))
+                .POST(HttpRequest.BodyPublishers.ofString(encodeForm(form), StandardCharsets.UTF_8))
+                .build();
+
+        try {
+            HttpResponse<String> response = client.send(request,
+                    HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+            if (response.statusCode() != 200) {
+                throw new HarApiException("上游返回 HTTP " + response.statusCode()
+                        + ",报文: " + abbreviate(response.body()), null, null);
+            }
+            return response.body();
+        } catch (HarApiException e) {
+            throw e;
+        } catch (IOException e) {
+            throw new HarApiException("调用上游失败: " + e.getMessage(), e);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new HarApiException("调用上游被中断", e);
+        }
+    }
+
+    /** 与 demo 的 UrlEncodedFormEntity(params, UTF-8) 输出一致:+ 表示空格,%XX 大写十六进制 */
+    private static String encodeForm(Map<String, String> form) {
+        List<String> pairs = new ArrayList<>();
+        for (Map.Entry<String, String> e : form.entrySet()) {
+            pairs.add(encode(e.getKey()) + "=" + encode(e.getValue()));
+        }
+        return String.join("&", pairs);
+    }
+
+    private static String encode(String v) {
+        return URLEncoder.encode(v == null ? "" : v, StandardCharsets.UTF_8);
+    }
+
+    private static String abbreviate(String s) {
+        if (s == null) {
+            return "";
+        }
+        return s.length() <= 500 ? s : s.substring(0, 500) + "...(truncated)";
+    }
+}

+ 23 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/HarResponse.java

@@ -0,0 +1,23 @@
+package com.payment.platform.module.payment.har.client;
+
+/**
+ * 解析后的上游响应。
+ *
+ * detailRaw 是 detail 的原文(form 为 URL 解码后,JSON 为字符串值)。
+ * 验签必须用这个串,不可用 detail 反序列化再序列化的结果 ——
+ * JSON 键序、空格、转义的任何变化都会导致验签失败。
+ */
+public record HarResponse(
+        String responseCode,
+        String msg,
+        String sign,
+        String detailRaw,
+        String bizContentRaw) {
+
+    /** 规范 1.1.2:10000 表示请求成功 */
+    public static final String SUCCESS_CODE = "10000";
+
+    public boolean isSuccess() {
+        return SUCCESS_CODE.equals(responseCode);
+    }
+}

+ 120 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/HarResponseParser.java

@@ -0,0 +1,120 @@
+package com.payment.platform.module.payment.har.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 上游响应解析。
+ *
+ * 规范 1.1.1 称报文「整体为 JSON 格式」,但 demo 的请求体是 form-urlencoded,
+ * 响应格式上游未确认(demo 只 readLine 拼接后原样返回,没解析)。
+ * 此处先按 form 解析,解析不出 response_code 再按 JSON 解析;
+ * 两条路都保留 detail 原文,保证验签可用。
+ *
+ * 解析失败一律抛 HarApiException,不返回半成品 ——
+ * 否则上游改了格式,调用方会拿到一个字段全空的响应继续往下走。
+ */
+public final class HarResponseParser {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private HarResponseParser() {
+    }
+
+    public static HarResponse parse(String rawBody) {
+        if (rawBody == null || rawBody.isBlank()) {
+            throw new HarApiException("上游响应为空");
+        }
+        String body = rawBody.trim();
+        if (body.startsWith("{")) {
+            return parseJson(body);
+        }
+        return parseForm(body);
+    }
+
+    /**
+     * 把 detail 原文反序列化成具体响应体。
+     *
+     * @return detail 为空时返回 null
+     */
+    public static <T> T toBean(String json, Class<T> type) {
+        if (json == null || json.isBlank()) {
+            return null;
+        }
+        try {
+            return MAPPER.readValue(json, type);
+        } catch (Exception e) {
+            throw new HarApiException("detail 解析失败: " + e.getMessage(), e);
+        }
+    }
+
+    private static HarResponse parseForm(String body) {
+        String responseCode = null;
+        String msg = null;
+        String sign = null;
+        String detail = null;
+        String bizContent = null;
+
+        for (String pair : body.split("&")) {
+            if (pair.isEmpty()) {
+                continue;
+            }
+            int idx = pair.indexOf('=');
+            String key = idx < 0 ? pair : pair.substring(0, idx);
+            String value = idx < 0 ? "" : decode(pair.substring(idx + 1));
+            switch (key) {
+                case "response_code" -> responseCode = value;
+                case "msg" -> msg = value;
+                case "sign" -> sign = value;
+                case "detail" -> detail = value;
+                case "biz_content" -> bizContent = value;
+                default -> { /* 未知字段忽略:上游随时可能加字段 */ }
+            }
+        }
+        if (responseCode == null) {
+            throw new HarApiException("上游响应缺少 response_code,原始报文: " + abbreviate(body));
+        }
+        return new HarResponse(responseCode, msg, sign, detail, bizContent);
+    }
+
+    private static HarResponse parseJson(String body) {
+        JsonNode node;
+        try {
+            node = MAPPER.readTree(body);
+        } catch (Exception e) {
+            throw new HarApiException("上游响应不是合法 JSON: " + e.getMessage(), e);
+        }
+        String responseCode = text(node, "response_code");
+        if (responseCode == null) {
+            throw new HarApiException("上游响应缺少 response_code,原始报文: " + abbreviate(body));
+        }
+        return new HarResponse(
+                responseCode,
+                text(node, "msg"),
+                text(node, "sign"),
+                text(node, "detail"),
+                text(node, "biz_content"));
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode v = node.get(field);
+        return v == null || v.isNull() ? null : v.asText();
+    }
+
+    private static String decode(String value) {
+        try {
+            return URLDecoder.decode(value, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            // 不是合法百分号编码时按原样使用,交由后续验签判定
+            return value;
+        }
+    }
+
+    /** 诊断日志用,避免把整个报文体写进异常消息 */
+    private static String abbreviate(String s) {
+        return s.length() <= 500 ? s : s.substring(0, 500) + "...(truncated)";
+    }
+}

+ 190 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/HarSigner.java

@@ -0,0 +1,190 @@
+package com.payment.platform.module.payment.har.client;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 汇安融签名与验签。
+ *
+ * 算法与编码对齐 kf-request-demo 的 com.har.utils.XRsa:
+ *   - 算法 SHA256WithRSA
+ *   - 签名结果等价于 Base64.encodeBase64URLSafeString(URL-safe 字母表,不补 padding)
+ *   - 公钥 X.509、私钥 PKCS#8,均为纯 Base64
+ *
+ * 与 demo 的两点差异,均为有意为之:
+ *
+ * 1. demo 的 verify() 在签名格式非法时抛 RuntimeException,调用方容易写成
+ *    try { verify() } catch { 放过去 },从而把未验签的数据当合法数据处理。
+ *    本实现只在密钥无法加载(配置错误)时抛出,签名不匹配一律返回 false。
+ *
+ * 2. 签名结果用 java.util.Base64 而非 commons-codec —— 项目 pom 未声明该依赖,
+ *    getUrlEncoder().withoutPadding() 与 encodeBase64URLSafeString 输出完全一致。
+ */
+public final class HarSigner {
+
+    private static final String KEY_ALGORITHM = "RSA";
+    private static final String SIGN_ALGORITHM = "SHA256WithRSA";
+
+    /** URL-safe 字母表 + 不补 padding,与 demo 的 encodeBase64URLSafeString 等价 */
+    private static final Base64.Encoder URL_SAFE_ENCODER =
+            Base64.getUrlEncoder().withoutPadding();
+
+    private HarSigner() {
+    }
+
+    /**
+     * 用我方私钥对 bizContent 原文签名。
+     *
+     * @param data             待签字符串(调用时传 bizContent 原文)
+     * @param privateKeyBase64 我方私钥,PKCS#8 纯 Base64
+     * @return URL-safe Base64 签名串
+     * @throws HarApiException 私钥无法加载或签名算法不可用时
+     */
+    public static String sign(String data, String privateKeyBase64) {
+        PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+        try {
+            Signature signature = Signature.getInstance(SIGN_ALGORITHM);
+            signature.initSign(privateKey);
+            signature.update(data.getBytes(StandardCharsets.UTF_8));
+            return URL_SAFE_ENCODER.encodeToString(signature.sign());
+        } catch (Exception e) {
+            throw new HarApiException("签名失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 用汇安融公钥验签。
+     *
+     * @param data             待验原文(响应传 detail 原文,通知传 buildNotifySignContent 的结果)
+     * @param sign             签名串,兼容标准与 URL-safe 两种 Base64 字母表
+     * @param publicKeyBase64  汇安融公钥,X.509 纯 Base64
+     * @return 验签通过返回 true;签名不匹配、格式非法、参数为空均返回 false
+     * @throws HarApiException 仅当公钥无法加载(配置错误)时抛出
+     */
+    public static boolean verify(String data, String sign, String publicKeyBase64) {
+        if (data == null || sign == null || sign.isBlank()) {
+            return false;
+        }
+        PublicKey publicKey = loadPublicKey(publicKeyBase64);
+        byte[] signBytes = decodeLenient(sign);
+        if (signBytes == null) {
+            return false;
+        }
+        try {
+            Signature signature = Signature.getInstance(SIGN_ALGORITHM);
+            signature.initVerify(publicKey);
+            signature.update(data.getBytes(StandardCharsets.UTF_8));
+            return signature.verify(signBytes);
+        } catch (Exception e) {
+            // 签名字节长度/结构非法时 verify 抛 SignatureException,
+            // 这属于「对方给的签名不对」,是拒绝的理由,不是程序错误
+            return false;
+        }
+    }
+
+    /**
+     * 构造通知验签的待签串。
+     *
+     * 规则取自规范 5.6「按照参数名称的升序排列」:
+     * 剔除 sign 与空值,剩余参数按参数名升序,以 k=v 用 & 连接。
+     *
+     * 注:规范给的示例 RSA.sign(merchantCode=123&name=111&status=2) 用的是驼峰命名,
+     * 且 name 字段不在通知字段表中,故照文字规则实现,示例不作为依据。
+     */
+    public static String buildNotifySignContent(Map<String, String> params) {
+        TreeMap<String, String> sorted = new TreeMap<>();
+        for (Map.Entry<String, String> e : params.entrySet()) {
+            String key = e.getKey();
+            String value = e.getValue();
+            if ("sign".equals(key) || value == null || value.isEmpty()) {
+                continue;
+            }
+            sorted.put(key, value);
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, String> e : sorted.entrySet()) {
+            if (sb.length() > 0) {
+                sb.append('&');
+            }
+            sb.append(e.getKey()).append('=').append(e.getValue());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 宽松 Base64 解码:同时接受标准字母表(+ /)与 URL-safe 字母表(- _),
+     * 忽略换行与空白,容忍缺失的 padding。
+     *
+     * 上游用哪种字母表签名未确认,两种都认才能避免联调期因编码差异全量拒签。
+     *
+     * @return 解码结果;长度非法(模 4 余 1)时返回 null
+     */
+    private static byte[] decodeLenient(String base64) {
+        // 先剥掉所有空白再算 padding —— 密钥/签名常见按 64 列折行,
+        // 若把换行计数进来,% 4 算出的补位数就是错的,验签会莫名失败
+        String normalized = base64.replaceAll("\\s+", "")
+                .replace('-', '+').replace('_', '/');
+        int pad = normalized.length() % 4;
+        if (pad == 1) {
+            // 余 1 不可能是任何 Base64 编码的结果
+            return null;
+        }
+        if (pad == 2) {
+            normalized += "==";
+        } else if (pad == 3) {
+            normalized += "=";
+        }
+        try {
+            // MimeDecoder 会忽略字母表之外的字符(换行、空格等),正好用于宽松解析
+            return Base64.getMimeDecoder().decode(normalized);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private static PublicKey loadPublicKey(String base64) {
+        if (base64 == null || base64.isBlank()) {
+            throw new HarApiException("汇安融公钥未配置");
+        }
+        // ★ 必须走 decodeLenient,不能直接 getMimeDecoder():上游 demo 的
+        //   XRsa.java:69/72 用 encodeBase64URLSafeString 生成密钥对,即 URL-safe
+        //   字母表是上游自己的约定。而 MIME 字母表只有 A-Za-z0-9+/,串里的 '-' 与 '_'
+        //   会被 MimeDecoder **静默丢弃**(不报错),DER 短了字节之后才由 KeyFactory
+        //   抛 InvalidKeySpecException —— 报错文本里看不出是字母表问题。
+        byte[] der = decodeLenient(base64);
+        if (der == null) {
+            throw new HarApiException("汇安融公钥不是合法的 Base64");
+        }
+        try {
+            X509EncodedKeySpec spec = new X509EncodedKeySpec(der);
+            return KeyFactory.getInstance(KEY_ALGORITHM).generatePublic(spec);
+        } catch (Exception e) {
+            throw new HarApiException("汇安融公钥无法加载,请检查配置: " + e.getMessage(), e);
+        }
+    }
+
+    private static PrivateKey loadPrivateKey(String base64) {
+        if (base64 == null || base64.isBlank()) {
+            throw new HarApiException("我方私钥未配置");
+        }
+        // 同上:上游发的私钥同样是 URL-safe 字母表,直接 MimeDecoder 会丢字符
+        byte[] der = decodeLenient(base64);
+        if (der == null) {
+            throw new HarApiException("我方私钥不是合法的 Base64");
+        }
+        try {
+            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
+            return KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(spec);
+        } catch (Exception e) {
+            throw new HarApiException("我方私钥无法加载,请检查配置: " + e.getMessage(), e);
+        }
+    }
+}

+ 51 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPayRequest.java

@@ -0,0 +1,51 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 收款(被扫)业务参数 — 对应规范 5.2 请求参数,比 precreate 多 auth_code。 */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class HarPayRequest {
+
+    private String price;
+
+    private String title;
+
+    /** 用户付款码 */
+    @JsonProperty("auth_code")
+    private String authCode;
+
+    @JsonProperty("stage_num")
+    private String stageNum;
+
+    @JsonProperty("trade_type")
+    private String tradeType;
+
+    private String percent;
+
+    @JsonProperty("merchant_code")
+    private String merchantCode;
+
+    @JsonProperty("third_merchant_id")
+    private String thirdMerchantId;
+
+    @JsonProperty("third_notify_url")
+    private String thirdNotifyUrl;
+
+    @JsonProperty("third_order_no")
+    private String thirdOrderNo;
+
+    @JsonProperty("shop_code")
+    private String shopCode;
+
+    @JsonProperty("third_shop_code")
+    private String thirdShopCode;
+
+    @JsonProperty("pay_timeout")
+    private Integer payTimeout;
+
+    @JsonProperty("extra_params")
+    private String extraParams;
+}

+ 22 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPrecreateDetail.java

@@ -0,0 +1,22 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 下单(precreate)/ 收款(pay)接口 detail — 对应规范 5.1 / 5.2 响应结果。 */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HarPrecreateDetail {
+
+    /** 收款链接,主扫模式下需要展示给付款人 */
+    @JsonProperty("pay_url")
+    private String payUrl;
+
+    /** 汇安融订单号 */
+    @JsonProperty("order_no")
+    private String orderNo;
+
+    @JsonProperty("third_order_no")
+    private String thirdOrderNo;
+}

+ 51 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarPrecreateRequest.java

@@ -0,0 +1,51 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 下单(主扫)业务参数 — 对应规范 5.1 请求参数。 */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class HarPrecreateRequest {
+
+    private String price;
+
+    private String title;
+
+    /** 分期时必填,3/6/12/24 */
+    @JsonProperty("stage_num")
+    private String stageNum;
+
+    /** alipay_stage:花呗分期  alipay_payment:支付宝支付 */
+    @JsonProperty("trade_type")
+    private String tradeType;
+
+    /** 分期时必填,100:商户承担 0:用户承担 */
+    private String percent;
+
+    @JsonProperty("merchant_code")
+    private String merchantCode;
+
+    @JsonProperty("third_merchant_id")
+    private String thirdMerchantId;
+
+    @JsonProperty("third_notify_url")
+    private String thirdNotifyUrl;
+
+    /** 我方订单号,传上游作为 third_order_no */
+    @JsonProperty("third_order_no")
+    private String thirdOrderNo;
+
+    @JsonProperty("shop_code")
+    private String shopCode;
+
+    @JsonProperty("third_shop_code")
+    private String thirdShopCode;
+
+    @JsonProperty("pay_timeout")
+    private Integer payTimeout;
+
+    @JsonProperty("extra_params")
+    private String extraParams;
+}

+ 66 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarQueryDetail.java

@@ -0,0 +1,66 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 查询接口 detail — 对应规范 5.3 响应结果。
+ *
+ * ignoreUnknown 保证上游新增字段不会导致解析失败。
+ * 金额字段一律用 String 接收:上游给的是 "12.25" 这样的字符串,
+ * 用 BigDecimal 接收在字段为空串时会抛异常,而空串在上游是有可能出现。
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HarQueryDetail {
+
+    /** 汇安融订单号 */
+    @JsonProperty("order_no")
+    private String orderNo;
+
+    @JsonProperty("merchant_code")
+    private String merchantCode;
+
+    /** 0:等待支付 1:支付成功 2:退款成功 4:退款中 8:交易关闭 */
+    private Integer status;
+
+    @JsonProperty("create_time")
+    private String createTime;
+
+    @JsonProperty("trade_type")
+    private String tradeType;
+
+    @JsonProperty("third_shop_id")
+    private String thirdShopId;
+
+    private String price;
+
+    @JsonProperty("stage_num")
+    private String stageNum;
+
+    private String percent;
+
+    @JsonProperty("trade_time")
+    private String tradeTime;
+
+    @JsonProperty("refund_time")
+    private String refundTime;
+
+    @JsonProperty("platform_trade_no")
+    private String platformTradeNo;
+
+    private String title;
+
+    @JsonProperty("rcv_amount")
+    private String rcvAmount;
+
+    @JsonProperty("fee_amount")
+    private String feeAmount;
+
+    @JsonProperty("channel_amount")
+    private String channelAmount;
+
+    @JsonProperty("extra_params")
+    private String extraParams;
+}

+ 19 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarQueryRequest.java

@@ -0,0 +1,19 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 订单查询业务参数 — 对应规范 5.3 请求参数。 */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class HarQueryRequest {
+
+    /** 汇安融订单号 */
+    @JsonProperty("order_no")
+    private String orderNo;
+
+    /** 第三方订单号(我方 order_no)。与 order_no 同时存在时上游忽略该属性 */
+    @JsonProperty("third_order_no")
+    private String thirdOrderNo;
+}

+ 19 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateDetail.java

@@ -0,0 +1,19 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 门店创建 detail — 对应规范 5.5 响应结果。 */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class HarShopCreateDetail {
+
+    /** 汇安融门店 ID */
+    @JsonProperty("shop_id")
+    private String shopId;
+
+    /** 我方门店编码回显 */
+    @JsonProperty("third_shop_id")
+    private String thirdShopId;
+}

+ 20 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateRequest.java

@@ -0,0 +1,20 @@
+package com.payment.platform.module.payment.har.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/** 门店创建业务参数 — 对应规范 5.5 请求参数。 */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class HarShopCreateRequest {
+
+    @JsonProperty("merchant_code")
+    private String merchantCode;
+
+    /** 我方门店编码,作为幂等键传给上游 */
+    @JsonProperty("third_shop_id")
+    private String thirdShopId;
+
+    private String name;
+}

+ 45 - 0
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java

@@ -0,0 +1,45 @@
+package com.payment.platform.module.payment.har.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 汇安融渠道配置。
+ *
+ * 未决项:appId 与密钥的归属层级(机构级 / 商户级)尚未与上游确认,
+ * 本期按机构级单份配置实现。若联调后确认是商户级,改为落表按 merchant_code
+ * 索引即可,属纯加法,不影响既有调用方。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "har")
+public class HarProperties {
+
+    /** 总开关。默认 false,避免密钥未配好就发起调用 */
+    private boolean enabled = false;
+
+    /** 上游根地址,例如 https://xxx.com(不含 context-path) */
+    private String baseUrl;
+
+    /** 上游分配的应用编号 */
+    private String appId;
+
+    /** 接口版本,规范固定 2.0 */
+    private String version = "2.0";
+
+    /** 我方私钥,PKCS#8 纯 Base64 */
+    private String ourPrivateKey;
+
+    /** 汇安融公钥,X.509 纯 Base64 */
+    private String platformPublicKey;
+
+    /** 异步回调企业商户的线程数上限 */
+    private int callbackPoolSize = 4;
+
+    /** 连接超时(毫秒) */
+    private int connectTimeoutMs = 5000;
+
+    /** 读取超时(毫秒) */
+    private int readTimeoutMs = 15000;
+}

+ 68 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarMerchantController.java

@@ -0,0 +1,68 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarMerchantQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarMerchantSaveDTO;
+import com.payment.platform.module.payment.har.dto.HarMerchantVO;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "汇安融-商户配置")
+@RestController
+@RequestMapping("/payment/har/merchant")
+@RequiredArgsConstructor
+public class HarMerchantController {
+
+    private final HarMerchantService service;
+
+    /** 租户也能看自己的配置(只读),所以这一条是**权限串**,不是超管专用 */
+    @GetMapping
+    @Operation(summary = "商户配置分页列表")
+    @PreAuthorize("@perm.hasAny('module_payment:har:merchant:list')")
+    public Result<PageResult<HarMerchantVO>> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+            @RequestParam(name = "tenant_id", required = false) Long tenantId,
+            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+            @RequestParam(name = "merchant_code", required = false) String merchantCode,
+            @RequestParam(name = "merchant_name", required = false) String merchantName) {
+        HarMerchantQueryDTO q = new HarMerchantQueryDTO();
+        q.setTenantId(tenantId);
+        q.setEnterpriseId(enterpriseId);
+        q.setMerchantCode(merchantCode);
+        q.setMerchantName(merchantName);
+        return Result.ok(service.list(pageNo, pageSize, q));
+    }
+
+    /**
+     * 新增或修改商户配置。**只有超管**:商户进件在上游是人工流程,merchant_code 由运营
+     * 报备后录入,租户自助建号等于允许抢注别人已报备的商户编号(merchant_code 全局唯一)。
+     *
+     * <p>★ 这里**故意不用** {@code @perm.hasAny('module_payment:har:merchant:save')}:
+     * 权限串是「授给哪个角色哪个角色就能调」,而本端点的约束是**身份**(是不是超管),
+     * 不是角色授权。改动它之前先想清楚:换回权限串就等于把这个口子重新打开。
+     * <p>随之而来的两条:新增必须带 {@code tenant_id}(超管替哪个租户建号),
+     * 前端也把入口对非超管整个隐藏(同一个判据 {@code is_superuser},见 HarMerchantService#save)。
+     */
+    @PostMapping
+    @Operation(summary = "新增或修改商户配置(仅超管)")
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
+    public Result<HarMerchantVO> save(@Valid @RequestBody HarMerchantSaveDTO dto) {
+        return Result.ok(service.save(dto));
+    }
+
+    /** 同 {@link #save}:删除也是超管专属,理由相同(删掉等于让该租户无法下单) */
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除商户配置(仅超管)")
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
+    public Result<Void> delete(@PathVariable(name = "id") Long id) {
+        service.delete(id);
+        return Result.ok();
+    }
+}

+ 43 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarNotifyController.java

@@ -0,0 +1,43 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.module.payment.har.service.HarNotifyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 汇安融通知入口。
+ *
+ * 免登录态,真实性由 RSA 验签保证(与 /payment/notify/alipay 同一模式)。
+ * 上游要求成功时返回字符串 "success",否则每 5 秒重试、共 5 次。
+ *
+ * 用 @RequestParam Map 而非 @RequestBody:上游 demo 用的是 form-urlencoded,
+ * 且 Map 形式对表单字段增减不敏感,不会因为上游加字段而反序列化失败。
+ */
+@Slf4j
+@Tag(name = "汇安融-通知接收")
+@RestController
+@RequestMapping("/payment/notify")
+@RequiredArgsConstructor
+public class HarNotifyController {
+
+    private final HarNotifyService notifyService;
+
+    @PostMapping("/har")
+    @Operation(summary = "汇安融异步通知(免登录,RSA 验签)")
+    public ResponseEntity<String> harNotify(@RequestParam Map<String, String> params) {
+        String result = notifyService.handle(params);
+        return ResponseEntity.ok()
+                .contentType(MediaType.TEXT_PLAIN)
+                .body(result);
+    }
+}

+ 70 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java

@@ -0,0 +1,70 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "汇安融-订单管理")
+@RestController
+@RequestMapping("/payment/har/order")
+@RequiredArgsConstructor
+public class HarOrderController {
+
+    private final HarOrderService service;
+
+    @GetMapping
+    @Operation(summary = "订单分页列表")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:list')")
+    public Result<PageResult<HarOrderVO>> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+            @RequestParam(name = "merchant_code", required = false) String merchantCode,
+            @RequestParam(name = "order_no", required = false) String orderNo,
+            @RequestParam(name = "out_biz_no", required = false) String outBizNo,
+            @RequestParam(name = "trade_status", required = false) String tradeStatus,
+            @RequestParam(name = "trade_type", required = false) String tradeType,
+            @RequestParam(name = "start_date", required = false) String startDate,
+            @RequestParam(name = "end_date", required = false) String endDate) {
+        HarOrderQueryDTO q = new HarOrderQueryDTO();
+        q.setEnterpriseId(enterpriseId);
+        q.setMerchantCode(merchantCode);
+        q.setOrderNo(orderNo);
+        q.setOutBizNo(outBizNo);
+        q.setTradeStatus(tradeStatus);
+        q.setTradeType(tradeType);
+        q.setStartDate(startDate);
+        q.setEndDate(endDate);
+        return Result.ok(service.list(pageNo, pageSize, q));
+    }
+
+    @GetMapping("/{id}")
+    @Operation(summary = "订单详情")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:detail')")
+    public Result<HarOrderVO> detail(@PathVariable(name = "id") Long id) {
+        return Result.ok(service.detail(id));
+    }
+
+    @PostMapping
+    @Operation(summary = "后台下单(调用上游)")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:create')")
+    public Result<HarOrderVO> create(@Valid @RequestBody HarOrderCreateDTO dto) {
+        return Result.ok(service.create(dto, dto.getEnterpriseId(), null));
+    }
+
+    @PostMapping("/{id}/sync")
+    @Operation(summary = "主动查询上游并回写状态")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:sync')")
+    public Result<HarOrderVO> sync(@PathVariable(name = "id") Long id) {
+        return Result.ok(service.syncFromUpstream(service.getEntityById(id)));
+    }
+}

+ 55 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarShopController.java

@@ -0,0 +1,55 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarShopQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarShopVO;
+import com.payment.platform.module.payment.har.service.HarShopService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@Tag(name = "汇安融-门店管理")
+@RestController
+@RequestMapping("/payment/har/shop")
+@RequiredArgsConstructor
+public class HarShopController {
+
+    private final HarShopService service;
+
+    @GetMapping
+    @Operation(summary = "门店分页列表")
+    @PreAuthorize("@perm.hasAny('module_payment:har:shop:list')")
+    public Result<PageResult<HarShopVO>> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+            @RequestParam(name = "enterprise_id", required = false) String enterpriseId,
+            @RequestParam(name = "merchant_code", required = false) String merchantCode,
+            @RequestParam(name = "third_shop_id", required = false) String thirdShopId,
+            @RequestParam(name = "create_status", required = false) String createStatus) {
+        HarShopQueryDTO q = new HarShopQueryDTO();
+        q.setEnterpriseId(enterpriseId);
+        q.setMerchantCode(merchantCode);
+        q.setThirdShopId(thirdShopId);
+        q.setCreateStatus(createStatus);
+        return Result.ok(service.list(pageNo, pageSize, q));
+    }
+
+    @GetMapping("/{id}")
+    @Operation(summary = "门店详情")
+    @PreAuthorize("@perm.hasAny('module_payment:har:shop:detail')")
+    public Result<HarShopVO> detail(@PathVariable(name = "id") Long id) {
+        return Result.ok(HarShopVO.of(service.getById(id)));
+    }
+
+    @PostMapping
+    @Operation(summary = "创建门店(调用上游)")
+    @PreAuthorize("@perm.hasAny('module_payment:har:shop:create')")
+    public Result<HarShopVO> create(@Valid @RequestBody HarShopCreateDTO dto) {
+        return Result.ok(service.create(dto, dto.getEnterpriseId()));
+    }
+}

+ 124 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

@@ -0,0 +1,124 @@
+package com.payment.platform.module.payment.har.controller.open;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarShopVO;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import com.payment.platform.module.payment.har.service.HarShopService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 汇安融开放 API — 企业商户自助创建门店、下单、查单。
+ *
+ * 认证走 TenantApiKeyAuthFilter(Authorization: ApiKey xxx + Signature 头),
+ * 与 /payment/openapi/account/** 同一套机制。该 filter 认证成功后注入一个携带
+ * tenantId 的 LoginUser,因此后续查询会被 TenantInnerInterceptor 自动施加租户过滤。
+ *
+ * 归属维度的越权在本层再挡一道:校验方式是查 pay_har_merchant 映射 —— 该表受租户
+ * 过滤约束,能查到的 enterprise_id 必然属于当前租户且已完成入驻配置。
+ *
+ * <p>2026-09-24 去企业化:enterprise_id 自本日起**选填**,不填即按租户寻址。
+ * 两条路径**并存**,不是替代(形态同乐收赢,见 db/har-tenant-level.sql)。
+ *
+ * <p>同日追加:一个租户允许配置**多个**商户,故请求体可带 merchant_code 指定用哪一条;
+ * 不带而该租户又有多条时直接报 400,不由系统挑(见 {@link #requireMerchant})。
+ */
+@Slf4j
+@Tag(name = "汇安融-开放API")
+@RestController
+@RequestMapping("/payment/openapi/har")
+@RequiredArgsConstructor
+public class HarOpenController {
+
+    private final HarOrderService orderService;
+    private final HarShopService shopService;
+    private final HarMerchantService merchantService;
+    private final HttpServletRequest request;
+
+    @PostMapping("/shop/create")
+    @Operation(summary = "创建门店(API Key 认证)")
+    public Result<HarShopVO> shopCreate(@Valid @RequestBody HarShopCreateDTO dto) {
+        requireMerchant(dto.getEnterpriseId(), dto.getMerchantCode());
+        return Result.ok(shopService.create(dto, dto.getEnterpriseId()));
+    }
+
+    @PostMapping("/order/create")
+    @Operation(summary = "下单/收款(API Key 认证)")
+    public Result<HarOrderVO> orderCreate(@Valid @RequestBody HarOrderCreateDTO dto) {
+        requireMerchant(dto.getEnterpriseId(), dto.getMerchantCode());
+        // api_key 落库:异步回调时据此解析该企业配置的 return_url
+        return Result.ok(orderService.create(dto, dto.getEnterpriseId(), getOpenapiApiKey()));
+    }
+
+    @PostMapping("/order/query")
+    @Operation(summary = "查询订单(API Key 认证)")
+    public Result<HarOrderVO> orderQuery(@Valid @RequestBody HarOpenOrderQueryDTO query) {
+        // merchant_code 必须一路带到闸这里:本租户有多条配置而不传它时,
+        // requireByTenant 会报 400 要求指定 —— 若此处写死 null,那句提示就成了
+        // 无法照办的死路(请求体里根本没有这个字段可填)。
+        requireMerchant(query.getEnterpriseId(), query.getMerchantCode());
+        return Result.ok(orderService.openQuery(query));
+    }
+
+    // ---------- helpers ----------
+
+    /**
+     * 校验已通过 API Key 认证,且本次调用有可用的商户配置。
+     *
+     * 三条路径**并存**,次序就是优先级(与 HarOrderService / HarShopService 内的解析
+     * 完全一致 —— 这里是闸,那里是防御性的第二道,几处必须同时改):
+     * ① 传了 merchant_code ⇒ 按它取,必须属于本租户(2026-09-24 起一个租户可配置多个商户);
+     * ② 传了 enterprise_id ⇒ 企业级(同一租户下可能有多个企业,只靠租户过滤会让
+     *    A 企业查到 B 企业的订单);
+     * ③ 都没传 ⇒ 按**租户**寻址;此时该租户有多条配置就报 400 要求指定,不替他挑。
+     *
+     * merchantService 的查询走的是普通查询,被 TenantInnerInterceptor 施加了
+     * tenant_id = 当前 API Key 所属租户 的条件;查不到即代表该企业/商户不在本租户下
+     * (或尚未配置商户),两种情况都无权操作。
+     */
+    private void requireMerchant(String enterpriseId, String merchantCode) {
+        Long tenantId = getOpenapiTenantId();
+        if (tenantId == null) {
+            throw new BusinessException(401, "未通过 API Key 认证");
+        }
+        if (StrUtil.isNotBlank(merchantCode)) {
+            // 显式指定即以它为准;不存在/不属于本租户都是同一个 404(不区分)
+            merchantService.requireByTenantAndCode(tenantId, merchantCode);
+            return;
+        }
+        if (StrUtil.isNotBlank(enterpriseId)) {
+            if (merchantService.getByEnterpriseId(enterpriseId) == null) {
+                // 「不属于本租户」与「尚未配置」合并成同一个 403,不区分 ——
+                // 区分等于告诉调用方「这个企业存在,只是不属于你」。
+                throw new BusinessException(403, "无权限操作该企业");
+            }
+            return;
+        }
+        merchantService.requireByTenant(tenantId);
+    }
+
+    private Long getOpenapiTenantId() {
+        Object tid = request.getAttribute("openapi.tenantId");
+        return tid instanceof Long ? (Long) tid : null;
+    }
+
+    private String getOpenapiApiKey() {
+        Object key = request.getAttribute("openapi.apiKey");
+        return key != null ? key.toString() : null;
+    }
+}

+ 20 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantQueryDTO.java

@@ -0,0 +1,20 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+@Data
+public class HarMerchantQueryDTO {
+
+    /**
+     * 租户筛选。**只有超管看得见所有租户的行**(TenantInnerInterceptor 对超管
+     * 直接跳过租户过滤),所以这个条件是为超管理解列表而设;租户侧查询本来就被
+     * 拦截器钉死在自己的 tenant_id 上,传了也不越权。
+     */
+    private Long tenantId;
+
+    private String enterpriseId;
+
+    private String merchantCode;
+
+    private String merchantName;
+}

+ 39 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantSaveDTO.java

@@ -0,0 +1,39 @@
+package com.payment.platform.module.payment.har.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class HarMerchantSaveDTO {
+
+    /** 为空表示新增,非空表示修改 */
+    private Long id;
+
+    /**
+     * 这条配置建在**哪个租户**下。**新增时必填**,修改时忽略(归属不可改)。
+     *
+     * <p>本端点已限超管(见 HarMerchantController#save),所以这个字段是超管替租户建号时
+     * 指定的归属。不能省:超管 bypass 了租户拦截器(TenantInnerInterceptor.ignoreTable),
+     * 不显式给值就会由 AutoFillMetaObjectHandler 填成**超管自己**的租户。
+     */
+    private Long tenantId;
+
+    /** 选填。为空 ⇒ 记为**租户级**商户映射(enterprise_id 落 NULL),见 db/har-tenant-level.sql */
+    private String enterpriseId;
+
+    @NotBlank(message = "商户编号不能为空")
+    @Size(max = 20, message = "商户编号最长 20 位")
+    private String merchantCode;
+
+    @Size(max = 128, message = "商户名称最长 128 位")
+    private String merchantName;
+
+    @Size(max = 64, message = "联系人最长 64 位")
+    private String contactName;
+
+    @Size(max = 32, message = "联系电话最长 32 位")
+    private String contactMobile;
+
+    private String description;
+}

+ 40 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantVO.java

@@ -0,0 +1,40 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+@Data
+public class HarMerchantVO {
+
+    private Long id;
+    /** 归属租户。超管建号时选定,之后不可改;列表侧供超管分辨各行属于谁 */
+    private Long tenantId;
+    private String enterpriseId;
+    private String merchantCode;
+    private String merchantName;
+    private String contactName;
+    private String contactMobile;
+    private String description;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+
+    public static HarMerchantVO of(HarMerchantEntity e) {
+        if (e == null) {
+            return null;
+        }
+        HarMerchantVO vo = new HarMerchantVO();
+        vo.setId(e.getId());
+        vo.setTenantId(e.getTenantId());
+        vo.setEnterpriseId(e.getEnterpriseId());
+        vo.setMerchantCode(e.getMerchantCode());
+        vo.setMerchantName(e.getMerchantName());
+        vo.setContactName(e.getContactName());
+        vo.setContactMobile(e.getContactMobile());
+        vo.setDescription(e.getDescription());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        return vo;
+    }
+}

+ 29 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOpenOrderQueryDTO.java

@@ -0,0 +1,29 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+/** 开放 API 查单入参。order_no 与 out_biz_no 二选一。 */
+@Data
+public class HarOpenOrderQueryDTO {
+
+    /** 选填。为空 ⇒ 按**租户**范围查(同一租户下不再区分企业) */
+    private String enterpriseId;
+
+    /**
+     * 选填。限定到某一条商户配置(2026-09-24 起一个租户可配置多个商户)。
+     *
+     * <p>它同时是**必需**的:本租户有多条配置时,不传就会在寻址那一步被 400 挡下
+     * (见 HarMerchantService#requireByTenant)—— 所以这个字段不是可有可无的装饰,
+     * 少了它,多商户的租户连自己下的单都查不了。
+     *
+     * <p>传了就参与比对:命中的订单必须正是这个商户的,否则一律「订单不存在」
+     * (与 enterprise_id 同口径,不区分越权与不存在)。
+     */
+    private String merchantCode;
+
+    /** 我方订单号(下单响应里返回的 order_no) */
+    private String orderNo;
+
+    /** 企业自己的订单号 */
+    private String outBizNo;
+}

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

@@ -0,0 +1,68 @@
+package com.payment.platform.module.payment.har.dto;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 下单入参。后台与开放 API 共用。
+ *
+ * auth_code 有值走收款(被扫,kf.alipay.trade.pay),为空走下单(主扫,precreate)。
+ */
+@Data
+public class HarOrderCreateDTO {
+
+    /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
+    private String enterpriseId;
+
+    /**
+     * 选填。**指定这笔钱收进哪个商户编号**。
+     *
+     * <p>一个租户可以配置多个商户(2026-09-24)。显式给出时以它为准(企业号只是
+     * 归属校验,不再决定商户);不给时:该租户只有一条配置 ⇒ 用它;
+     * 有多条 ⇒ **拒绝**并提示指定 —— 钱进哪个商户账号必须由调用方说清楚,
+     * 不能让系统 LIMIT 1 随便挑一条。取值必须是**本租户**的商户编号。
+     */
+    private String merchantCode;
+
+    /** 企业商户自己的订单号,原样存下供其查询 */
+    @Size(max = 64, message = "商户订单号最长 64 位")
+    private String outBizNo;
+
+    @NotNull(message = "金额不能为空")
+    @DecimalMin(value = "0.01", message = "金额必须大于 0")
+    private BigDecimal price;
+
+    @NotBlank(message = "商品名称不能为空")
+    @Size(max = 64, message = "商品名称最长 64 位")
+    private String title;
+
+    /** alipay_stage:花呗分期  alipay_payment:支付宝支付 */
+    @NotBlank(message = "交易方式不能为空")
+    private String tradeType;
+
+    /** 分期时必填,3/6/12/24 */
+    private String stageNum;
+
+    /** 分期时必填,100:商户承担 0:用户承担 */
+    private String percent;
+
+    /** 商户自己的门店编码 */
+    @Size(max = 32, message = "门店编码最长 32 位")
+    private String thirdShopCode;
+
+    /** 平台门店编号 */
+    private String shopCode;
+
+    /** 支付过期时间,2 到 60,单位以上游为准,本地原样透传 */
+    private Integer payTimeout;
+
+    private String extraParams;
+
+    /** 用户付款码。有值即走被扫收款 */
+    private String authCode;
+}

+ 25 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderQueryDTO.java

@@ -0,0 +1,25 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+@Data
+public class HarOrderQueryDTO {
+
+    private String enterpriseId;
+
+    private String merchantCode;
+
+    private String orderNo;
+
+    private String outBizNo;
+
+    private String tradeStatus;
+
+    private String tradeType;
+
+    /** yyyy-MM-dd */
+    private String startDate;
+
+    /** yyyy-MM-dd */
+    private String endDate;
+}

+ 74 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java

@@ -0,0 +1,74 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+
+@Data
+public class HarOrderVO {
+
+    private Long id;
+    private String enterpriseId;
+    private String orderNo;
+    private String outBizNo;
+    private String upstreamOrderNo;
+    private String platformTradeNo;
+    private String merchantCode;
+    private String shopCode;
+    private String thirdShopCode;
+    private BigDecimal price;
+    private String title;
+    private String tradeType;
+    private String stageNum;
+    private String percent;
+    private Integer payTimeout;
+    private String extraParams;
+    private String payUrl;
+    private String tradeStatus;
+    private OffsetDateTime tradeTime;
+    private OffsetDateTime refundTime;
+    private OffsetDateTime upstreamCreateTime;
+    private BigDecimal rcvAmount;
+    private BigDecimal feeAmount;
+    private BigDecimal channelAmount;
+    private String notifyStatus;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+
+    public static HarOrderVO of(HarOrderEntity e) {
+        if (e == null) {
+            return null;
+        }
+        HarOrderVO vo = new HarOrderVO();
+        vo.setId(e.getId());
+        vo.setEnterpriseId(e.getEnterpriseId());
+        vo.setOrderNo(e.getOrderNo());
+        vo.setOutBizNo(e.getOutBizNo());
+        vo.setUpstreamOrderNo(e.getUpstreamOrderNo());
+        vo.setPlatformTradeNo(e.getPlatformTradeNo());
+        vo.setMerchantCode(e.getMerchantCode());
+        vo.setShopCode(e.getShopCode());
+        vo.setThirdShopCode(e.getThirdShopCode());
+        vo.setPrice(e.getPrice());
+        vo.setTitle(e.getTitle());
+        vo.setTradeType(e.getTradeType());
+        vo.setStageNum(e.getStageNum());
+        vo.setPercent(e.getPercent());
+        vo.setPayTimeout(e.getPayTimeout());
+        vo.setExtraParams(e.getExtraParams());
+        vo.setPayUrl(e.getPayUrl());
+        vo.setTradeStatus(e.getTradeStatus());
+        vo.setTradeTime(e.getTradeTime());
+        vo.setRefundTime(e.getRefundTime());
+        vo.setUpstreamCreateTime(e.getUpstreamCreateTime());
+        vo.setRcvAmount(e.getRcvAmount());
+        vo.setFeeAmount(e.getFeeAmount());
+        vo.setChannelAmount(e.getChannelAmount());
+        vo.setNotifyStatus(e.getNotifyStatus());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        return vo;
+    }
+}

+ 42 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java

@@ -0,0 +1,42 @@
+package com.payment.platform.module.payment.har.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 门店创建入参。后台与开放 API 共用 —— 开放 API 时 enterprise_id 由调用方在
+ * 请求体中传入(与 OpenapiController 的既有约定一致),租户由 API Key 决定。
+ *
+ * enterprise_id 自 2026-09-24 起**选填**:留空即按租户寻址(去企业化,
+ * 见 db/har-tenant-level.sql 与 HarOpenController#requireMerchant 的分流)。
+ *
+ * merchant_code 同日新增:一个租户可以有**多条**商户配置,此时「用哪一条」
+ * 必须是调用方说了算 —— 见 {@link #merchantCode}。
+ */
+@Data
+public class HarShopCreateDTO {
+
+    /** 商户自己的门店编码,作为幂等键 */
+    @NotBlank(message = "门店编码不能为空")
+    @Size(max = 32, message = "门店编码最长 32 位")
+    private String thirdShopId;
+
+    @NotBlank(message = "门店名称不能为空")
+    @Size(max = 128, message = "门店名称最长 128 位")
+    private String shopName;
+
+    /**
+     * 选填。**指定这家门店挂在哪个商户编号下**。
+     *
+     * <p>一个租户可以配置多个商户(2026-09-24),门店铺在上游是挂在商户下的
+     * (创建门店的报文里就带 merchant_code),所以由调用方指定才是准的。
+     * 不指定时:该租户只有一条配置 ⇒ 用它;有多条 ⇒ **拒绝**并提示指定,
+     * 绝不替调用方挑一条(挑错了门店就挂到别的商户名下,且没有任何报错)。
+     * 取值必须是**本租户**的商户编号,跨租户的会被 404 挡掉。
+     */
+    private String merchantCode;
+
+    /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
+    private String enterpriseId;
+}

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopQueryDTO.java

@@ -0,0 +1,15 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+@Data
+public class HarShopQueryDTO {
+
+    private String enterpriseId;
+
+    private String merchantCode;
+
+    private String thirdShopId;
+
+    private String createStatus;
+}

+ 43 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java

@@ -0,0 +1,43 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+@Data
+public class HarShopVO {
+
+    private Long id;
+    private String enterpriseId;
+    private String merchantCode;
+    private String thirdShopId;
+    private String shopName;
+    private String shopId;
+    private String createStatus;
+    private String failReason;
+    private Integer retryCount;
+    private String description;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+
+    public static HarShopVO of(HarShopEntity e) {
+        if (e == null) {
+            return null;
+        }
+        HarShopVO vo = new HarShopVO();
+        vo.setId(e.getId());
+        vo.setEnterpriseId(e.getEnterpriseId());
+        vo.setMerchantCode(e.getMerchantCode());
+        vo.setThirdShopId(e.getThirdShopId());
+        vo.setShopName(e.getShopName());
+        vo.setShopId(e.getShopId());
+        vo.setCreateStatus(e.getCreateStatus());
+        vo.setFailReason(e.getFailReason());
+        vo.setRetryCount(e.getRetryCount());
+        vo.setDescription(e.getDescription());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        return vo;
+    }
+}

+ 30 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarMerchantEntity.java

@@ -0,0 +1,30 @@
+package com.payment.platform.module.payment.har.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 企业 ↔ 汇安融商户映射。
+ *
+ * 商户入驻进件在汇安融侧是纯人工流程,上游通过后由运营在本表录入 merchant_code。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_har_merchant")
+public class HarMerchantEntity extends PaymentEnterpriseBaseEntity {
+
+    /** 汇安融分配的商户编号 */
+    private String merchantCode;
+
+    private String merchantName;
+
+    private String contactName;
+
+    private String contactMobile;
+
+    @Version
+    private Integer version;
+}

+ 94 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java

@@ -0,0 +1,94 @@
+package com.payment.platform.module.payment.har.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+
+/**
+ * 汇安融订单。形状对齐 pay_f2f_trade。
+ *
+ * 业务状态走 trade_status —— 基类 PaymentBaseEntity.status 是普通字段但另有用途
+ * (见 pay_f2f_trade:status 恒为 '0',真实状态在 trade_status)。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_har_order")
+public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
+
+    /** 我方订单号,作为 third_order_no 传上游,唯一 */
+    private String orderNo;
+
+    /** 企业商户自己的订单号 */
+    private String outBizNo;
+
+    /**
+     * 下单时使用的租户 apiKey。
+     * 回调时用它反查该 Key 上配置的 return_url —— 与 OpenapiService.resolveReturnUrl
+     * 的取址优先级一致(api_key.return_url → open_conf.return_url)。
+     */
+    private String apiKey;
+
+    /** 上游返回的 order_no */
+    private String upstreamOrderNo;
+
+    /** 支付通道订单号 */
+    private String platformTradeNo;
+
+    private String merchantCode;
+
+    /** 平台门店编号 */
+    private String shopCode;
+
+    /** 商户门店编号 */
+    private String thirdShopCode;
+
+    private BigDecimal price;
+
+    private String title;
+
+    /** alipay_stage:花呗分期  alipay_payment:支付宝支付 */
+    private String tradeType;
+
+    private String stageNum;
+
+    private String percent;
+
+    private Integer payTimeout;
+
+    private String extraParams;
+
+    /** 收款链接(主扫) */
+    private String payUrl;
+
+    /** 上游状态枚举,见 HarOrderStatus */
+    private String tradeStatus;
+
+    private OffsetDateTime tradeTime;
+
+    private OffsetDateTime refundTime;
+
+    private OffsetDateTime upstreamCreateTime;
+
+    private BigDecimal rcvAmount;
+
+    private BigDecimal feeAmount;
+
+    private BigDecimal channelAmount;
+
+    private OffsetDateTime lastQueryTime;
+
+    private OffsetDateTime nextQueryTime;
+
+    private Integer queryCount;
+
+    /** 回调企业商户的结果,见 HarNotifyStatus */
+    private String notifyStatus;
+
+    @Version
+    private Integer version;
+}

+ 43 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarShopEntity.java

@@ -0,0 +1,43 @@
+package com.payment.platform.module.payment.har.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.payment.platform.common.base.PaymentEnterpriseBaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 门店。
+ *
+ * 本地表是唯一真相源 —— 汇安融未提供门店查询接口,创建后只能靠本地记录找回,
+ * 故 request_body / response_body 必须留存原文,供超时后人工判断远端状态。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_har_shop")
+public class HarShopEntity extends PaymentEnterpriseBaseEntity {
+
+    private String merchantCode;
+
+    /** 我方门店编码,创建时传给上游,作为幂等键 */
+    private String thirdShopId;
+
+    private String shopName;
+
+    /** 汇安融返回的门店 ID,未成功时为空 */
+    private String shopId;
+
+    /** PENDING / SUCCESS / FAILED,见 HarShopCreateStatus */
+    private String createStatus;
+
+    private String failReason;
+
+    private String requestBody;
+
+    private String responseBody;
+
+    private Integer retryCount;
+
+    @Version
+    private Integer version;
+}

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/har/enums/HarNotifyStatus.java

@@ -0,0 +1,15 @@
+package com.payment.platform.module.payment.har.enums;
+
+/** 回调企业商户的结果。 */
+public final class HarNotifyStatus {
+
+    /** 尚未回调(或租户未配置回调地址) */
+    public static final String PENDING = "PENDING";
+
+    public static final String SUCCESS = "SUCCESS";
+
+    public static final String FAILED = "FAILED";
+
+    private HarNotifyStatus() {
+    }
+}

+ 37 - 0
java/src/main/java/com/payment/platform/module/payment/har/enums/HarOrderStatus.java

@@ -0,0 +1,37 @@
+package com.payment.platform.module.payment.har.enums;
+
+import java.util.Set;
+
+/**
+ * 订单状态。
+ *
+ * 直接采用上游枚举,不做本地转义 —— 上游即契约,本地再造一套映射只会
+ * 在排查问题时多一层翻译。
+ *
+ * 0 等待支付 / 1 支付成功 / 2 退款成功 / 4 退款中 / 8 交易关闭
+ */
+public final class HarOrderStatus {
+
+    public static final String WAIT_PAY = "0";
+    public static final String PAID = "1";
+    public static final String REFUNDED = "2";
+    public static final String REFUNDING = "4";
+    public static final String CLOSED = "8";
+
+    /** 非终态 —— 需要通过轮询收敛 */
+    public static final Set<String> PENDING = Set.of(WAIT_PAY, REFUNDING);
+
+    /** 终态 —— 不再轮询 */
+    public static final Set<String> FINAL = Set.of(PAID, REFUNDED, CLOSED);
+
+    public static boolean isPending(String status) {
+        return status != null && PENDING.contains(status);
+    }
+
+    public static boolean isFinal(String status) {
+        return status != null && FINAL.contains(status);
+    }
+
+    private HarOrderStatus() {
+    }
+}

+ 22 - 0
java/src/main/java/com/payment/platform/module/payment/har/enums/HarShopCreateStatus.java

@@ -0,0 +1,22 @@
+package com.payment.platform.module.payment.har.enums;
+
+/**
+ * 门店创建状态。
+ *
+ * 上游没有门店查询接口,超时后无法核实远端是否已创建,
+ * 因此 PENDING 不是「处理中」而是「结果未知,需人工核实」。
+ */
+public final class HarShopCreateStatus {
+
+    /** 结果未知,待人工核实 */
+    public static final String PENDING = "PENDING";
+
+    /** 上游已确认创建,shop_id 已回填 */
+    public static final String SUCCESS = "SUCCESS";
+
+    /** 上游明确拒绝 */
+    public static final String FAILED = "FAILED";
+
+    private HarShopCreateStatus() {
+    }
+}

+ 14 - 0
java/src/main/java/com/payment/platform/module/payment/har/enums/HarTradeType.java

@@ -0,0 +1,14 @@
+package com.payment.platform.module.payment.har.enums;
+
+/** 交易方式 — 取值来自规范 5.1。 */
+public final class HarTradeType {
+
+    /** 花呗分期 */
+    public static final String ALIPAY_STAGE = "alipay_stage";
+
+    /** 支付宝支付 */
+    public static final String ALIPAY_PAYMENT = "alipay_payment";
+
+    private HarTradeType() {
+    }
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/har/mapper/HarMerchantMapper.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.har.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HarMerchantMapper extends BaseMapper<HarMerchantEntity> {
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/har/mapper/HarOrderMapper.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.har.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HarOrderMapper extends BaseMapper<HarOrderEntity> {
+}

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/har/mapper/HarShopMapper.java

@@ -0,0 +1,9 @@
+package com.payment.platform.module.payment.har.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HarShopMapper extends BaseMapper<HarShopEntity> {
+}

+ 64 - 0
java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java

@@ -0,0 +1,64 @@
+package com.payment.platform.module.payment.har.scheduler;
+
+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.service.HarOrderService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 汇安融订单轮询补偿。
+ *
+ * 这不是兜底,是主路径 —— 上游通知「未收到 success 则每 5 秒重试共 5 次」,
+ * 窗口只有约 25 秒。任何超过 25 秒的不可用(重启、发布、网络抖动)都会永久丢失通知,
+ * 未终态订单必须靠本任务主动查询收敛。
+ */
+@Slf4j
+@Component
+public class HarOrderPollScheduler {
+
+    /** 单次最多处理条数,避免上游抖动时任务堆积 */
+    private static final int BATCH_SIZE = 100;
+
+    private final HarOrderService orderService;
+    private final HarProperties harProperties;
+
+    public HarOrderPollScheduler(HarOrderService orderService, HarProperties harProperties) {
+        this.orderService = orderService;
+        this.harProperties = harProperties;
+    }
+
+    @Scheduled(fixedDelay = 60 * 1000, initialDelay = 60 * 1000)
+    public void pollPendingOrders() {
+        if (!harProperties.isEnabled()) {
+            return;
+        }
+
+        List<HarOrderEntity> orders;
+        try {
+            orders = orderService.findPendingForPoll(BATCH_SIZE);
+        } catch (Exception e) {
+            log.error("[汇安融轮询] 查询待处理订单失败: {}", e.getMessage(), e);
+            return;
+        }
+        if (orders.isEmpty()) {
+            return;
+        }
+
+        int ok = 0;
+        for (HarOrderEntity order : orders) {
+            try {
+                orderService.syncFromUpstream(order);
+                ok++;
+            } catch (Exception e) {
+                // 单笔失败不影响其余订单
+                log.error("[汇安融轮询] 订单同步失败: order_no={}, error={}",
+                        order.getOrderNo(), e.getMessage());
+            }
+        }
+        log.info("[汇安融轮询] 完成: {}/{}", ok, orders.size());
+    }
+}

+ 247 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java

@@ -0,0 +1,247 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.payment.platform.common.utils.SnowflakeIdGenerator;
+import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
+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.openapi.entity.OpenConfEntity;
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+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。
+ *
+ * 两点刻意的设计:
+ *
+ * 1. 用 CompletableFuture + 自有线程池而非 @Async —— 项目没有开 @EnableAsync,
+ *    @Async 会静默地同步执行,把上游通知响应时间拖到 30 秒以上,直接超出上游
+ *    25 秒的重试窗口。为了一个回调去开全局 @EnableAsync 又会改变其他地方的语义。
+ *
+ * 2. 回调失败只记日志并落 notify_status,不抛异常 —— 上游通知要求我们尽快返回
+ *    success,不能被下游回调拖住。
+ */
+@Slf4j
+@Service
+public class HarCallbackService {
+
+    private static final int MAX_ATTEMPTS = 3;
+
+    /** 退避秒数,与 OpenapiService.CALLBACK_BACKOFF_SECONDS 保持一致 */
+    private static final int[] BACKOFF_SECONDS = {1, 2};
+
+    /** 回调报文的序列化器。显式注册 JavaTimeModule,保证 OffsetDateTime 输出 ISO 字符串 */
+    private static final ObjectMapper MAPPER = new ObjectMapper()
+            .registerModule(new JavaTimeModule())
+            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+
+    private final TenantApiKeyMapper apiKeyMapper;
+    private final OpenConfMapper confMapper;
+    private final HarOrderMapper orderMapper;
+    private final RestTemplate restTemplate;
+    private final ExecutorService callbackPool;
+
+    public HarCallbackService(TenantApiKeyMapper apiKeyMapper,
+                              OpenConfMapper confMapper,
+                              HarOrderMapper orderMapper,
+                              RestTemplate restTemplate,
+                              HarProperties harProperties) {
+        this.apiKeyMapper = apiKeyMapper;
+        this.confMapper = confMapper;
+        this.orderMapper = orderMapper;
+        this.restTemplate = restTemplate;
+        int poolSize = Math.max(1, harProperties.getCallbackPoolSize());
+        this.callbackPool = Executors.newFixedThreadPool(poolSize, r -> {
+            Thread t = new Thread(r, "har-callback");
+            t.setDaemon(true);
+            return t;
+        });
+    }
+
+    @PreDestroy
+    public void shutdown() {
+        callbackPool.shutdown();
+        try {
+            if (!callbackPool.awaitTermination(5, TimeUnit.SECONDS)) {
+                callbackPool.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            callbackPool.shutdownNow();
+        }
+    }
+
+    /**
+     * 异步回调企业商户。方法立即返回,不阻塞上游通知的响应。
+     */
+    public void notifyEnterprise(HarOrderEntity order) {
+        if (order == null) {
+            return;
+        }
+        String returnUrl = resolveReturnUrl(order.getTenantId(), order.getApiKey());
+        if (StrUtil.isBlank(returnUrl)) {
+            log.info("[汇安融回调] 租户未配置回调地址,跳过: tenant_id={}, order_no={}",
+                    order.getTenantId(), order.getOrderNo());
+            return;
+        }
+
+        String orderNo = order.getOrderNo();
+        CompletableFuture.runAsync(() -> doNotify(order, returnUrl, orderNo), callbackPool)
+                .exceptionally(ex -> {
+                    log.error("[汇安融回调] 异步任务异常: order_no={}, error={}",
+                            orderNo, ex.getMessage(), ex);
+                    return null;
+                });
+    }
+
+    private void doNotify(HarOrderEntity order, String returnUrl, String orderNo) {
+        String notifyId = "n" + SnowflakeIdGenerator.nextIdStr();
+        long timestamp = System.currentTimeMillis();
+        String content = buildContent(order);
+
+        boolean ok = post(returnUrl, notifyId, timestamp, content);
+        writeNotifyStatus(orderNo, ok);
+    }
+
+    /**
+     * 回写回调结果。
+     *
+     * 重新查一次再更新,而不是直接 update 传入的实体 —— 传入的实体可能已被
+     * 通知线程后续的状态更新覆盖,用乐观锁直接更新会因 version 过期而失败。
+     */
+    private void writeNotifyStatus(String orderNo, boolean ok) {
+        try {
+            HarOrderEntity latest = orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
+                    .eq(HarOrderEntity::getOrderNo, orderNo)
+                    .last("LIMIT 1"));
+            if (latest == null) {
+                return;
+            }
+            latest.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
+            orderMapper.updateById(latest);
+        } catch (Exception e) {
+            log.warn("[汇安融回调] 回写 notify_status 失败: order_no={}, error={}", orderNo, e.getMessage());
+        }
+    }
+
+    /**
+     * 回调地址解析 — 与 OpenapiService.resolveReturnUrl 同一优先级。
+     *
+     * 先按 apiKey 精确定位(企业可能配了多个 Key,各自指向不同的接收地址),
+     * 再退化到按租户取 open_conf 的默认地址。
+     */
+    private String resolveReturnUrl(Long tenantId, String apiKey) {
+        if (StrUtil.isNotBlank(apiKey)) {
+            TenantApiKeyEntity key = apiKeyMapper.selectOne(
+                    new LambdaQueryWrapper<TenantApiKeyEntity>()
+                            .eq(TenantApiKeyEntity::getApiKey, apiKey)
+                            .last("LIMIT 1"));
+            if (key != null && StrUtil.isNotBlank(key.getReturnUrl())) {
+                return key.getReturnUrl();
+            }
+        }
+        if (tenantId == null) {
+            return null;
+        }
+        OpenConfEntity conf = confMapper.selectOne(new LambdaQueryWrapper<OpenConfEntity>()
+                .eq(OpenConfEntity::getTenantId, tenantId)
+                .orderByDesc(OpenConfEntity::getUpdatedTime)
+                .last("LIMIT 1"));
+        return conf == null ? null : conf.getReturnUrl();
+    }
+
+    private String buildContent(HarOrderEntity order) {
+        Map<String, Object> content = new LinkedHashMap<>();
+        content.put("third_order_no", order.getOrderNo());
+        content.put("out_biz_no", order.getOutBizNo());
+        content.put("order_no", order.getUpstreamOrderNo());
+        content.put("platform_trade_no", order.getPlatformTradeNo());
+        content.put("merchant_code", order.getMerchantCode());
+        content.put("third_shop_code", order.getThirdShopCode());
+        content.put("price", order.getPrice());
+        content.put("title", order.getTitle());
+        content.put("trade_type", order.getTradeType());
+        content.put("status", order.getTradeStatus());
+        content.put("trade_time", order.getTradeTime());
+        content.put("refund_time", order.getRefundTime());
+        content.put("rcv_amount", order.getRcvAmount());
+        content.put("fee_amount", order.getFeeAmount());
+        content.put("channel_amount", order.getChannelAmount());
+        try {
+            return MAPPER.writeValueAsString(content);
+        } catch (Exception e) {
+            log.error("[汇安融回调] 报文序列化失败: {}", e.getMessage());
+            return "{}";
+        }
+    }
+
+    private boolean post(String url, String notifyId, long timestamp, String content) {
+        for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
+            try {
+                MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
+                formData.add("notify_id", notifyId);
+                formData.add("timestamp", String.valueOf(timestamp));
+                formData.add("content", content);
+
+                HttpHeaders headers = new HttpHeaders();
+                headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+                ResponseEntity<String> response = restTemplate.postForEntity(
+                        url, new HttpEntity<>(formData, headers), String.class);
+
+                if (response.getStatusCode().is2xxSuccessful()) {
+                    log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
+                    return true;
+                }
+                // 4xx 是对方的问题,重试没有意义
+                if (response.getStatusCode().is4xxClientError()) {
+                    log.warn("[汇安融回调] 返回 {},不再重试: notify_id={}",
+                            response.getStatusCode(), notifyId);
+                    return false;
+                }
+                log.warn("[汇安融回调] 第 {} 次返回 {}", attempt + 1, response.getStatusCode());
+            } catch (Exception e) {
+                log.warn("[汇安融回调] 第 {} 次失败: {}", attempt + 1, e.getMessage());
+            }
+            if (attempt < MAX_ATTEMPTS - 1) {
+                try {
+                    Thread.sleep(BACKOFF_SECONDS[attempt] * 1000L);
+                } catch (InterruptedException ie) {
+                    Thread.currentThread().interrupt();
+                    return false;
+                }
+            }
+        }
+        log.error("[汇安融回调] {} 次尝试后仍失败: url={}", MAX_ATTEMPTS, url);
+        return false;
+    }
+}

+ 222 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java

@@ -0,0 +1,222 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.module.payment.har.dto.HarMerchantQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarMerchantSaveDTO;
+import com.payment.platform.module.payment.har.dto.HarMerchantVO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.mapper.HarMerchantMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 汇安融商户配置。
+ *
+ * 商户入驻进件在汇安融侧是纯人工流程,上游通过后由运营在本模块录入 merchant_code。
+ * 本模块不发起任何上游调用。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarMerchantService {
+
+    private final HarMerchantMapper merchantMapper;
+
+    public PageResult<HarMerchantVO> list(int pageNo, int pageSize, HarMerchantQueryDTO q) {
+        LambdaQueryWrapper<HarMerchantEntity> w = buildQuery(q);
+        w.orderByDesc(HarMerchantEntity::getId);
+        Page<HarMerchantEntity> page = merchantMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        List<HarMerchantVO> rows = page.getRecords().stream().map(HarMerchantVO::of).toList();
+        return PageResult.of(pageNo, pageSize, page.getTotal(), rows);
+    }
+
+    /** 无配置返回 null,供可空场景使用 */
+    public HarMerchantEntity getByEnterpriseId(String enterpriseId) {
+        if (StrUtil.isBlank(enterpriseId)) {
+            return null;
+        }
+        return merchantMapper.selectOne(new LambdaQueryWrapper<HarMerchantEntity>()
+                .eq(HarMerchantEntity::getEnterpriseId, enterpriseId)
+                .last("LIMIT 1"));
+    }
+
+    /** 无配置直接抛业务异常,供下单等必需场景使用 */
+    public HarMerchantEntity requireByEnterpriseId(String enterpriseId) {
+        HarMerchantEntity entity = getByEnterpriseId(enterpriseId);
+        if (entity == null) {
+            throw new BusinessException("企业尚未配置汇安融商户编号,请联系运营报备进件");
+        }
+        return entity;
+    }
+
+    /**
+     * 取**租户级**商户映射(2026-09-24 去企业化)—— enterprise_id 为空的那几行。
+     *
+     * <p>与 {@link #getByEnterpriseId} 是**并存**关系,不是替代:传了 {@code enterpriseId}
+     * 的调用方仍走企业级路径(分流见 {@code HarOpenController#requireMerchant})。
+     *
+     * <p>返回**列表**而不是单条,是因为 2026-09-24 用户裁定「一个租户允许配置多个商户」:
+     * 之前靠 uk_har_merchant_tenant_level 把租户级映射钉成一条,那条索引已随
+     * db/har-merchant-multi-per-tenant.sql 删除。取哪一条不再由本方法决定 ——
+     * 见 {@link #requireByTenant} 与 {@link #requireByTenantAndCode}。
+     */
+    public List<HarMerchantEntity> listByTenant(Long tenantId) {
+        if (tenantId == null) {
+            return List.of();
+        }
+        return merchantMapper.selectList(new LambdaQueryWrapper<HarMerchantEntity>()
+                .eq(HarMerchantEntity::getTenantId, tenantId)
+                .isNull(HarMerchantEntity::getEnterpriseId)
+                .orderByAsc(HarMerchantEntity::getId));
+    }
+
+    /**
+     * 按**商户编号**精确取本租户的映射。
+     *
+     * <p>带 tenant_id 是硬要求:跨租户的商户编号必须查不到 —— merchant_code 全局唯一
+     * (uk_har_merchant_code),少这个条件等于允许租户 A 拿租户 B 的号去下单。
+     */
+    public HarMerchantEntity getByTenantAndCode(Long tenantId, String merchantCode) {
+        if (tenantId == null || StrUtil.isBlank(merchantCode)) {
+            return null;
+        }
+        return merchantMapper.selectOne(new LambdaQueryWrapper<HarMerchantEntity>()
+                .eq(HarMerchantEntity::getTenantId, tenantId)
+                .eq(HarMerchantEntity::getMerchantCode, merchantCode)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 无配置直接抛业务异常,供下单/建店等必需场景使用(租户级、未指定商户编号)。
+     *
+     * <p>码值取 404「未配置」而非 403:调用方据此能区分「还没给这个租户报备」与
+     * 「报备了但不是你的」两种处境,改法完全不同。
+     *
+     * <p>★ 有多条时**报 400 而不是随便挑一条**:钱进哪个商户账号 / 门店挂到谁名下,
+     * 必须由调用方说清楚。挑错了不会报错,只会在对账时才发现。
+     */
+    public HarMerchantEntity requireByTenant(Long tenantId) {
+        List<HarMerchantEntity> rows = listByTenant(tenantId);
+        if (rows.isEmpty()) {
+            throw new BusinessException(404, "当前租户尚未配置商户编号,请联系运营报备进件");
+        }
+        if (rows.size() > 1) {
+            throw new BusinessException(400,
+                    "当前租户配置了多个商户编号,请在请求中指定 merchant_code");
+        }
+        return rows.get(0);
+    }
+
+    /** 指定了商户编号时用它,查不到即 404(越权与不存在合并成一句,不区分) */
+    public HarMerchantEntity requireByTenantAndCode(Long tenantId, String merchantCode) {
+        HarMerchantEntity entity = getByTenantAndCode(tenantId, merchantCode);
+        if (entity == null) {
+            throw new BusinessException(404, "商户编号不存在或不属于当前租户: " + merchantCode);
+        }
+        return entity;
+    }
+
+    /**
+     * 新增或修改商户配置。**只由超管调用** —— 门禁在 HarMerchantController#save 的
+     * {@code @PreAuthorize("@perm.hasRole('SUPERUSER')")},不在本方法里。
+     *
+     * <p>新增时必须给出 {@code tenantId}:超管 bypass 了租户拦截器,不显式给值这条配置会
+     * 落到超管自己的租户上(见下方注释)。修改时不接受改归属。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public HarMerchantVO save(HarMerchantSaveDTO dto) {
+        HarMerchantEntity entity;
+        if (dto.getId() != null) {
+            entity = merchantMapper.selectById(dto.getId());
+            if (entity == null) {
+                throw new BusinessException("商户配置不存在");
+            }
+            // 归属不可改:已经建好的门店/订单带的是**旧** tenant_id,改了这里等于让
+            // 同一笔业务横跨两个租户,两边的列表都只看得到一半。
+            // 前端把编辑态的「租户」下拉置灰,那是第二道,不是这一道的替代。
+        } else {
+            // 归属必须显式给出。少这一步不会报错,而是**静默**落到超管自己的租户上:
+            // 超管 bypass 了 TenantInnerInterceptor.ignoreTable ⇒ insert 不会被改写成
+            // 目标租户;于是只剩 AutoFillMetaObjectHandler 从 LoginUser 填 tenantId。
+            if (dto.getTenantId() == null) {
+                throw new BusinessException(400, "请选择租户");
+            }
+            // 一个**企业**只允许一条映射(enterprise_id 全局唯一),否则下单时 merchant_code
+            // 取哪个是不确定的。**租户**则不设限:2026-09-24 用户裁定「一个租户允许配置多个商户」
+            // (原 uk_har_merchant_tenant_level 单条唯一索引已随
+            //   db/har-merchant-multi-per-tenant.sql 删除)。
+            // 多条的代价是要有人指定用哪条 —— 由 requireByTenant 在多条时报 400 兜住,
+            // 不在这里禁掉。这里的查重是**读-判-写**,并发双击会同时通过;
+            // DB 侧的两道是 uk_har_merchant_enterprise 与 uk_har_merchant_code,由它们兜底。
+            if (StrUtil.isNotBlank(dto.getEnterpriseId())) {
+                // enterprise_id 是**全局**唯一(uk_har_merchant_enterprise),
+                // 所以这里不加租户条件才是对的。
+                if (getByEnterpriseId(dto.getEnterpriseId()) != null) {
+                    throw new BusinessException("该企业已配置汇安融商户编号");
+                }
+            }
+            if (merchantMapper.selectCount(new LambdaQueryWrapper<HarMerchantEntity>()
+                    // merchant_code 同样是**全局**唯一(uk_har_merchant_code):
+                    // 跨租户也不能重复,否则上游分不清是哪一家的号。
+                    .eq(HarMerchantEntity::getMerchantCode, dto.getMerchantCode())) > 0) {
+                throw new BusinessException("商户编号已存在: " + dto.getMerchantCode());
+            }
+            entity = new HarMerchantEntity();
+            entity.setTenantId(dto.getTenantId());
+        }
+
+        // 空 ⇒ NULL(不是空串):租户级映射找的正是 enterprise_id IS NULL,
+        // 存成空串的话任何 IS NULL 都查不到这一行,而写入方以为写的是「没值」。
+        // 口径同 LsyStrings.blankToNull —— 那个类在乐收赢包下,不为这一行跨模块引;
+        // 表达式本身与它逐字一致(isBlank ⇒ null,非空白原样,不 trim)。
+        entity.setEnterpriseId(StrUtil.isBlank(dto.getEnterpriseId()) ? null : dto.getEnterpriseId());
+        entity.setMerchantCode(dto.getMerchantCode());
+        entity.setMerchantName(dto.getMerchantName());
+        entity.setContactName(dto.getContactName());
+        entity.setContactMobile(dto.getContactMobile());
+        entity.setDescription(dto.getDescription());
+
+        if (entity.getId() == null) {
+            merchantMapper.insert(entity);
+        } else {
+            merchantMapper.updateById(entity);
+        }
+        return HarMerchantVO.of(entity);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        if (merchantMapper.selectById(id) == null) {
+            throw new BusinessException("商户配置不存在");
+        }
+        merchantMapper.deleteById(id);
+    }
+
+    private LambdaQueryWrapper<HarMerchantEntity> buildQuery(HarMerchantQueryDTO q) {
+        LambdaQueryWrapper<HarMerchantEntity> w = new LambdaQueryWrapper<>();
+        if (q == null) {
+            return w;
+        }
+        if (q.getTenantId() != null) {
+            w.eq(HarMerchantEntity::getTenantId, q.getTenantId());
+        }
+        if (StrUtil.isNotBlank(q.getEnterpriseId())) {
+            w.eq(HarMerchantEntity::getEnterpriseId, q.getEnterpriseId());
+        }
+        if (StrUtil.isNotBlank(q.getMerchantCode())) {
+            w.like(HarMerchantEntity::getMerchantCode, q.getMerchantCode());
+        }
+        if (StrUtil.isNotBlank(q.getMerchantName())) {
+            w.like(HarMerchantEntity::getMerchantName, q.getMerchantName());
+        }
+        return w;
+    }
+}

+ 142 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarNotifyService.java

@@ -0,0 +1,142 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.module.payment.har.client.HarSigner;
+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.mapper.HarOrderMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 汇安融通知处理。
+ *
+ * 验签失败一律拒绝,不设跳过开关 —— 加开关的当天它是调试工具,
+ * 上线那天它就是绕过签名验证的后门。联调期规则对不上时靠诊断日志定位:
+ * 失败时打印待签串、收到的 sign 与全部参数(不含密钥),足以复现问题。
+ *
+ * 上游重试策略是「未收到 success 则每 5 秒重试,共 5 次」,窗口只有约 25 秒。
+ * 因此对于「重试也不会成功」的情况(订单不存在、状态未变化、缺少关键字段)
+ * 直接返回 success,把有限的重试次数留给真正可能成功的场景。
+ */
+@Slf4j
+@Service
+public class HarNotifyService {
+
+    private static final String SUCCESS = "success";
+
+    private static final String SIGN_FAILED = "sign verify failed";
+
+    private static final DateTimeFormatter TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private final HarProperties harProperties;
+    private final HarOrderService orderService;
+    private final HarOrderMapper orderMapper;
+    private final HarCallbackService callbackService;
+
+    public HarNotifyService(HarProperties harProperties,
+                            HarOrderService orderService,
+                            HarOrderMapper orderMapper,
+                            HarCallbackService callbackService) {
+        this.harProperties = harProperties;
+        this.orderService = orderService;
+        this.orderMapper = orderMapper;
+        this.callbackService = callbackService;
+    }
+
+    /**
+     * 处理通知。
+     *
+     * @return 成功返回 "success";验签失败返回 "sign verify failed",上游据此重试
+     */
+    public String handle(Map<String, String> params) {
+        if (params == null || params.isEmpty()) {
+            log.error("[汇安融通知] 收到空参数请求,拒绝处理");
+            return SIGN_FAILED;
+        }
+
+        String sign = params.get("sign");
+        String signContent = HarSigner.buildNotifySignContent(params);
+
+        boolean verified;
+        try {
+            verified = HarSigner.verify(signContent, sign,
+                    harProperties.getPlatformPublicKey());
+        } catch (Exception e) {
+            log.error("[汇安融通知] 验签过程异常,拒绝处理: {}", e.getMessage(), e);
+            return SIGN_FAILED;
+        }
+
+        if (!verified) {
+            log.error("[汇安融通知] 验签失败。待签串=[{}],收到 sign=[{}],收到参数={}",
+                    signContent, sign, new TreeMap<>(params));
+            return SIGN_FAILED;
+        }
+
+        // third_order_no 是我方订单号;上游自己的单号在 order_no 字段
+        String orderNo = params.get("third_order_no");
+        if (StrUtil.isBlank(orderNo)) {
+            log.error("[汇安融通知] 缺少 third_order_no,无法定位订单: {}", params);
+            return SUCCESS;
+        }
+
+        HarOrderEntity order = orderService.getByOrderNo(orderNo);
+        if (order == null) {
+            // 返回 success 终止重试:重试 5 次也不会变出订单,徒占 25 秒窗口
+            log.error("[汇安融通知] 订单不存在,已确认终止重试: third_order_no={}", orderNo);
+            return SUCCESS;
+        }
+
+        String newStatus = params.get("status");
+        if (StrUtil.isBlank(newStatus)) {
+            log.error("[汇安融通知] 缺少 status,无法判断状态: third_order_no={}", orderNo);
+            return SUCCESS;
+        }
+
+        if (StrUtil.equals(order.getTradeStatus(), newStatus)) {
+            log.info("[汇安融通知] 状态未变化,幂等跳过: order_no={}, status={}", orderNo, newStatus);
+            return SUCCESS;
+        }
+
+        order.setTradeStatus(newStatus);
+        if (StrUtil.isNotBlank(params.get("order_no"))) {
+            order.setUpstreamOrderNo(params.get("order_no"));
+        }
+        if (StrUtil.isNotBlank(params.get("platform_trade_no"))) {
+            order.setPlatformTradeNo(params.get("platform_trade_no"));
+        }
+        order.setTradeTime(parseTime(params.get("trade_time")));
+        order.setRefundTime(parseTime(params.get("refund_time")));
+        OffsetDateTime upstreamCreateTime = parseTime(params.get("create_time"));
+        if (upstreamCreateTime != null) {
+            order.setUpstreamCreateTime(upstreamCreateTime);
+        }
+        orderService.reschedulePoll(order);
+        orderMapper.updateById(order);
+
+        callbackService.notifyEnterprise(order);
+        log.info("[汇安融通知] 已处理: order_no={}, status={}", orderNo, newStatus);
+        return SUCCESS;
+    }
+
+    private OffsetDateTime parseTime(String value) {
+        if (StrUtil.isBlank(value)) {
+            return null;
+        }
+        try {
+            return LocalDateTime.parse(value.trim(), TIME_FORMATTER)
+                    .atZone(ZoneId.systemDefault()).toOffsetDateTime();
+        } catch (Exception e) {
+            log.warn("[汇安融通知] 时间解析失败: {}", value);
+            return null;
+        }
+    }
+}

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

@@ -0,0 +1,475 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.utils.SnowflakeIdGenerator;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.client.HarClient;
+import com.payment.platform.module.payment.har.client.HarResponse;
+import com.payment.platform.module.payment.har.client.HarResponseParser;
+import com.payment.platform.module.payment.har.client.dto.HarPayRequest;
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateDetail;
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
+import com.payment.platform.module.payment.har.client.dto.HarQueryDetail;
+import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * 汇安融订单服务。
+ *
+ * 订单号策略:order_no 由我方生成(HAR 前缀 + 雪花 ID),作为 third_order_no 传上游。
+ * 企业自己的单号存 out_biz_no,不直接透传 —— 不同企业的单号可能重复,
+ * 而上游的 third_order_no 需要全局唯一。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarOrderService {
+
+    private static final String ORDER_NO_PREFIX = "HAR";
+
+    private static final DateTimeFormatter TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    /**
+     * 首次轮询延迟(秒)。
+     *
+     * 上游通知「未收到 success 则每 5 秒重试共 5 次」,窗口约 25 秒;
+     * 30 秒后开始查,正好在通知窗口彻底关闭、结果确定之后。
+     */
+    private static final int FIRST_POLL_DELAY_SECONDS = 30;
+
+    private final HarOrderMapper orderMapper;
+    private final HarMerchantService merchantService;
+    private final HarClient harClient;
+
+    public PageResult<HarOrderVO> list(int pageNo, int pageSize, HarOrderQueryDTO q) {
+        LambdaQueryWrapper<HarOrderEntity> w = new LambdaQueryWrapper<>();
+        if (q != null) {
+            if (StrUtil.isNotBlank(q.getEnterpriseId())) {
+                w.eq(HarOrderEntity::getEnterpriseId, q.getEnterpriseId());
+            }
+            if (StrUtil.isNotBlank(q.getMerchantCode())) {
+                w.eq(HarOrderEntity::getMerchantCode, q.getMerchantCode());
+            }
+            if (StrUtil.isNotBlank(q.getOrderNo())) {
+                w.eq(HarOrderEntity::getOrderNo, q.getOrderNo());
+            }
+            if (StrUtil.isNotBlank(q.getOutBizNo())) {
+                w.eq(HarOrderEntity::getOutBizNo, q.getOutBizNo());
+            }
+            if (StrUtil.isNotBlank(q.getTradeStatus())) {
+                w.eq(HarOrderEntity::getTradeStatus, q.getTradeStatus());
+            }
+            if (StrUtil.isNotBlank(q.getTradeType())) {
+                w.eq(HarOrderEntity::getTradeType, q.getTradeType());
+            }
+            if (StrUtil.isNotBlank(q.getStartDate())) {
+                w.ge(HarOrderEntity::getCreatedTime, parseDate(q.getStartDate()));
+            }
+            if (StrUtil.isNotBlank(q.getEndDate())) {
+                w.le(HarOrderEntity::getCreatedTime,
+                        parseDate(q.getEndDate()).plusDays(1));
+            }
+        }
+        w.orderByDesc(HarOrderEntity::getId);
+        Page<HarOrderEntity> page = orderMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        List<HarOrderVO> rows = page.getRecords().stream().map(HarOrderVO::of).toList();
+        return PageResult.of(pageNo, pageSize, page.getTotal(), rows);
+    }
+
+    public HarOrderEntity getEntityById(Long id) {
+        HarOrderEntity entity = orderMapper.selectById(id);
+        if (entity == null) {
+            throw new BusinessException("订单不存在");
+        }
+        return entity;
+    }
+
+    public HarOrderVO detail(Long id) {
+        return HarOrderVO.of(getEntityById(id));
+    }
+
+    public HarOrderEntity getByOrderNo(String orderNo) {
+        if (StrUtil.isBlank(orderNo)) {
+            return null;
+        }
+        return orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
+                .eq(HarOrderEntity::getOrderNo, orderNo)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 按企业业务单号反查。
+     *
+     * enterpriseId 为空 ⇒ **不加企业谓词**,退回租户范围(与 {@link #openQuery} 的口径一致:
+     * 不传企业即按租户办事)。
+     *
+     * ⚠ 不能写成无条件 `.eq(HarOrderEntity::getEnterpriseId, enterpriseId)`:
+     * enterpriseId 为 null 时它生成的是 `enterprise_id = NULL`,SQL 里恒为 UNKNOWN,
+     * **永远匹配不到任何行** —— 那是「查不到」而不是「不限」。
+     */
+    public HarOrderEntity getByOutBizNo(String enterpriseId, String outBizNo) {
+        LambdaQueryWrapper<HarOrderEntity> w = new LambdaQueryWrapper<>();
+        if (StrUtil.isNotBlank(enterpriseId)) {
+            w.eq(HarOrderEntity::getEnterpriseId, enterpriseId);
+        }
+        return orderMapper.selectOne(w
+                .eq(HarOrderEntity::getOutBizNo, outBizNo)
+                .orderByDesc(HarOrderEntity::getId)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 开放 API 查单。order_no 与 out_biz_no 二选一。
+     *
+     * 除 TenantInnerInterceptor 施加的租户过滤外,额外比对 enterprise_id ——
+     * 同一租户下可能有多个企业,只靠租户过滤会让 A 企业查到 B 企业的订单。
+     * 越权与不存在都返回同一个「订单不存在」,避免通过错误信息探测他人订单是否存在。
+     */
+    public HarOrderVO openQuery(HarOpenOrderQueryDTO q) {
+        HarOrderEntity order;
+        if (StrUtil.isNotBlank(q.getOrderNo())) {
+            order = getByOrderNo(q.getOrderNo());
+        } else if (StrUtil.isNotBlank(q.getOutBizNo())) {
+            order = getByOutBizNo(q.getEnterpriseId(), q.getOutBizNo());
+        } else {
+            throw new BusinessException("order_no 与 out_biz_no 至少填一个");
+        }
+        if (order == null) {
+            throw new BusinessException("订单不存在");
+        }
+        // 只有**传了** enterprise_id 才做企业维度的比对;不传即按租户范围
+        // (与乐收赢一致 —— 那边只有租户这一维)。越权与不存在返回同一句话,
+        // 避免通过错误信息探测他人订单是否存在。
+        if (StrUtil.isNotBlank(q.getEnterpriseId())
+                && !StrUtil.equals(order.getEnterpriseId(), q.getEnterpriseId())) {
+            throw new BusinessException("订单不存在");
+        }
+        // 商户维度同理(2026-09-24 一租户多商户):传了就只认这个商户的订单。
+        // 少了这句,调用方指定 M700 却可能收到 M701 的单 —— 返回体里的
+        // merchant_code 与请求对不上,而调用方没有任何办法察觉。
+        if (StrUtil.isNotBlank(q.getMerchantCode())
+                && !StrUtil.equals(order.getMerchantCode(), q.getMerchantCode())) {
+            throw new BusinessException("订单不存在");
+        }
+        return HarOrderVO.of(order);
+    }
+
+    /**
+     * 下单。
+     *
+     * 刻意不加 @Transactional:本地记录必须在任何上游结果下都存活。
+     * 若包了事务,上游超时抛异常会把 insert 一起回滚,而「超时」恰恰意味着
+     * 上游可能已经建单成功 —— 那样这笔单在上游有、在我们本地没有,
+     * 通知到达时因找不到订单被丢弃,就会出现扣了钱却没有记录的账。
+     * 宁可留一条状态未知的记录交给轮询收敛,也不留下无从追溯的空洞。
+     *
+     * 注意:本方法失败时会把异常抛给调用方,但本地记录保持 trade_status=0
+     * 且已排入轮询,因此结果仍会被查询收敛。
+     */
+    public HarOrderVO create(HarOrderCreateDTO dto, String enterpriseId, String apiKey) {
+        // 企业为空 ⇒ 按**租户**寻址(去企业化,2026-09-24,见 db/har-tenant-level.sql)。
+        // 分流规则与 HarOpenController#requireMerchant、HarShopService#create
+        // 是同一套 —— 三处必须同时改,漏一处就会出现「闸放行了下游却仍要求企业」。
+        String ownerEnterpriseId = StrUtil.isNotBlank(enterpriseId)
+                ? enterpriseId : dto.getEnterpriseId();
+        // 三条分支,次序就是优先级:① 显式 merchant_code(一个租户可以有多条配置,
+        // 2026-09-24 —— 钱进哪个商户账号由调用方说清楚);② 企业级;③ 租户级
+        // (租户级只有一条时才成立,多条由 requireByTenant 报 400 拦下)。
+        HarMerchantEntity merchant;
+        if (StrUtil.isNotBlank(dto.getMerchantCode())) {
+            merchant = merchantService.requireByTenantAndCode(
+                    getCurrentTenantId(), dto.getMerchantCode());
+        } else if (StrUtil.isNotBlank(ownerEnterpriseId)) {
+            merchant = merchantService.requireByEnterpriseId(ownerEnterpriseId);
+        } else {
+            merchant = merchantService.requireByTenant(getCurrentTenantId());
+        }
+
+        String orderNo = ORDER_NO_PREFIX + SnowflakeIdGenerator.nextIdStr();
+        boolean scanMode = StrUtil.isNotBlank(dto.getAuthCode());
+
+        HarOrderEntity entity = new HarOrderEntity();
+        // 空 ⇒ NULL,不是空串:租户级订单找的正是 enterprise_id IS NULL,
+        // 存成空串的话 openQuery 那条「IS NULL」口径就看不到这一行。
+        // 口径同 LsyStrings.blankToNull(isBlank ⇒ null,非空白原样,不 trim)。
+        entity.setEnterpriseId(StrUtil.isBlank(ownerEnterpriseId) ? null : ownerEnterpriseId);
+        entity.setOrderNo(orderNo);
+        entity.setOutBizNo(dto.getOutBizNo());
+        entity.setApiKey(apiKey);
+        entity.setMerchantCode(merchant.getMerchantCode());
+        entity.setShopCode(dto.getShopCode());
+        entity.setThirdShopCode(dto.getThirdShopCode());
+        entity.setPrice(dto.getPrice());
+        entity.setTitle(dto.getTitle());
+        entity.setTradeType(dto.getTradeType());
+        entity.setStageNum(dto.getStageNum());
+        entity.setPercent(dto.getPercent());
+        entity.setPayTimeout(dto.getPayTimeout());
+        entity.setExtraParams(dto.getExtraParams());
+        entity.setTradeStatus(HarOrderStatus.WAIT_PAY);
+        entity.setQueryCount(0);
+        entity.setNextQueryTime(OffsetDateTime.now().plusSeconds(FIRST_POLL_DELAY_SECONDS));
+        entity.setDescription(dto.getTitle());
+        orderMapper.insert(entity);
+
+        // 本地单已落库后才发起上游调用:上游超时/失败时本地仍有完整记录,
+        // 且 next_query_time 已排入轮询,不会因网络问题凭空丢单。
+        HarResponse response = scanMode
+                ? harClient.pay(buildPayRequest(dto, merchant, orderNo))
+                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo));
+
+        if (!response.isSuccess()) {
+            String reason = "上游返回 " + response.responseCode() + ": " + response.msg();
+            entity.setDescription(reason);
+            orderMapper.updateById(entity);
+            throw new BusinessException("下单失败: " + response.msg());
+        }
+
+        HarPrecreateDetail detail = HarResponseParser.toBean(
+                response.detailRaw(), HarPrecreateDetail.class);
+        if (detail != null) {
+            entity.setPayUrl(detail.getPayUrl());
+            entity.setUpstreamOrderNo(detail.getOrderNo());
+        }
+        orderMapper.updateById(entity);
+        return HarOrderVO.of(entity);
+    }
+
+    /**
+     * 主动查上游并回写。
+     *
+     * 上游通知重试窗口只有约 25 秒,超过即永久丢失,
+     * 因此主动查询是状态收敛的主路径,不是兜底。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public HarOrderVO syncFromUpstream(HarOrderEntity order) {
+        HarQueryRequest req = new HarQueryRequest();
+        // 优先用上游单号查;拿不到时用我方单号走 third_order_no。
+        // 不能只传 order_no —— 下单超时的订单本地根本没有上游单号,
+        // 那样每次查询都会因为 order_no 为空而查不到。
+        if (StrUtil.isNotBlank(order.getUpstreamOrderNo())) {
+            req.setOrderNo(order.getUpstreamOrderNo());
+        } else {
+            req.setThirdOrderNo(order.getOrderNo());
+        }
+
+        order.setLastQueryTime(OffsetDateTime.now());
+        order.setQueryCount(order.getQueryCount() == null ? 1 : order.getQueryCount() + 1);
+
+        HarResponse response = harClient.query(req);
+        if (!response.isSuccess()) {
+            log.warn("[汇安融订单] 查询返回非成功码: order_no={}, code={}, msg={}",
+                    order.getOrderNo(), response.responseCode(), response.msg());
+            // 查询失败也要推进 next_query_time,否则这条记录会被每轮任务反复捞起
+            order.setNextQueryTime(OffsetDateTime.now()
+                    .plusSeconds(nextDelaySeconds(order.getQueryCount(), false)));
+            orderMapper.updateById(order);
+            return HarOrderVO.of(order);
+        }
+
+        HarQueryDetail detail = HarResponseParser.toBean(response.detailRaw(), HarQueryDetail.class);
+        if (detail != null) {
+            applyQueryDetail(order, detail);
+        } else {
+            order.setNextQueryTime(OffsetDateTime.now()
+                    .plusSeconds(nextDelaySeconds(order.getQueryCount(), false)));
+        }
+        orderMapper.updateById(order);
+        return HarOrderVO.of(order);
+    }
+
+    /**
+     * 把上游 detail 映射到订单实体。
+     *
+     * 终态清空 nextQueryTime —— 否则轮询任务会永远捞起已经结束的订单。
+     */
+    public void applyQueryDetail(HarOrderEntity order, HarQueryDetail detail) {
+        if (detail.getStatus() != null) {
+            order.setTradeStatus(String.valueOf(detail.getStatus()));
+        }
+        if (StrUtil.isNotBlank(detail.getPlatformTradeNo())) {
+            order.setPlatformTradeNo(detail.getPlatformTradeNo());
+        }
+        if (StrUtil.isNotBlank(detail.getOrderNo())) {
+            order.setUpstreamOrderNo(detail.getOrderNo());
+        }
+        if (StrUtil.isNotBlank(detail.getPrice())) {
+            order.setPrice(parseAmount(detail.getPrice()));
+        }
+        order.setRcvAmount(parseAmount(detail.getRcvAmount()));
+        order.setFeeAmount(parseAmount(detail.getFeeAmount()));
+        order.setChannelAmount(parseAmount(detail.getChannelAmount()));
+        order.setTradeTime(parseTime(detail.getTradeTime()));
+        order.setRefundTime(parseTime(detail.getRefundTime()));
+        order.setUpstreamCreateTime(parseTime(detail.getCreateTime()));
+
+        reschedulePoll(order);
+    }
+
+    /**
+     * 按当前状态重排轮询。
+     *
+     * 终态清空 next_query_time,其余一律重新排期 —— 只做「终态清空」是不够的:
+     * 退款会把订单从终态(1 已支付)拉回非终态(4 退款中),此时 next_query_time
+     * 还停在之前被清空的 null 上,而轮询任务按 next_query_time IS NOT NULL 捞单,
+     * 这条订单就再也查不到了,退款结果彻底失联。
+     *
+     * 遇到不认识的状态同样继续排期:上游将来加了新状态,继续查比静默停掉安全。
+     */
+    public void reschedulePoll(HarOrderEntity order) {
+        if (HarOrderStatus.isFinal(order.getTradeStatus())) {
+            order.setNextQueryTime(null);
+        } else {
+            order.setNextQueryTime(OffsetDateTime.now()
+                    .plusSeconds(nextDelaySeconds(order.getQueryCount(), true)));
+        }
+    }
+
+    /**
+     * 轮询退避:30s → 1m → 2m → 5m → 15m 封顶。
+     *
+     * @param finalStateKnown 本次是否拿到了明确的非终态(拿到则按正常退避,
+     *                        查询本身失败则退避更快,避免上游故障时刷爆对方)
+     */
+    private long nextDelaySeconds(Integer queryCount, boolean finalStateKnown) {
+        int count = queryCount == null ? 0 : queryCount;
+        if (!finalStateKnown) {
+            return count <= 3 ? 60 : 300;
+        }
+        if (count <= 1) {
+            return 30;
+        }
+        if (count == 2) {
+            return 60;
+        }
+        if (count == 3) {
+            return 120;
+        }
+        if (count == 4) {
+            return 300;
+        }
+        return 900;
+    }
+
+    /** 供轮询任务使用:捞取到期待查的非终态订单 */
+    public List<HarOrderEntity> findPendingForPoll(int limit) {
+        return orderMapper.selectList(new LambdaQueryWrapper<HarOrderEntity>()
+                .in(HarOrderEntity::getTradeStatus, HarOrderStatus.PENDING)
+                .isNotNull(HarOrderEntity::getNextQueryTime)
+                .le(HarOrderEntity::getNextQueryTime, OffsetDateTime.now())
+                .orderByAsc(HarOrderEntity::getNextQueryTime)
+                .last("LIMIT " + limit));
+    }
+
+    /**
+     * 当前登录身份的租户 ID,供租户级寻址使用。
+     *
+     * 与 AutoFillMetaObjectHandler#getCurrentTenantId 同形 —— 本仓各处都是各持一份
+     * 私有副本(AlipayEmployeeService / ApikeyController / OpenapiController 等),
+     * 这里沿用同一写法而不是新抽一个工具类。
+     *
+     * 开放 API 路径下 TenantApiKeyAuthFilter 认证成功后注入的 LoginUser 同样带 tenantId,
+     * 所以管理端与开放 API 两条路径取到的是同一个来源。
+     */
+    private Long getCurrentTenantId() {
+        var auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth != null && auth.getPrincipal() instanceof LoginUser user) {
+            return user.getTenantId();
+        }
+        return null;
+    }
+
+    /** 空串不能被解析成 0,否则对账口径失真 */
+    private BigDecimal parseAmount(String value) {
+        if (StrUtil.isBlank(value)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(value.trim());
+        } catch (NumberFormatException e) {
+            log.warn("[汇安融订单] 金额解析失败: {}", value);
+            return null;
+        }
+    }
+
+    private OffsetDateTime parseTime(String value) {
+        if (StrUtil.isBlank(value)) {
+            return null;
+        }
+        try {
+            return LocalDateTime.parse(value.trim(), TIME_FORMATTER)
+                    .atZone(ZoneId.systemDefault()).toOffsetDateTime();
+        } catch (Exception e) {
+            log.warn("[汇安融订单] 时间解析失败: {}", value);
+            return null;
+        }
+    }
+
+    private OffsetDateTime parseDate(String value) {
+        return LocalDateTime.parse(value.trim() + " 00:00:00", TIME_FORMATTER)
+                .atZone(ZoneId.systemDefault()).toOffsetDateTime();
+    }
+
+    private HarPrecreateRequest buildPrecreateRequest(HarOrderCreateDTO dto,
+                                                      HarMerchantEntity merchant,
+                                                      String orderNo) {
+        HarPrecreateRequest req = new HarPrecreateRequest();
+        req.setPrice(dto.getPrice().toPlainString());
+        req.setTitle(dto.getTitle());
+        req.setStageNum(dto.getStageNum());
+        req.setTradeType(dto.getTradeType());
+        req.setPercent(dto.getPercent());
+        req.setMerchantCode(merchant.getMerchantCode());
+        req.setThirdOrderNo(orderNo);
+        req.setShopCode(dto.getShopCode());
+        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setPayTimeout(dto.getPayTimeout());
+        req.setExtraParams(dto.getExtraParams());
+        req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");
+        return req;
+    }
+
+    private HarPayRequest buildPayRequest(HarOrderCreateDTO dto,
+                                          HarMerchantEntity merchant,
+                                          String orderNo) {
+        HarPayRequest req = new HarPayRequest();
+        req.setPrice(dto.getPrice().toPlainString());
+        req.setTitle(dto.getTitle());
+        req.setAuthCode(dto.getAuthCode());
+        req.setStageNum(dto.getStageNum());
+        req.setTradeType(dto.getTradeType());
+        req.setPercent(dto.getPercent());
+        req.setMerchantCode(merchant.getMerchantCode());
+        req.setThirdOrderNo(orderNo);
+        req.setShopCode(dto.getShopCode());
+        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setPayTimeout(dto.getPayTimeout());
+        req.setExtraParams(dto.getExtraParams());
+        req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");
+        return req;
+    }
+}

+ 207 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java

@@ -0,0 +1,207 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.client.HarApiException;
+import com.payment.platform.module.payment.har.client.HarClient;
+import com.payment.platform.module.payment.har.client.HarResponse;
+import com.payment.platform.module.payment.har.client.HarResponseParser;
+import com.payment.platform.module.payment.har.client.dto.HarShopCreateDetail;
+import com.payment.platform.module.payment.har.client.dto.HarShopCreateRequest;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarShopQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarShopVO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
+import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
+import com.payment.platform.module.payment.har.mapper.HarShopMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 门店服务。
+ *
+ * 上游没有门店查询接口,本地表是唯一真相源,因此:
+ *
+ * 1. 先落 PENDING 再调上游 —— 保证任何情况下都留下记录,超时也不会凭空丢单;
+ * 2. 网络超时不标记 FAILED —— 上游可能已创建成功,标失败会误导人工;
+ * 3. 不自动重试 —— 无法核实远端状态,重试可能产生重复门店。
+ *
+ * PENDING 的语义是「结果未知,需人工核实」,不是「处理中」。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarShopService {
+
+    private final HarShopMapper shopMapper;
+    private final HarMerchantService merchantService;
+    private final HarClient harClient;
+
+    public PageResult<HarShopVO> list(int pageNo, int pageSize, HarShopQueryDTO q) {
+        LambdaQueryWrapper<HarShopEntity> w = new LambdaQueryWrapper<>();
+        if (q != null) {
+            if (StrUtil.isNotBlank(q.getEnterpriseId())) {
+                w.eq(HarShopEntity::getEnterpriseId, q.getEnterpriseId());
+            }
+            if (StrUtil.isNotBlank(q.getMerchantCode())) {
+                w.eq(HarShopEntity::getMerchantCode, q.getMerchantCode());
+            }
+            if (StrUtil.isNotBlank(q.getThirdShopId())) {
+                w.like(HarShopEntity::getThirdShopId, q.getThirdShopId());
+            }
+            if (StrUtil.isNotBlank(q.getCreateStatus())) {
+                w.eq(HarShopEntity::getCreateStatus, q.getCreateStatus());
+            }
+        }
+        w.orderByDesc(HarShopEntity::getId);
+        Page<HarShopEntity> page = shopMapper.selectPage(new Page<>(pageNo, pageSize), w);
+        List<HarShopVO> rows = page.getRecords().stream().map(HarShopVO::of).toList();
+        return PageResult.of(pageNo, pageSize, page.getTotal(), rows);
+    }
+
+    /**
+     * 按 (归属, 门店编码) 查重 —— third_shop_id 是**幂等键**,只在同一个归属内唯一。
+     *
+     * ⚠ 这里用 `.isNull()` 而**不是**不加谓词:本方法是「这个归属下是不是已经有这个
+     * 编码了」的判定,租户级归属查的就该只有租户级那些行。
+     * 更不能写成无条件 `.eq(HarShopEntity::getEnterpriseId, enterpriseId)` ——
+     * enterpriseId 为 null 时它生成 `enterprise_id = NULL`,恒为 UNKNOWN,
+     * 查重**永远返回 null**(形同虚设),只剩 db/har-tenant-level.sql 的
+     * uk_har_shop_tenant_third_shop 兜底,而那条兜底会把「重复建店」
+     * 变成一句 500,而不是这里这句能读懂的提示。
+     */
+    public HarShopEntity getByThirdShopId(String enterpriseId, String thirdShopId) {
+        LambdaQueryWrapper<HarShopEntity> w = new LambdaQueryWrapper<>();
+        if (StrUtil.isNotBlank(enterpriseId)) {
+            w.eq(HarShopEntity::getEnterpriseId, enterpriseId);
+        } else {
+            w.isNull(HarShopEntity::getEnterpriseId);
+        }
+        return shopMapper.selectOne(w
+                .eq(HarShopEntity::getThirdShopId, thirdShopId)
+                .last("LIMIT 1"));
+    }
+
+    public HarShopEntity getById(Long id) {
+        HarShopEntity entity = shopMapper.selectById(id);
+        if (entity == null) {
+            throw new BusinessException("门店不存在");
+        }
+        return entity;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public HarShopVO create(HarShopCreateDTO dto, String enterpriseId) {
+        // 企业为空 ⇒ 按**租户**寻址(去企业化,2026-09-24,见 db/har-tenant-level.sql)。
+        // 分流规则与 HarOpenController#requireMerchant、HarOrderService#create
+        // 是同一套 —— 三处必须同时改。
+        String ownerEnterpriseId = StrUtil.isNotBlank(enterpriseId)
+                ? enterpriseId : dto.getEnterpriseId();
+        if (getByThirdShopId(ownerEnterpriseId, dto.getThirdShopId()) != null) {
+            throw new BusinessException("门店编码已存在: " + dto.getThirdShopId());
+        }
+        // 三条分支,次序就是优先级:
+        // ① 显式 merchant_code —— 一个租户可以有多条配置(2026-09-24),
+        //    门店铺在上游是挂在商户下的,调用方指定才是准的;
+        // ② 企业级映射;③ 租户级(此时只有一条才成立,多条由 requireByTenant 报 400 拦下)。
+        HarMerchantEntity merchant;
+        if (StrUtil.isNotBlank(dto.getMerchantCode())) {
+            merchant = merchantService.requireByTenantAndCode(
+                    getCurrentTenantId(), dto.getMerchantCode());
+        } else if (StrUtil.isNotBlank(ownerEnterpriseId)) {
+            merchant = merchantService.requireByEnterpriseId(ownerEnterpriseId);
+        } else {
+            merchant = merchantService.requireByTenant(getCurrentTenantId());
+        }
+
+        HarShopCreateRequest req = new HarShopCreateRequest();
+        req.setMerchantCode(merchant.getMerchantCode());
+        req.setThirdShopId(dto.getThirdShopId());
+        req.setName(dto.getShopName());
+
+        // 1. 先落 PENDING —— 无论后续发生什么都留下记录
+        HarShopEntity entity = new HarShopEntity();
+        // 空 ⇒ NULL,不是空串:租户级门店找的正是 enterprise_id IS NULL
+        // (见 getByThirdShopId 与 uk_har_shop_tenant_third_shop)。
+        // 口径同 LsyStrings.blankToNull(isBlank ⇒ null,非空白原样,不 trim)。
+        entity.setEnterpriseId(StrUtil.isBlank(ownerEnterpriseId) ? null : ownerEnterpriseId);
+        entity.setMerchantCode(merchant.getMerchantCode());
+        entity.setThirdShopId(dto.getThirdShopId());
+        entity.setShopName(dto.getShopName());
+        entity.setCreateStatus(HarShopCreateStatus.PENDING);
+        entity.setRequestBody(HarClient.toBizContent(req));
+        entity.setRetryCount(0);
+        entity.setDescription(dto.getShopName());
+        shopMapper.insert(entity);
+
+        // 2. 调上游
+        try {
+            HarResponse response = harClient.createShop(req);
+            entity.setResponseBody(renderResponse(response));
+
+            if (!response.isSuccess()) {
+                entity.setCreateStatus(HarShopCreateStatus.FAILED);
+                entity.setFailReason("上游返回 " + response.responseCode() + ": " + response.msg());
+            } else {
+                HarShopCreateDetail detail = HarResponseParser.toBean(
+                        response.detailRaw(), HarShopCreateDetail.class);
+                if (detail == null || StrUtil.isBlank(detail.getShopId())) {
+                    entity.setCreateStatus(HarShopCreateStatus.FAILED);
+                    entity.setFailReason("上游返回成功但未给出 shop_id");
+                } else {
+                    entity.setShopId(detail.getShopId());
+                    entity.setCreateStatus(HarShopCreateStatus.SUCCESS);
+                }
+            }
+        } catch (HarApiException e) {
+            // 网络层失败:无法判断远端是否已创建,保持 PENDING 交人工处理
+            entity.setCreateStatus(HarShopCreateStatus.PENDING);
+            entity.setFailReason(e.getMessage());
+            log.error("[汇安融门店] 调用上游失败,保持 PENDING 待人工核实: third_shop_id={}, error={}",
+                    dto.getThirdShopId(), e.getMessage());
+        }
+
+        entity.setRetryCount(entity.getRetryCount() == null ? 1 : entity.getRetryCount() + 1);
+        shopMapper.updateById(entity);
+        return HarShopVO.of(entity);
+    }
+
+    /**
+     * 把响应还原成可读报文存库,供超时后人工核实远端状态。
+     *
+     * 此处不校验响应签名:门店创建由我方发起,返回的 shop_id 若不实只会导致后续
+     * 下单失败,风险可控;而响应格式尚未确认,此时加验签只会在联调期产生噪音。
+     * 与之相对,通知入口的验签是必须的 —— 那是外部可以主动构造的请求,
+     * 见 HarNotifyService。
+     */
+    private String renderResponse(HarResponse response) {
+        return "response_code=" + response.responseCode()
+                + "&msg=" + response.msg()
+                + "&detail=" + response.detailRaw()
+                + "&sign=" + response.sign();
+    }
+
+    /**
+     * 当前登录身份的租户 ID,供租户级寻址使用。
+     *
+     * 与 AutoFillMetaObjectHandler#getCurrentTenantId 同形 —— 本仓各处都是各持一份
+     * 私有副本,这里沿用同一写法而不是新抽一个工具类。
+     */
+    private Long getCurrentTenantId() {
+        var auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth != null && auth.getPrincipal() instanceof LoginUser user) {
+            return user.getTenantId();
+        }
+        return null;
+    }
+}

+ 18 - 0
java/src/main/resources/application.yml

@@ -116,6 +116,24 @@ alipay:
   request-timeout: 30
   rate-limit: 100
 
+# 汇安融(HAR)支付宝通道
+# 我们是二级服务商,上游汇安融是一级,实际支付能力由上游提供。
+# 密钥未配好前 enabled 保持 false —— 定时轮询据此跳过,不会带着空密钥发起调用。
+har:
+  enabled: false
+  # 上游根地址(不含 context-path,代码里固定拼 /kf-alipay-api)
+  base-url: ""
+  # 上游分配的应用编号,联调时确定
+  app-id: ""
+  version: "2.0"
+  # 我方私钥,PKCS#8 纯 Base64(换行自行去掉)
+  our-private-key: ""
+  # 汇安融公钥,X.509 纯 Base64(通知验签用,必须配)
+  platform-public-key: ""
+  callback-pool-size: 4
+  connect-timeout-ms: 5000
+  read-timeout-ms: 15000
+
 # Alibaba Cloud (OSS等)
 alibaba-cloud:
   access-key-id: LTAI5t5jHkQMY2T7V5n4ofMr

+ 102 - 0
java/src/main/resources/db/har-menu.sql

@@ -0,0 +1,102 @@
+-- 汇安融支付宝通道:独立菜单 + 权限点(2026-09-16)
+--
+-- 结构:type=1 目录 /payment/har → 3 个 type=2 页面 → 各自 type=3 权限点
+--
+-- 两个必须遵守的约定(都是踩过的坑):
+--   1. component_path 相对 views/,不带 views/ 前缀、不带 .vue 后缀
+--      (permission.store.ts 用 modules[`../../views/${component}.vue`] 查找,
+--       写成 views/xxx/index.vue 会查不到 → 落 404)
+--   2. 目录本身也必须写入 sys_role_menus。
+--      UserService.buildMenuTree 只会把 parent 同在集合内的节点挂上树,
+--      父节点不在集合里的子节点会被**直接丢弃** —— 只给子页面授权,
+--      结果是整个菜单静默消失,不报错。
+
+-- ---------- 顶层目录 ----------
+-- component_path 留空:目录只作分组,叶子节点才需要组件
+INSERT INTO sys_menu (parent_id, name, title, route_name, route_path, component_path,
+                      permission, icon, type, "order", hidden, keep_alive, always_show,
+                      affix, status, created_time, updated_time)
+VALUES (NULL, '汇安融通道', '汇安融通道', 'payment-har', '/payment/har', NULL,
+        NULL, 'money', 1,
+        (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id IS NULL),
+        false, false, false, false, '0', now(), now());
+
+-- ---------- 三个页面 ----------
+INSERT INTO sys_menu (parent_id, name, title, route_name, route_path, component_path,
+                      permission, type, "order", hidden, keep_alive, always_show,
+                      affix, status, created_time, updated_time)
+VALUES
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND type = 1),
+     '商户配置', '商户配置', 'payment-har-merchant', '/payment/har/merchant',
+     'module_payment/har/merchant/index', 'module_payment:har:merchant:list', 2, 1,
+     false, true, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND type = 1),
+     '门店管理', '门店管理', 'payment-har-shop', '/payment/har/shop',
+     'module_payment/har/shop/index', 'module_payment:har:shop:list', 2, 2,
+     false, true, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND type = 1),
+     '订单管理', '订单管理', 'payment-har-order', '/payment/har/order',
+     'module_payment/har/order/index', 'module_payment:har:order:list', 2, 3,
+     false, true, false, false, '0', now(), now());
+
+-- ---------- 权限点 ----------
+-- permission 取值必须与 @PreAuthorize("@perm.hasAny('...')") 里的字符串逐字一致,
+-- PermissionCheck.hasAny 是按 authorities 集合做精确匹配的
+INSERT INTO sys_menu (parent_id, name, title, permission, type, "order",
+                      hidden, keep_alive, always_show, affix, status, created_time, updated_time)
+VALUES
+    -- 商户配置(对应 HarMerchantController)
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/merchant'),
+     '查询商户配置', '查询商户配置', 'module_payment:har:merchant:list', 3, 1,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/merchant'),
+     '保存商户配置', '保存商户配置', 'module_payment:har:merchant:save', 3, 2,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/merchant'),
+     '删除商户配置', '删除商户配置', 'module_payment:har:merchant:delete', 3, 3,
+     false, false, false, false, '0', now(), now()),
+
+    -- 门店管理(对应 HarShopController)
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/shop'),
+     '查询门店', '查询门店', 'module_payment:har:shop:list', 3, 1,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/shop'),
+     '门店详情', '门店详情', 'module_payment:har:shop:detail', 3, 2,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/shop'),
+     '创建门店', '创建门店', 'module_payment:har:shop:create', 3, 3,
+     false, false, false, false, '0', now(), now()),
+
+    -- 订单管理(对应 HarOrderController)
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/order'),
+     '查询订单', '查询订单', 'module_payment:har:order:list', 3, 1,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/order'),
+     '订单详情', '订单详情', 'module_payment:har:order:detail', 3, 2,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/order'),
+     '创建订单', '创建订单', 'module_payment:har:order:create', 3, 3,
+     false, false, false, false, '0', now(), now()),
+    ((SELECT id FROM sys_menu WHERE route_path = '/payment/har/order'),
+     '同步上游状态', '同步上游状态', 'module_payment:har:order:sync', 3, 4,
+     false, false, false, false, '0', now(), now());
+
+-- ---------- 角色授权 ----------
+-- role 1 = ADMIN:全部
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 1, id FROM sys_menu
+WHERE route_path IN ('/payment/har', '/payment/har/merchant', '/payment/har/shop', '/payment/har/order')
+   OR permission LIKE 'module_payment:har:%';
+
+-- role 2 = tenant:门店管理 + 订单管理,不含商户配置。
+--
+-- 商户入驻进件在上游是纯人工流程,merchant_code 由运营报备后录入。
+-- 若开放给租户,任何租户都能把别人的 merchant_code 抢注到自己的映射上 ——
+-- pay_har_merchant 的租户隔离挡不住这个:它只保证「只能建自己租户的行」,
+-- 拦不住「占用别人已报备的商户编号」,而 merchant_code 上有唯一约束,
+-- 结果就是真正的商户被锁在门外。门店与订单是租户日常自助操作,正常开放。
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 2, id FROM sys_menu
+WHERE route_path IN ('/payment/har', '/payment/har/shop', '/payment/har/order')
+   OR permission LIKE 'module_payment:har:shop:%'
+   OR permission LIKE 'module_payment:har:order:%';

+ 49 - 0
java/src/main/resources/db/har-merchant-multi-per-tenant.sql

@@ -0,0 +1,49 @@
+-- 一个租户允许多个商户编号(2026-09-24 用户裁定)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- db/har-tenant-level.sql 在 2026-09-24 早些时候建了这条索引:
+--     uk_har_merchant_tenant_level  ON pay_har_merchant (tenant_id)
+--                                   WHERE enterprise_id IS NULL
+-- 它的语义是「一个租户只能有一条租户级商户映射」。用户随后裁定:
+-- **一个租户允许配置多个商户** ⇒ 这条索引必须去掉,否则第二条永远插不进去
+-- (报 23505 唯一约束冲突,落到 GlobalExceptionHandler 就是一句 500)。
+--
+-- 去掉了会不会失去保护?不会:
+--   · merchant_code 仍是**全局**唯一(uk_har_merchant_code,V1.8 建表时就有)
+--     ⇒ 同一个商户编号不可能被插两次,跨租户也不行;
+--   · enterprise_id 仍是**全局**唯一(uk_har_merchant_enterprise)
+--     ⇒ 一个企业不会挂到两条映射上。
+-- 也就是说本条索引本来只管「租户内条数」,而条数现在由业务自己决定。
+--
+-- ⚠ 代价(如实写):多条之后,「用哪一条」不再有唯一答案。
+--   代码侧已按「谁下单谁指定」处理 —— HarMerchantService#requireByTenant 在该租户有
+--   多条时抛 400「请指定 merchant_code」,**不做 LIMIT 1 随便挑**(挑错了钱会进错
+--   商户账号,且全程无报错)。请求体里新增的 merchant_code 见三个入参:
+--   HarOrderCreateDTO / HarShopCreateDTO / HarOpenOrderQueryDTO(查单也要带 ——
+--   不带而本租户有多条时,那句 400 提示就成了无法照办的死路)。
+--
+-- 顺序:本脚本与代码**同批**上即可,不像 har-tenant-level.sql 那样有先后约束 ——
+-- 代码在索引还在时也能跑(只是第二个商户存不进去),索引先删也不会让老代码出错。
+
+DROP INDEX IF EXISTS uk_har_merchant_tenant_level;
+
+
+-- ── 只读核对(执行后手工跑一遍)──────────────────────────────────────
+--
+-- 1) 那条索引确实没了(**期望 0 行**)
+--   SELECT indexname FROM pg_indexes
+--    WHERE indexname = 'uk_har_merchant_tenant_level';
+--
+-- 2) 两道仍然在的保护(**期望 2 行**)
+--   SELECT indexname FROM pg_indexes
+--    WHERE indexname IN ('uk_har_merchant_enterprise', 'uk_har_merchant_code');
+--   ★ 若这两行少了一行,先别建第二条商户 —— 那是重复商户编号的唯一防线。
+--
+-- 3) 端到端(唯一能证明「多个商户」真的成立的一条):
+--   用超管在「商户配置」页给**同一个租户**录两条 merchant_code(第二条留空企业),
+--   两条都应保存成功;再用该租户的账号调开放 API 下单:
+--     · 不带 merchant_code ⇒ 400「当前租户配置了多个商户编号,请在请求中指定 merchant_code」;
+--     · 带其中一条 ⇒ 成功,且订单列表里那一行的商户编号就是它。

+ 75 - 0
java/src/main/resources/db/har-merchant-tenant-readonly.sql

@@ -0,0 +1,75 @@
+-- 汇安融「商户配置」对租户开放**只读**(2026-09-24)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前请先读「★ 门禁在哪」一节。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 商户进件在上游是**人工流程**,merchant_code 由运营报备后录入。因此本次改动把
+-- 写操作整个收归超管:
+--   · 新增/修改/删除商户配置 —— **只有超管**,且新增时必须**选择租户**;
+--   · 租户 —— 只能**查看自己**的商户配置(本脚本要放开的正是这一条);
+--   · 门店管理 / 订单管理 / 开放接口 —— 租户照旧自助(不在本脚本范围)。
+--
+-- ── ★ 门禁在哪:在注解,不在本脚本 ────────────────────────────────────
+--     HarMerchantController#save    @PreAuthorize("@perm.hasRole('SUPERUSER')")
+--     HarMerchantController#delete  @PreAuthorize("@perm.hasRole('SUPERUSER')")
+--   ⇒ **别**以为把 module_payment:har:merchant:save / :delete 授给某个角色就能放开写权限:
+--     这两个串对那两个端点**已经不起作用**(改前它们是 @perm.hasAny(...),即「授给谁谁就能建」,
+--     那正是本次要堵的口子)。反过来,授了也不会因此放开 —— 门禁判的是**身份**
+--     (sys_user.is_superuser ⇒ ROLE_SUPERUSER),不是角色授权。
+--   ⇒ 本脚本因此**故意不**把 :save/:delete 授给 role 2(授了只会误导后来人),
+--     也**不需要**动 role 1 的现有授权(那两行留着无害,删除它们同样与端点行为无关)。
+--
+-- ── 本脚本只做一件事 ─────────────────────────────────────────────────
+-- 把**已存在**的「商户配置」页面行与它的 list 权限点授给 role 2(租户角色)。
+-- **不新增任何 sys_menu 行** —— 菜单行由 har-menu.sql 建,那一份用户已执行。
+-- 目录行 /payment/har 早已授给 role 2(har-menu.sql:98-102),这里不用再动;
+-- ★ 但页面行若不给,`buildMenuTree` 会把父不在集合内的子节点**直接丢弃** ——
+--   表现为整个「商户配置」菜单静默消失,不报错(那个坑 har-menu.sql 文件头有记录)。
+--
+-- ── 幂等 ────────────────────────────────────────────────────────────
+-- NOT EXISTS 挡住重复插入:重复执行不报错、也不会多出行。
+-- (与 lsy-cashier-menu.sql 那种「行已存在就整段不插」的写法不同 —— 这里只插授权行,
+--   不存在「页面行已存在 ⇒ 授权也不插」的连带失效。)
+--
+-- ── 代价(如实写)──────────────────────────────────────────────────
+-- 授了 role 2 ⇒ 该租户的用户能在页面上看到**自己那一条** merchant_code。
+-- 那是他自己的号,且写操作已经收归超管,抢注链(见 har-menu.sql:91-97 的原始论证)已断。
+
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 2, m.id
+  FROM sys_menu m
+ WHERE m.status = '0'
+   AND (
+        (m.type = 2 AND m.route_path = '/payment/har/merchant')
+     OR (m.type = 3 AND m.permission = 'module_payment:har:merchant:list')
+   )
+   AND NOT EXISTS (
+        SELECT 1 FROM sys_role_menus rm WHERE rm.role_id = 2 AND rm.menu_id = m.id
+   );
+
+
+-- ── 只读核对(执行后手工跑一遍)──────────────────────────────────────
+--
+-- 1) 租户侧授权行(**期望 2 行**:type=2 页面行 + type=3 权限点行)
+--   SELECT rm.role_id, m.id, m.name, m.type, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE rm.role_id = 2
+--      AND (m.route_path = '/payment/har/merchant'
+--           OR m.permission = 'module_payment:har:merchant:list')
+--    ORDER BY m.type;
+--
+-- 2) 没有顺手把写权限也授出去(**期望 0 行**)
+--   SELECT rm.role_id, m.id, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE rm.role_id = 2
+--      AND m.permission IN ('module_payment:har:merchant:save',
+--                           'module_payment:har:merchant:delete');
+--
+-- 3) 端到端(**唯一能证明租户真的看得见的一条**):用一个**非超管**的租户账号登录,确认
+--      ① 左侧出现「汇安融通道 → 商户配置」;
+--      ② 页面上**没有**「新增配置」按钮,列表行上**没有**「编辑」「删除」;
+--      ③ 列表里只有自己租户的行。
+--    ★ 超管账号证明不了 ①②:PermissionCheck 对 ROLE_SUPERUSER 直接放行,
+--      而按钮的显示与否由前端的 is_superuser 决定,两边都恰好对他为真。
+--
+-- 4) 反证(超管侧):用超管账号登录,确认「新增配置」在,且弹窗里**租户为必填**。

+ 67 - 0
java/src/main/resources/db/har-tenant-level.sql

@@ -0,0 +1,67 @@
+-- 汇安融去企业化(租户账号级)2026-09-24
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 背景: 汇安融与原有企业逻辑不相干,商户映射应按**租户账号**寻址。
+--       本脚本让 pay_har_merchant 支持「租户级商户」—— enterprise_id 为空的那一行。
+--       不新增列、不新增表。形态与 db/lsy-tenant-level.sql 一致(乐收赢 2026-09-22
+--       做过同一件事,本脚本是它在 HAR 侧的对应物)。
+--
+-- ⚠ 三条 ALTER 都**不可逆**: NOT NULL 加不回来,除非先把所有 enterprise_id IS NULL
+--    的行清掉或补上企业 ID。执行前请确认可以接受。
+--
+-- ⚠ **先执行本脚本,再部署代码**(顺序不能反)。配套的代码提交去掉了
+--    HarMerchantSaveDTO / HarShopCreateDTO / HarOrderCreateDTO / HarOpenOrderQueryDTO
+--    上的「企业不能为空」校验,而列还是 NOT NULL 的话,留空保存会从 400
+--    变成 500(NOT NULL 违约落进 GlobalExceptionHandler 兜底)。
+--    本脚本执行前,现有行为一切照旧(老调用方都带 enterprise_id)。
+
+-- 1. 允许租户级商户
+ALTER TABLE pay_har_merchant ALTER COLUMN enterprise_id DROP NOT NULL;
+
+-- 2. 租户级商户的唯一性
+--    现有唯一约束 uk_har_merchant_enterprise 建在 (enterprise_id) **单列**上,
+--    而 PostgreSQL 唯一索引里 **NULL 互不相等** ⇒ 缺了下面这条局部索引,
+--    同一个租户可以插入任意多行租户级商户,数据库一声不吭,
+--    之后 HarMerchantService.requireByTenant 的 LIMIT 1 会随机返回其中一行。
+--
+--    存量数据核对(建索引前先跑,避免「索引不存在但存量有重复值」导致本语句硬报错):
+--      SELECT tenant_id, count(*) FROM pay_har_merchant
+--       WHERE enterprise_id IS NULL GROUP BY 1 HAVING count(*) > 1;
+--    本表建表后尚未写入业务数据(2026-09-24 用户执行 V1.8 后),预期 0 行。
+--    将来若真出现同租户多行租户级商户(只可能来自索引建成之前的窗口),先清理再建索引:
+--      DELETE FROM pay_har_merchant a USING pay_har_merchant b
+--      WHERE a.id < b.id AND a.tenant_id = b.tenant_id
+--        AND a.enterprise_id IS NULL AND b.enterprise_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_merchant_tenant_level
+    ON pay_har_merchant (tenant_id)
+    WHERE enterprise_id IS NULL;
+
+-- 3. 门店同理
+ALTER TABLE pay_har_shop ALTER COLUMN enterprise_id DROP NOT NULL;
+
+--    uk_har_shop_third_shop_id 是 (enterprise_id, third_shop_id),enterprise_id 为 NULL 时
+--    **唯一性完全失效**(NULL 互不相等)⇒ 同一租户可以重复创建同一个 third_shop_id。
+--    这一条比商户映射那条更严重:上游**没有**门店查询接口,本地表是唯一真相源
+--    (见 V1.8 建表处的注释),重复行没有任何办法收敛。
+--    应用层的 getByThirdShopId 也在同一处失效 —— `.eq(enterprise_id, null)` 生成的是
+--    `enterprise_id = NULL`,SQL 里恒为 UNKNOWN,**永远匹配不到任何行**
+--    ⇒ 查重形同虚设,只能靠这条索引兜底。
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_shop_tenant_third_shop
+    ON pay_har_shop (tenant_id, third_shop_id)
+    WHERE enterprise_id IS NULL;
+
+-- 4. 订单
+--    pay_har_order.enterprise_id 建表时是 NOT NULL,租户级订单插不进去。
+--    本列**无**唯一约束(idx_har_order_enterprise 是普通索引),故无需补局部索引。
+--    订单的定位键是 order_no(uk_har_order_order_no,全局唯一,我方生成的雪花 ID),
+--    与 enterprise_id 无关 —— 轮询与上游通知两条未认证路径都按它定位。
+ALTER TABLE pay_har_order ALTER COLUMN enterprise_id DROP NOT NULL;
+
+-- 5. 只读核对(执行后可手工跑一遍确认)
+--    SELECT table_name, column_name, is_nullable FROM information_schema.columns
+--     WHERE column_name = 'enterprise_id'
+--       AND table_name IN ('pay_har_merchant','pay_har_shop','pay_har_order');
+--    -- 期望:三行全为 YES
+--    SELECT indexname FROM pg_indexes
+--     WHERE indexname IN ('uk_har_merchant_tenant_level','uk_har_shop_tenant_third_shop');
+--    -- 期望:两行

+ 97 - 0
java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql

@@ -0,0 +1,97 @@
+-- 汇安融(HAR)支付宝通道 — 三张表
+--
+-- 我们的角色:汇安融是支付宝一级服务商,我们是二级,把能力转售给企业商户。
+-- 平台不碰钱,只记录订单生命周期,因此没有资金/对账字段。
+--
+-- 注意:三张表都必须列在 TenantInnerInterceptor.CONDITIONAL_TABLES 中。
+-- 否则免登录的上游通知(/payment/notify/har)与定时轮询会以 tenant_id=0 查询,
+-- 永远匹配不到订单 —— 此前 pay_batch_authorize 就踩过这个坑。
+
+-- 企业 ↔ 汇安融商户映射。进件在上游是纯人工流程,通过后由运营在此录入 merchant_code。
+CREATE TABLE pay_har_merchant (
+    id             bigint PRIMARY KEY,
+    tenant_id      bigint,
+    enterprise_id  varchar(64)  NOT NULL,
+    merchant_code  varchar(64)  NOT NULL,
+    merchant_name  varchar(128),
+    contact_name   varchar(64),
+    contact_mobile varchar(32),
+    status         varchar(10)  NOT NULL DEFAULT '0',
+    description    text,
+    created_time   timestamp,
+    updated_time   timestamp,
+    version        int          NOT NULL DEFAULT 0,
+    CONSTRAINT uk_har_merchant_enterprise UNIQUE (enterprise_id),
+    CONSTRAINT uk_har_merchant_code UNIQUE (merchant_code)
+);
+
+-- 门店。上游没有门店查询接口,本地表是唯一真相源:
+-- request_body / response_body 留存原文,供调用超时后人工核实远端状态。
+CREATE TABLE pay_har_shop (
+    id            bigint PRIMARY KEY,
+    tenant_id     bigint,
+    enterprise_id varchar(64)  NOT NULL,
+    merchant_code varchar(64)  NOT NULL,
+    third_shop_id varchar(32)  NOT NULL,
+    shop_name     varchar(128) NOT NULL,
+    shop_id       varchar(64),
+    create_status varchar(16)  NOT NULL DEFAULT 'PENDING',
+    fail_reason   varchar(512),
+    request_body  text,
+    response_body text,
+    retry_count   int          NOT NULL DEFAULT 0,
+    status        varchar(10)  NOT NULL DEFAULT '0',
+    description   text,
+    created_time  timestamp,
+    updated_time  timestamp,
+    version       int          NOT NULL DEFAULT 0,
+    -- 应用层已做重复校验,这里是并发双击的 DB 兜底
+    CONSTRAINT uk_har_shop_third_shop_id UNIQUE (enterprise_id, third_shop_id)
+);
+CREATE INDEX idx_har_shop_enterprise ON pay_har_shop (enterprise_id);
+CREATE INDEX idx_har_shop_status ON pay_har_shop (create_status);
+
+-- 订单。order_no 由我方生成(HAR + 雪花ID)作为 third_order_no 传上游;
+-- 企业自己的单号存 out_biz_no,不直接透传 —— 不同企业的单号可能重复。
+CREATE TABLE pay_har_order (
+    id                   bigint PRIMARY KEY,
+    tenant_id            bigint,
+    enterprise_id        varchar(64)  NOT NULL,
+    order_no             varchar(64)  NOT NULL,
+    out_biz_no           varchar(64),
+    api_key              varchar(128),
+    upstream_order_no    varchar(64),
+    platform_trade_no    varchar(64),
+    merchant_code        varchar(64)  NOT NULL,
+    shop_code            varchar(64),
+    third_shop_code      varchar(32),
+    price                decimal(16,2),
+    title                varchar(64),
+    trade_type           varchar(32),
+    stage_num            varchar(8),
+    percent              varchar(8),
+    pay_timeout          int,
+    extra_params         text,
+    pay_url              text,
+    trade_status         varchar(8)   NOT NULL DEFAULT '0',
+    trade_time           timestamp,
+    refund_time          timestamp,
+    upstream_create_time timestamp,
+    rcv_amount           decimal(16,2),
+    fee_amount           decimal(16,2),
+    channel_amount       decimal(16,2),
+    last_query_time      timestamp,
+    next_query_time      timestamp,
+    query_count          int          NOT NULL DEFAULT 0,
+    notify_status        varchar(16)  NOT NULL DEFAULT 'PENDING',
+    status               varchar(10)  NOT NULL DEFAULT '0',
+    description          text,
+    created_time         timestamp,
+    updated_time         timestamp,
+    version              int          NOT NULL DEFAULT 0,
+    CONSTRAINT uk_har_order_order_no UNIQUE (order_no)
+);
+CREATE INDEX idx_har_order_enterprise ON pay_har_order (enterprise_id, out_biz_no);
+CREATE INDEX idx_har_order_upstream_no ON pay_har_order (upstream_order_no);
+-- 轮询任务按 (trade_status, next_query_time) 捞单,这是它唯一的热路径
+CREATE INDEX idx_har_order_poll ON pay_har_order (trade_status, next_query_time);

+ 115 - 0
java/src/test/java/com/payment/platform/module/payment/har/client/HarResponseParserTest.java

@@ -0,0 +1,115 @@
+package com.payment.platform.module.payment.har.client;
+
+import com.payment.platform.module.payment.har.client.dto.HarQueryDetail;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HarResponseParserTest {
+
+    @Test
+    void parse_formEncoded() {
+        HarResponse response = HarResponseParser.parse(
+                "response_code=10000&msg=SUCCESS&sign=abc&detail=%7B%22status%22%3A1%7D");
+
+        assertEquals("10000", response.responseCode());
+        assertEquals("SUCCESS", response.msg());
+        assertEquals("abc", response.sign());
+        assertEquals("{\"status\":1}", response.detailRaw());
+        assertTrue(response.isSuccess());
+    }
+
+    @Test
+    void parse_jsonBody() {
+        HarResponse response = HarResponseParser.parse(
+                "{\"response_code\":\"10000\",\"msg\":\"SUCCESS\",\"detail\":\"{\\\"status\\\":1}\"}");
+
+        assertEquals("10000", response.responseCode());
+        assertTrue(response.isSuccess());
+        assertEquals("{\"status\":1}", response.detailRaw());
+    }
+
+    /** 上游随时可能加字段,不能因为不认识就把整个响应判为非法 */
+    @Test
+    void parse_unknownFields_areIgnored() {
+        HarResponse response = HarResponseParser.parse(
+                "response_code=10000&msg=SUCCESS&some_new_field=x&another=1");
+
+        assertEquals("10000", response.responseCode());
+    }
+
+    @Test
+    void parse_nonSuccessCode_isNotSuccess() {
+        HarResponse response = HarResponseParser.parse("response_code=40004&msg=业务处理失败");
+
+        assertFalse(response.isSuccess());
+        assertEquals("40004", response.responseCode());
+        assertEquals("业务处理失败", response.msg());
+    }
+
+    /** 缺 response_code 说明响应根本不是上游的格式,必须抛而不是返回半成品 */
+    @Test
+    void parse_missingResponseCode_throws() {
+        assertThrows(HarApiException.class, () -> HarResponseParser.parse("msg=SUCCESS"));
+        assertThrows(HarApiException.class, () -> HarResponseParser.parse("{\"msg\":\"SUCCESS\"}"));
+    }
+
+    @Test
+    void parse_blankBody_throws() {
+        assertThrows(HarApiException.class, () -> HarResponseParser.parse(null));
+        assertThrows(HarApiException.class, () -> HarResponseParser.parse("   "));
+    }
+
+    @Test
+    void parse_malformedJson_throws() {
+        assertThrows(HarApiException.class, () -> HarResponseParser.parse("{not-json"));
+    }
+
+    @Test
+    void toBean_mapsDetail() {
+        String detail = """
+                {"order_no":"2026091600001","status":1,"price":"12.34",
+                 "trade_time":"2026-09-16 10:30:00","rcv_amount":"12.20",
+                 "trade_type":"alipay_payment"}
+                """;
+
+        HarQueryDetail bean = HarResponseParser.toBean(detail, HarQueryDetail.class);
+
+        assertEquals("2026091600001", bean.getOrderNo());
+        assertEquals(1, bean.getStatus());
+        assertEquals("12.34", bean.getPrice());
+        assertEquals("2026-09-16 10:30:00", bean.getTradeTime());
+        assertEquals("12.20", bean.getRcvAmount());
+    }
+
+    /** 空串在上游确实会出现,用 String 接收才不会炸 */
+    @Test
+    void toBean_blankAmounts_doNotThrow() {
+        HarQueryDetail bean = HarResponseParser.toBean(
+                "{\"status\":0,\"price\":\"\",\"rcv_amount\":\"\",\"fee_amount\":\"\"}",
+                HarQueryDetail.class);
+
+        assertEquals("", bean.getPrice());
+        assertEquals("", bean.getRcvAmount());
+    }
+
+    @Test
+    void toBean_unknownFields_areIgnored() {
+        HarQueryDetail bean = HarResponseParser.toBean(
+                "{\"status\":1,\"brand_new_field\":\"x\"}", HarQueryDetail.class);
+
+        assertEquals(1, bean.getStatus());
+    }
+
+    @Test
+    void toBean_blankInput_returnsNull() {
+        assertNull(HarResponseParser.toBean(null, HarQueryDetail.class));
+        assertNull(HarResponseParser.toBean("  ", HarQueryDetail.class));
+    }
+
+    @Test
+    void toBean_malformedJson_throws() {
+        assertThrows(HarApiException.class,
+                () -> HarResponseParser.toBean("{oops", HarQueryDetail.class));
+    }
+}

+ 310 - 0
java/src/test/java/com/payment/platform/module/payment/har/client/HarSignerTest.java

@@ -0,0 +1,310 @@
+package com.payment.platform.module.payment.har.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 验签是这个通道唯一的安全边界 —— 通知入口免登录,签名就是全部的身份证明。
+ * 因此这里除了正常路径,重点覆盖「什么样的输入必须被拒绝」。
+ */
+class HarSignerTest {
+
+    private static final KeyPair KEY_PAIR = generateKeyPair();
+
+    private static final String PRIVATE_KEY = base64(KEY_PAIR.getPrivate().getEncoded());
+    private static final String PUBLIC_KEY = base64(KEY_PAIR.getPublic().getEncoded());
+
+    /**
+     * 另一对密钥,编码成 <b>URL-safe 字母表</b>(含 {@code -} / {@code _})。
+     *
+     * <p>为什么必须单独要一对:上游 demo 的 {@code XRsa.java:69/72} 就是用
+     * {@code Base64.encodeBase64URLSafeString} 生成密钥对的 —— 「密钥串里带 - 和 _」
+     * 是上游自己的约定,不是边角情况。而上面那对固定用标准字母表编码,
+     * 恰好让 {@code loadPublicKey}/{@code loadPrivateKey} 的字母表缺陷完全隐形。
+     *
+     * <p>同一对的<b>标准字母表</b>编码也留着({@code STANDARD_PUB}/{@code STANDARD_PRIV}):
+     * 两条用例各自只动一个变量 —— 验公钥时私钥走标准,签私钥时公钥走标准。
+     */
+    private static final KeyPair URLSAFE_PAIR = generateUrlSafeKeyPair();
+    private static final String URLSAFE_PUB = urlSafe(URLSAFE_PAIR.getPublic().getEncoded());
+    private static final String URLSAFE_PRIV = urlSafe(URLSAFE_PAIR.getPrivate().getEncoded());
+    private static final String STANDARD_PUB = base64(URLSAFE_PAIR.getPublic().getEncoded());
+    private static final String STANDARD_PRIV = base64(URLSAFE_PAIR.getPrivate().getEncoded());
+
+    private static KeyPair generateKeyPair() {
+        try {
+            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+            generator.initialize(2048);
+            return generator.generateKeyPair();
+        } catch (Exception e) {
+            throw new IllegalStateException("测试密钥生成失败", e);
+        }
+    }
+
+    private static String base64(byte[] bytes) {
+        return Base64.getEncoder().encodeToString(bytes);
+    }
+
+    /** 上游 demo 用的那种编码:URL-safe 字母表、不补 padding。 */
+    private static String urlSafe(byte[] bytes) {
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
+    /**
+     * 造一对「URL-safe 编码里确实含 {@code -} 或 {@code _}」的密钥。
+     *
+     * <p>造不出来就抛 —— 不能让夹具悄悄退化成一对不含 URL-safe 字符的密钥,
+     * 那两条字母表用例会变成空的(断言恒真)。RSA-2048 下每串 392/1624 字符,
+     * 命中概率极高,循环只是兜底。
+     */
+    private static KeyPair generateUrlSafeKeyPair() {
+        for (int i = 0; i < 64; i++) {
+            KeyPair candidate = generateKeyPair();
+            if (hasUrlSafeFill(candidate.getPublic().getEncoded())
+                    && hasUrlSafeFill(candidate.getPrivate().getEncoded())) {
+                return candidate;
+            }
+        }
+        throw new IllegalStateException("64 次都没造出 URL-safe 编码含 -/_ 的密钥对,用例前提不成立");
+    }
+
+    private static boolean hasUrlSafeFill(byte[] der) {
+        String encoded = urlSafe(der);
+        return encoded.indexOf('-') >= 0 || encoded.indexOf('_') >= 0;
+    }
+
+    // ---------- 签名 ----------
+
+    @Test
+    void sign_thenVerify_roundTrips() {
+        String bizContent = "{\"third_order_no\":\"HAR123\",\"price\":\"0.01\"}";
+
+        String sign = HarSigner.sign(bizContent, PRIVATE_KEY);
+
+        assertTrue(HarSigner.verify(bizContent, sign, PUBLIC_KEY));
+    }
+
+    /** demo 用 encodeBase64URLSafeString,输出不应含 + / = */
+    @Test
+    void sign_usesUrlSafeAlphabetWithoutPadding() {
+        String sign = HarSigner.sign("{\"a\":\"b\"}", PRIVATE_KEY);
+
+        assertFalse(sign.contains("+"), "不应出现标准 Base64 的 +");
+        assertFalse(sign.contains("/"), "不应出现标准 Base64 的 /");
+        assertFalse(sign.contains("="), "不应有 padding");
+    }
+
+    @Test
+    void sign_privateKeyMissing_throws() {
+        assertThrows(HarApiException.class, () -> HarSigner.sign("{}", null));
+        assertThrows(HarApiException.class, () -> HarSigner.sign("{}", "  "));
+    }
+
+    @Test
+    void sign_privateKeyMalformed_throws() {
+        assertThrows(HarApiException.class, () -> HarSigner.sign("{}", "not-a-key"));
+    }
+
+    // ---------- 验签:必须拒绝的输入 ----------
+
+    @Test
+    void verify_tamperedData_returnsFalse() {
+        String sign = HarSigner.sign("{\"price\":\"0.01\"}", PRIVATE_KEY);
+
+        assertFalse(HarSigner.verify("{\"price\":\"9999.00\"}", sign, PUBLIC_KEY));
+    }
+
+    @Test
+    void verify_signedByAnotherParty_returnsFalse() {
+        KeyPair attacker = generateKeyPair();
+        String attackerPrivateKey = base64(attacker.getPrivate().getEncoded());
+        String sign = HarSigner.sign("{\"price\":\"0.01\"}", attackerPrivateKey);
+
+        assertFalse(HarSigner.verify("{\"price\":\"0.01\"}", sign, PUBLIC_KEY));
+    }
+
+    @Test
+    void verify_nullOrBlankSign_returnsFalse() {
+        assertFalse(HarSigner.verify("{}", null, PUBLIC_KEY));
+        assertFalse(HarSigner.verify("{}", "", PUBLIC_KEY));
+        assertFalse(HarSigner.verify("{}", "   ", PUBLIC_KEY));
+    }
+
+    @Test
+    void verify_nullData_returnsFalse() {
+        assertFalse(HarSigner.verify(null, "abc", PUBLIC_KEY));
+    }
+
+    /**
+     * 垃圾签名必须返回 false 而不是抛异常。
+     *
+     * demo 的 XRsa.verify 在此处抛 RuntimeException,诱使调用方写
+     * try { verify() } catch { 放行 },那样伪造的通知就直接进业务逻辑了。
+     */
+    @Test
+    void verify_malformedSign_returnsFalseWithoutThrowing() {
+        assertFalse(HarSigner.verify("{}", "!!!not-base64!!!", PUBLIC_KEY));
+        assertFalse(HarSigner.verify("{}", "YWJj", PUBLIC_KEY));            // 合法 Base64 但不是签名结构
+        assertFalse(HarSigner.verify("{}", "A", PUBLIC_KEY));               // 长度模 4 余 1,不可能是 Base64
+    }
+
+    @Test
+    void verify_publicKeyMissing_throws() {
+        String sign = HarSigner.sign("{}", PRIVATE_KEY);
+
+        // 公钥没配是部署错误,必须显式暴露,不能静默放行
+        assertThrows(HarApiException.class, () -> HarSigner.verify("{}", sign, null));
+        assertThrows(HarApiException.class, () -> HarSigner.verify("{}", sign, ""));
+    }
+
+    // ---------- 验签:编码兼容 ----------
+
+    /** 上游用哪种字母表未确认,标准 Base64 也要能验过,否则联调期会全量拒签 */
+    @Test
+    void verify_standardBase64Alphabet_returnsTrue() {
+        String urlSafeSign = HarSigner.sign("{\"a\":\"b\"}", PRIVATE_KEY);
+        String standard = urlSafeSign.replace('-', '+').replace('_', '/');
+        standard += "=".repeat((4 - standard.length() % 4) % 4);
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", standard, PUBLIC_KEY));
+    }
+
+    /**
+     * URL-safe 字母表的 <b>公钥</b> 必须能加载。
+     *
+     * <p>这不是理论边角。实测(2026-09-24,{@code logs/_har-key-probe/result.txt}):
+     * 上游给的真公钥 392 字符,含 7 个 {@code -} 与 3 个 {@code _};旧实现走
+     * {@code Base64.getMimeDecoder()},而 MIME 字母表只有 {@code A-Za-z0-9+/},
+     * 那 10 个字符被 <b>静默丢弃</b> ⇒ 解出 286 字节而非 294,
+     * {@code KeyFactory} 抛 {@code InvalidKeySpecException: Unable to decode key}。
+     * 同一次量测的正对照:同长度的标准字母表密钥解出 294 字节、逐字节相同
+     * ⇒ 量具(MimeDecoder 本身)没坏,差别只在字母表。
+     *
+     * <p>私钥这一侧走标准字母表,好让本用例只验「公钥加载器」这一件事。
+     */
+    @Test
+    void verify_urlSafeAlphabetPublicKey_returnsTrue() {
+        assertTrue(hasUrlSafeFill(URLSAFE_PAIR.getPublic().getEncoded()),
+                "前提:夹具公钥的 URL-safe 编码里没有 -/_,本用例会变成空的");
+
+        String sign = HarSigner.sign("{\"a\":\"b\"}", STANDARD_PRIV);
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", sign, URLSAFE_PUB));
+    }
+
+    /**
+     * URL-safe 字母表的 <b>私钥</b> 也必须能加载 —— 出站签名用的就是它,
+     * 这条断了等于整个通道出不了网。公钥侧走标准字母表,同样只动一个变量。
+     */
+    @Test
+    void sign_urlSafeAlphabetPrivateKey_works() {
+        assertTrue(hasUrlSafeFill(URLSAFE_PAIR.getPrivate().getEncoded()),
+                "前提:夹具私钥的 URL-safe 编码里没有 -/_,本用例会变成空的");
+
+        String sign = HarSigner.sign("{\"a\":\"b\"}", URLSAFE_PRIV);
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", sign, STANDARD_PUB));
+    }
+
+    @Test
+    void verify_signWithWrappedLines_returnsTrue() {
+        String sign = HarSigner.sign("{\"a\":\"b\"}", PRIVATE_KEY);
+        String wrapped = sign.substring(0, 20) + "\n" + sign.substring(20);
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", wrapped, PUBLIC_KEY));
+    }
+
+    /** 密钥常按 64 列折行粘贴,配置里带换行不能导致验签失败 */
+    @Test
+    void verify_publicKeyWithWrappedLines_returnsTrue() {
+        String sign = HarSigner.sign("{\"a\":\"b\"}", PRIVATE_KEY);
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", sign, wrap(PUBLIC_KEY)));
+    }
+
+    @Test
+    void sign_privateKeyWithWrappedLines_works() {
+        String sign = HarSigner.sign("{\"a\":\"b\"}", wrap(PRIVATE_KEY));
+
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}", sign, PUBLIC_KEY));
+    }
+
+    @Test
+    void sign_privateKeyWithWhitespace_works() {
+        assertTrue(HarSigner.verify("{\"a\":\"b\"}",
+                HarSigner.sign("{\"a\":\"b\"}", "  " + PRIVATE_KEY + "\n"), PUBLIC_KEY));
+    }
+
+    private static String wrap(String base64) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < base64.length(); i += 64) {
+            sb.append(base64, i, Math.min(i + 64, base64.length())).append('\n');
+        }
+        return sb.toString();
+    }
+
+    // ---------- 通知待签串 ----------
+
+    @Test
+    void buildNotifySignContent_dropsSignAndSortsByKey() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("third_order_no", "HAR123");
+        params.put("status", "1");
+        params.put("sign", "should-be-dropped");
+        params.put("order_no", "2026091600001");
+
+        String content = HarSigner.buildNotifySignContent(params);
+
+        assertEquals("order_no=2026091600001&status=1&third_order_no=HAR123", content);
+    }
+
+    @Test
+    void buildNotifySignContent_dropsBlankValues() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("refund_time", "");
+        params.put("status", "1");
+        params.put("trade_time", null);
+
+        assertEquals("status=1", HarSigner.buildNotifySignContent(params));
+    }
+
+    @Test
+    void buildNotifySignContent_emptyMap_returnsEmptyString() {
+        assertEquals("", HarSigner.buildNotifySignContent(new LinkedHashMap<>()));
+    }
+
+    /** 验签必须对同一份参数稳定 —— 否则上游重发的同一个通知会时通时不通 */
+    @Test
+    void buildNotifySignContent_isOrderIndependent() {
+        Map<String, String> a = new LinkedHashMap<>();
+        a.put("b", "2");
+        a.put("a", "1");
+        Map<String, String> b = new LinkedHashMap<>();
+        b.put("a", "1");
+        b.put("b", "2");
+
+        assertEquals(HarSigner.buildNotifySignContent(a), HarSigner.buildNotifySignContent(b));
+    }
+
+    @Test
+    void verify_endToEndWithNotifyContent() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("third_order_no", "HAR999");
+        params.put("status", "1");
+        params.put("price", "12.34");
+        String content = HarSigner.buildNotifySignContent(params);
+
+        Map<String, String> received = new LinkedHashMap<>(params);
+        received.put("sign", HarSigner.sign(content, PRIVATE_KEY));
+
+        assertTrue(HarSigner.verify(
+                HarSigner.buildNotifySignContent(received), received.get("sign"), PUBLIC_KEY));
+    }
+}

+ 151 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java

@@ -0,0 +1,151 @@
+package com.payment.platform.module.payment.har.controller.open;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.service.HarMerchantService;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import com.payment.platform.module.payment.har.service.HarShopService;
+import org.junit.jupiter.api.BeforeEach;
+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.mock.web.MockHttpServletRequest;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+/**
+ * 开放 API 的寻址闸({@code HarOpenController#requireMerchant})。
+ *
+ * 本类盯的是**闸与下游的分流一致**,尤其是 2026-09-24 引入的三条路径的次序:
+ * ① 显式 merchant_code;② enterprise_id;③ 租户级。
+ *
+ * 为什么必须有这一层测试:闸里的分支写得再对,只要调用点漏传一个参数
+ * (order/query 曾经就是写死 null),那条分支在生产里就是死的 ——
+ * 而 service 层的用例全部直调 service,一条都覆盖不到控制器。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarOpenControllerTest {
+
+    private static final Long TENANT = 7L;
+
+    @Mock private HarOrderService orderService;
+    @Mock private HarShopService shopService;
+    @Mock private HarMerchantService merchantService;
+
+    private MockHttpServletRequest request;
+    private HarOpenController controller;
+
+    @BeforeEach
+    void setUp() {
+        request = new MockHttpServletRequest();
+        request.setAttribute("openapi.tenantId", TENANT);   // 模拟 TenantApiKeyAuthFilter 注入
+        controller = new HarOpenController(orderService, shopService, merchantService, request);
+    }
+
+    private HarMerchantEntity merchant(String code) {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setTenantId(TENANT);
+        m.setMerchantCode(code);
+        return m;
+    }
+
+    // ==================== 闸:认证 ====================
+
+    @Test
+    void withoutApiKeyAuth_rejectedBeforeTouchingAnything() {
+        request.removeAttribute("openapi.tenantId");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.orderQuery(new HarOpenOrderQueryDTO()));
+
+        assertEquals(401, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, shopService, merchantService);
+    }
+
+    // ==================== 闸:三条路径与优先级 ====================
+
+    /**
+     * 闸里的 merchant_code 分支必须**被调用点真的传进来**。
+     *
+     * 这条是行为级的:把调用点改回 {@code requireMerchant(..., null)},本用例即红 ——
+     * 而 service 层的用例全绿(它们直调 service,不经过控制器)。
+     */
+    @Test
+    void orderQuery_forwardsMerchantCode_soMultiMerchantTenantsCanQuery() {
+        when(merchantService.requireByTenantAndCode(TENANT, "M700")).thenReturn(merchant("M700"));
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setMerchantCode("M700");
+        q.setOutBizNo("BIZ-0001");
+
+        controller.orderQuery(q);
+
+        verify(merchantService).requireByTenantAndCode(TENANT, "M700");
+        verify(merchantService, never()).requireByTenant(anyLong());
+        verify(orderService).openQuery(q);
+    }
+
+    @Test
+    void orderQuery_withoutMerchantCode_fallsBackToTenantLevel() {
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchant("M700"));
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setOutBizNo("BIZ-0001");
+
+        controller.orderQuery(q);
+
+        verify(merchantService).requireByTenant(TENANT);
+        verify(merchantService, never()).requireByTenantAndCode(anyLong(), any());
+    }
+
+    /** 显式 merchant_code 优先于 enterprise_id —— 两个都传时必须只听前者 */
+    @Test
+    void orderCreate_withMerchantCode_doesNotConsultEnterprise() {
+        when(merchantService.requireByTenantAndCode(TENANT, "M700")).thenReturn(merchant("M700"));
+        HarOrderCreateDTO dto = new HarOrderCreateDTO();
+        dto.setEnterpriseId("E1");
+        dto.setMerchantCode("M700");
+
+        controller.orderCreate(dto);
+
+        verify(merchantService).requireByTenantAndCode(TENANT, "M700");
+        verify(merchantService, never()).getByEnterpriseId(any());
+        verify(orderService).create(eq(dto), eq("E1"), any());
+    }
+
+    @Test
+    void shopCreate_foreignMerchant_rejectedBeforeCallingShopService() {
+        when(merchantService.requireByTenantAndCode(TENANT, "M999"))
+                .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: M999"));
+        HarShopCreateDTO dto = new HarShopCreateDTO();
+        dto.setMerchantCode("M999");
+        dto.setThirdShopId("SHOP001");
+        dto.setShopName("测试门店");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.shopCreate(dto));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(shopService);
+    }
+
+    /**
+     * 企业路径失败仍是 403 且**不区分**「不属于本租户」与「尚未配置」——
+     * 区分等于告诉调用方「这个企业存在,只是不是你的」。
+     */
+    @Test
+    void enterprisePath_unknownEnterprise_is403() {
+        when(merchantService.getByEnterpriseId("E1")).thenReturn(null);
+        HarOrderCreateDTO dto = new HarOrderCreateDTO();
+        dto.setEnterpriseId("E1");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.orderCreate(dto));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+}

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

@@ -0,0 +1,98 @@
+package com.payment.platform.module.payment.har.scheduler;
+
+import com.payment.platform.common.exception.BusinessException;
+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.service.HarOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class HarOrderPollSchedulerTest {
+
+    @Mock private HarOrderService orderService;
+
+    private HarProperties properties;
+    private HarOrderPollScheduler scheduler;
+
+    @BeforeEach
+    void setUp() {
+        properties = new HarProperties();
+        scheduler = new HarOrderPollScheduler(orderService, properties);
+    }
+
+    private HarOrderEntity order(String orderNo) {
+        HarOrderEntity o = new HarOrderEntity();
+        o.setOrderNo(orderNo);
+        return o;
+    }
+
+    /** 密钥没配好时不能发起调用,否则每轮都在刷空密钥错误日志 */
+    @Test
+    void poll_disabled_doesNotTouchDatabase() {
+        properties.setEnabled(false);
+
+        scheduler.pollPendingOrders();
+
+        verify(orderService, never()).findPendingForPoll(anyInt());
+    }
+
+    @Test
+    void poll_empty_doesNotSync() {
+        properties.setEnabled(true);
+        when(orderService.findPendingForPoll(anyInt())).thenReturn(List.of());
+
+        scheduler.pollPendingOrders();
+
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    /**
+     * 单笔失败不能中断整批 —— 上游抖动时若第一笔就抛出去,
+     * 后面的订单永远排不到,状态无法收敛。
+     */
+    @Test
+    void poll_singleFailure_doesNotInterruptRemaining() {
+        properties.setEnabled(true);
+        HarOrderEntity first = order("H1");
+        HarOrderEntity second = order("H2");
+        when(orderService.findPendingForPoll(anyInt())).thenReturn(List.of(first, second));
+        when(orderService.syncFromUpstream(first))
+                .thenThrow(new BusinessException("查询上游失败"));
+
+        assertDoesNotThrow(scheduler::pollPendingOrders);
+
+        verify(orderService).syncFromUpstream(second);
+    }
+
+    @Test
+    void poll_allSucceed_syncsEach() {
+        properties.setEnabled(true);
+        when(orderService.findPendingForPoll(anyInt()))
+                .thenReturn(List.of(order("H1"), order("H2")));
+
+        scheduler.pollPendingOrders();
+
+        verify(orderService, times(2)).syncFromUpstream(any());
+    }
+
+    /** 捞单本身失败(如数据库抖动)不应把异常抛到调度线程外 */
+    @Test
+    void poll_findPendingFails_doesNotThrow() {
+        properties.setEnabled(true);
+        when(orderService.findPendingForPoll(anyInt()))
+                .thenThrow(new BusinessException("数据库连接失败"));
+
+        assertDoesNotThrow(scheduler::pollPendingOrders);
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+}

+ 347 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java

@@ -0,0 +1,347 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.har.dto.HarMerchantSaveDTO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.mapper.HarMerchantMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 商户配置由**超管**代租户建立(端点门禁见 HarMerchantController#save)。
+ *
+ * 本类盯的是这条链上几处「不会报错但会错」的地方:
+ *
+ * 1. 少给 tenant_id —— 超管 bypass 了租户拦截器,不显式给值由
+ *    AutoFillMetaObjectHandler 填成**超管自己**的租户,配置就落到别人头上;
+ * 2. 企业级查重被写成按租户查 —— 同一个企业号会在两个租户下各建一条;
+ * 3. 一个租户多条配置时由系统**随便挑一条**(2026-09-24 起允许一租户多商户,
+ *    挑错钱会进错账号且全程无报错,所以必须报 400 让调用方指定)。
+ *
+ * 查重与寻址的谓词只在 SQL 结构上可验:mock 的 mapper 不执行 wrapper,
+ * 只断言「抛没抛」区分不出「带没带 tenant_id」。所以这里两样都断言。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarMerchantServiceTest {
+
+    @Mock private HarMerchantMapper merchantMapper;
+
+    private HarMerchantService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarMerchantService(merchantMapper);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
+        //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarMerchantEntity.class);
+    }
+
+    private HarMerchantSaveDTO dto() {
+        HarMerchantSaveDTO dto = new HarMerchantSaveDTO();
+        dto.setMerchantCode("M001");
+        dto.setMerchantName("测试商户");
+        return dto;
+    }
+
+    // ==================== 新增:必须有归属 ====================
+
+    /**
+     * 缺 tenant_id 必须**拒绝**,不能放过去。
+     *
+     * 放过去的后果不是报错而是静默错位:AutoFillMetaObjectHandler 会拿 LoginUser
+     * 的 tenantId 填上,于是超管本想给租户 7 建的号落在超管自己名下,
+     * 而租户 7 下单时永远被告知「尚未配置」。
+     */
+    @Test
+    void save_newWithoutTenantId_rejectedBeforeInsert() {
+        HarMerchantSaveDTO dto = dto();   // 故意不给 tenantId
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(400, e.getCode(), "缺租户是入参问题,不是服务端错误");
+        assertTrue(e.getMessage().contains("请选择租户"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    @Test
+    void save_newWithTenantId_writesThatTenantNotCallersOwn() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        service.save(dto);
+
+        ArgumentCaptor<HarMerchantEntity> captor = ArgumentCaptor.forClass(HarMerchantEntity.class);
+        verify(merchantMapper).insert(captor.capture());
+        assertEquals(7L, captor.getValue().getTenantId(), "归属必须是超管选的那个租户");
+    }
+
+    // ==================== 新增:空企业 ⇒ 租户级,落 NULL ====================
+
+    /**
+     * 空串必须写成 NULL。
+     *
+     * 租户级映射的**唯一**识别方式就是 {@code enterprise_id IS NULL}(见
+     * HarMerchantService#listByTenant 与 db/har-tenant-level.sql)。
+     * 写成空串的话:写入方以为写的是「没值」,而所有 IS NULL 查询都看不到这一行 ——
+     * 配置建好了、租户下单仍然说「尚未配置」,且没有任何一处会报错。
+     */
+    @Test
+    void save_blankEnterpriseId_storedAsNull_soIsNullLookupsCanFindIt() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        dto.setEnterpriseId("   ");   // 前端清空下拉框给的是空串,不是 null
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        service.save(dto);
+
+        ArgumentCaptor<HarMerchantEntity> captor = ArgumentCaptor.forClass(HarMerchantEntity.class);
+        verify(merchantMapper).insert(captor.capture());
+        assertNull(captor.getValue().getEnterpriseId(), "空串必须落 NULL");
+        assertEquals(7L, captor.getValue().getTenantId());
+    }
+
+    // ==================== 新增:两条查重口径不同,都不能搞错 ====================
+
+    /**
+     * 一个租户可以配**多条**(2026-09-24 用户裁定)。
+     *
+     * 这一条是反向的:只要还存在「同租户第二条被拒」的检查,它就必须红。
+     * 之前那道检查(`.eq(tenantId).isNull(enterpriseId) > 0 ⇒ 拒绝`)已随
+     * db/har-merchant-multi-per-tenant.sql 一起删掉。
+     */
+    @Test
+    void save_secondTenantLevelConfigForSameTenant_isAllowed() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        dto.setMerchantCode("M002");     // 与已有那条不同号
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        service.save(dto);
+
+        ArgumentCaptor<HarMerchantEntity> captor = ArgumentCaptor.forClass(HarMerchantEntity.class);
+        verify(merchantMapper).insert(captor.capture());
+        assertEquals(7L, captor.getValue().getTenantId());
+        assertEquals("M002", captor.getValue().getMerchantCode());
+    }
+
+    /** 但同一个 merchant_code 仍不能重复 —— 那是**全局**唯一(uk_har_merchant_code) */
+    @Test
+    void save_duplicateMerchantCode_rejectedEvenForSameTenant() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        when(merchantMapper.selectCount(any())).thenReturn(1L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertTrue(e.getMessage().contains("商户编号已存在"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /**
+     * 企业级查重则是**全局**唯一(uk_har_merchant_enterprise)—— 这里**不能**带 tenant_id。
+     *
+     * 带上就等于让同一个企业号在两个租户下各建一条,下游下单时 merchant_code
+     * 取哪一条无从确定。
+     */
+    @Test
+    void save_enterpriseLevelDuplicateCheck_isGlobalNotTenantScoped() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        dto.setEnterpriseId("E1");
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        service.save(dto);
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("enterprise_id"), "实测 WHERE: " + where);
+        assertFalse(where.contains("tenant_id"), "企业号是全局唯一,不能按租户查重,实测 WHERE: " + where);
+
+        ArgumentCaptor<HarMerchantEntity> inserted = ArgumentCaptor.forClass(HarMerchantEntity.class);
+        verify(merchantMapper).insert(inserted.capture());
+        assertEquals("E1", inserted.getValue().getEnterpriseId(), "非空白原样写入,不 trim 不改写");
+    }
+
+    @Test
+    void save_duplicateEnterpriseId_rejectedBeforeInsert() {
+        HarMerchantSaveDTO dto = dto();
+        dto.setTenantId(7L);
+        dto.setEnterpriseId("E1");
+        HarMerchantEntity existing = new HarMerchantEntity();
+        existing.setId(9L);
+        existing.setTenantId(1L);          // 别的租户已占用这个企业号
+        existing.setEnterpriseId("E1");
+        when(merchantMapper.selectOne(any())).thenReturn(existing);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertTrue(e.getMessage().contains("该企业已配置"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    // ==================== 修改:归属不可改 ====================
+
+    /**
+     * 修改时**忽略**请求里的 tenant_id。
+     *
+     * 改归属等于让同一笔业务横跨两个租户:已建的门店/订单带的是**旧** tenant_id,
+     * 两个租户的列表各自只看得到一半,而这条配置本身谁也说不清算谁的。
+     */
+    @Test
+    void save_update_keepsOriginalTenant_ignoresRequestedOne() {
+        HarMerchantEntity existing = new HarMerchantEntity();
+        existing.setId(5L);
+        existing.setTenantId(1L);
+        existing.setEnterpriseId("E1");
+        existing.setMerchantCode("M001");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+
+        HarMerchantSaveDTO dto = dto();
+        dto.setId(5L);
+        dto.setTenantId(9L);      // 试图改归属
+        service.save(dto);
+
+        ArgumentCaptor<HarMerchantEntity> captor = ArgumentCaptor.forClass(HarMerchantEntity.class);
+        verify(merchantMapper).updateById(captor.capture());
+        assertEquals(1L, captor.getValue().getTenantId(), "归属必须保持原值");
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    @Test
+    void save_updateMissingRow_throws() {
+        when(merchantMapper.selectById(5L)).thenReturn(null);
+        HarMerchantSaveDTO dto = dto();
+        dto.setId(5L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertTrue(e.getMessage().contains("商户配置不存在"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).updateById(any());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    // ==================== 租户级读取(0 / 1 / 多条) ====================
+
+    /** 拿不到租户就**不要查**:null 传进 SQL 只会得到 `tenant_id = NULL`,恒空 */
+    @Test
+    void listByTenant_nullTenant_returnsEmptyWithoutQuerying() {
+        assertTrue(service.listByTenant(null).isEmpty());
+        verify(merchantMapper, never()).selectList(any());
+    }
+
+    @Test
+    void listByTenant_queriesTenantScopedNullEnterpriseRows() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        service.listByTenant(7L);
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectList(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("tenant_id"), "实测 WHERE: " + where);
+        assertTrue(where.contains("IS NULL"), "租户级映射 = 未挂企业的那条,实测 WHERE: " + where);
+    }
+
+    /** 404 而不是 403:调用方据此区分「还没报备」与「报备了但不是你的」,两种改法完全不同 */
+    @Test
+    void requireByTenant_noMapping_throws404() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireByTenant(7L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void requireByTenant_singleMapping_returnsIt() {
+        HarMerchantEntity only = merchant(11L, "M001");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(only));
+
+        assertSame(only, service.requireByTenant(7L));
+    }
+
+    /**
+     * 多条 ⇒ **400 要求指定**,绝不 LIMIT 1 随便挑一条。
+     *
+     * 挑一条不会报错,只会把钱收进另一个商户账号 / 门店挂到另一个商户名下 ——
+     * 到对账那天才看得见。所以这里断言的是「抛且不说挑谁」。
+     */
+    @Test
+    void requireByTenant_multipleMappings_throws400InsteadOfPickingOne() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(merchant(11L, "M001"), merchant(12L, "M002")));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireByTenant(7L));
+
+        assertEquals(400, e.getCode(), "多条是入参缺项,不是找不到,实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("merchant_code"),
+                "报错必须告诉调用方补哪个字段,实测消息: " + e.getMessage());
+    }
+
+    // ==================== 按商户编号寻址 ====================
+
+    /** 按 code 寻址**必须**带 tenant_id:带少了,租户 A 能拿租户 B 的号下单 */
+    @Test
+    void getByTenantAndCode_isScopedByTenant() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        service.getByTenantAndCode(7L, "M001");
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("tenant_id"), "实测 WHERE: " + where);
+        assertTrue(where.contains("merchant_code"), "实测 WHERE: " + where);
+    }
+
+    /** 查不到与不属于本租户**合并**成 404,不区分 —— 区分等于告诉调用方「这个号存在,只是不是你的」 */
+    @Test
+    void requireByTenantAndCode_unknownOrForeignCode_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByTenantAndCode(7L, "M999"));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void getByTenantAndCode_blankArgs_returnNullWithoutQuerying() {
+        assertNull(service.getByTenantAndCode(null, "M001"));
+        assertNull(service.getByTenantAndCode(7L, "   "));
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    private HarMerchantEntity merchant(Long id, String code) {
+        HarMerchantEntity e = new HarMerchantEntity();
+        e.setId(id);
+        e.setTenantId(7L);
+        e.setMerchantCode(code);
+        return e;
+    }
+}

+ 282 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarNotifyServiceTest.java

@@ -0,0 +1,282 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.payment.platform.module.payment.har.client.HarSigner;
+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.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 通知入口免登录,签名是唯一身份证明。
+ * 这里的每个「验签失败」用例都对应一种必须被挡住的伪造请求。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarNotifyServiceTest {
+
+    private static final KeyPair KEY_PAIR = generateKeyPair();
+    private static final String PRIVATE_KEY =
+            Base64.getEncoder().encodeToString(KEY_PAIR.getPrivate().getEncoded());
+    private static final String PUBLIC_KEY =
+            Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded());
+
+    private static KeyPair generateKeyPair() {
+        try {
+            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+            generator.initialize(2048);
+            return generator.generateKeyPair();
+        } catch (Exception e) {
+            throw new IllegalStateException("测试密钥生成失败", e);
+        }
+    }
+
+    @Mock private HarOrderService orderService;
+    @Mock private HarOrderMapper orderMapper;
+    @Mock private HarCallbackService callbackService;
+
+    private HarProperties properties;
+    private HarNotifyService service;
+
+    @BeforeEach
+    void setUp() {
+        properties = new HarProperties();
+        properties.setPlatformPublicKey(PUBLIC_KEY);
+        service = new HarNotifyService(properties, orderService, orderMapper, callbackService);
+    }
+
+    private HarOrderEntity order(String orderNo, String tradeStatus) {
+        HarOrderEntity o = new HarOrderEntity();
+        o.setOrderNo(orderNo);
+        o.setEnterpriseId("E1");
+        o.setTradeStatus(tradeStatus);
+        o.setPrice(new BigDecimal("12.34"));
+        o.setApiKey("KEY1");
+        return o;
+    }
+
+    /** 按上游规则签名:待签串 = 剔除 sign 后按 key 升序的 k=v 拼接 */
+    private Map<String, String> signed(Map<String, String> raw) {
+        Map<String, String> params = new LinkedHashMap<>(raw);
+        params.put("sign", HarSigner.sign(HarSigner.buildNotifySignContent(params), PRIVATE_KEY));
+        return params;
+    }
+
+    private Map<String, String> paidNotify() {
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("third_order_no", "HAR1");
+        raw.put("order_no", "UP-1");
+        raw.put("platform_trade_no", "PT-1");
+        raw.put("status", "1");
+        raw.put("price", "12.34");
+        raw.put("trade_time", "2026-09-16 10:30:00");
+        return signed(raw);
+    }
+
+    // ==================== 验签 ====================
+
+    @Test
+    void handle_validSignature_updatesOrderAndReturnsSuccess() {
+        when(orderService.getByOrderNo("HAR1")).thenReturn(order("HAR1", HarOrderStatus.WAIT_PAY));
+
+        assertEquals("success", service.handle(paidNotify()));
+
+        verify(orderMapper).updateById(any());
+        verify(callbackService).notifyEnterprise(any());
+    }
+
+    @Test
+    void handle_tamperedParam_returnsSignFailedAndDoesNotUpdate() {
+        Map<String, String> params = paidNotify();
+        params.put("price", "9999.00");   // 签名是原始报文签的,改价即失配
+
+        assertEquals("sign verify failed", service.handle(params));
+
+        // 验签不过就到此为止,连订单都不该去查
+        verify(orderService, never()).getByOrderNo(any());
+        verify(orderMapper, never()).updateById(any());
+        verify(callbackService, never()).notifyEnterprise(any());
+    }
+
+    @Test
+    void handle_signedByAnotherParty_returnsSignFailed() {
+        KeyPair attacker = generateKeyPair();
+        String attackerPrivateKey =
+                Base64.getEncoder().encodeToString(attacker.getPrivate().getEncoded());
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("third_order_no", "HAR1");
+        raw.put("status", "1");
+        Map<String, String> params = new LinkedHashMap<>(raw);
+        params.put("sign", HarSigner.sign(HarSigner.buildNotifySignContent(raw), attackerPrivateKey));
+
+        assertEquals("sign verify failed", service.handle(params));
+
+        verify(orderService, never()).getByOrderNo(any());
+    }
+
+    @Test
+    void handle_missingSign_returnsSignFailed() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("third_order_no", "HAR1");
+        params.put("status", "1");
+
+        assertEquals("sign verify failed", service.handle(params));
+
+        verify(orderService, never()).getByOrderNo(any());
+    }
+
+    @Test
+    void handle_emptyParams_returnsSignFailed() {
+        assertEquals("sign verify failed", service.handle(new LinkedHashMap<>()));
+        assertEquals("sign verify failed", service.handle(null));
+    }
+
+    /**
+     * 公钥没配时必须拒绝,不能放行。
+     * 这里断言「返回 SIGN_FAILED 而不是抛异常」—— 抛异常会走到全局异常处理器,
+     * 上游收到 500 也会重试,但语义上这是拒绝而不是系统故障。
+     */
+    @Test
+    void handle_platformPublicKeyNotConfigured_returnsSignFailed() {
+        properties.setPlatformPublicKey("");
+
+        assertEquals("sign verify failed", service.handle(paidNotify()));
+
+        verify(orderService, never()).getByOrderNo(any());
+    }
+
+    // ==================== 业务处理 ====================
+
+    /**
+     * 订单不存在时返回 success 终止重试。
+     *
+     * 上游重试窗口只有约 25 秒,重试 5 次也不会凭空变出订单,
+     * 把窗口浪费在这里会挤掉真正可能成功的重试。
+     */
+    @Test
+    void handle_unknownOrder_returnsSuccessToStopRetries() {
+        when(orderService.getByOrderNo("HAR1")).thenReturn(null);
+
+        assertEquals("success", service.handle(paidNotify()));
+
+        verify(orderMapper, never()).updateById(any());
+        verify(callbackService, never()).notifyEnterprise(any());
+    }
+
+    @Test
+    void handle_sameStatus_isIdempotent() {
+        when(orderService.getByOrderNo("HAR1")).thenReturn(order("HAR1", HarOrderStatus.PAID));
+
+        assertEquals("success", service.handle(paidNotify()));
+
+        verify(orderMapper, never()).updateById(any());
+        verify(callbackService, never()).notifyEnterprise(any());
+    }
+
+    @Test
+    void handle_paidStatus_mapsFieldsAndDelegatesPollRescheduling() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+
+        service.handle(paidNotify());
+
+        verify(orderMapper).updateById(o);
+        assertEquals(HarOrderStatus.PAID, o.getTradeStatus());
+        assertEquals("UP-1", o.getUpstreamOrderNo());
+        assertEquals("PT-1", o.getPlatformTradeNo());
+        // 排期逻辑自身在 HarOrderServiceTest 覆盖,这里只验证确实委托了
+        verify(orderService).reschedulePoll(o);
+    }
+
+    @Test
+    void handle_refundedStatus_mapsStatus() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("third_order_no", "HAR1");
+        raw.put("status", "2");
+
+        service.handle(signed(raw));
+
+        assertEquals(HarOrderStatus.REFUNDED, o.getTradeStatus());
+        verify(orderService).reschedulePoll(o);
+    }
+
+    /**
+     * 退款把订单从终态拉回非终态,必须重新排期 —— 支付成功时 next_query_time
+     * 已被清空,只「不清空」而不重新排期的话这条订单就永远查不到了。
+     * 真实的排期结果由 HarOrderServiceTest#reschedulePoll_* 断言。
+     */
+    @Test
+    void handle_refundingStatus_delegatesPollRescheduling() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("third_order_no", "HAR1");
+        raw.put("status", "4");
+
+        service.handle(signed(raw));
+
+        assertEquals(HarOrderStatus.REFUNDING, o.getTradeStatus());
+        verify(orderService).reschedulePoll(o);
+    }
+
+    @Test
+    void handle_missingThirdOrderNo_returnsSuccess() {
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("status", "1");
+        raw.put("price", "12.34");
+
+        assertEquals("success", service.handle(signed(raw)));
+
+        verify(orderService, never()).getByOrderNo(any());
+    }
+
+    @Test
+    void handle_missingStatus_returnsSuccess() {
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("third_order_no", "HAR1");
+        raw.put("price", "12.34");
+        when(orderService.getByOrderNo("HAR1")).thenReturn(order("HAR1", HarOrderStatus.WAIT_PAY));
+
+        assertEquals("success", service.handle(signed(raw)));
+
+        verify(orderMapper, never()).updateById(any());
+    }
+
+    @Test
+    void handle_amountsAreNotOverwrittenByNotify() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        o.setRcvAmount(new BigDecimal("12.20"));
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+
+        service.handle(paidNotify());
+
+        // 通知报文不含实收/手续费,这些字段由主动查询补齐,不能被通知抹掉
+        assertEquals(new BigDecimal("12.20"), o.getRcvAmount());
+    }
+
+    @Test
+    void handle_validSignature_triggersEnterpriseCallback() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+
+        service.handle(paidNotify());
+
+        verify(callbackService).notifyEnterprise(o);
+    }
+}

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

@@ -0,0 +1,572 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.har.client.HarApiException;
+import com.payment.platform.module.payment.har.client.HarClient;
+import com.payment.platform.module.payment.har.client.HarResponse;
+import com.payment.platform.module.payment.har.client.dto.HarPayRequest;
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
+import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarOrderVO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class HarOrderServiceTest {
+
+    @Mock private HarOrderMapper orderMapper;
+    @Mock private HarMerchantService merchantService;
+    @Mock private HarClient harClient;
+
+    private HarOrderService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarOrderService(orderMapper, merchantService, harClient);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
+        //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarOrderEntity.class);
+    }
+
+    private HarOrderCreateDTO dto() {
+        HarOrderCreateDTO dto = new HarOrderCreateDTO();
+        dto.setEnterpriseId("E1");
+        dto.setOutBizNo("BIZ-0001");
+        dto.setPrice(new BigDecimal("12.34"));
+        dto.setTitle("测试商品");
+        dto.setTradeType("alipay_payment");
+        return dto;
+    }
+
+    private HarMerchantEntity merchant() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setEnterpriseId("E1");
+        m.setMerchantCode("M001");
+        return m;
+    }
+
+    private HarResponse response(String code, String detail) {
+        return new HarResponse(code, "msg", "sign", detail, null);
+    }
+
+    private HarOrderEntity order(String orderNo, String tradeStatus) {
+        HarOrderEntity o = new HarOrderEntity();
+        o.setOrderNo(orderNo);
+        o.setEnterpriseId("E1");
+        o.setTradeStatus(tradeStatus);
+        o.setQueryCount(0);
+        return o;
+    }
+
+    private String precreateDetail() {
+        return "{\"pay_url\":\"https://qr.example/pay\",\"order_no\":\"UP-1\"}";
+    }
+
+    // ==================== 下单 ====================
+
+    /**
+     * 本地记录必须先于上游调用落库。
+     *
+     * 顺序反过来的话,上游超时抛异常时本地什么都没有 —— 而那笔单在上游
+     * 可能已经建好了,通知到达时找不到订单只能丢弃,就成了扣了钱没记录的账。
+     */
+    @Test
+    void create_persistsLocalRecordBeforeCallingUpstream() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        InOrder inOrder = inOrder(orderMapper, harClient);
+        inOrder.verify(orderMapper).insert(any());
+        inOrder.verify(harClient).precreate(any());
+    }
+
+    @Test
+    void create_upstreamTimeout_stillPersistedLocalRecord() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenThrow(new HarApiException("read timeout"));
+
+        assertThrows(HarApiException.class, () -> service.create(dto(), "E1", "KEY1"));
+
+        // 记录已落库才有机会被轮询收敛
+        verify(orderMapper).insert(any());
+    }
+
+    /**
+     * third_order_no 必须是「我方生成的订单号」而不是企业自己的 out_biz_no:
+     * out_biz_no 由企业提供,不同企业的单号可能重复,而上游要求全局唯一。
+     */
+    @Test
+    void create_sendsGeneratedOrderNoAsThirdOrderNo_notOutBizNo() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        HarOrderVO vo = service.create(dto(), "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> captor =
+                ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(captor.capture());
+        assertEquals(vo.getOrderNo(), captor.getValue().getThirdOrderNo());
+        assertNotEquals("BIZ-0001", captor.getValue().getThirdOrderNo());
+        assertEquals("BIZ-0001", vo.getOutBizNo());
+        assertTrue(vo.getOrderNo().startsWith("HAR"));
+    }
+
+    /** 下单时排入轮询:上游通知窗口只有约 25 秒,超时后只能靠主动查询收敛 */
+    @Test
+    void create_schedulesFirstPoll() {
+        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());
+        assertNotNull(captor.getValue().getNextQueryTime());
+        assertEquals(HarOrderStatus.WAIT_PAY, captor.getValue().getTradeStatus());
+    }
+
+    @Test
+    void create_storesApiKeyForCallbackRouting() {
+        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("KEY1", captor.getValue().getApiKey());
+    }
+
+    /** 有付款码即被扫,走 pay 而非 precreate */
+    @Test
+    void create_withAuthCode_usesPayInsteadOfPrecreate() {
+        HarOrderCreateDTO dto = dto();
+        dto.setAuthCode("280812345678901234");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.pay(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        verify(harClient).pay(any(HarPayRequest.class));
+        verify(harClient, never()).precreate(any());
+    }
+
+    @Test
+    void create_upstreamNonSuccessCode_throws() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("40004", null));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(dto(), "E1", "KEY1"));
+
+        assertTrue(e.getMessage().contains("下单失败"));
+        verify(orderMapper).insert(any());
+    }
+
+    @Test
+    void create_noMerchantMapping_throwsBeforeTouchingUpstream() {
+        when(merchantService.requireByEnterpriseId("E1"))
+                .thenThrow(new BusinessException("企业尚未配置汇安融商户编号"));
+
+        assertThrows(BusinessException.class, () -> service.create(dto(), "E1", "KEY1"));
+
+        verify(orderMapper, never()).insert(any());
+        verify(harClient, never()).precreate(any());
+    }
+
+    // ==================== 主动查询 ====================
+
+    /**
+     * 优先用上游单号查。
+     *
+     * 曾经写成「传我方 order_no 当 order_no」—— 下单超时的订单本地没有上游单号,
+     * 那样每次查询都是空值,永远查不出结果。
+     */
+    @Test
+    void sync_prefersUpstreamOrderNo() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        o.setUpstreamOrderNo("UP-1");
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":0}"));
+
+        service.syncFromUpstream(o);
+
+        ArgumentCaptor<HarQueryRequest> captor = ArgumentCaptor.forClass(HarQueryRequest.class);
+        verify(harClient).query(captor.capture());
+        assertEquals("UP-1", captor.getValue().getOrderNo());
+        assertNull(captor.getValue().getThirdOrderNo());
+    }
+
+    @Test
+    void sync_fallsBackToThirdOrderNo_whenUpstreamOrderNoUnknown() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":0}"));
+
+        service.syncFromUpstream(o);
+
+        ArgumentCaptor<HarQueryRequest> captor = ArgumentCaptor.forClass(HarQueryRequest.class);
+        verify(harClient).query(captor.capture());
+        assertNull(captor.getValue().getOrderNo());
+        assertEquals("HAR1", captor.getValue().getThirdOrderNo());
+    }
+
+    @Test
+    void sync_paidStatus_mapsAllFieldsAndStopsPolling() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", """
+                {"order_no":"UP-1","platform_trade_no":"PT-1","status":1,"price":"12.34",
+                 "rcv_amount":"12.20","fee_amount":"0.14","channel_amount":"12.34",
+                 "trade_time":"2026-09-16 10:30:00","create_time":"2026-09-16 10:29:00"}
+                """));
+
+        HarOrderVO vo = service.syncFromUpstream(o);
+
+        assertEquals(HarOrderStatus.PAID, vo.getTradeStatus());
+        assertEquals("UP-1", vo.getUpstreamOrderNo());
+        assertEquals("PT-1", vo.getPlatformTradeNo());
+        assertEquals(new BigDecimal("12.34"), vo.getPrice());
+        assertEquals(new BigDecimal("12.20"), vo.getRcvAmount());
+        assertEquals(new BigDecimal("0.14"), vo.getFeeAmount());
+        assertNotNull(vo.getTradeTime());
+        assertNotNull(vo.getUpstreamCreateTime());
+        // 终态必须清空,否则轮询任务永远捞起已结束的订单
+        assertNull(o.getNextQueryTime());
+    }
+
+    @Test
+    void sync_waitingStatus_schedulesNextPoll() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":0}"));
+
+        HarOrderVO vo = service.syncFromUpstream(o);
+
+        assertEquals(HarOrderStatus.WAIT_PAY, vo.getTradeStatus());
+        assertNotNull(o.getNextQueryTime());
+    }
+
+    // ==================== 轮询排期 ====================
+
+    @Test
+    void reschedulePoll_finalStatus_clearsNextQueryTime() {
+        for (String status : new String[]{
+                HarOrderStatus.PAID, HarOrderStatus.REFUNDED, HarOrderStatus.CLOSED}) {
+            HarOrderEntity o = order("HAR1", status);
+            o.setNextQueryTime(java.time.OffsetDateTime.now().plusMinutes(5));
+
+            service.reschedulePoll(o);
+
+            assertNull(o.getNextQueryTime(), "终态应停止轮询: " + status);
+        }
+    }
+
+    /**
+     * 退款把订单从终态(1 已支付)拉回非终态(4 退款中)。
+     * 若只做「终态清空」而不给非终态重新排期,next_query_time 会停在
+     * 之前清空的 null 上,轮询按 IS NOT NULL 捞单就再也捞不到它。
+     */
+    @Test
+    void reschedulePoll_refundingAfterPaid_reschedulesNextQueryTime() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.REFUNDING);
+        o.setNextQueryTime(null);   // 支付成功时已被清空
+
+        service.reschedulePoll(o);
+
+        assertNotNull(o.getNextQueryTime());
+    }
+
+    @Test
+    void reschedulePoll_waiting_schedulesNextQueryTime() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+
+        service.reschedulePoll(o);
+
+        assertNotNull(o.getNextQueryTime());
+    }
+
+    /** 上游将来加了新状态,继续查比静默停掉安全 */
+    @Test
+    void reschedulePoll_unknownStatus_keepsPolling() {
+        HarOrderEntity o = order("HAR1", "99");
+
+        service.reschedulePoll(o);
+
+        assertNotNull(o.getNextQueryTime());
+    }
+
+    /** 查询失败也要推进下次查询时间,否则这条记录每轮都被捞起,打爆上游 */
+    @Test
+    void sync_queryFailure_stillAdvancesNextQueryTime() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("40004", null));
+
+        service.syncFromUpstream(o);
+
+        assertNotNull(o.getNextQueryTime());
+        assertEquals(1, o.getQueryCount());
+        verify(orderMapper).updateById(any());
+    }
+
+    @Test
+    void sync_queryFailure_doesNotThrow() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("40004", null));
+
+        assertDoesNotThrow(() -> service.syncFromUpstream(o));
+    }
+
+    /** 空串不能被当成 0,否则对账口径失真 */
+    @Test
+    void sync_blankAmounts_stayNull() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", """
+                {"status":0,"price":"","rcv_amount":"","fee_amount":"","channel_amount":""}
+                """));
+
+        HarOrderVO vo = service.syncFromUpstream(o);
+
+        assertNull(vo.getRcvAmount());
+        assertNull(vo.getFeeAmount());
+        assertNull(vo.getChannelAmount());
+    }
+
+    // ==================== 开放 API 查单 ====================
+
+    @Test
+    void openQuery_byOrderNo_returnsOrder() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setEnterpriseId("E1");
+        q.setOrderNo("HAR1");
+
+        HarOrderVO vo = service.openQuery(q);
+
+        assertEquals("HAR1", vo.getOrderNo());
+    }
+
+    /** 同一租户下可能有多个企业,A 企业不能查到 B 企业的订单 */
+    @Test
+    void openQuery_otherEnterpriseOrder_throws() {
+        HarOrderEntity other = order("HAR1", HarOrderStatus.PAID);
+        other.setEnterpriseId("E2");
+        when(orderMapper.selectOne(any())).thenReturn(other);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setEnterpriseId("E1");
+        q.setOrderNo("HAR1");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.openQuery(q));
+        // 越权与不存在返回同一句话,不泄露他人订单是否存在
+        assertEquals("订单不存在", e.getMessage());
+    }
+
+    @Test
+    void openQuery_orderNotFound_throws() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setEnterpriseId("E1");
+        q.setOutBizNo("BIZ-1");
+
+        assertThrows(BusinessException.class, () -> service.openQuery(q));
+    }
+
+    @Test
+    void openQuery_neitherKeyProvided_throws() {
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setEnterpriseId("E1");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.openQuery(q));
+        assertTrue(e.getMessage().contains("至少填一个"));
+        verify(orderMapper, never()).selectOne(any());
+    }
+
+    // ==================== 租户级下单/查单(去企业化,2026-09-24) ====================
+
+    private HarMerchantEntity tenantMerchant() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setTenantId(7L);
+        m.setEnterpriseId(null);
+        m.setMerchantCode("M700");
+        return m;
+    }
+
+    /**
+     * 企业两处都没有 ⇒ 按**租户**寻址。分流写反的话会拿 null 去查企业映射,
+     * 直接抛「企业尚未配置」—— 而调用方明明是一个配好了租户级映射的租户。
+     */
+    @Test
+    void create_noEnterpriseAnywhere_usesTenantMapping() {
+        when(merchantService.requireByTenant(7L)).thenReturn(tenantMerchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+        HarOrderCreateDTO dto = dto();
+        dto.setEnterpriseId(null);
+
+        HarOrderVO vo = HarTestAuth.asTenant(7L, () -> service.create(dto, null, "KEY1"));
+
+        verify(merchantService).requireByTenant(7L);
+        verify(merchantService, never()).requireByEnterpriseId(any());
+        assertEquals("M700", vo.getMerchantCode());
+    }
+
+    /** 租户级订单的 enterprise_id 必须落 NULL:openQuery 里那条「不传企业即按租户」正是靠它 */
+    @Test
+    void create_tenantLevel_storesNullEnterpriseId() {
+        when(merchantService.requireByTenant(7L)).thenReturn(tenantMerchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+        HarOrderCreateDTO dto = dto();
+        dto.setEnterpriseId("");   // 前端清空下拉框给的是空串
+
+        HarTestAuth.asTenant(7L, () -> service.create(dto, null, "KEY1"));
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        assertNull(captor.getValue().getEnterpriseId(), "空串必须落 NULL");
+    }
+
+    /**
+     * 显式 merchant_code **优先于** enterprise_id(2026-09-24 一租户多商户)。
+     *
+     * dto 同时带着 E1 与商户号,两条分支都能走通 —— 只有这样才验得出优先级。
+     * 若两个 if 调换,企业级那条先命中,本断言即红:那时「钱进哪个商户账号」
+     * 由企业号决定,调用方指定的那张码形同虚设。
+     */
+    @Test
+    void create_withExplicitMerchantCode_takesPriorityOverEnterprise() {
+        when(merchantService.requireByTenantAndCode(7L, "M700")).thenReturn(tenantMerchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+        HarOrderCreateDTO dto = dto();          // 带 enterpriseId = "E1"
+        dto.setMerchantCode("M700");
+
+        HarOrderVO vo = HarTestAuth.asTenant(7L, () -> service.create(dto, "E1", "KEY1"));
+
+        verify(merchantService).requireByTenantAndCode(7L, "M700");
+        verify(merchantService, never()).requireByEnterpriseId(any());
+        verify(merchantService, never()).requireByTenant(any());
+        assertEquals("M700", vo.getMerchantCode());
+        ArgumentCaptor<HarPrecreateRequest> req =
+                ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("M700", req.getValue().getMerchantCode(), "发给上游的必须是调用方指定的那个");
+    }
+
+    /**
+     * 缺企业号的查单必须**不加**企业谓词,而不是加一句恒假的 {@code = NULL}。
+     *
+     * 只能在 SQL 结构上验:mock 的 mapper 不执行 wrapper,两种写法返回同一个值。
+     * 而现实中 {@code = NULL} 恒为 UNKNOWN,租户级订单**永远查不到**。
+     */
+    @Test
+    void getByOutBizNo_blankEnterprise_hasNoEnterprisePredicate() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        service.getByOutBizNo(null, "BIZ-0001");
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarOrderEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orderMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("out_biz_no"), "实测 WHERE: " + where);
+        assertFalse(where.contains("enterprise_id"), "不传企业就不该有企业谓词,实测 WHERE: " + where);
+    }
+
+    /**
+     * 不传 enterprise_id ⇒ 按租户范围查单,同一租户下任何一个企业的订单都算命中。
+     *
+     * 这条是行为级的:比对那一步若写成无条件执行,租户级调用方拿着 null
+     * 去比 enterpriseId,任何订单都会变成「订单不存在」。
+     */
+    @Test
+    void openQuery_blankEnterprise_acceptsAnyOrderOfTheTenant() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);   // 属于企业 E1
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setOutBizNo("BIZ-0001");                               // 不传企业
+
+        HarOrderVO vo = service.openQuery(q);
+
+        assertEquals("HAR1", vo.getOrderNo());
+    }
+
+    /** 传了企业就仍要比对 —— 去企业化不能把同租户内的企业隔离一起取消掉 */
+    @Test
+    void openQuery_withEnterprise_stillRejectsOtherEnterprisesOrder() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        o.setEnterpriseId("E2");
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setEnterpriseId("E1");
+        q.setOutBizNo("BIZ-0001");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.openQuery(q));
+
+        assertEquals("订单不存在", e.getMessage());
+    }
+
+    /**
+     * 指定了商户就只认这个商户的订单。
+     *
+     * 少了这一步,多商户的租户按 order_no 查到的可能是**另一个商户**的单,
+     * 而返回体和请求对不上时调用方无从察觉(两个商户都在他名下,谁看都「正常」)。
+     */
+    @Test
+    void openQuery_withMerchantCode_rejectsOrderOfAnotherMerchant() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        o.setMerchantCode("M701");
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setMerchantCode("M700");
+        q.setOutBizNo("BIZ-0001");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.openQuery(q));
+
+        assertEquals("订单不存在", e.getMessage(), "与越权同一个说法,不区分");
+    }
+
+    @Test
+    void openQuery_withMerchantCode_acceptsMatchingOrder() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        o.setMerchantCode("M700");
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setMerchantCode("M700");
+        q.setOutBizNo("BIZ-0001");
+
+        assertEquals("HAR1", service.openQuery(q).getOrderNo());
+    }
+
+    /** 不传商户就不比对 —— 否则单商户租户(绝大多数)会一笔都查不到 */
+    @Test
+    void openQuery_blankMerchantCode_acceptsAnyMerchantOfTheTenant() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        o.setMerchantCode("M701");
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        HarOpenOrderQueryDTO q = new HarOpenOrderQueryDTO();
+        q.setOutBizNo("BIZ-0001");
+
+        assertEquals("HAR1", service.openQuery(q).getOrderNo());
+    }
+}

+ 356 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java

@@ -0,0 +1,356 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.har.client.HarApiException;
+import com.payment.platform.module.payment.har.client.HarClient;
+import com.payment.platform.module.payment.har.client.HarResponse;
+import com.payment.platform.module.payment.har.client.dto.HarShopCreateRequest;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.dto.HarShopVO;
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
+import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
+import com.payment.platform.module.payment.har.mapper.HarShopMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 上游没有门店查询接口,本地表是唯一真相源。
+ * 这里的重点是「任何时候都不能丢记录」和「不确定时不要谎报失败」。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarShopServiceTest {
+
+    @Mock private HarShopMapper shopMapper;
+    @Mock private HarMerchantService merchantService;
+    @Mock private HarClient harClient;
+
+    private HarShopService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarShopService(shopMapper, merchantService, harClient);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
+        //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
+    }
+
+    private HarShopCreateDTO dto() {
+        HarShopCreateDTO dto = new HarShopCreateDTO();
+        dto.setEnterpriseId("E1");
+        dto.setThirdShopId("SHOP001");
+        dto.setShopName("测试门店");
+        return dto;
+    }
+
+    private HarMerchantEntity merchant() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setEnterpriseId("E1");
+        m.setMerchantCode("M001");
+        return m;
+    }
+
+    private HarResponse response(String code, String detail) {
+        return new HarResponse(code, "msg", "sign", detail, null);
+    }
+
+    /** 在 insert 那一刻快照状态 —— 实体后续会被改写,事后读 captor 看到的不是插入时的值 */
+    private AtomicReference<String> snapshotStatusAtInsert() {
+        AtomicReference<String> captured = new AtomicReference<>();
+        when(shopMapper.insert(any())).thenAnswer(invocation -> {
+            captured.set(((HarShopEntity) invocation.getArgument(0)).getCreateStatus());
+            return 1;
+        });
+        return captured;
+    }
+
+    @Test
+    void create_persistsPendingBeforeCallingUpstream() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        AtomicReference<String> statusAtInsert = snapshotStatusAtInsert();
+        when(harClient.createShop(any())).thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+
+        service.create(dto(), "E1");
+
+        assertEquals(HarShopCreateStatus.PENDING, statusAtInsert.get(),
+                "调用上游之前必须已落库,否则超时就会凭空丢单");
+        verify(harClient).createShop(any());
+    }
+
+    @Test
+    void create_success_setsShopIdAndStatus() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\",\"third_shop_id\":\"SHOP001\"}"));
+
+        HarShopVO vo = service.create(dto(), "E1");
+
+        assertEquals(HarShopCreateStatus.SUCCESS, vo.getCreateStatus());
+        assertEquals("S1", vo.getShopId());
+    }
+
+    /**
+     * 网络超时不能标 FAILED —— 上游很可能已经建好了门店,
+     * 标失败会让运营重复创建,出现同一门店两个 shop_id。
+     */
+    @Test
+    void create_upstreamNetworkFailure_staysPending() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any())).thenThrow(new HarApiException("read timeout"));
+
+        HarShopVO vo = service.create(dto(), "E1");
+
+        assertEquals(HarShopCreateStatus.PENDING, vo.getCreateStatus());
+        assertTrue(vo.getFailReason().contains("read timeout"));
+        assertNull(vo.getShopId());
+        verify(shopMapper).updateById(any());
+    }
+
+    /** 上游明确回业务失败,才是真的失败 */
+    @Test
+    void create_upstreamBusinessFailure_marksFailed() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any())).thenReturn(response("40004", "门店已存在"));
+
+        HarShopVO vo = service.create(dto(), "E1");
+
+        assertEquals(HarShopCreateStatus.FAILED, vo.getCreateStatus());
+        assertTrue(vo.getFailReason().contains("40004"));
+    }
+
+    /** 成功码但没有 shop_id 等于没建成,不能当成功 */
+    @Test
+    void create_successCodeWithoutShopId_marksFailed() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any())).thenReturn(response("10000", "{}"));
+
+        HarShopVO vo = service.create(dto(), "E1");
+
+        assertEquals(HarShopCreateStatus.FAILED, vo.getCreateStatus());
+        assertNull(vo.getShopId());
+    }
+
+    /** third_shop_id 是幂等键,重复时必须在调上游之前拦住 */
+    @Test
+    void create_duplicateThirdShopId_throwsBeforeCallingUpstream() {
+        HarShopEntity existing = new HarShopEntity();
+        existing.setThirdShopId("SHOP001");
+        when(shopMapper.selectOne(any())).thenReturn(existing);
+
+        assertThrows(BusinessException.class, () -> service.create(dto(), "E1"));
+
+        verify(harClient, never()).createShop(any());
+        verify(shopMapper, never()).insert(any());
+    }
+
+    @Test
+    void create_noMerchantMapping_throws() {
+        when(merchantService.requireByEnterpriseId("E1"))
+                .thenThrow(new BusinessException("企业尚未配置汇安融商户编号"));
+
+        assertThrows(BusinessException.class, () -> service.create(dto(), "E1"));
+
+        verify(shopMapper, never()).insert(any());
+        verify(harClient, never()).createShop(any());
+    }
+
+    /** 请求报文必须留存,超时后运营要拿它去上游核实 */
+    @Test
+    void create_storesRequestBodyForManualVerification() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper).insert(captor.capture());
+        assertTrue(captor.getValue().getRequestBody().contains("SHOP001"));
+        assertTrue(captor.getValue().getRequestBody().contains("M001"));
+    }
+
+    @Test
+    void create_forwardsEnterpriseAndMerchantCodeUpstream() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopCreateRequest> captor =
+                ArgumentCaptor.forClass(HarShopCreateRequest.class);
+        verify(harClient).createShop(captor.capture());
+        assertEquals("M001", captor.getValue().getMerchantCode());
+        assertEquals("SHOP001", captor.getValue().getThirdShopId());
+        assertEquals("测试门店", captor.getValue().getName());
+    }
+
+    @Test
+    void create_enterpriseIdFromDto_whenNotPassedIn() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+
+        HarShopVO vo = service.create(dto(), null);
+
+        assertEquals("E1", vo.getEnterpriseId());
+    }
+
+    // ==================== 租户级门店(去企业化,2026-09-24) ====================
+
+    private HarMerchantEntity tenantMerchant() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setTenantId(7L);
+        m.setEnterpriseId(null);
+        m.setMerchantCode("M700");
+        return m;
+    }
+
+    /**
+     * 企业两处都没有 ⇒ 按**租户**寻址:取当前登录身份的租户,走 requireByTenant。
+     *
+     * 这条分支是本次新增的(见 db/har-tenant-level.sql)。它与 requireByEnterpriseId
+     * 是并存的两条路,分流只由「企业号有没有值」决定 —— 分流写反的话,
+     * 租户级门店会拿着 null 去查企业映射,直接抛「企业尚未配置」。
+     */
+    @Test
+    void create_noEnterpriseAnywhere_usesTenantMapping() {
+        when(merchantService.requireByTenant(7L)).thenReturn(tenantMerchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+        HarShopCreateDTO dto = dto();
+        dto.setEnterpriseId(null);
+
+        HarShopVO vo = HarTestAuth.asTenant(7L, () -> service.create(dto, null));
+
+        verify(merchantService).requireByTenant(7L);
+        verify(merchantService, never()).requireByEnterpriseId(any());
+        assertEquals("M700", vo.getMerchantCode(), "商户号必须来自租户级映射");
+        assertNotNull(vo.getShopId());
+    }
+
+    /**
+     * 租户级门店的 enterprise_id 必须落 NULL —— 这是它唯一的识别方式
+     * (见 getByThirdShopId 与 uk_har_shop_tenant_third_shop)。
+     * 落成空串的话查重、列表、下单寻址所有 IS NULL 的地方都看不到它,且不报错。
+     */
+    @Test
+    void create_tenantLevel_storesNullEnterpriseId() {
+        when(merchantService.requireByTenant(7L)).thenReturn(tenantMerchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+        HarShopCreateDTO dto = dto();
+        dto.setEnterpriseId("");   // 前端清空下拉框给的是空串
+
+        HarTestAuth.asTenant(7L, () -> service.create(dto, null));
+
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper).insert(captor.capture());
+        assertNull(captor.getValue().getEnterpriseId(), "空串必须落 NULL");
+    }
+
+    /**
+     * 显式 merchant_code **优先于** enterprise_id(2026-09-24 一租户多商户)。
+     *
+     * 这条 dto 同时带着 E1 和企业号,两处分支都能走通 —— 只有这样构造才验得出优先级:
+     * 若把两个 if 调换,企业级那条会先命中,本断言即红,而「用哪条商户配置」
+     * 就变成了由企业号决定,调用方指定的那张码形同虚设(钱进的是另一个商户账号)。
+     */
+    @Test
+    void create_withExplicitMerchantCode_takesPriorityOverEnterprise() {
+        HarMerchantEntity picked = tenantMerchant();
+        picked.setMerchantCode("M700");
+        when(merchantService.requireByTenantAndCode(7L, "M700")).thenReturn(picked);
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+        HarShopCreateDTO dto = dto();          // 带 enterpriseId = "E1"
+        dto.setMerchantCode("M700");
+
+        HarShopVO vo = HarTestAuth.asTenant(7L, () -> service.create(dto, "E1"));
+
+        verify(merchantService).requireByTenantAndCode(7L, "M700");
+        verify(merchantService, never()).requireByEnterpriseId(any());
+        verify(merchantService, never()).requireByTenant(any());
+        assertEquals("M700", vo.getMerchantCode());
+        ArgumentCaptor<HarShopCreateRequest> req =
+                ArgumentCaptor.forClass(HarShopCreateRequest.class);
+        verify(harClient).createShop(req.capture());
+        assertEquals("M700", req.getValue().getMerchantCode(), "发给上游的必须是调用方指定的那个");
+    }
+
+    /**
+     * 租户级查重必须落成 {@code enterprise_id IS NULL} 而**不是** {@code = NULL}。
+     *
+     * 这一条只能在 SQL 结构上验:mock 的 mapper 不执行 wrapper,
+     * 无论传什么条件它都返回同一个值 ⇒「查没查出重复」这件事在单测里不可分辨。
+     * 而现实里 {@code = NULL} 恒为 UNKNOWN,查重**永远返回 null**(形同虚设),
+     * 只剩部分唯一索引兜底,把可读的提示换成一句 500。
+     */
+    @Test
+    void getByThirdShopId_blankEnterprise_scopesToNullRows() {
+        when(shopMapper.selectOne(any())).thenReturn(null);
+
+        service.getByThirdShopId(null, "SHOP001");
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarShopEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(shopMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("IS NULL"), "租户级查重必须是 IS NULL,实测 WHERE: " + where);
+        assertFalse(where.contains("= NULL"), "= NULL 恒为 UNKNOWN,实测 WHERE: " + where);
+    }
+
+    /** 企业级那条路不能跟着改成 IS NULL —— 那样查重会把别的企业的同码门店算成本企业的 */
+    @Test
+    void getByThirdShopId_withEnterprise_scopesToThatEnterprise() {
+        when(shopMapper.selectOne(any())).thenReturn(null);
+
+        service.getByThirdShopId("E1", "SHOP001");
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarShopEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(shopMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("enterprise_id ="), "实测 WHERE: " + where);
+        assertFalse(where.contains("IS NULL"), "实测 WHERE: " + where);
+    }
+}

+ 41 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarTestAuth.java

@@ -0,0 +1,41 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.payment.platform.core.security.LoginUser;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * 测试专用:在 SecurityContext 里放一个「某租户的登录身份」。
+ *
+ * 租户级寻址(去企业化)取的是 {@code SecurityContextHolder} 里的 LoginUser.tenantId,
+ * 而这些服务全是纯 Mockito 单测、没有 Spring 上下文,所以要自己把身份摆进去。
+ * 用完必须还原,否则会顺着线程漏进后面的用例。
+ */
+final class HarTestAuth {
+
+    private HarTestAuth() {
+    }
+
+    static <T> T asTenant(long tenantId, Supplier<T> action) {
+        var previous = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = new LoginUser();
+        user.setTenantId(tenantId);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, List.of()));
+        try {
+            return action.get();
+        } finally {
+            SecurityContextHolder.getContext().setAuthentication(previous);
+        }
+    }
+
+    static void asTenant(long tenantId, Runnable action) {
+        asTenant(tenantId, () -> {
+            action.run();
+            return null;
+        });
+    }
+}