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
com.payment.platform。PaymentEnterpriseBaseEntity(含 tenantId + enterpriseId)用于三张业务表。trade_status / create_status,不用 status —— PaymentBaseEntity.status 另有用途。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/ — 新增文件,不改既有上游签名是整个模块里唯一现在就能严格验证的部分,先做。
Files:
java/src/main/java/com/payment/platform/module/payment/har/client/HarSigner.javajava/src/test/java/com/payment/platform/module/payment/har/client/HarSignerTest.javaInterfaces:
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: 写失败的测试
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
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
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;
}
}
Run: cd java && ./mvnw test -Dtest=HarSignerTest
Expected: 7 个测试全部 PASS
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
Files:
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.javajava/src/main/resources/application.ymlInterfaces:
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
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 顶层追加(缩进与既有顶层键一致):
# 汇安融支付宝通道(密钥由运维注入,不要提交真实值)
har:
enabled: false
base-url: ""
app-id: ""
version: "2.0"
our-private-key: ""
platform-public-key: ""
connect-timeout-ms: 5000
read-timeout-ms: 15000
Run: cd java && ./mvnw -q compile
Expected: BUILD SUCCESS
Files:
.../har/client/HarResponse.java.../har/client/HarResponseParser.java.../har/client/HarResponseParserTest.javaInterfaces:
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: 写失败的测试
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
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
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
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
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
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;
}
Run: cd java && ./mvnw test -Dtest=HarResponseParserTest
Expected: 5 个测试全部 PASS
Files:
.../har/client/HarClient.java.../har/client/dto/HarPrecreateRequest.java.../har/client/dto/HarPayRequest.java.../har/client/dto/HarQueryRequest.java.../har/client/dto/HarShopCreateRequest.java.../har/client/HarClientTest.javaInterfaces:
HarSigner(Task 1)、HarProperties(Task 2)、HarResponseParser / HarResponse(Task 3)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: 写失败的测试
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: 编译失败
.../har/client/dto/HarQueryRequest.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
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
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
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
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)。
Run: cd java && ./mvnw test -Dtest=HarClientTest
Expected: 2 个测试 PASS
Files:
java/src/main/resources/db/migration/V1.8__create_pay_har_tables.sql.../har/entity/HarMerchantEntity.java / HarShopEntity.java / HarOrderEntity.java.../har/mapper/HarMerchantMapper.java / HarShopMapper.java / HarOrderMapper.java.../har/enums/HarShopCreateStatus.java / HarTradeType.java / HarNotifyStatus.javaInterfaces:
Produces: 三个实体与 Mapper,供 Task 6-12 使用
[ ] Step 1: 写建表 SQL
java/src/main/resources/db/migration/V1.8__create_pay_har_tables.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);
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/下的文件由人工执行。
.../har/entity/HarMerchantEntity.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
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
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;
}
三个 Mapper 照 F2fTradeRecordMapper 的写法:
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
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
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
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
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() {
}
}
Run: cd java && ./mvnw -q compile
Expected: BUILD SUCCESS
Files:
.../har/dto/HarMerchantSaveDTO.java / HarMerchantQueryDTO.java / HarMerchantVO.java.../har/service/HarMerchantService.java.../har/controller/HarMerchantController.javaInterfaces:
Produces:
HarMerchantEntity HarMerchantService.getByEnterpriseId(String enterpriseId) — 无配置返回 nullHarMerchantEntity HarMerchantService.requireByEnterpriseId(String enterpriseId) — 无配置抛 BusinessExceptionPageResult<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
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
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
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
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
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
Files:
.../har/dto/HarShopCreateDTO.java / HarShopQueryDTO.java / HarShopVO.java.../har/service/HarShopService.java.../har/controller/HarShopController.java.../har/service/HarShopServiceTest.javaInterfaces:
HarMerchantService.requireByEnterpriseId(Task 6)、HarClient.createShop(Task 4)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: 写失败的测试
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: 编译失败
.../har/dto/HarShopCreateDTO.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
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
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
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 说明。
Run: cd java && ./mvnw test -Dtest=HarShopServiceTest
Expected: 5 个测试 PASS
[ ] Step 6: 实现 Controller
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()));
}
}
Files:
.../har/dto/HarOrderCreateDTO.java / HarOrderQueryDTO.java / HarOrderVO.java.../har/service/HarOrderService.java.../har/controller/HarOrderController.java.../har/service/HarOrderServiceTest.javaInterfaces:
HarMerchantService.requireByEnterpriseId(Task 6)、HarClient.precreate/pay/query(Task 4)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: 写失败的测试
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: 编译失败
.../har/dto/HarOrderCreateDTO.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
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
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
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(...)。
Run: cd java && ./mvnw test -Dtest=HarOrderServiceTest
Expected: 6 个测试 PASS
[ ] Step 6: 实现 Controller
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())));
}
}
Files:
.../har/service/HarNotifyService.java.../har/controller/HarNotifyController.javacore/security/SecurityConfig.java — 白名单追加.../har/service/HarNotifyServiceTest.javaInterfaces:
HarSigner.verify / buildNotifySignContent(Task 1)、HarOrderService(Task 8)String HarNotifyService.handle(Map<String,String> params) — 返回 "success" 或失败原因验签失败一律拒绝,不设开关。 联调期规则对不上时靠日志定位,不靠关闭校验绕过。
[ ] Step 1: 写失败的测试
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
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
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 之后追加一行:
// 汇安融通知入口(无登录态,真实性由 RSA 验签保证)
"/payment/notify/har",
Run: cd java && ./mvnw test -Dtest=HarNotifyServiceTest
Expected: 6 个测试 PASS
Files:
.../har/service/HarCallbackService.javaInterfaces:
HarOrderEntity(Task 5)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
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(...)调用,避免为了一个回调去改全局配置。
Run: cd java && ./mvnw -q compile
Expected: BUILD SUCCESS
Files:
.../har/scheduler/HarOrderPollScheduler.java.../har/scheduler/HarOrderPollSchedulerTest.javaInterfaces:
HarOrderService.findPendingForPoll / syncFromUpstream(Task 8)这是主路径不是兜底。 上游通知重试窗口只有 25 秒,任何超过 25 秒的不可用都会永久丢失通知。
[ ] Step 1: 写失败的测试
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
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)。
Run: cd java && ./mvnw test -Dtest=HarOrderPollSchedulerTest
Expected: 2 个测试 PASS
Files:
.../har/open/HarOpenController.javacore/security/SecurityConfig.java — 白名单追加core/security/TenantApiKeyAuthFilter.java — 路径数组追加Interfaces:
HarShopService.create(Task 7)、HarOrderService.create / getByOrderNo(Task 8)易漏点:
TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS是精确匹配(path.equals(p)),漏登记会导致认证被整体跳过。新增接口必须同步登记。
[ ] Step 1: 实现 HarOpenController
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按该方式补全。
core/security/TenantApiKeyAuthFilter.java,在 OPENAPI_ACCOUNT_PATHS 数组末尾追加:
"/payment/openapi/har/shop/create",
"/payment/openapi/har/order/create",
"/payment/openapi/har/order/query",
core/security/SecurityConfig.java,在 /payment/openapi/account/** 之后追加:
// 汇安融开放接口由 TenantApiKeyAuthFilter 独立认证(API Key + 签名)
"/payment/openapi/har/**",
Run: cd java && ./mvnw -q compile
Expected: BUILD SUCCESS
Files:
frontend/src/api/module_payment/har.tsfrontend/src/views/module_payment/har/merchant/index.vuefrontend/src/views/module_payment/har/shop/index.vuefrontend/src/views/module_payment/har/order/index.vueInterfaces:
参考实现:frontend/src/api/module_payment/facetoface.ts 与 frontend/src/views/module_payment/batch/index.vue 是同项目最新写法,照它们的风格写(request 工具、ElMessage、el-table + el-pagination、状态用 el-tag)。
frontend/src/api/module_payment/har.ts
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)),按实际签名改写。
三个页面结构一致:顶部筛选区 + el-table + el-pagination,操作按钮用 v-permission 指令或 @perm 判断控制。以订单页为例:
frontend/src/views/module_payment/har/order/index.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确认后只保留正确的那一个。
Run: cd frontend && npm run build 或 npx vue-tsc --noEmit
Expected: 无类型错误
Files:
java/src/main/resources/db/har-menu.sqlInterfaces:
Produces: sys_menu + sys_role_menu 记录
[ ] Step 1: 写菜单 SQL
java/src/main/resources/db/har-menu.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);
Run: psql -h <host> -U admin -d payment_platform_java -f java/src/main/resources/db/har-menu.sql
Expected: 多条 INSERT 0 1
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 SUCCESSnpm run build 无错误git status --short 确认只有新增文件,没有任何 commit| # | 项 | 代码位置 |
|---|---|---|
| 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 | 退款 | 本期未实现 |
用户裁定(原话):「一个租户允许配置多个商户」+「建店要可选,列表也要可筛选,租户自己拿商户号和门店号请求接口」。 这推翻了同日上午「一租户一条租户级映射」的设计,故单列一节。
寻址三条路径,次序即优先级(四处必须同时改,改一处漏一处会让闸放行而下游仍要企业):
| 次序 | 条件 | 行为 |
|---|---|---|
| ① | 传了 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),只能手工输入编号 |
本批读数(实测,非推断):
HarOpenControllerTest 6 条——本模块首个控制器层用例)。
逐类读数(grep -c '@Test',与 surefire 报数一致):merchant 12→17、shop 14→15、order 28→32、notify 15(未动)、controller 0→6。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']))。故下单弹窗同样加了商户选择,而不是按「没有按钮」的理解省掉它。