浏览代码

docs(HAR): 设计稿、实现计划与上游接口规范

docx 是上游给的原始接口规范(这一批的输入依据),plan 与 spec 是我们自己
的设计稿与实现计划。放进来是为了让上面三个提交的每个决定都有出处可查 ——
在这之前它们只存在于工作区。
alphaH 3 周之前
父节点
当前提交
b18c609c62

+ 4271 - 0
.claude/plan/2026-09-16-har-alipay-channel.md

@@ -0,0 +1,4271 @@
+# 汇安融支付宝支付通道模块 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把汇安融(HAR)的支付宝收单能力接进平台,并以开放 API 的形式转给企业商户,平台只记录订单生命周期、不碰资金。
+
+**Architecture:** 在 `module/payment/har/` 下新建独立模块。上游侧由 `client/` 包封装签名、报文组装、发送、响应解析与验签;业务侧三张表(商户映射、门店、订单);下游侧复用平台既有的 tenant apiKey + HMAC 签名底座,新增开放接口;通知入口独立于既有 `notification/` 模块(消息模型不同)。
+
+**Tech Stack:** Java 21 / Spring Boot / MyBatis-Plus / PostgreSQL / JUnit 5 / Vue 3 + Element Plus
+
+**Spec:** `docs/superpowers/specs/2026-09-16-har-alipay-channel-design.md`
+
+## Global Constraints
+
+- **禁止 git commit。** 本次交付只写工作区,不产生任何提交。计划中原有的「Commit」步骤一律替换为「验证」步骤。
+- Java 21,包名根 `com.payment.platform`。
+- 实体基类:`PaymentEnterpriseBaseEntity`(含 `tenantId` + `enterpriseId`)用于三张业务表。
+- **业务状态字段用 `trade_status` / `create_status`,不用 `status`** —— `PaymentBaseEntity.status` 另有用途。
+- 数据库为 PostgreSQL,主键 `BIGSERIAL`,时间列 `TIMESTAMP WITHOUT TIME ZONE`。
+- 控制器用 `@RestController` + `@RequiredArgsConstructor`,返回 `Result<T>` / `PageResult<T>`,入参用 `@RequestParam(name = "snake_case")`。
+- 需要登录态的接口加 `@PreAuthorize("@perm.hasAny('module_payment:har:<action>')")`。
+- 上游字段名直接沿用,不做映射层。
+- 上游签名算法固定 `SHA256WithRSA`,签名结果 `Base64.encodeBase64URLSafeString`。
+- **验签失败一律拒绝,不设跳过开关。**
+
+---
+
+## 文件结构
+
+```
+java/src/main/java/com/payment/platform/module/payment/har/
+├── config/HarProperties.java              渠道配置(baseUrl/appId/version/密钥)
+├── client/
+│   ├── HarSigner.java                     签名与验签(纯函数)
+│   ├── HarApiException.java               上游调用异常
+│   ├── HarResponse.java                   解析后的响应
+│   ├── HarResponseParser.java             响应解析(form / JSON 双路)
+│   ├── HarClient.java                     报文组装 + 发送
+│   └── dto/                               上游请求与响应体
+│       ├── HarPrecreateRequest.java
+│       ├── HarPayRequest.java
+│       ├── HarQueryRequest.java
+│       ├── HarShopCreateRequest.java
+│       ├── HarPrecreateDetail.java
+│       ├── HarQueryDetail.java
+│       └── HarShopCreateDetail.java
+├── controller/
+│   ├── HarMerchantController.java
+│   ├── HarShopController.java
+│   ├── HarOrderController.java
+│   └── HarNotifyController.java
+├── open/HarOpenController.java
+├── dto/                                  后台查询与视图
+├── entity/                               HarMerchantEntity / HarShopEntity / HarOrderEntity
+├── enums/                                HarShopCreateStatus / HarTradeType / HarNotifyStatus
+├── mapper/                               三个 Mapper
+├── service/                              HarMerchantService / HarShopService / HarOrderService
+│                                         HarNotifyService / HarCallbackService
+└── scheduler/HarOrderPollScheduler.java
+
+java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql
+java/src/main/resources/db/har-menu.sql
+java/src/test/java/com/payment/platform/module/payment/har/   测试
+
+frontend/src/api/module_payment/har.ts
+frontend/src/views/module_payment/har/merchant/index.vue
+frontend/src/views/module_payment/har/shop/index.vue
+frontend/src/views/module_payment/har/order/index.vue
+```
+
+**需要修改的既有文件(仅 4 处):**
+- `core/security/SecurityConfig.java` — 白名单追加两条
+- `core/security/TenantApiKeyAuthFilter.java` — 开放路径数组追加三条
+- `java/src/main/resources/application.yml` — 追加 `har:` 配置块
+- `frontend/src/api/module_payment/` 与 `views/module_payment/` — 新增文件,不改既有
+
+---
+
+## Task 1: HarSigner 签名与验签
+
+上游签名是整个模块里唯一现在就能严格验证的部分,先做。
+
+**Files:**
+- Create: `java/src/main/java/com/payment/platform/module/payment/har/client/HarSigner.java`
+- Test: `java/src/test/java/com/payment/platform/module/payment/har/client/HarSignerTest.java`
+
+**Interfaces:**
+- Produces:
+  - `static String HarSigner.sign(String data, String privateKeyBase64)` — 返回 URL-safe Base64 签名串
+  - `static boolean HarSigner.verify(String data, String sign, String publicKeyBase64)` — 签名不匹配返回 `false`,**不抛异常**
+  - `static String HarSigner.buildNotifySignContent(Map<String,String> params)` — 通知验签的待签串
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+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.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HarSignerTest {
+
+    private static final org.apache.commons.codec.binary.Base64 B64 =
+            new org.apache.commons.codec.binary.Base64();
+
+    /** 生成一对测试密钥,公钥 X.509、私钥 PKCS#8,均为纯 Base64(带 PEM 头会解析失败,故此处不带) */
+    private static String[] newKeyPair() throws Exception {
+        KeyPairGenerator g = KeyPairGenerator.getInstance("RSA");
+        g.initialize(2048);
+        KeyPair kp = g.generateKeyPair();
+        return new String[]{
+                java.util.Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()),
+                java.util.Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded())
+        };
+    }
+
+    @Test
+    void signThenVerifyRoundTrip() throws Exception {
+        String[] kp = newKeyPair();
+        String pub = kp[0], pri = kp[1];
+        String biz = "{\"order_no\":1111}";
+
+        String sign = HarSigner.sign(biz, pri);
+
+        assertNotNull(sign);
+        assertTrue(HarSigner.verify(biz, sign, pub), "自己签的自己必须验得过");
+    }
+
+    @Test
+    void signatureIsUrlSafeBase64() throws Exception {
+        String[] kp = newKeyPair();
+        String sign = HarSigner.sign("{\"a\":1}", kp[1]);
+
+        // demo 用 Base64.encodeBase64URLSafeString,产出不含 + / = 的 URL-safe 串
+        assertFalse(sign.contains("+"), "不得出现标准 Base64 的 +");
+        assertFalse(sign.contains("/"), "不得出现标准 Base64 的 /");
+        assertFalse(sign.contains("="), "URL-safe 变体不补 padding");
+    }
+
+    @Test
+    void verifyAcceptsStandardBase64Too() throws Exception {
+        String[] kp = newKeyPair();
+        String biz = "{\"order_no\":1111}";
+
+        // 用标准 Base64 重编码同一份签名,验签必须同样通过(宽松解码,兼容两种字母表)
+        byte[] raw = org.apache.commons.codec.binary.Base64.decodeBase64(HarSigner.sign(biz, kp[1]));
+        String standard = org.apache.commons.codec.binary.Base64.encodeBase64String(raw);
+
+        assertTrue(HarSigner.verify(biz, standard, kp[0]));
+    }
+
+    @Test
+    void verifyReturnsFalseOnTamperedContent() throws Exception {
+        String[] kp = newKeyPair();
+        String sign = HarSigner.sign("{\"order_no\":1111}", kp[1]);
+
+        assertFalse(HarSigner.verify("{\"order_no\":2222}", sign, kp[0]), "内容被改必须验不过");
+    }
+
+    @Test
+    void verifyReturnsFalseOnGarbageSignatureInsteadOfThrowing() throws Exception {
+        String[] kp = newKeyPair();
+
+        // demo 的 XRsa.verify 会把这种情况包成 RuntimeException 抛出,
+        // 调用方容易写出 try{verify()}catch{放过去} 从而放过未验签数据。
+        // 本实现必须返回 false。
+        assertFalse(HarSigner.verify("{\"a\":1}", "not-a-signature", kp[0]));
+        assertFalse(HarSigner.verify("{\"a\":1}", "", kp[0]));
+        assertFalse(HarSigner.verify("{\"a\":1}", null, kp[0]));
+    }
+
+    @Test
+    void verifyReturnsFalseWhenSignedByAnotherKey() throws Exception {
+        String[] kpA = newKeyPair();
+        String[] kpB = newKeyPair();
+        String sign = HarSigner.sign("{\"a\":1}", kpA[1]);
+
+        assertFalse(HarSigner.verify("{\"a\":1}", sign, kpB[0]), "换了公钥必须验不过");
+    }
+
+    @Test
+    void buildNotifySignContentSortsByKeyAndDropsSignAndBlank() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("third_order_no", "T1");
+        params.put("sign", "SHOULD_BE_DROPPED");
+        params.put("merchant_code", "M001");
+        params.put("title", "");          // 空值剔除
+        params.put("status", "1");
+
+        String content = HarSigner.buildNotifySignContent(params);
+
+        assertEquals("merchant_code=M001&status=1&third_order_no=T1", content);
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarSignerTest`
+Expected: 编译失败,`HarSigner` 不存在
+
+- [ ] **Step 3: 实现 HarSigner**
+
+```java
+package com.payment.platform.module.payment.har.client;
+
+import org.apache.commons.codec.binary.Base64;
+
+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.Map;
+import java.util.TreeMap;
+
+/**
+ * 汇安融签名与验签。
+ *
+ * 算法与编码对齐 kf-request-demo 的 com.har.utils.XRsa:
+ *   - 算法 SHA256WithRSA
+ *   - 签名结果 Base64.encodeBase64URLSafeString(URL-safe 字母表,不补 padding)
+ *   - 公钥 X.509、私钥 PKCS#8,均为纯 Base64
+ *
+ * 与 demo 的差异(有意为之):
+ *   demo 的 verify() 在签名格式非法时抛 RuntimeException,调用方容易写成
+ *   try{verify()}catch{放过去},从而把未验签的数据当合法数据处理。
+ *   本实现只在密钥加载失败(配置错误)时抛出,签名不匹配一律返回 false。
+ */
+public final class HarSigner {
+
+    private static final String KEY_ALGORITHM = "RSA";
+    private static final String SIGN_ALGORITHM = "SHA256WithRSA";
+
+    private HarSigner() {
+    }
+
+    /**
+     * 用我方私钥对 bizContent 原文签名。
+     *
+     * @param data              待签字符串(调用时传 bizContent 原文)
+     * @param privateKeyBase64  我方私钥,PKCS#8 纯 Base64
+     * @return URL-safe Base64 签名串
+     */
+    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 Base64.encodeBase64URLSafeString(signature.sign());
+        } catch (Exception e) {
+            throw new HarApiException("签名失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 用汇安融公钥验签。
+     *
+     * @param data             待验原文(响应传 detail 原文,通知传拼串结果)
+     * @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);
+        try {
+            Signature signature = Signature.getInstance(SIGN_ALGORITHM);
+            signature.initVerify(publicKey);
+            signature.update(data.getBytes(StandardCharsets.UTF_8));
+            return signature.verify(Base64.decodeBase64(sign));
+        } catch (Exception e) {
+            // 签名串字节非法(比如对方传了非 Base64 内容)时 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();
+    }
+
+    private static PublicKey loadPublicKey(String base64) {
+        try {
+            X509EncodedKeySpec spec = new X509EncodedKeySpec(Base64.decodeBase64(base64));
+            return KeyFactory.getInstance(KEY_ALGORITHM).generatePublic(spec);
+        } catch (Exception e) {
+            throw new HarApiException("汇安融公钥无法加载,请检查配置: " + e.getMessage(), e);
+        }
+    }
+
+    private static PrivateKey loadPrivateKey(String base64) {
+        try {
+            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(Base64.decodeBase64(base64));
+            return KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(spec);
+        } catch (Exception e) {
+            throw new HarApiException("我方私钥无法加载,请检查配置: " + e.getMessage(), e);
+        }
+    }
+}
+```
+
+- [ ] **Step 4: 实现 HarApiException**
+
+`java/src/main/java/com/payment/platform/module/payment/har/client/HarApiException.java`
+
+```java
+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;
+    }
+}
+```
+
+- [ ] **Step 5: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarSignerTest`
+Expected: 7 个测试全部 PASS
+
+- [ ] **Step 6: 验证(不提交)**
+
+Run: `cd java && git status --short src/main/java/com/payment/platform/module/payment/har src/test/java/com/payment/platform/module/payment/har`
+Expected: 只列出新增文件,**不得执行 git add / git commit**
+
+---
+
+## Task 2: 渠道配置
+
+**Files:**
+- Create: `java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java`
+- Modify: `java/src/main/resources/application.yml`
+
+**Interfaces:**
+- Produces: `HarProperties`,注入方式 `private final HarProperties harProperties;`
+  - `String getBaseUrl()` / `String getAppId()` / `String getVersion()`
+  - `String getOurPrivateKey()` / `String getPlatformPublicKey()`
+  - `int getConnectTimeoutMs()` / `int getReadTimeoutMs()` / `boolean isEnabled()`
+
+> **已知未决**:规范未确定 appId 与密钥是机构级还是商户级。本期按机构级单份配置实现。若联调后确认为商户级,改为按 `merchant_code` 建配置表即可,属纯加法。
+
+- [ ] **Step 1: 实现 HarProperties**
+
+```java
+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 {
+
+    /** 总开关,默认关闭,避免误配后启动即发起调用 */
+    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;
+
+    /** 连接超时(毫秒),默认 5 秒 */
+    private int connectTimeoutMs = 5000;
+
+    /** 读取超时(毫秒),默认 15 秒 */
+    private int readTimeoutMs = 15000;
+}
+```
+
+- [ ] **Step 2: 追加 application.yml 配置块**
+
+在 `java/src/main/resources/application.yml` 顶层追加(缩进与既有顶层键一致):
+
+```yaml
+# 汇安融支付宝通道(密钥由运维注入,不要提交真实值)
+har:
+  enabled: false
+  base-url: ""
+  app-id: ""
+  version: "2.0"
+  our-private-key: ""
+  platform-public-key: ""
+  connect-timeout-ms: 5000
+  read-timeout-ms: 15000
+```
+
+- [ ] **Step 3: 验证**
+
+Run: `cd java && ./mvnw -q compile`
+Expected: BUILD SUCCESS
+
+---
+
+## Task 3: 上游响应解析
+
+**Files:**
+- Create: `.../har/client/HarResponse.java`
+- Create: `.../har/client/HarResponseParser.java`
+- Test: `.../har/client/HarResponseParserTest.java`
+
+**Interfaces:**
+- Produces:
+  - `record HarResponse(String responseCode, String msg, String sign, String detailRaw, String bizContentRaw)`,`boolean isSuccess()` 判定 `"10000".equals(responseCode)`
+  - `static HarResponse HarResponseParser.parse(String rawBody)` — 支持 form 与 JSON 两种格式
+  - `static <T> T HarResponseParser.toBean(String json, Class<T> type)`
+
+> **关键设计**:`detailRaw` 保存的是 **detail 的原文**,验签必须用它。绝不可反序列化后再序列化——JSON 键序、空格、转义的任何变化都会导致验签失败。响应格式上游未确认,故双路解析。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HarResponseParserTest {
+
+    @Test
+    void parsesFormEncodedResponse() {
+        String detail = "{\"order_no\":\"123\",\"status\":1}";
+        String raw = "response_code=10000&msg=" + enc("请求成功")
+                + "&detail=" + enc(detail) + "&sign=ABC";
+
+        HarResponse r = HarResponseParser.parse(raw);
+
+        assertEquals("10000", r.responseCode());
+        assertEquals("请求成功", r.msg());
+        assertEquals(detail, r.detailRaw(), "detail 必须是解码后的原文,供验签使用");
+        assertEquals("ABC", r.sign());
+        assertTrue(r.isSuccess());
+    }
+
+    @Test
+    void parsesJsonResponse() {
+        String raw = "{\"response_code\":\"10000\",\"msg\":\"请求成功\","
+                + "\"detail\":\"{\\\"order_no\\\":\\\"123\\\"}\",\"sign\":\"ABC\"}";
+
+        HarResponse r = HarResponseParser.parse(raw);
+
+        assertEquals("10000", r.responseCode());
+        assertEquals("{\"order_no\":\"123\"}", r.detailRaw());
+        assertEquals("ABC", r.sign());
+    }
+
+    @Test
+    void treatsNonSuccessCodeAsNotSuccess() {
+        HarResponse r = HarResponseParser.parse("response_code=40004&msg=system error");
+        assertFalse(r.isSuccess());
+    }
+
+    @Test
+    void toleratesUnknownExtraFields() {
+        String raw = "biz_content=%7B%7D&response_code=10000&msg=ok&detail=%7B%7D&sign=S&extra=x";
+        HarResponse r = HarResponseParser.parse(raw);
+        assertEquals("10000", r.responseCode());
+        assertEquals("{}", r.detailRaw());
+        assertEquals("{}", r.bizContentRaw());
+    }
+
+    @Test
+    void toBeanParsesDetail() {
+        HarQueryDetail detail = HarResponseParser.toBean(
+                "{\"order_no\":\"123\",\"status\":1,\"price\":\"12.25\"}", HarQueryDetail.class);
+        assertEquals("123", detail.getOrderNo());
+        assertEquals(1, detail.getStatus());
+        assertEquals("12.25", detail.getPrice());
+    }
+
+    private static String enc(String s) {
+        return URLEncoder.encode(s, StandardCharsets.UTF_8);
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarResponseParserTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现 HarResponse**
+
+```java
+package com.payment.platform.module.payment.har.client;
+
+/**
+ * 解析后的上游响应。
+ *
+ * detailRaw 是 detail 的原文(form 为 URL 解码后,JSON 为字符串值),
+ * 验签必须用这个串,不可用 detail 反序列化再序列化的结果。
+ */
+public record HarResponse(
+        String responseCode,
+        String msg,
+        String sign,
+        String detailRaw,
+        String bizContentRaw) {
+
+    public static final String SUCCESS_CODE = "10000";
+
+    public boolean isSuccess() {
+        return SUCCESS_CODE.equals(responseCode);
+    }
+}
+```
+
+- [ ] **Step 4: 实现 HarResponseParser**
+
+```java
+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,
+ * 响应格式上游未确认。此处先按 form 解析,解析不出 detail 再按 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);
+    }
+
+    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, msg = null, sign = null, detail = null, 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) {
+        try {
+            JsonNode node = MAPPER.readTree(body);
+            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"));
+        } catch (HarApiException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new HarApiException("上游响应不是合法 JSON: " + e.getMessage(), e);
+        }
+    }
+
+    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)";
+    }
+}
+```
+
+- [ ] **Step 5: 实现响应 DTO**
+
+`.../har/client/dto/HarQueryDetail.java`
+
+```java
+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 保证上游加字段不会导致解析失败。
+ */
+@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;
+}
+```
+
+`.../har/client/dto/HarPrecreateDetail.java`
+
+```java
+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)接口 detail。字段对应规范 5.1 响应结果。 */
+@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;
+}
+```
+
+`.../har/client/dto/HarShopCreateDetail.java`
+
+```java
+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;
+}
+```
+
+- [ ] **Step 6: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarResponseParserTest`
+Expected: 5 个测试全部 PASS
+
+---
+
+## Task 4: 上游客户端 HarClient
+
+**Files:**
+- Create: `.../har/client/HarClient.java`
+- Create: `.../har/client/dto/HarPrecreateRequest.java`
+- Create: `.../har/client/dto/HarPayRequest.java`
+- Create: `.../har/client/dto/HarQueryRequest.java`
+- Create: `.../har/client/dto/HarShopCreateRequest.java`
+- Test: `.../har/client/HarClientTest.java`
+
+**Interfaces:**
+- Consumes: `HarSigner`(Task 1)、`HarProperties`(Task 2)、`HarResponseParser` / `HarResponse`(Task 3)
+- Produces:
+  - `HarResponse HarClient.precreate(HarPrecreateRequest req)`
+  - `HarResponse HarClient.pay(HarPayRequest req)`
+  - `HarResponse HarClient.query(HarQueryRequest req)`
+  - `HarResponse HarClient.createShop(HarShopCreateRequest req)`
+  - `static String HarClient.toBizContent(Object req)` — 序列化业务参数,**签名与发送必须用同一个串**
+
+> **关键设计**:`bizContent` 只序列化一次,`sign` 与 form 字段都引用同一个字符串。序列化两次会因字段顺序不确定导致签名与内容不一致。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.client;
+
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
+import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HarClientTest {
+
+    @Test
+    void bizContentOmitsNullFields() {
+        HarQueryRequest req = new HarQueryRequest();
+        req.setOrderNo("123");
+        // thirdOrderNo 为 null 时应被省略,而不是序列化成 null
+
+        String biz = HarClient.toBizContent(req);
+
+        assertEquals("{\"order_no\":\"123\"}", biz);
+    }
+
+    @Test
+    void bizContentKeepsUpstreamFieldNames() {
+        HarPrecreateRequest req = new HarPrecreateRequest();
+        req.setPrice("12.25");
+        req.setTitle("iPhone 16");
+        req.setTradeType("alipay_payment");
+        req.setMerchantCode("M001");
+        req.setThirdOrderNo("T001");
+        req.setThirdShopCode("S001");
+        req.setThirdNotifyUrl("https://example.com/notify");
+        req.setPayTimeout(5);
+
+        String biz = HarClient.toBizContent(req);
+
+        // 上游字段名直接沿用,不做映射
+        assertTrue(biz.contains("\"third_order_no\":\"T001\""));
+        assertTrue(biz.contains("\"merchant_code\":\"M001\""));
+        assertTrue(biz.contains("\"third_shop_code\":\"S001\""));
+        assertTrue(biz.contains("\"trade_type\":\"alipay_payment\""));
+        assertTrue(biz.contains("\"pay_timeout\":5"));
+        assertFalse(biz.contains("null"));
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarClientTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现请求 DTO**
+
+`.../har/client/dto/HarQueryRequest.java`
+
+```java
+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 同时存在时上游忽略该属性 */
+    @JsonProperty("third_order_no")
+    private String thirdOrderNo;
+}
+```
+
+`.../har/client/dto/HarPrecreateRequest.java`
+
+```java
+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;
+
+    @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;
+}
+```
+
+`.../har/client/dto/HarPayRequest.java`
+
+```java
+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;
+}
+```
+
+`.../har/client/dto/HarShopCreateRequest.java`
+
+```java
+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;
+}
+```
+
+- [ ] **Step 4: 实现 HarClient**
+
+```java
+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.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.URLEncoder;
+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:application/x-www-form-urlencoded,
+ * 五个字段 appId / method / version / sign / bizContent。
+ * 规范 1.1.1 称「整体为 JSON 格式」与 demo 冲突,以 demo 为准。
+ *
+ * sign = SHA256WithRSA(bizContent 原文, 我方私钥) 的 URL-safe Base64。
+ * bizContent 只序列化一次,签名与发送引用同一个串。
+ */
+@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 */
+    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)");
+        }
+        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);
+        }
+    }
+
+    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)";
+    }
+}
+```
+
+**注意**:`HarApiException("...", null, null)` 三参构造在 Task 1 已定义,签名为 `(String message, String responseCode, Throwable cause)`。
+
+- [ ] **Step 5: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarClientTest`
+Expected: 2 个测试 PASS
+
+---
+
+## Task 5: 数据库表与实体
+
+**Files:**
+- Create: `java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql`
+- Create: `.../har/entity/HarMerchantEntity.java` / `HarShopEntity.java` / `HarOrderEntity.java`
+- Create: `.../har/mapper/HarMerchantMapper.java` / `HarShopMapper.java` / `HarOrderMapper.java`
+- Create: `.../har/enums/HarShopCreateStatus.java` / `HarTradeType.java` / `HarNotifyStatus.java`
+
+**Interfaces:**
+- Produces: 三个实体与 Mapper,供 Task 6-12 使用
+
+- [ ] **Step 1: 写建表 SQL**
+
+`java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql`
+
+```sql
+-- 汇安融支付宝通道(2026-09-16)
+-- 三张表:商户映射 / 门店 / 订单。平台不碰资金,只记录订单生命周期。
+
+CREATE TABLE IF NOT EXISTS pay_har_merchant (
+    id              BIGSERIAL PRIMARY KEY,
+    status          VARCHAR(10)  NOT NULL DEFAULT '0',
+    description     TEXT,
+    created_time    TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    updated_time    TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    tenant_id       BIGINT,
+    enterprise_id   VARCHAR(64)  NOT NULL,
+
+    merchant_code   VARCHAR(20)  NOT NULL,
+    merchant_name   VARCHAR(128),
+    contact_name    VARCHAR(64),
+    contact_mobile  VARCHAR(32),
+    version         INTEGER DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_merchant_code ON pay_har_merchant(merchant_code);
+CREATE INDEX IF NOT EXISTS idx_har_merchant_enterprise ON pay_har_merchant(enterprise_id);
+
+
+CREATE TABLE IF NOT EXISTS pay_har_shop (
+    id              BIGSERIAL PRIMARY KEY,
+    status          VARCHAR(10)  NOT NULL DEFAULT '0',
+    description     TEXT,
+    created_time    TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    updated_time    TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    tenant_id       BIGINT,
+    enterprise_id   VARCHAR(64)  NOT NULL,
+
+    merchant_code   VARCHAR(20)  NOT NULL,
+    third_shop_id   VARCHAR(32)  NOT NULL,
+    shop_name       VARCHAR(128) NOT NULL,
+    shop_id         VARCHAR(32),
+    create_status   VARCHAR(16)  NOT NULL DEFAULT 'PENDING',
+    fail_reason     VARCHAR(512),
+    request_body    TEXT,
+    response_body   TEXT,
+    retry_count     INTEGER DEFAULT 0,
+    version         INTEGER DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_shop_third_shop_id
+    ON pay_har_shop(enterprise_id, third_shop_id);
+CREATE INDEX IF NOT EXISTS idx_har_shop_merchant ON pay_har_shop(merchant_code);
+
+
+CREATE TABLE IF NOT EXISTS pay_har_order (
+    id                   BIGSERIAL PRIMARY KEY,
+    status               VARCHAR(10)  NOT NULL DEFAULT '0',
+    description          TEXT,
+    created_time         TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    updated_time         TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
+    tenant_id            BIGINT,
+    enterprise_id        VARCHAR(64)  NOT NULL,
+
+    order_no             VARCHAR(64)  NOT NULL,
+    out_biz_no           VARCHAR(64),
+    upstream_order_no    VARCHAR(32),
+    platform_trade_no    VARCHAR(64),
+    merchant_code        VARCHAR(20)  NOT NULL,
+    shop_code            VARCHAR(32),
+    third_shop_code      VARCHAR(32),
+
+    price                NUMERIC(12,2),
+    title                VARCHAR(64),
+    trade_type           VARCHAR(32)  NOT NULL,
+    stage_num            VARCHAR(8),
+    percent              VARCHAR(8),
+    pay_timeout          INTEGER,
+    extra_params         TEXT,
+
+    pay_url              TEXT,
+    trade_status         VARCHAR(8)   NOT NULL DEFAULT '0',
+    trade_time           TIMESTAMP WITHOUT TIME ZONE,
+    refund_time          TIMESTAMP WITHOUT TIME ZONE,
+    upstream_create_time TIMESTAMP WITHOUT TIME ZONE,
+    rcv_amount           NUMERIC(12,2),
+    fee_amount           NUMERIC(12,2),
+    channel_amount       NUMERIC(12,2),
+
+    last_query_time      TIMESTAMP WITHOUT TIME ZONE,
+    next_query_time      TIMESTAMP WITHOUT TIME ZONE,
+    query_count          INTEGER DEFAULT 0,
+    notify_status        VARCHAR(16),
+    version              INTEGER DEFAULT 0
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_har_order_order_no ON pay_har_order(order_no);
+CREATE INDEX IF NOT EXISTS idx_har_order_enterprise ON pay_har_order(enterprise_id);
+CREATE INDEX IF NOT EXISTS idx_har_order_out_biz_no ON pay_har_order(out_biz_no);
+CREATE INDEX IF NOT EXISTS idx_har_order_upstream ON pay_har_order(upstream_order_no);
+CREATE INDEX IF NOT EXISTS idx_har_order_poll ON pay_har_order(trade_status, next_query_time);
+```
+
+- [ ] **Step 2: 执行 SQL**
+
+Run: `psql -h <host> -U admin -d payment_platform_java -f java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql`
+Expected: `CREATE TABLE` ×3、`CREATE INDEX` ×7
+
+> 注:项目未启用 Flyway,`db/migration/` 下的文件由人工执行。
+
+- [ ] **Step 3: 实现三个实体**
+
+`.../har/entity/HarMerchantEntity.java`
+
+```java
+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;
+
+/** 企业 ↔ 汇安融商户映射。运营在报备完成后手工录入。 */
+@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;
+}
+```
+
+`.../har/entity/HarShopEntity.java`
+
+```java
+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 */
+    private String createStatus;
+
+    private String failReason;
+
+    private String requestBody;
+
+    private String responseBody;
+
+    private Integer retryCount;
+
+    @Version
+    private Integer version;
+}
+```
+
+`.../har/entity/HarOrderEntity.java`
+
+```java
+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 —— 基类 status 另有用途。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pay_har_order")
+public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
+
+    /** 我方订单号,作为 third_order_no 传上游 */
+    private String orderNo;
+
+    /** 企业商户自己的订单号 */
+    private String outBizNo;
+
+    /** 上游返回的 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;
+
+    /** 0:等待支付 1:支付成功 2:退款成功 4:退款中 8:交易关闭 */
+    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;
+
+    private String notifyStatus;
+
+    @Version
+    private Integer version;
+}
+```
+
+- [ ] **Step 4: 实现 Mapper 与枚举**
+
+三个 Mapper 照 `F2fTradeRecordMapper` 的写法:
+
+```java
+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> {
+}
+```
+
+`HarMerchantMapper` / `HarShopMapper` 同构,替换实体类型即可。
+
+`.../har/enums/HarShopCreateStatus.java`
+
+```java
+package com.payment.platform.module.payment.har.enums;
+
+/** 门店创建状态。上游无查询接口,PENDING 需人工介入。 */
+public final class HarShopCreateStatus {
+
+    public static final String PENDING = "PENDING";
+    public static final String SUCCESS = "SUCCESS";
+    public static final String FAILED = "FAILED";
+
+    private HarShopCreateStatus() {
+    }
+}
+```
+
+`.../har/enums/HarTradeType.java`
+
+```java
+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() {
+    }
+}
+```
+
+`.../har/enums/HarOrderStatus.java`
+
+```java
+package com.payment.platform.module.payment.har.enums;
+
+import java.util.Set;
+
+/** 订单状态。直接采用上游枚举,不做本地转义。 */
+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() {
+    }
+}
+```
+
+`.../har/enums/HarNotifyStatus.java`
+
+```java
+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() {
+    }
+}
+```
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd java && ./mvnw -q compile`
+Expected: BUILD SUCCESS
+
+---
+
+## Task 6: 商户配置
+
+**Files:**
+- Create: `.../har/dto/HarMerchantSaveDTO.java` / `HarMerchantQueryDTO.java` / `HarMerchantVO.java`
+- Create: `.../har/service/HarMerchantService.java`
+- Create: `.../har/controller/HarMerchantController.java`
+
+**Interfaces:**
+- Produces:
+  - `HarMerchantEntity HarMerchantService.getByEnterpriseId(String enterpriseId)` — 无配置返回 null
+  - `HarMerchantEntity HarMerchantService.requireByEnterpriseId(String enterpriseId)` — 无配置抛 `BusinessException`
+  - `PageResult<HarMerchantVO> HarMerchantService.list(int pageNo, int pageSize, HarMerchantQueryDTO q)`
+  - `HarMerchantVO HarMerchantService.save(HarMerchantSaveDTO dto)`
+  - `void HarMerchantService.delete(Long id)`
+
+- [ ] **Step 1: 实现 DTO**
+
+`.../har/dto/HarMerchantQueryDTO.java`
+
+```java
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+@Data
+public class HarMerchantQueryDTO {
+
+    private String enterpriseId;
+
+    private String merchantCode;
+
+    private String merchantName;
+}
+```
+
+`.../har/dto/HarMerchantSaveDTO.java`
+
+```java
+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;
+
+    @NotBlank(message = "企业不能为空")
+    private String enterpriseId;
+
+    @NotBlank(message = "商户编号不能为空")
+    @Size(max = 20, message = "商户编号最长 20 位")
+    private String merchantCode;
+
+    @Size(max = 128, message = "商户名称最长 128 位")
+    private String merchantName;
+
+    private String contactName;
+
+    private String contactMobile;
+
+    private String description;
+}
+```
+
+`.../har/dto/HarMerchantVO.java`
+
+```java
+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 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) {
+        HarMerchantVO vo = new HarMerchantVO();
+        vo.setId(e.getId());
+        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;
+    }
+}
+```
+
+- [ ] **Step 2: 实现 Service**
+
+```java
+package com.payment.platform.module.payment.har.service;
+
+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 cn.hutool.core.util.StrUtil;
+
+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 = new LambdaQueryWrapper<>();
+        if (q != null) {
+            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());
+            }
+        }
+        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(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;
+    }
+
+    @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("商户配置不存在");
+            }
+        } else {
+            entity = new HarMerchantEntity();
+        }
+        entity.setEnterpriseId(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);
+    }
+}
+```
+
+> 若 `PageResult` 的静态构造方法名不是 `of`,改用在 `FacetofaceService` 中出现的那个构造方式。
+
+- [ ] **Step 3: 实现 Controller**
+
+```java
+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 jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/payment/har/merchant")
+@RequiredArgsConstructor
+public class HarMerchantController {
+
+    private final HarMerchantService service;
+
+    @GetMapping
+    @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 = "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.setEnterpriseId(enterpriseId);
+        q.setMerchantCode(merchantCode);
+        q.setMerchantName(merchantName);
+        return Result.ok(service.list(pageNo, pageSize, q));
+    }
+
+    @PostMapping
+    @PreAuthorize("@perm.hasAny('module_payment:har:merchant:save')")
+    public Result<HarMerchantVO> save(@Valid @RequestBody HarMerchantSaveDTO dto) {
+        return Result.ok(service.save(dto));
+    }
+
+    @DeleteMapping("/{id}")
+    @PreAuthorize("@perm.hasAny('module_payment:har:merchant:delete')")
+    public Result<Void> delete(@PathVariable(name = "id") Long id) {
+        service.delete(id);
+        return Result.ok();
+    }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd java && ./mvnw -q compile`
+Expected: BUILD SUCCESS
+
+---
+
+## Task 7: 门店
+
+**Files:**
+- Create: `.../har/dto/HarShopCreateDTO.java` / `HarShopQueryDTO.java` / `HarShopVO.java`
+- Create: `.../har/service/HarShopService.java`
+- Create: `.../har/controller/HarShopController.java`
+- Test: `.../har/service/HarShopServiceTest.java`
+
+**Interfaces:**
+- Consumes: `HarMerchantService.requireByEnterpriseId`(Task 6)、`HarClient.createShop`(Task 4)
+- Produces:
+  - `HarShopVO HarShopService.create(HarShopCreateDTO dto, String enterpriseId)`
+  - `PageResult<HarShopVO> HarShopService.list(int pageNo, int pageSize, HarShopQueryDTO q)`
+  - `HarShopEntity HarShopService.getByThirdShopId(String enterpriseId, String thirdShopId)`
+
+> **关键设计**:先落 `PENDING` 再调上游。上游无门店查询接口,超时后无法核实远端是否已创建,因此**不自动重试**,保持 `PENDING` 并留存原始报文交人工判断。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.service;
+
+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.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.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HarShopServiceTest {
+
+    private final HarShopMapper shopMapper = mock(HarShopMapper.class);
+    private final HarMerchantService merchantService = mock(HarMerchantService.class);
+    private final HarClient harClient = mock(HarClient.class);
+    private final HarShopService service =
+            new HarShopService(shopMapper, merchantService, harClient);
+
+    private HarShopCreateDTO dto() {
+        HarShopCreateDTO dto = new HarShopCreateDTO();
+        dto.setThirdShopId("S001");
+        dto.setShopName("上海南京路店");
+        return dto;
+    }
+
+    private void merchantConfigured() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setMerchantCode("M001");
+        m.setEnterpriseId("E1");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(m);
+    }
+
+    @Test
+    void persistedAsPendingBeforeCallingUpstream() {
+        merchantConfigured();
+        when(harClient.createShop(any())).thenReturn(successResponse());
+
+        service.create(dto(), "E1");
+
+        // 先插入 PENDING,后回填 SUCCESS —— 插入必须发生在调用上游之前
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper, atLeastOnce()).insert(captor.capture());
+        verify(harClient).createShop(any());
+    }
+
+    @Test
+    void fillsShopIdOnSuccess() {
+        merchantConfigured();
+        when(harClient.createShop(any())).thenReturn(successResponse());
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper, atLeastOnce()).updateById(captor.capture());
+        HarShopEntity updated = captor.getValue();
+        assertEquals(HarShopCreateStatus.SUCCESS, updated.getCreateStatus());
+        assertEquals("SHOP-9", updated.getShopId());
+    }
+
+    @Test
+    void keepsPendingAndRecordsRawBodyOnTimeout() {
+        merchantConfigured();
+        when(harClient.createShop(any())).thenThrow(new HarApiException("调用上游失败: timeout"));
+
+        service.create(dto(), "E1");
+
+        // 超时不得标记为 FAILED —— 上游可能已经建好了,标记失败会误导人工判断
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper, atLeastOnce()).updateById(captor.capture());
+        HarShopEntity updated = captor.getValue();
+        assertEquals(HarShopCreateStatus.PENDING, updated.getCreateStatus());
+        assertTrue(updated.getFailReason().contains("timeout"));
+    }
+
+    @Test
+    void marksFailedWhenUpstreamReturnsNonSuccessCode() {
+        merchantConfigured();
+        when(harClient.createShop(any())).thenReturn(
+                new HarResponse("40004", "system error", "S", "{}", "{}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper, atLeastOnce()).updateById(captor.capture());
+        assertEquals(HarShopCreateStatus.FAILED, captor.getValue().getCreateStatus());
+    }
+
+    @Test
+    void passesOurShopCodeAsThirdShopId() {
+        merchantConfigured();
+        when(harClient.createShop(any())).thenReturn(successResponse());
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopCreateRequest> captor = ArgumentCaptor.forClass(HarShopCreateRequest.class);
+        verify(harClient).createShop(captor.capture());
+        assertEquals("S001", captor.getValue().getThirdShopId());
+        assertEquals("M001", captor.getValue().getMerchantCode());
+        assertEquals("上海南京路店", captor.getValue().getName());
+    }
+
+    private HarResponse successResponse() {
+        return new HarResponse("10000", "请求成功", "S", "{\"shop_id\":\"SHOP-9\"}", "{}");
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarShopServiceTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现 DTO**
+
+`.../har/dto/HarShopCreateDTO.java`
+
+```java
+package com.payment.platform.module.payment.har.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class HarShopCreateDTO {
+
+    /** 商户自己的门店编码 */
+    @NotBlank(message = "门店编码不能为空")
+    @Size(max = 32, message = "门店编码最长 32 位")
+    private String thirdShopId;
+
+    @NotBlank(message = "门店名称不能为空")
+    @Size(max = 128, message = "门店名称最长 128 位")
+    private String shopName;
+
+    /** 仅后台代操作时使用;开放 API 走当前登录租户的企业 */
+    private String enterpriseId;
+}
+```
+
+`.../har/dto/HarShopQueryDTO.java`
+
+```java
+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;
+}
+```
+
+`.../har/dto/HarShopVO.java`
+
+```java
+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) {
+        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;
+    }
+}
+```
+
+- [ ] **Step 4: 实现 Service**
+
+```java
+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.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.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 门店服务。
+ *
+ * 上游没有门店查询接口,本地表是唯一真相源,因此:
+ *   1. 先落 PENDING 再调上游,保证任何情况下都留下记录;
+ *   2. 超时不标记 FAILED —— 上游可能已创建成功,标失败会误导人工;
+ *   3. 不自动重试 —— 无法核实远端状态,重试可能产生重复门店。
+ */
+@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(page.getTotal(), rows);
+    }
+
+    public HarShopEntity getByThirdShopId(String enterpriseId, String thirdShopId) {
+        return shopMapper.selectOne(new LambdaQueryWrapper<HarShopEntity>()
+                .eq(HarShopEntity::getEnterpriseId, enterpriseId)
+                .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) {
+        if (getByThirdShopId(enterpriseId, dto.getThirdShopId()) != null) {
+            throw new BusinessException("门店编码已存在: " + dto.getThirdShopId());
+        }
+        HarMerchantEntity merchant = merchantService.requireByEnterpriseId(enterpriseId);
+
+        HarShopCreateRequest req = new HarShopCreateRequest();
+        req.setMerchantCode(merchant.getMerchantCode());
+        req.setThirdShopId(dto.getThirdShopId());
+        req.setName(dto.getShopName());
+        String requestBody = HarClient.toBizContent(req);
+
+        // 1. 先落 PENDING —— 无论后续发生什么都留下记录
+        HarShopEntity entity = new HarShopEntity();
+        entity.setEnterpriseId(enterpriseId);
+        entity.setMerchantCode(merchant.getMerchantCode());
+        entity.setThirdShopId(dto.getThirdShopId());
+        entity.setShopName(dto.getShopName());
+        entity.setCreateStatus(HarShopCreateStatus.PENDING);
+        entity.setRequestBody(requestBody);
+        entity.setRetryCount(0);
+        entity.setDescription(dto.getShopName());
+        shopMapper.insert(entity);
+
+        // 2. 调上游
+        try {
+            HarResponse response = harClient.createShop(req);
+            entity.setResponseBody(buildResponseBody(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);
+    }
+
+    /** responseBody 存原始报文,供超时后人工判断使用 */
+    private String buildResponseBody(HarResponse response) {
+        return "response_code=" + response.responseCode()
+                + "&msg=" + response.msg()
+                + "&detail=" + response.detailRaw()
+                + "&sign=" + response.sign();
+    }
+}
+```
+
+> 该实现**不校验响应签名**:门店创建由我方发起,返回的 `shop_id` 若不实只会导致后续下单失败,风险可控;且响应格式尚未确认,此时加验签会在联调期产生噪音。订单相关链路同样处理,见 Task 8 说明。
+
+- [ ] **Step 5: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarShopServiceTest`
+Expected: 5 个测试 PASS
+
+- [ ] **Step 6: 实现 Controller**
+
+```java
+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 jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/payment/har/shop")
+@RequiredArgsConstructor
+public class HarShopController {
+
+    private final HarShopService service;
+
+    @GetMapping
+    @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}")
+    @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
+    @PreAuthorize("@perm.hasAny('module_payment:har:shop:create')")
+    public Result<HarShopVO> create(@Valid @RequestBody HarShopCreateDTO dto) {
+        return Result.ok(service.create(dto, dto.getEnterpriseId()));
+    }
+}
+```
+
+---
+
+## Task 8: 订单
+
+**Files:**
+- Create: `.../har/dto/HarOrderCreateDTO.java` / `HarOrderQueryDTO.java` / `HarOrderVO.java`
+- Create: `.../har/service/HarOrderService.java`
+- Create: `.../har/controller/HarOrderController.java`
+- Test: `.../har/service/HarOrderServiceTest.java`
+
+**Interfaces:**
+- Consumes: `HarMerchantService.requireByEnterpriseId`(Task 6)、`HarClient.precreate/pay/query`(Task 4)
+- Produces:
+  - `HarOrderVO HarOrderService.create(HarOrderCreateDTO dto, String enterpriseId)`
+  - `HarOrderVO HarOrderService.queryByOrderNo(String orderNo)` — 主动查上游并回写
+  - `HarOrderVO HarOrderService.syncFromUpstream(HarOrderEntity order)` — 供轮询任务调用
+  - `PageResult<HarOrderVO> HarOrderService.list(int pageNo, int pageSize, HarOrderQueryDTO q)`
+  - `List<HarOrderEntity> HarOrderService.findPendingForPoll(int limit)`
+  - `void HarOrderService.applyQueryDetail(HarOrderEntity order, HarQueryDetail detail)`
+
+> **订单号策略**:`order_no` 由我方生成(雪花 ID 加 `HAR` 前缀),作为 `third_order_no` 传上游。企业自己的单号存在 `out_biz_no`,不直接透传——不同企业的单号可能重复,而上游的 `third_order_no` 必须是全局唯一的。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.service;
+
+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.HarQueryDetail;
+import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
+import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
+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.enums.HarTradeType;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HarOrderServiceTest {
+
+    private final HarOrderMapper orderMapper = mock(HarOrderMapper.class);
+    private final HarMerchantService merchantService = mock(HarMerchantService.class);
+    private final HarClient harClient = mock(HarClient.class);
+    private final HarOrderService service = new HarOrderService(orderMapper, merchantService, harClient);
+
+    private HarOrderCreateDTO dto() {
+        HarOrderCreateDTO dto = new HarOrderCreateDTO();
+        dto.setOutBizNo("BIZ-1");
+        dto.setPrice(new BigDecimal("12.25"));
+        dto.setTitle("iPhone 16");
+        dto.setTradeType(HarTradeType.ALIPAY_PAYMENT);
+        dto.setThirdShopCode("S001");
+        dto.setPayTimeout(5);
+        return dto;
+    }
+
+    private void merchantConfigured() {
+        HarMerchantEntity m = new HarMerchantEntity();
+        m.setMerchantCode("M001");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(m);
+    }
+
+    @Test
+    void generatesOurOwnOrderNoAndSendsItAsThirdOrderNo() {
+        merchantConfigured();
+        when(harClient.precreate(any())).thenReturn(new HarResponse(
+                "10000", "ok", "S", "{\"pay_url\":\"https://pay\",\"order_no\":\"UP-1\"}", "{}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarPrecreateRequest> captor = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(captor.capture());
+        String thirdOrderNo = captor.getValue().getThirdOrderNo();
+
+        assertNotNull(thirdOrderNo);
+        assertTrue(thirdOrderNo.startsWith("HAR"), "我方订单号需带前缀便于识别");
+
+        ArgumentCaptor<HarOrderEntity> entityCaptor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entityCaptor.capture());
+        assertEquals(thirdOrderNo, entityCaptor.getValue().getOrderNo());
+        assertEquals("BIZ-1", entityCaptor.getValue().getOutBizNo());
+    }
+
+    @Test
+    void neverSendsEnterpriseOutBizNoAsThirdOrderNo() {
+        merchantConfigured();
+        when(harClient.precreate(any())).thenReturn(new HarResponse(
+                "10000", "ok", "S", "{\"pay_url\":\"https://pay\"}", "{}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarPrecreateRequest> captor = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(captor.capture());
+        assertNotEquals("BIZ-1", captor.getValue().getThirdOrderNo(),
+                "企业单号可能重复,不能直接当上游的 third_order_no");
+    }
+
+    @Test
+    void setsNextQueryTimeSoPollTaskPicksItUp() {
+        merchantConfigured();
+        when(harClient.precreate(any())).thenReturn(new HarResponse(
+                "10000", "ok", "S", "{\"pay_url\":\"https://pay\"}", "{}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        assertNotNull(captor.getValue().getNextQueryTime(), "必须排入轮询,否则通知丢失后状态永远不收敛");
+    }
+
+    @Test
+    void applyQueryDetailMapsAllStatusFields() {
+        HarOrderEntity order = new HarOrderEntity();
+        order.setTradeStatus(HarOrderStatus.WAIT_PAY);
+
+        HarQueryDetail detail = new HarQueryDetail();
+        detail.setStatus(1);
+        detail.setPlatformTradeNo("PT-1");
+        detail.setRcvAmount("12.00");
+        detail.setFeeAmount("0.25");
+        detail.setChannelAmount("0.10");
+        detail.setTradeTime("2026-09-24 10:00:00");
+
+        service.applyQueryDetail(order, detail);
+
+        assertEquals(HarOrderStatus.PAID, order.getTradeStatus());
+        assertEquals("PT-1", order.getPlatformTradeNo());
+        assertEquals(new BigDecimal("12.00"), order.getRcvAmount());
+        assertEquals(new BigDecimal("0.25"), order.getFeeAmount());
+        assertEquals(new BigDecimal("0.10"), order.getChannelAmount());
+        assertNotNull(order.getTradeTime());
+    }
+
+    @Test
+    void applyQueryDetailToleratesBlankAmounts() {
+        HarOrderEntity order = new HarOrderEntity();
+        HarQueryDetail detail = new HarQueryDetail();
+        detail.setStatus(0);
+        detail.setRcvAmount("");
+        detail.setFeeAmount(null);
+
+        service.applyQueryDetail(order, detail);
+
+        assertEquals(HarOrderStatus.WAIT_PAY, order.getTradeStatus());
+        assertNull(order.getRcvAmount(), "空金额不能被解析成 0,否则对账口径失真");
+    }
+
+    @Test
+    void applyQueryDetailStopsPollingOnceFinal() {
+        HarOrderEntity order = new HarOrderEntity();
+        HarQueryDetail detail = new HarQueryDetail();
+        detail.setStatus(8);
+
+        service.applyQueryDetail(order, detail);
+
+        assertEquals(HarOrderStatus.CLOSED, order.getTradeStatus());
+        assertNull(order.getNextQueryTime(), "终态必须清空下次查询时间,否则轮询任务永远捞它");
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarOrderServiceTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现 DTO**
+
+`.../har/dto/HarOrderCreateDTO.java`
+
+```java
+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;
+
+@Data
+public class HarOrderCreateDTO {
+
+    /** 企业商户自己的订单号,原样存下供其查询 */
+    @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;
+
+    /** 收款(被扫)时必填,为主扫(precreate)时留空 */
+    private String authCode;
+
+    /** 仅后台代操作时使用 */
+    private String enterpriseId;
+}
+```
+
+`.../har/dto/HarOrderQueryDTO.java`
+
+```java
+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;
+
+    private String startDate;
+
+    private String endDate;
+}
+```
+
+`.../har/dto/HarOrderVO.java`
+
+```java
+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) {
+        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;
+    }
+}
+```
+
+- [ ] **Step 4: 实现 Service**
+
+```java
+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.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.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.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");
+
+    /** 首次轮询延迟(秒)—— 通知窗口只有 25 秒,轮询必须紧跟其后接管 */
+    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()));
+            }
+        }
+        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(page.getTotal(), rows);
+    }
+
+    public HarOrderVO detail(Long id) {
+        HarOrderEntity entity = orderMapper.selectById(id);
+        if (entity == null) {
+            throw new BusinessException("订单不存在");
+        }
+        return HarOrderVO.of(entity);
+    }
+
+    public HarOrderEntity getByOrderNo(String orderNo) {
+        return orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
+                .eq(HarOrderEntity::getOrderNo, orderNo)
+                .last("LIMIT 1"));
+    }
+
+    public HarOrderEntity getByOutBizNo(String enterpriseId, String outBizNo) {
+        return orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
+                .eq(HarOrderEntity::getEnterpriseId, enterpriseId)
+                .eq(HarOrderEntity::getOutBizNo, outBizNo)
+                .orderByDesc(HarOrderEntity::getId)
+                .last("LIMIT 1"));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public HarOrderVO create(HarOrderCreateDTO dto, String enterpriseId) {
+        HarMerchantEntity merchant = merchantService.requireByEnterpriseId(enterpriseId);
+
+        String orderNo = ORDER_NO_PREFIX + SnowflakeIdGenerator.nextIdStr();
+        boolean scanMode = StrUtil.isNotBlank(dto.getAuthCode());
+
+        HarOrderEntity entity = new HarOrderEntity();
+        entity.setEnterpriseId(enterpriseId);
+        entity.setOrderNo(orderNo);
+        entity.setOutBizNo(dto.getOutBizNo());
+        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);
+
+        HarResponse response = scanMode
+                ? harClient.pay(buildPayRequest(dto, merchant, orderNo))
+                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo));
+
+        if (!response.isSuccess()) {
+            entity.setTradeStatus(HarOrderStatus.WAIT_PAY);
+            entity.setDescription("下单失败: " + response.responseCode() + " " + response.msg());
+            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();
+        req.setOrderNo(order.getOrderNo());
+
+        HarResponse response = harClient.query(req);
+        order.setLastQueryTime(OffsetDateTime.now());
+        order.setQueryCount(order.getQueryCount() == null ? 1 : order.getQueryCount() + 1);
+
+        if (!response.isSuccess()) {
+            log.warn("[汇安融订单] 查询失败: order_no={}, code={}, msg={}",
+                    order.getOrderNo(), response.responseCode(), response.msg());
+            orderMapper.updateById(order);
+            return HarOrderVO.of(order);
+        }
+
+        HarQueryDetail detail = HarResponseParser.toBean(response.detailRaw(), HarQueryDetail.class);
+        if (detail != null) {
+            applyQueryDetail(order, detail);
+        }
+        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());
+        }
+        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()));
+        if (StrUtil.isNotBlank(detail.getPrice())) {
+            order.setPrice(parseAmount(detail.getPrice()));
+        }
+
+        if (HarOrderStatus.isFinal(order.getTradeStatus())) {
+            order.setNextQueryTime(null);
+        } else {
+            order.setNextQueryTime(OffsetDateTime.now().plusSeconds(nextDelaySeconds(order)));
+        }
+    }
+
+    /** 轮询退避:30s → 1m → 2m → 5m → 15m 封顶 */
+    private long nextDelaySeconds(HarOrderEntity order) {
+        int count = order.getQueryCount() == null ? 0 : order.getQueryCount();
+        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));
+    }
+
+    /** 空串不能被解析成 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) {
+        try {
+            return LocalDateTime.parse(value.trim() + " 00:00:00", TIME_FORMATTER)
+                    .atZone(ZoneId.systemDefault()).toOffsetDateTime();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    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());
+        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());
+        return req;
+    }
+}
+```
+
+> **`third_notify_url` 说明**:上游要求必填。本期**不主动传**——通知由我方统一入口接收后再转发给企业(见 Task 9/10),且该字段的真实取值需要联调确认。若联调要求必填,在 `HarProperties` 加 `notifyUrl` 并在两个 build 方法里 `req.setThirdNotifyUrl(...)`。
+
+- [ ] **Step 5: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarOrderServiceTest`
+Expected: 6 个测试 PASS
+
+- [ ] **Step 6: 实现 Controller**
+
+```java
+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 jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/payment/har/order")
+@RequiredArgsConstructor
+public class HarOrderController {
+
+    private final HarOrderService service;
+
+    @GetMapping
+    @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}")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:detail')")
+    public Result<HarOrderVO> detail(@PathVariable(name = "id") Long id) {
+        return Result.ok(service.detail(id));
+    }
+
+    @PostMapping
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:create')")
+    public Result<HarOrderVO> create(@Valid @RequestBody HarOrderCreateDTO dto) {
+        return Result.ok(service.create(dto, dto.getEnterpriseId()));
+    }
+
+    @PostMapping("/{id}/sync")
+    @PreAuthorize("@perm.hasAny('module_payment:har:order:sync')")
+    public Result<HarOrderVO> sync(@PathVariable(name = "id") Long id) {
+        return Result.ok(service.syncFromUpstream(service.getByOrderNo(
+                service.detail(id).getOrderNo())));
+    }
+}
+```
+
+---
+
+## Task 9: 通知接收
+
+**Files:**
+- Create: `.../har/service/HarNotifyService.java`
+- Create: `.../har/controller/HarNotifyController.java`
+- Modify: `core/security/SecurityConfig.java` — 白名单追加
+- Test: `.../har/service/HarNotifyServiceTest.java`
+
+**Interfaces:**
+- Consumes: `HarSigner.verify` / `buildNotifySignContent`(Task 1)、`HarOrderService`(Task 8)
+- Produces: `String HarNotifyService.handle(Map<String,String> params)` — 返回 `"success"` 或失败原因
+
+> **验签失败一律拒绝,不设开关。** 联调期规则对不上时靠日志定位,不靠关闭校验绕过。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.service;
+
+import com.payment.platform.module.payment.har.client.HarSigner;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import org.junit.jupiter.api.BeforeEach;
+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.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class HarNotifyServiceTest {
+
+    private final HarOrderMapper orderMapper = mock(HarOrderMapper.class);
+    private final HarOrderService orderService = mock(HarOrderService.class);
+    private final HarCallbackService callbackService = mock(HarCallbackService.class);
+
+    private String publicKey;
+    private String privateKey;
+    private HarNotifyService service;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        KeyPairGenerator g = KeyPairGenerator.getInstance("RSA");
+        g.initialize(2048);
+        KeyPair kp = g.generateKeyPair();
+        publicKey = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
+        privateKey = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
+
+        service = new HarNotifyService(publicKey, orderService, orderMapper, callbackService);
+    }
+
+    private Map<String, String> signedParams() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("order_no", "UP-1");
+        params.put("third_order_no", "HAR1");
+        params.put("merchant_code", "M001");
+        params.put("status", "1");
+        params.put("trade_time", "2026-09-24 10:00:00");
+        params.put("third_shop_id", "S001");
+        params.put("platform_trade_no", "PT-1");
+        params.put("sign", HarSigner.sign(
+                HarSigner.buildNotifySignContent(params), privateKey));
+        return params;
+    }
+
+    @Test
+    void rejectsWhenSignatureInvalid() {
+        Map<String, String> params = signedParams();
+        params.put("sign", "tampered");
+
+        String result = service.handle(params);
+
+        assertNotEquals("success", result, "验签失败绝不能返回 success");
+        verifyNoInteractions(orderMapper);
+    }
+
+    @Test
+    void rejectsWhenSignMissing() {
+        Map<String, String> params = signedParams();
+        params.remove("sign");
+
+        assertNotEquals("success", service.handle(params));
+        verifyNoInteractions(orderMapper);
+    }
+
+    @Test
+    void rejectsWhenContentTamperedAfterSigning() {
+        Map<String, String> params = signedParams();
+        params.put("status", "8");   // 签完之后改内容
+
+        assertNotEquals("success", service.handle(params));
+        verifyNoInteractions(orderMapper);
+    }
+
+    @Test
+    void updatesOrderAndReturnsSuccess() {
+        HarOrderEntity order = new HarOrderEntity();
+        order.setOrderNo("HAR1");
+        order.setTradeStatus("0");
+        when(orderService.getByOrderNo("HAR1")).thenReturn(order);
+
+        String result = service.handle(signedParams());
+
+        assertEquals("success", result);
+        verify(orderMapper).updateById(any(HarOrderEntity.class));
+        verify(callbackService).notifyEnterprise(any(HarOrderEntity.class));
+    }
+
+    @Test
+    void isIdempotentOnRepeatedSameStatus() {
+        HarOrderEntity order = new HarOrderEntity();
+        order.setOrderNo("HAR1");
+        order.setTradeStatus("1");            // 已经是支付成功
+        when(orderService.getByOrderNo("HAR1")).thenReturn(order);
+
+        String result = service.handle(signedParams());   // 又推一次 status=1
+
+        assertEquals("success", result, "重复推送仍需回 success,否则上游会一直重试");
+        verify(orderMapper, never()).updateById(any());
+        verify(callbackService, never()).notifyEnterprise(any());
+    }
+
+    @Test
+    void returnsSuccessForUnknownOrderToStopRetries() {
+        when(orderService.getByOrderNo(any())).thenReturn(null);
+
+        String result = service.handle(signedParams());
+
+        assertEquals("success", result,
+                "订单不存在时返回 success —— 返回失败只会换来 25 秒内 5 次无效重试");
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarNotifyServiceTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现 HarNotifyService**
+
+```java
+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.enums.HarOrderStatus;
+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;
+
+/**
+ * 汇安融通知处理。
+ *
+ * 验签失败一律拒绝,不设跳过开关 —— 加开关的当天它是调试工具,
+ * 上线那天它就是绕过签名验证的后门。联调期规则对不上时靠诊断日志定位。
+ *
+ * 上游重试策略是「未收到 success 则每 5 秒重试,共 5 次」,窗口只有约 25 秒,
+ * 因此对于「重试也不会成功」的情况(订单不存在、重复推送)直接返回 success,
+ * 把重试次数留给真正可能成功的场景。
+ */
+@Slf4j
+@Service
+public class HarNotifyService {
+
+    private static final String SUCCESS = "success";
+    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) {
+        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 verify failed";
+        }
+
+        if (!verified) {
+            // 诊断日志:规则未确认,失败时必须留下足够定位的证据(不含密钥)
+            log.error("[汇安融通知] 验签失败。待签串=[{}],收到 sign=[{}],收到参数={}",
+                    signContent, sign, new TreeMap<>(params));
+            return "sign verify failed";
+        }
+
+        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.equals(order.getTradeStatus(), newStatus)) {
+            log.info("[汇安融通知] 状态未变化,幂等跳过: order_no={}, status={}", orderNo, newStatus);
+            return SUCCESS;
+        }
+
+        order.setTradeStatus(newStatus);
+        order.setUpstreamOrderNo(params.get("order_no"));
+        order.setPlatformTradeNo(params.get("platform_trade_no"));
+        order.setTradeTime(parseTime(params.get("trade_time")));
+        order.setRefundTime(parseTime(params.get("refund_time")));
+        order.setUpstreamCreateTime(parseTime(params.get("create_time")));
+        if (HarOrderStatus.isFinal(newStatus)) {
+            order.setNextQueryTime(null);
+        }
+        orderMapper.updateById(order);
+
+        callbackService.notifyEnterprise(order);
+        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;
+        }
+    }
+}
+```
+
+> 测试里构造 `HarNotifyService` 用的第一个参数是公钥字符串,生产代码用的是 `HarProperties`。写测试时按 `new HarNotifyService(harProperties, orderService, orderMapper, callbackService)` 构造一个 `HarProperties` 并 `setPlatformPublicKey(publicKey)` 即可。
+
+- [ ] **Step 4: 实现 HarNotifyController**
+
+```java
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.module.payment.har.service.HarNotifyService;
+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;
+
+/**
+ * 汇安融通知入口。
+ *
+ * 免登录态,真实性由验签保证(同 /payment/notify/alipay 的模式)。
+ * 上游要求成功时返回字符串 "success",否则 5 秒后重试,共 5 次。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/payment/notify")
+@RequiredArgsConstructor
+public class HarNotifyController {
+
+    private final HarNotifyService notifyService;
+
+    @PostMapping("/har")
+    public ResponseEntity<String> harNotify(@RequestParam Map<String, String> params) {
+        String result = notifyService.handle(params);
+        return ResponseEntity.ok()
+                .contentType(MediaType.TEXT_PLAIN)
+                .body(result);
+    }
+}
+```
+
+- [ ] **Step 5: 追加安全白名单**
+
+`core/security/SecurityConfig.java`,在 `WHITE_LIST` 的 `/payment/notify/alipay` 之后追加一行:
+
+```java
+            // 汇安融通知入口(无登录态,真实性由 RSA 验签保证)
+            "/payment/notify/har",
+```
+
+- [ ] **Step 6: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarNotifyServiceTest`
+Expected: 6 个测试 PASS
+
+---
+
+## Task 10: 企业商户回调
+
+**Files:**
+- Create: `.../har/service/HarCallbackService.java`
+
+**Interfaces:**
+- Consumes: `HarOrderEntity`(Task 5)
+- Produces: `void HarCallbackService.notifyEnterprise(HarOrderEntity order)` — 异步,失败只记日志不回滚
+
+> **复用既有模式**:`OpenapiService` 已有 `resolveReturnUrl`(`api_key.return_url` → `conf.return_url` 兜底)与 `sendCallback`。本 Task 按同样的数据结构实现,回调地址取自 `sys_tenant_api_key.return_url`。
+
+- [ ] **Step 1: 实现 HarCallbackService**
+
+```java
+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.module.payment.apikey.entity.TenantApiKeyEntity;
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
+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.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.net.URI;
+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.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 把订单状态变化回调给企业商户。
+ *
+ * 回调地址取自 sys_tenant_api_key.return_url,与 OpenapiService 的取址逻辑一致。
+ * 回调失败只记日志并落 notify_status,不抛异常 —— 上游通知要求我们尽快返回 success,
+ * 不能被下游回调拖住(他们的重试窗口只有 25 秒)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HarCallbackService {
+
+    private static final int MAX_ATTEMPTS = 3;
+    private static final long RETRY_INTERVAL_MS = 1000;
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final TenantApiKeyMapper apiKeyMapper;
+    private final HarOrderMapper orderMapper;
+
+    @Async
+    public void notifyEnterprise(HarOrderEntity order) {
+        String returnUrl = resolveReturnUrl(order.getTenantId());
+        if (StrUtil.isBlank(returnUrl)) {
+            log.info("[汇安融回调] 租户未配置回调地址: tenant_id={}, order_no={}",
+                    order.getTenantId(), order.getOrderNo());
+            return;
+        }
+
+        String payload = buildPayload(order);
+        boolean ok = post(returnUrl, payload);
+
+        order.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
+        try {
+            orderMapper.updateById(order);
+        } catch (Exception e) {
+            log.warn("[汇安融回调] 回写 notify_status 失败: {}", e.getMessage());
+        }
+    }
+
+    private String resolveReturnUrl(Long tenantId) {
+        if (tenantId == null) {
+            return null;
+        }
+        TenantApiKeyEntity key = apiKeyMapper.selectOne(
+                new LambdaQueryWrapper<TenantApiKeyEntity>()
+                        .eq(TenantApiKeyEntity::getTenantId, tenantId)
+                        .eq(TenantApiKeyEntity::getStatus, "0")
+                        .last("LIMIT 1"));
+        return key == null ? null : key.getReturnUrl();
+    }
+
+    private String buildPayload(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 payload) {
+        HttpClient client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(5))
+                .build();
+
+        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+            try {
+                HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+                        .header("Content-Type", "application/json;charset=UTF-8")
+                        .timeout(Duration.ofSeconds(10))
+                        .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
+                        .build();
+                HttpResponse<String> response = client.send(request,
+                        HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+                if (response.statusCode() >= 200 && response.statusCode() < 300) {
+                    return true;
+                }
+                log.warn("[汇安融回调] 第 {} 次返回 HTTP {}", attempt, response.statusCode());
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return false;
+            } catch (Exception e) {
+                log.warn("[汇安融回调] 第 {} 次失败: {}", attempt, e.getMessage());
+            }
+            if (attempt < MAX_ATTEMPTS) {
+                try {
+                    Thread.sleep(RETRY_INTERVAL_MS);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    return false;
+                }
+            }
+        }
+        log.error("[汇安融回调] {} 次尝试后仍失败: url={}", MAX_ATTEMPTS, url);
+        return false;
+    }
+}
+```
+
+> `@Async` 需要 `@EnableAsync`。项目若未启用,改为在 `HarNotifyService.handle` 里用 `CompletableFuture.runAsync(...)` 调用,避免为了一个回调去改全局配置。
+
+- [ ] **Step 2: 验证编译**
+
+Run: `cd java && ./mvnw -q compile`
+Expected: BUILD SUCCESS
+
+---
+
+## Task 11: 轮询补偿
+
+**Files:**
+- Create: `.../har/scheduler/HarOrderPollScheduler.java`
+- Test: `.../har/scheduler/HarOrderPollSchedulerTest.java`
+
+**Interfaces:**
+- Consumes: `HarOrderService.findPendingForPoll` / `syncFromUpstream`(Task 8)
+- Produces: 无(定时任务)
+
+> **这是主路径不是兜底。** 上游通知重试窗口只有 25 秒,任何超过 25 秒的不可用都会永久丢失通知。
+
+- [ ] **Step 1: 写失败的测试**
+
+```java
+package com.payment.platform.module.payment.har.scheduler;
+
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.*;
+
+class HarOrderPollSchedulerTest {
+
+    private final HarOrderService orderService = mock(HarOrderService.class);
+
+    @Test
+    void oneOrderFailingDoesNotStopTheRest() {
+        HarOrderPollScheduler scheduler = new HarOrderPollScheduler(orderService);
+
+        HarOrderEntity bad = new HarOrderEntity();
+        bad.setOrderNo("BAD");
+        HarOrderEntity good = new HarOrderEntity();
+        good.setOrderNo("GOOD");
+
+        when(orderService.findPendingForPoll(anyInt())).thenReturn(List.of(bad, good));
+        when(orderService.syncFromUpstream(bad)).thenThrow(new RuntimeException("boom"));
+
+        scheduler.pollPendingOrders();
+
+        verify(orderService).syncFromUpstream(good);
+    }
+
+    @Test
+    void doesNothingWhenNoPendingOrders() {
+        HarOrderPollScheduler scheduler = new HarOrderPollScheduler(orderService);
+        when(orderService.findPendingForPoll(anyInt())).thenReturn(List.of());
+
+        scheduler.pollPendingOrders();
+
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+Run: `cd java && ./mvnw test -Dtest=HarOrderPollSchedulerTest`
+Expected: 编译失败
+
+- [ ] **Step 3: 实现 HarOrderPollScheduler**
+
+```java
+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)
+    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());
+    }
+}
+```
+
+> 测试构造 `new HarOrderPollScheduler(orderService, harProperties)`,`harProperties` 需 `setEnabled(true)`。
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+Run: `cd java && ./mvnw test -Dtest=HarOrderPollSchedulerTest`
+Expected: 2 个测试 PASS
+
+---
+
+## Task 12: 企业开放 API
+
+**Files:**
+- Create: `.../har/open/HarOpenController.java`
+- Modify: `core/security/SecurityConfig.java` — 白名单追加
+- Modify: `core/security/TenantApiKeyAuthFilter.java` — 路径数组追加
+
+**Interfaces:**
+- Consumes: `HarShopService.create`(Task 7)、`HarOrderService.create` / `getByOrderNo`(Task 8)
+- Produces: 三个开放接口
+
+> **易漏点**:`TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS` 是**精确匹配**(`path.equals(p)`),漏登记会导致认证被整体跳过。新增接口必须同步登记。
+
+- [ ] **Step 1: 实现 HarOpenController**
+
+```java
+package com.payment.platform.module.payment.har.open;
+
+import com.payment.platform.common.response.Result;
+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.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarOrderService;
+import com.payment.platform.module.payment.har.service.HarShopService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+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;
+
+import com.payment.platform.common.exception.BusinessException;
+
+/**
+ * 汇安融开放接口 —— 供企业商户调用。
+ *
+ * 认证由 TenantApiKeyAuthFilter 完成(Authorization: ApiKey xxx + Signature),
+ * tenantId 由 filter 写入 request attribute。
+ * 注意:新增接口必须同步登记到 TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS,
+ * 该数组是精确匹配,漏登记会导致认证被整体跳过。
+ */
+@RestController
+@RequestMapping("/payment/openapi/har")
+@RequiredArgsConstructor
+public class HarOpenController {
+
+    private final HarShopService shopService;
+    private final HarOrderService orderService;
+
+    @PostMapping("/shop/create")
+    public Result<HarShopVO> createShop(@Valid @RequestBody HarShopCreateDTO dto,
+                                        HttpServletRequest request) {
+        String enterpriseId = requireEnterpriseId(request);
+        return Result.ok(shopService.create(dto, enterpriseId));
+    }
+
+    @PostMapping("/order/create")
+    public Result<HarOrderVO> createOrder(@Valid @RequestBody HarOrderCreateDTO dto,
+                                          HttpServletRequest request) {
+        String enterpriseId = requireEnterpriseId(request);
+        return Result.ok(orderService.create(dto, enterpriseId));
+    }
+
+    @PostMapping("/order/query")
+    public Result<HarOrderVO> queryOrder(@RequestBody HarOrderCreateDTO ignore,
+                                         HttpServletRequest request) {
+        throw new BusinessException("order/query 待实现");
+    }
+
+    /**
+     * 取当前租户对应的企业。
+     * filter 只写入 tenantId,企业由登录态或参数决定,此处沿用平台既有约定。
+     */
+    private String requireEnterpriseId(HttpServletRequest request) {
+        Object enterpriseId = request.getAttribute("openapi.enterpriseId");
+        if (enterpriseId == null) {
+            throw new BusinessException("无法确定企业身份,请通过 enterprise_id 显式指定");
+        }
+        return enterpriseId.toString();
+    }
+}
+```
+
+> **实现提示**:`order/query` 的入参尚不确定(按 `order_no` 还是 `out_biz_no`),且 `enterpriseId` 的来源取决于平台既有的开放 API 如何确定企业(`OpenapiController` 的 `/account/transfer` 有现成写法)。实现前先读 `OpenapiController` 第 106-200 行,照它的方式取企业身份,再把 `requireEnterpriseId` 替换掉,并把 `queryOrder` 按该方式补全。
+
+- [ ] **Step 2: 登记开放路径**
+
+`core/security/TenantApiKeyAuthFilter.java`,在 `OPENAPI_ACCOUNT_PATHS` 数组末尾追加:
+
+```java
+            "/payment/openapi/har/shop/create",
+            "/payment/openapi/har/order/create",
+            "/payment/openapi/har/order/query",
+```
+
+- [ ] **Step 3: 追加授权白名单**
+
+`core/security/SecurityConfig.java`,在 `/payment/openapi/account/**` 之后追加:
+
+```java
+            // 汇安融开放接口由 TenantApiKeyAuthFilter 独立认证(API Key + 签名)
+            "/payment/openapi/har/**",
+```
+
+- [ ] **Step 4: 验证**
+
+Run: `cd java && ./mvnw -q compile`
+Expected: BUILD SUCCESS
+
+---
+
+## Task 13: 前端 API 与页面
+
+**Files:**
+- Create: `frontend/src/api/module_payment/har.ts`
+- Create: `frontend/src/views/module_payment/har/merchant/index.vue`
+- Create: `frontend/src/views/module_payment/har/shop/index.vue`
+- Create: `frontend/src/views/module_payment/har/order/index.vue`
+
+**Interfaces:**
+- Consumes: Task 6/7/8 的后台接口
+- Produces: 三个页面
+
+**参考实现**:`frontend/src/api/module_payment/facetoface.ts` 与 `frontend/src/views/module_payment/batch/index.vue` 是同项目最新写法,照它们的风格写(`request` 工具、`ElMessage`、`el-table` + `el-pagination`、状态用 `el-tag`)。
+
+- [ ] **Step 1: 实现 API 文件**
+
+`frontend/src/api/module_payment/har.ts`
+
+```typescript
+import request from "@/utils/request";
+
+/** 订单状态 —— 与上游枚举一致,不做本地转义 */
+export const TRADE_STATUS_LABEL: Record<string, string> = {
+  "0": "等待支付",
+  "1": "支付成功",
+  "2": "退款成功",
+  "4": "退款中",
+  "8": "交易关闭",
+};
+
+export const TRADE_STATUS_TAG_TYPE: Record<string, string> = {
+  "0": "info",
+  "1": "success",
+  "2": "warning",
+  "4": "warning",
+  "8": "danger",
+};
+
+export const TRADE_STATUS_OPTIONS = [
+  { label: "全部", value: "" },
+  { label: "等待支付", value: "0" },
+  { label: "支付成功", value: "1" },
+  { label: "退款成功", value: "2" },
+  { label: "退款中", value: "4" },
+  { label: "交易关闭", value: "8" },
+];
+
+export const TRADE_TYPE_LABEL: Record<string, string> = {
+  alipay_stage: "花呗分期",
+  alipay_payment: "支付宝支付",
+};
+
+export const TRADE_TYPE_OPTIONS = [
+  { label: "全部", value: "" },
+  { label: "花呗分期", value: "alipay_stage" },
+  { label: "支付宝支付", value: "alipay_payment" },
+];
+
+/** 门店创建状态 */
+export const SHOP_CREATE_STATUS_LABEL: Record<string, string> = {
+  PENDING: "待确认",
+  SUCCESS: "创建成功",
+  FAILED: "创建失败",
+};
+
+export const SHOP_CREATE_STATUS_TAG_TYPE: Record<string, string> = {
+  PENDING: "warning",
+  SUCCESS: "success",
+  FAILED: "danger",
+};
+
+export const SHOP_CREATE_STATUS_OPTIONS = [
+  { label: "全部", value: "" },
+  { label: "待确认", value: "PENDING" },
+  { label: "创建成功", value: "SUCCESS" },
+  { label: "创建失败", value: "FAILED" },
+];
+
+const harApi = {
+  // ---------- 商户配置 ----------
+  merchantList: (pageNo = 1, pageSize = 20, search: Record<string, any> = {}) =>
+    request({ url: "/payment/har/merchant", method: "get",
+      params: { page_no: pageNo, page_size: pageSize, ...search } }),
+
+  merchantSave: (data: Record<string, any>) =>
+    request({ url: "/payment/har/merchant", method: "post", data }),
+
+  merchantDelete: (id: number) =>
+    request({ url: `/payment/har/merchant/${id}`, method: "delete" }),
+
+  // ---------- 门店 ----------
+  shopList: (pageNo = 1, pageSize = 20, search: Record<string, any> = {}) =>
+    request({ url: "/payment/har/shop", method: "get",
+      params: { page_no: pageNo, page_size: pageSize, ...search } }),
+
+  shopDetail: (id: number) =>
+    request({ url: `/payment/har/shop/${id}`, method: "get" }),
+
+  shopCreate: (data: Record<string, any>) =>
+    request({ url: "/payment/har/shop", method: "post", data }),
+
+  // ---------- 订单 ----------
+  orderList: (pageNo = 1, pageSize = 20, search: Record<string, any> = {}) =>
+    request({ url: "/payment/har/order", method: "get",
+      params: { page_no: pageNo, page_size: pageSize, ...search } }),
+
+  orderDetail: (id: number) =>
+    request({ url: `/payment/har/order/${id}`, method: "get" }),
+
+  orderCreate: (data: Record<string, any>) =>
+    request({ url: "/payment/har/order", method: "post", data }),
+
+  orderSync: (id: number) =>
+    request({ url: `/payment/har/order/${id}/sync`, method: "post" }),
+};
+
+export default harApi;
+```
+
+> 先读 `frontend/src/utils/request.ts` 确认 `request` 的调用签名,若与上面写法不同(例如已经封装成 `request.get(url, params)`),按实际签名改写。
+
+- [ ] **Step 2: 实现三个页面**
+
+三个页面结构一致:顶部筛选区 + `el-table` + `el-pagination`,操作按钮用 `v-permission` 指令或 `@perm` 判断控制。以订单页为例:
+
+`frontend/src/views/module_payment/har/order/index.vue`
+
+```vue
+<template>
+  <div class="har-order-page">
+    <el-card shadow="never">
+      <el-form :inline="true" :model="search" class="filter-bar">
+        <el-form-item label="订单号">
+          <el-input v-model="search.order_no" clearable placeholder="平台订单号" />
+        </el-form-item>
+        <el-form-item label="商户单号">
+          <el-input v-model="search.out_biz_no" clearable placeholder="商户自己的单号" />
+        </el-form-item>
+        <el-form-item label="交易状态">
+          <el-select v-model="search.trade_status" clearable style="width: 140px">
+            <el-option v-for="o in TRADE_STATUS_OPTIONS" :key="o.value"
+                       :label="o.label" :value="o.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="交易方式">
+          <el-select v-model="search.trade_type" clearable style="width: 140px">
+            <el-option v-for="o in TRADE_TYPE_OPTIONS" :key="o.value"
+                       :label="o.label" :value="o.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="loading" :data="rows" border stripe>
+        <el-table-column prop="orderNo" label="平台订单号" width="200" show-overflow-tooltip />
+        <el-table-column prop="outBizNo" label="商户单号" width="160" show-overflow-tooltip />
+        <el-table-column prop="title" label="商品名称" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="price" label="金额(元)" width="110" align="right" />
+        <el-table-column label="交易方式" width="120">
+          <template #default="{ row }">
+            {{ TRADE_TYPE_LABEL[row.tradeType] || row.tradeType }}
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="110">
+          <template #default="{ row }">
+            <el-tag :type="TRADE_STATUS_TAG_TYPE[row.tradeStatus] || 'info'">
+              {{ TRADE_STATUS_LABEL[row.tradeStatus] || row.tradeStatus }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdTime" label="创建时间" width="170" />
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleDetail(row)">详情</el-button>
+            <el-button link type="primary" @click="handleSync(row)">同步</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        class="pager"
+        background
+        layout="total, sizes, prev, pager, next"
+        :total="total"
+        :current-page="pageNo"
+        :page-size="pageSize"
+        @current-change="handlePageChange"
+        @size-change="handleSizeChange"
+      />
+    </el-card>
+
+    <el-dialog v-model="detailVisible" title="订单详情" width="720px">
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="平台订单号">{{ detail.orderNo }}</el-descriptions-item>
+        <el-descriptions-item label="商户单号">{{ detail.outBizNo }}</el-descriptions-item>
+        <el-descriptions-item label="上游订单号">{{ detail.upstreamOrderNo }}</el-descriptions-item>
+        <el-descriptions-item label="通道订单号">{{ detail.platformTradeNo }}</el-descriptions-item>
+        <el-descriptions-item label="商户编号">{{ detail.merchantCode }}</el-descriptions-item>
+        <el-descriptions-item label="门店编码">{{ detail.thirdShopCode }}</el-descriptions-item>
+        <el-descriptions-item label="金额">{{ detail.price }}</el-descriptions-item>
+        <el-descriptions-item label="到账金额">{{ detail.rcvAmount }}</el-descriptions-item>
+        <el-descriptions-item label="分期手续费">{{ detail.feeAmount }}</el-descriptions-item>
+        <el-descriptions-item label="通道费">{{ detail.channelAmount }}</el-descriptions-item>
+        <el-descriptions-item label="交易时间">{{ detail.tradeTime }}</el-descriptions-item>
+        <el-descriptions-item label="回调状态">{{ detail.notifyStatus }}</el-descriptions-item>
+        <el-descriptions-item label="收款链接" :span="2">
+          <el-link v-if="detail.payUrl" :href="detail.payUrl" target="_blank" type="primary">
+            {{ detail.payUrl }}
+          </el-link>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from "vue";
+import { ElMessage } from "element-plus";
+import harApi, {
+  TRADE_STATUS_LABEL,
+  TRADE_STATUS_TAG_TYPE,
+  TRADE_STATUS_OPTIONS,
+  TRADE_TYPE_LABEL,
+  TRADE_TYPE_OPTIONS,
+} from "@/api/module_payment/har";
+
+const loading = ref(false);
+const rows = ref<any[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const search = ref<Record<string, any>>({ order_no: "", out_biz_no: "", trade_status: "", trade_type: "" });
+
+const detailVisible = ref(false);
+const detail = ref<any>({});
+
+const fetchList = async () => {
+  loading.value = true;
+  try {
+    const res: any = await harApi.orderList(pageNo.value, pageSize.value, search.value);
+    rows.value = res.data?.rows ?? res.data?.records ?? [];
+    total.value = res.data?.total ?? 0;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleSearch = () => {
+  pageNo.value = 1;
+  fetchList();
+};
+
+const handleReset = () => {
+  search.value = { order_no: "", out_biz_no: "", trade_status: "", trade_type: "" };
+  handleSearch();
+};
+
+const handlePageChange = (p: number) => {
+  pageNo.value = p;
+  fetchList();
+};
+
+const handleSizeChange = (s: number) => {
+  pageSize.value = s;
+  pageNo.value = 1;
+  fetchList();
+};
+
+const handleDetail = async (row: any) => {
+  const res: any = await harApi.orderDetail(row.id);
+  detail.value = res.data ?? {};
+  detailVisible.value = true;
+};
+
+const handleSync = async (row: any) => {
+  await harApi.orderSync(row.id);
+  ElMessage.success("已同步");
+  fetchList();
+};
+
+onMounted(fetchList);
+</script>
+
+<style scoped>
+.har-order-page .filter-bar {
+  margin-bottom: 12px;
+}
+.har-order-page .pager {
+  margin-top: 12px;
+  justify-content: flex-end;
+}
+</style>
+```
+
+> `PageResult` 的字段名(`rows` 还是 `records`)以 `PageResult` 实际定义为准,读 `common/response/PageResult.java` 确认后只保留正确的那一个。
+
+- [ ] **Step 3: 验证前端可编译**
+
+Run: `cd frontend && npm run build` 或 `npx vue-tsc --noEmit`
+Expected: 无类型错误
+
+---
+
+## Task 14: 菜单权限种子
+
+**Files:**
+- Create: `java/src/main/resources/db/har-menu.sql`
+
+**Interfaces:**
+- Produces: `sys_menu` + `sys_role_menu` 记录
+
+- [ ] **Step 1: 写菜单 SQL**
+
+`java/src/main/resources/db/har-menu.sql`
+
+```sql
+-- 汇安融支付宝通道 菜单 + 权限点(2026-09-16)
+-- 幂等:重复执行不产生重复记录
+
+-- 顶层目录
+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)
+SELECT NULL, '汇安融通道', '汇安融通道', 'payment-har', '/payment/har',
+       NULL, NULL, 1,
+       (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id IS NULL),
+       false, false, false, false, '0', now(), now()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/payment/har' AND status = '0');
+
+-- 三个页面
+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)
+SELECT (SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND status = '0'),
+       '商户配置', '商户配置', '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()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/payment/har/merchant' AND status = '0');
+
+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)
+SELECT (SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND status = '0'),
+       '门店管理', '门店管理', '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()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/payment/har/shop' AND status = '0');
+
+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)
+SELECT (SELECT id FROM sys_menu WHERE route_path = '/payment/har' AND status = '0'),
+       '订单管理', '订单管理', '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()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/payment/har/order' AND status = '0');
+
+-- 按钮权限点
+INSERT INTO sys_menu (parent_id, name, title, permission, type, "order", hidden,
+                      keep_alive, always_show, affix, status, created_time, updated_time)
+SELECT p.id, v.name, v.title, v.permission, 3, v.ord, false, false, false, false, '0', now(), now()
+FROM (VALUES
+    ('/payment/har/merchant', '商户配置-新增', '商户配置-新增', 'module_payment:har:merchant:save', 1),
+    ('/payment/har/merchant', '商户配置-删除', '商户配置-删除', 'module_payment:har:merchant:delete', 2),
+    ('/payment/har/shop',     '门店-新增',     '门店-新增',     'module_payment:har:shop:create', 1),
+    ('/payment/har/shop',     '门店-详情',     '门店-详情',     'module_payment:har:shop:detail', 2),
+    ('/payment/har/order',    '订单-新增',     '订单-新增',     'module_payment:har:order:create', 1),
+    ('/payment/har/order',    '订单-详情',     '订单-详情',     'module_payment:har:order:detail', 2),
+    ('/payment/har/order',    '订单-同步',     '订单-同步',     'module_payment:har:order:sync', 3)
+) AS v(parent_path, name, title, permission, ord)
+JOIN sys_menu p ON p.route_path = v.parent_path AND p.status = '0'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.permission = v.permission AND m.status = '0');
+
+-- 授予超级管理员角色
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, m.id FROM sys_menu m
+WHERE m.route_path LIKE '/payment/har%'
+   OR m.permission LIKE 'module_payment:har:%'
+   AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.id);
+```
+
+- [ ] **Step 2: 执行**
+
+Run: `psql -h <host> -U admin -d payment_platform_java -f java/src/main/resources/db/har-menu.sql`
+Expected: 多条 INSERT 0 1
+
+- [ ] **Step 3: 核对**
+
+Run:
+```
+psql -h <host> -U admin -d payment_platform_java -c \
+  "SELECT id, parent_id, title, route_path, permission, type FROM sys_menu WHERE route_path LIKE '/payment/har%' OR permission LIKE 'module_payment:har:%' ORDER BY id;"
+```
+Expected: 1 个 type=1 目录 + 3 个 type=2 页面 + 7 个 type=3 按钮,共 11 行
+
+---
+
+## 完成检查
+
+全部 Task 完成后:
+
+- [ ] `cd java && ./mvnw test` 全绿
+- [ ] `cd java && ./mvnw -q compile` BUILD SUCCESS
+- [ ] 前端 `npm run build` 无错误
+- [ ] `git status --short` 确认只有新增文件,**没有任何 commit**
+
+### 遗留项(需联调确认,见 spec §8)
+
+| # | 项 | 代码位置 |
+|---|---|---|
+| 1 | 响应格式与真实样例 | `HarResponseParser` |
+| 2 | `detail` 签名边界(decode 前/后) | `HarResponseParser.parseForm` 的 `decode()` |
+| 3 | 通知验签拼串规则 | `HarSigner.buildNotifySignContent` |
+| 4 | `third_notify_url` 是否必填 | `HarOrderService` 的两个 build 方法 |
+| 5 | appId/密钥是否商户级 | `HarProperties` |
+| 6 | `order/query` 开放接口入参与企业身份来源 | `HarOpenController` |
+| 7 | 退款 | 本期未实现 |
+
+---
+
+## 2026-09-24 追加:一个租户允许配置多个商户
+
+用户裁定(原话):「一个租户允许配置多个商户」+「建店要可选,列表也要可筛选,租户自己拿商户号和门店号请求接口」。
+这**推翻**了同日上午「一租户一条租户级映射」的设计,故单列一节。
+
+**寻址三条路径,次序即优先级**(四处必须同时改,改一处漏一处会让闸放行而下游仍要企业):
+
+| 次序 | 条件 | 行为 |
+|---|---|---|
+| ① | 传了 `merchant_code` | `requireByTenantAndCode`:必须属于本租户,否则 **404**(与「不存在」合并,不区分) |
+| ② | 传了 `enterprise_id` | `requireByEnterpriseId`:查不到即 **403**「无权限操作该企业」 |
+| ③ | 都没传 | `requireByTenant`:0 条 ⇒ 404;**多条 ⇒ 400 要求指定**;1 条 ⇒ 用它 |
+
+> 多条时**绝不 `LIMIT 1` 随便挑**:挑错了钱进另一个商户账号、门店挂到别的商户名下,且全程无报错,到对账才发现。
+
+**改动落点**:`HarMerchantService`(`getByTenant` → `listByTenant` 返回列表/新 `getByTenantAndCode` + `requireByTenantAndCode`/`save` 删掉「同租户第二条被拒」的查重)、`HarShopService#create`、`HarOrderService#create`、`HarOrderService#openQuery`、`HarOpenController#requireMerchant`(签名加 `merchantCode`)、三个 DTO(`HarOrderCreateDTO`/`HarShopCreateDTO`/`HarOpenOrderQueryDTO` 各加字段)、前端 `har.ts` + `shop/index.vue` + `order/index.vue`(建店/下单弹窗加可清空的商户下拉、订单列表加商户编号筛选)、`collection-openapi.md` §2/§3.6/§10。
+
+**两个待执行 SQL(用户执行)**:
+
+| 文件 | 作用 | 不执行的后果 |
+|---|---|---|
+| `db/har-merchant-multi-per-tenant.sql` | `DROP INDEX uk_har_merchant_tenant_level` | 同一租户的第二条插不进去(23505 → 500 页) |
+| `db/har-merchant-tenant-readonly.sql` | 把商户配置页 `:list` 授给 role 2(**不授** `:save`/`:delete`) | 前端「商户」下拉为空(403),只能手工输入编号 |
+
+**本批读数**(实测,非推断):
+
+- 后端:HAR 测试包 **124 绿**(本批 **+16 条**:service 层 10 条 + 新建 `HarOpenControllerTest` 6 条——本模块首个控制器层用例)。
+  逐类读数(`grep -c '@Test'`,与 surefire 报数一致):merchant 12→**17**、shop 14→**15**、order 28→**32**、notify 15(未动)、controller 0→**6**。
+- 两向变异:删「多条 ⇒ 400」守卫 ⇒ 红 1;对调 ①/② 分支 ⇒ 红 2;`order/query` 调用点改回写死 `null` ⇒ 红 1;删 `openQuery` 的商户比对 ⇒ 红 1;对调两个谓词顺序(无害)⇒ **仍全绿**。
+- 前端:`pnpm ts:check` **289 → 289**(无新增;正对照:故意写错一处模板表达式 ⇒ 290 且命中该行);`pnpm build` exit 0,三条新 UI 串各落在自己页面的 chunk 里。
+
+**过程中修掉的一处自造缺陷**:`/payment/har/order/query` 最初给闸写死 `requireMerchant(enterpriseId, null)` ⇒ 多商户租户被 400 挡住却**无处可填** `merchant_code`(DTO 当时没有这个字段)。教训:凡「多条必须指定」这类提示,要连同**调用方能否照办**一起验。
+
+**用户前提与代码不符(按意图执行并标注)**:用户称「前端哪来的下单按钮」,实测 `frontend/src/views/module_payment/har/order/index.vue:8-15` 确有「创建订单」按钮与弹窗(`v-hasPerm(['module_payment:har:order:create'])`)。故下单弹窗**同样加了**商户选择,而不是按「没有按钮」的理解省掉它。

