# 汇安融支付宝支付通道模块 实现计划 > **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` / `PageResult`,入参用 `@RequestParam(name = "snake_case")`。 - 需要登录态的接口加 `@PreAuthorize("@perm.hasAny('module_payment:har:')")`。 - 上游字段名直接沿用,不做映射层。 - 上游签名算法固定 `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 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 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 params) { TreeMap sorted = new TreeMap<>(); for (Map.Entry 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 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 HarResponseParser.toBean(String json, Class 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 toBean(String json, Class 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 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 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 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 form) { List pairs = new ArrayList<>(); for (Map.Entry 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 -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 { } ``` `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 PENDING = Set.of(WAIT_PAY, REFUNDING); /** 终态 —— 不再轮询 */ public static final Set 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 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 list(int pageNo, int pageSize, HarMerchantQueryDTO q) { LambdaQueryWrapper 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 page = merchantMapper.selectPage(new Page<>(pageNo, pageSize), w); List 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() .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> 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 save(@Valid @RequestBody HarMerchantSaveDTO dto) { return Result.ok(service.save(dto)); } @DeleteMapping("/{id}") @PreAuthorize("@perm.hasAny('module_payment:har:merchant:delete')") public Result 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 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 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 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 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 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 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 list(int pageNo, int pageSize, HarShopQueryDTO q) { LambdaQueryWrapper 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 page = shopMapper.selectPage(new Page<>(pageNo, pageSize), w); List 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() .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> 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 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 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 HarOrderService.list(int pageNo, int pageSize, HarOrderQueryDTO q)` - `List 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 captor = ArgumentCaptor.forClass(HarPrecreateRequest.class); verify(harClient).precreate(captor.capture()); String thirdOrderNo = captor.getValue().getThirdOrderNo(); assertNotNull(thirdOrderNo); assertTrue(thirdOrderNo.startsWith("HAR"), "我方订单号需带前缀便于识别"); ArgumentCaptor 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 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 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 list(int pageNo, int pageSize, HarOrderQueryDTO q) { LambdaQueryWrapper 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 page = orderMapper.selectPage(new Page<>(pageNo, pageSize), w); List 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() .eq(HarOrderEntity::getOrderNo, orderNo) .last("LIMIT 1")); } public HarOrderEntity getByOutBizNo(String enterpriseId, String outBizNo) { return orderMapper.selectOne(new LambdaQueryWrapper() .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 findPendingForPoll(int limit) { return orderMapper.selectList(new LambdaQueryWrapper() .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> 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 detail(@PathVariable(name = "id") Long id) { return Result.ok(service.detail(id)); } @PostMapping @PreAuthorize("@perm.hasAny('module_payment:har:order:create')") public Result 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 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 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 signedParams() { Map 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 params = signedParams(); params.put("sign", "tampered"); String result = service.handle(params); assertNotEquals("success", result, "验签失败绝不能返回 success"); verifyNoInteractions(orderMapper); } @Test void rejectsWhenSignMissing() { Map params = signedParams(); params.remove("sign"); assertNotEquals("success", service.handle(params)); verifyNoInteractions(orderMapper); } @Test void rejectsWhenContentTamperedAfterSigning() { Map 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 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 harNotify(@RequestParam Map 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() .eq(TenantApiKeyEntity::getTenantId, tenantId) .eq(TenantApiKeyEntity::getStatus, "0") .last("LIMIT 1")); return key == null ? null : key.getReturnUrl(); } private String buildPayload(HarOrderEntity order) { Map 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 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 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 createShop(@Valid @RequestBody HarShopCreateDTO dto, HttpServletRequest request) { String enterpriseId = requireEnterpriseId(request); return Result.ok(shopService.create(dto, enterpriseId)); } @PostMapping("/order/create") public Result createOrder(@Valid @RequestBody HarOrderCreateDTO dto, HttpServletRequest request) { String enterpriseId = requireEnterpriseId(request); return Result.ok(orderService.create(dto, enterpriseId)); } @PostMapping("/order/query") public Result 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 = { "0": "等待支付", "1": "支付成功", "2": "退款成功", "4": "退款中", "8": "交易关闭", }; export const TRADE_STATUS_TAG_TYPE: Record = { "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 = { 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 = { PENDING: "待确认", SUCCESS: "创建成功", FAILED: "创建失败", }; export const SHOP_CREATE_STATUS_TAG_TYPE: Record = { 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 = {}) => request({ url: "/payment/har/merchant", method: "get", params: { page_no: pageNo, page_size: pageSize, ...search } }), merchantSave: (data: Record) => 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 = {}) => 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) => request({ url: "/payment/har/shop", method: "post", data }), // ---------- 订单 ---------- orderList: (pageNo = 1, pageSize = 20, search: Record = {}) => 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) => 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 ``` > `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 -U admin -d payment_platform_java -f java/src/main/resources/db/har-menu.sql` Expected: 多条 INSERT 0 1 - [ ] **Step 3: 核对** Run: ``` psql -h -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'])`)。故下单弹窗**同样加了**商户选择,而不是按「没有按钮」的理解省掉它。