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