+ 309 - 0
docs/superpowers/specs/2026-09-16-har-alipay-channel-design.md

@@ -0,0 +1,309 @@
+# 汇安融支付宝支付通道接入 设计文档
+
+- 日期:2026-09-16
+- 状态:设计已定,等待 2026-09-24 联调
+- 上游文档:`docs/支付宝支付接口接入规范260915.docx`(v1.10)
+- 上游示例:`kf-request-demo/`(`com.har` 包,5 个文件)
+
+## 1. 背景
+
+汇安融(下称 HAR)是支付宝一级服务商,我们是其二级服务商。HAR 出支付能力,我们负责获客,用户是**企业商户**。本模块把 HAR 的支付宝收单能力(含花呗分期)接进平台,并把能力以开放 API 的形式转给企业商户。
+
+### 1.1 双向角色
+
+```
+企业商户 ──①──> 我们平台 ──②──> HAR ──> 支付宝
+   ↑                                │
+   └──────④ 回调 / ⑤ 查询───────────┘(③ 通知回流)
+
+①  我们的开放 API   tenant apiKey + HMAC-SHA256 签名   [平台已有底座]
+②  HAR API         form-urlencoded + SHA256WithRSA     [本模块新建]
+③  HAR 通知        推给我们,成功需返回 "success"       [本模块新建]
+④  回调企业商户      POST 到 tenant returnUrl           [平台已有 sendCallback]
+⑤  企业查单         开放 API / 后台页面                  [本模块新建]
+```
+
+### 1.2 资金流
+
+**本模块不碰钱。** 款项直接进入企业商户在 HAR 侧的商户号,平台不记账、不落余额、不做提现。本模块只记录订单生命周期供查询。
+
+**不做对账任务。** 订单收敛到终态即结束,不按日拉取上游数据做比对。
+
+## 2. 范围
+
+### 2.1 本期做
+
+| 项 | 说明 |
+|---|---|
+| 数据模型 | `pay_har_merchant` / `pay_har_shop` / `pay_har_order` |
+| 签名与验签 | `HarSigner`,可单元测试 |
+| 上游客户端 | `HarClient`:报文组装、签名、发送、响应解析、验签 |
+| 门店 | 后台 + 开放 API 创建,本地落库 |
+| 订单 | 下单(主扫 precreate / 被扫 pay)、查询 |
+| 通知接收 | 上游通知入口 + 验签 + 幂等 + 转发商户 |
+| 轮询补偿 | 非终态订单主动查询 |
+| 后台页面 | 商户配置、门店管理、订单管理 |
+| 菜单权限 | `sys_menu` + `sys_role_menu` 幂等种子 |
+
+### 2.2 本期不做
+
+| 项 | 原因 | 后续 |
+|---|---|---|
+| 退款 | HAR 侧接口仍在开发中,响应 `detail` 结构未公开 | 联调后补,表结构已预留字段 |
+| 商户入驻进件 | HAR 侧纯人工办理,我们只做手工配置 | 不做自动化 |
+| 对账任务 | 业务上不需要 | 不做 |
+| 资金记账 | 平台不碰钱 | 不做 |
+| 通用渠道抽象层 | 当前只有一个上游,抽象形状无依据 | 出现第二个上游时再考虑 |
+| appId / 密钥的多商户配置形态 | HAR 未确定是机构级还是商户级 | 联调后定,见 §8 |
+
+## 3. 上游协议要点
+
+以 HAR 文档和 `kf-request-demo` 为准。文档与 demo 冲突处**以 demo 为准**,理由见各行备注。
+
+### 3.1 请求
+
+- **协议**:HTTP POST,**`application/x-www-form-urlencoded`**
+  (文档 §1.1.1 写「整体为 JSON 格式」,与 demo 的 `UrlEncodedFormEntity` 冲突,以 demo 为准)
+- **编码**:UTF-8
+- **外层字段**(5 个,平铺在 form 里):
+
+| 字段 | 值 |
+|---|---|
+| `appId` | 接口接收方(HAR)提供 |
+| `method` | 接口标识 |
+| `version` | 固定 `2.0` |
+| `sign` | `SHA256WithRSA(bizContent, 我方私钥)` 的 URL-safe Base64 |
+| `bizContent` | 业务参数的 JSON 字符串 |
+
+- **URL 前缀**:`{baseUrl}/kf-alipay-api`,业务路径见表 §3.2
+  (demo 为 `http://localhost:9090/kf-alipay-api/api/alipay/trade/query`;文档 §4 接口声明表的 URL 缺 `kf-alipay-api` 这段 context-path)
+
+### 3.2 `method` 取值
+
+| 接口 | method | 路径 |
+|---|---|---|
+| 下单(主扫) | `kf.alipay.trade.precreate` | `/api/alipay/trade/precreate` |
+| 收款(被扫) | `kf.alipay.trade.pay` | `/api/alipay/trade/pay` |
+| 查询 | `kf.alipay.trade.query` | `/api/alipay/trade/query` |
+| 退款(本期不实现) | `kf.alipay.trade.refund` | `/api/alipay/trade/refund` |
+| 门店创建 | `kf.har.shop.create` | `/api/shop/create` |
+
+文档 §5.1/§5.2 的 `method` 备注写成 `kf.jh.stage.trade.*`,与 §4 接口声明表的 Application 列矛盾。demo(`Main.java:13`)传的是 `kf.alipay.trade.query`,故以 `kf.alipay.trade.*` 为准。
+
+### 3.3 响应
+
+- `response_code`:`10000` 表示成功
+- 业务数据在 `detail` 里,`sign` 是 **`detail` 的签名**
+- **响应报文格式未确认**(demo 未解析响应)。实现按「先 form,再 JSON」双路解析,两条路都保留 `detail` 原文用于验签,失败时打全量诊断日志
+
+### 3.4 密钥
+
+| 用途 | 密钥 | 格式 |
+|---|---|---|
+| 我方签名 | 我方私钥 | PKCS#8,纯 Base64 |
+| 我方验签 HAR 响应/通知 | HAR 公钥 | X.509,纯 Base64 |
+
+算法 `SHA256WithRSA`;签名结果 `Base64.encodeBase64URLSafeString`(URL-safe 字母表);验签用宽松解码(兼容标准与 URL-safe 两种字母表)。
+
+### 3.5 通知
+
+- HAR 推给我们,**成功必须返回字符串 `success`**
+- 未收到 `success` 时,**每隔 5 秒重试,共重试 5 次**(总窗口约 25 秒)
+- 字段:`order_no` / `merchant_code` / `third_shop_id` / `title` / `trade_type` / `third_order_no` / `status` / `percent` / `trade_time` / `stage_num` / `create_time` / `refund_time` / `extra_params` / `platform_trade_no` / `sign`
+- 验签规则(文档 §5.6):参数名升序拼接后 RSA 签名。文档给的示例 `RSA.sign(merchantCode=123&name=111&status=2)` 用驼峰命名且含不存在的 `name` 字段,**照文字规则实现**:剔除 `sign` 与空值,按参数名升序,`k=v` 用 `&` 连接
+
+**重试窗口只有 25 秒是本设计的关键约束**:任何超过 25 秒的不可用都会永久丢失通知,因此主动查询是主路径而非兜底。
+
+### 3.6 订单状态
+
+`0` 等待支付 / `1` 支付成功 / `2` 退款成功 / `4` 退款中 / `8` 交易关闭
+
+直接采用上游枚举,不做本地转义。文档 §5.3 查询响应含 `4`,§5.6 通知枚举不含 `4`,按「通知可能不推 `4`」处理。
+
+## 4. 数据模型
+
+三张表,均为 `BIGSERIAL` 主键、Postgres。基类字段沿用 `PaymentBaseEntity` / `PaymentTenantBaseEntity` / `PaymentEnterpriseBaseEntity` 的约定。
+
+> 注意:`PaymentBaseEntity` 的 `status` 是普通字段(无 `@TableLogic`),业务状态统一走 `trade_status` / `create_status`,与 `pay_f2f_trade` 保持一致。
+
+### 4.1 `pay_har_merchant` — 企业 ↔ HAR 商户映射
+
+运营在报备完成后手工录入,无自动化入驻。
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| `merchant_code` | VARCHAR(20) NOT NULL | HAR 分配,唯一 |
+| `enterprise_id` | VARCHAR(64) NOT NULL | 平台企业 ID |
+| `merchant_name` | VARCHAR(128) | 商户名称 |
+| `contact_name` / `contact_mobile` | VARCHAR | 联系人 |
+| `version` | INT | 乐观锁 |
+
+索引:`UNIQUE(merchant_code)`、`INDEX(enterprise_id)`
+
+### 4.2 `pay_har_shop` — 门店
+
+**本地表是唯一真相源**:HAR 未提供门店查询接口,创建后只能靠本地记录找回。
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| `merchant_code` | VARCHAR(20) NOT NULL | 所属商户 |
+| `third_shop_id` | VARCHAR(32) NOT NULL | 我方门店编码,传给 HAR,幂等键 |
+| `shop_name` | VARCHAR(128) NOT NULL | 门店名称 |
+| `shop_id` | VARCHAR(32) | HAR 返回的门店 ID,未成功时为空 |
+| `create_status` | VARCHAR(16) NOT NULL DEFAULT 'PENDING' | `PENDING` / `SUCCESS` / `FAILED` |
+| `fail_reason` | VARCHAR(512) | 失败原因 |
+| `request_body` / `response_body` | TEXT | 原始请求与响应报文 |
+| `retry_count` | INT DEFAULT 0 | 调用次数 |
+| `version` | INT | 乐观锁 |
+
+索引:`UNIQUE(enterprise_id, third_shop_id)`、`INDEX(merchant_code)`
+
+**超时处理**:本地先落 `PENDING` 记录,再调上游,成功回填 `shop_id`。超时保持 `PENDING`,记录原始报文,**不自动重试**,交人工判断——因为没有查询接口,无任何手段核实远端是否已创建,自动重试可能产生重复门店。
+
+### 4.3 `pay_har_order` — 订单
+
+形状对齐 `pay_f2f_trade`。
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| `order_no` | VARCHAR(64) NOT NULL | 我方生成,作为 `third_order_no` 传上游,唯一 |
+| `out_biz_no` | VARCHAR(64) | 企业商户自己的订单号 |
+| `upstream_order_no` | VARCHAR(32) | HAR 返回的 `order_no` |
+| `platform_trade_no` | VARCHAR(64) | 支付通道订单号 |
+| `merchant_code` | VARCHAR(20) NOT NULL | 商户编号 |
+| `shop_code` / `third_shop_code` | VARCHAR(32) | 平台门店编号 / 商户门店编号 |
+| `price` | NUMERIC(12,2) | 交易金额(元) |
+| `title` | VARCHAR(64) | 商品名称 |
+| `trade_type` | VARCHAR(32) NOT NULL | `alipay_stage`(花呗分期)/ `alipay_payment`(支付宝支付) |
+| `stage_num` / `percent` | VARCHAR(8) | 分期期数 / 手续费承担方 |
+| `pay_timeout` | INTEGER | 支付过期时间 |
+| `extra_params` | TEXT | 额外参数 JSON |
+| `pay_url` | TEXT | 收款链接(主扫) |
+| `trade_status` | VARCHAR(8) NOT NULL DEFAULT '0' | 上游状态枚举 |
+| `trade_time` / `refund_time` / `upstream_create_time` | TIMESTAMP | 支付 / 退款 / 上游下单时间 |
+| `rcv_amount` / `fee_amount` / `channel_amount` | NUMERIC(12,2) | 到账金额 / 分期手续费 / 通道费 |
+| `last_query_time` / `next_query_time` / `query_count` | | 轮询调度 |
+| `notify_status` | VARCHAR(16) | 回调企业商户的结果 |
+| `version` | INT | 乐观锁 |
+
+索引:`UNIQUE(order_no)`、`INDEX(enterprise_id)`、`INDEX(out_biz_no)`、`INDEX(upstream_order_no)`、`INDEX(trade_status, next_query_time)`
+
+无需独立的「下游映射表」:企业的 `out_biz_no`、我方的 `order_no`、上游的 `order_no` 三个号都在这张表上,可直接查询。
+
+## 5. 模块结构
+
+```
+module/payment/har/
+├── config/       HarProperties          baseUrl / appId / version / 密钥
+├── client/       HarSigner              签名与验签(纯函数,可单测)
+│                 HarClient              报文组装 + 发送 + 响应解析
+│                 HarResponseParser      响应解析(form / JSON 双路)
+│                 HarApiException
+├── controller/   HarMerchantController  运营:商户配置
+│                 HarShopController      后台:门店管理
+│                 HarOrderController     后台:订单管理
+│                 HarNotifyController    上游通知入口
+├── open/         HarOpenController      企业调用:/payment/openapi/har/*
+├── dto/          req/ resp/ query/ vo/
+├── entity/       HarMerchantEntity / HarShopEntity / HarOrderEntity
+├── enums/        HarOrderStatus / HarShopCreateStatus / HarTradeType
+├── mapper/
+├── service/      HarMerchantService / HarShopService / HarOrderService
+│                 HarNotifyService / HarCallbackService
+└── scheduler/    HarOrderPollScheduler
+```
+
+字段名不做映射层,直接沿用上游命名(`third_order_no`、`merchant_code`、`pay_url`、`stage_num` 等)。上游即契约。
+
+`client/` 的存在不是为了抽象渠道,而是把签名、报文组装、发送收在一处,使其可脱离 Spring 单独单元测试。
+
+## 6. 关键链路
+
+### 6.1 下单
+
+```
+企业 → POST /payment/openapi/har/order/create
+     → TenantApiKeyAuthFilter 认证
+     → HarOrderService.create()
+         ├ 校验商户配置存在
+         ├ 生成 order_no,落库(trade_status=0)
+         ├ HarClient.precreate() 或 pay()
+         │    └ sign = SHA256WithRSA(bizContent, 私钥)
+         ├ 验签响应 → 成功则回填 pay_url / upstream_order_no
+         └ 设置 next_query_time = now + 30s
+```
+
+### 6.2 通知接收
+
+```
+HAR → POST /payment/notify/har  (form,免鉴权,见 §7)
+    → HarNotifyService.handle()
+        ├ 验签(参数名升序拼接)—— 失败即拒绝并打全量日志
+        ├ 按 order_no + status 幂等
+        ├ 更新订单
+        ├ 异步回调企业 returnUrl
+        └ 返回 "success"
+```
+
+**验签失败一律拒绝,不设跳过开关。** 联调期若规则对不上,靠诊断日志定位(打印收到的原始参数、拼串结果、期望签名),不靠关闭校验绕过。
+
+### 6.3 轮询补偿
+
+`HarOrderPollScheduler`,`@Scheduled(fixedDelay = 60_000)`,照 `F2fTradePollScheduler` 写。
+
+- 扫描条件:`trade_status IN ('0','4')` AND `next_query_time <= now()` AND `query_count < 上限`
+- 调 `HarClient.query()`,更新状态与金额字段
+- 退避:`next_query_time` 按 `query_count` 递增(30s → 1m → 2m → 5m → 15m 封顶)
+- 超过次数上限仍非终态 → 停止轮询,等人工介入
+
+### 6.4 回调企业商户
+
+复用 `OpenapiService` 的既有模式(`resolveReturnUrl` + `sendCallback`):取 `sys_tenant_api_key.return_url`,POST JSON,带重试。
+
+## 7. 安全集成
+
+需要改动的既有文件,共两处:
+
+**`SecurityConfig.WHITE_LIST`** 追加:
+- `/payment/notify/har` — 上游通知,无登录态,靠验签保证真实性
+- `/payment/openapi/har/**` — 企业开放接口,授权层放行,由 ApiKey filter 认证
+
+**`TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS`** 追加企业开放接口的精确路径:
+- `/payment/openapi/har/shop/create`
+- `/payment/openapi/har/order/create`
+- `/payment/openapi/har/order/query`
+
+> 该数组是**精确匹配**(`path.equals(p)`),新增接口必须同步登记,漏登记会导致认证被整体跳过。
+
+## 8. 未决项(联调时确认)
+
+| # | 项 | 影响 | 当前处理 |
+|---|---|---|---|
+| 1 | appId 与密钥是机构级还是商户级 | 决定配置是一份还是多份 | 先用 `HarProperties` + `application.yml`,联调后如需落表是纯加法 |
+| 2 | 响应报文格式与真实样例 | 验签能否通过 | 双路解析 + 全量诊断日志 |
+| 3 | `detail` 的签名边界(URL-decode 前 / 后) | 验签能否通过 | 按 decode 后实现,失败时日志同时打印两种结果 |
+| 4 | 通知验签的确切拼串规则 | 通知能否处理 | 按文档文字实现,日志留全量证据 |
+| 5 | 退款响应 `detail` 结构 | 退款实现 | 本期不做 |
+| 6 | `pay_timeout` 单位(2~60) | 参数语义 | 按文档原样透传,不转换 |
+| 7 | 「重试 5 次」是总计 5 次还是额外 5 次 | 补偿任务频率 | 不影响设计,按最坏情况(25 秒)处理 |
+| 8 | 门店创建是否支持幂等 | 重复门店风险 | 不自动重试,人工介入 |
+| 9 | `status=4` 是否推送通知 | 状态收敛 | 轮询补偿覆盖 |
+
+## 9. 测试策略
+
+| 层 | 方式 | 现在可验证 |
+|---|---|---|
+| `HarSigner` | 单元测试:签名/验签往返、密钥格式、URL-safe Base64、验签失败返回 false 而非抛异常 | ✅ |
+| `HarClient` 报文组装 | 单元测试:断言 form 字段与 `bizContent` JSON 结构、`sign` 覆盖内容正确 | ✅ |
+| `HarResponseParser` | 单元测试:form / JSON 两种格式、`detail` 原文保留 | ✅ |
+| 门店 / 订单本地生命周期 | 单元测试 + 接口测试 | ✅ |
+| 菜单权限种子 | 手工执行 SQL 后核对 | ✅ |
+| 真实网络调用 | 联调(2026-09-24) | ❌ |
+| 通知端到端 | 联调 | ❌ |
+
+**约束**:上游接口仍在开发中,联调前不存在可用的测试环境。因此本期所有网络调用路径只能通过单元测试验证「组装与解析逻辑正确」,无法验证「协议理解正确」。协议理解的正确性只能等联调。
+
+## 10. 交付约束
+
+- 本期代码**不提交 git**,交付到工作区由人工审阅
+- 上游文档与 demo 归入仓库 `docs/` 与 `kf-request-demo/` 供追溯

二进制
docs/支付宝支付接口接入规范260915.docx