feat:支付宝H5直付通支付适配

This commit is contained in:
dzc committed 2026-10-26 17:03:32 +08:00
1 parent 413394014e
commit fed69fbe11
6 files changed
+2377 -2

No files matched your search

File diff suppressed because it is too large. Load diff
+4
View File
@@ -32,6 +32,10 @@ H5 只使用项目根目录一个 `.env`,变量命名沿用参考 App:开发
- 支付宝沿用现有应用和固定 OAuth 回调 `/third/aggregate-order-pay/oauth/alipay`,租户通过一次性 state 恢复。不要把独立 H5 地址替换为后端回调地址。
- 两种渠道继续使用商户既有支付配置、子商户和默认分佣关系快照,配置需已具备相应 OAuth/JSAPI 权限。仅域名能访问不代表上述平台配置已完成。
支付宝 H5 通过 `alipay.trade.create` 创建交易并返回 `tradeNO`,前端使用 `AlipayJSBridge.tradePay` 调起支付。直付通结算参数沿用现有 `ThirdAppPlusAliPayServiceImpl.appPay`:始终携带 `sub_merchant.merchant_id`,以及 `settle_info.settle_period_time=1d` 和一条 `defaultSettle` 结算明细;明细金额为交易全额,单位为元,`trans_in` 留空。关闭平台抽佣仍保留这组结算参数,不在创建交易时扣减平台分佣。
产品码保持当前 JSAPI 场景的 `JSAPI_PAY`,商户授权 Token 继续按已有关系快照和抽佣规则传递。`1d` 账期只在商户签约账期结算模式时有效;补齐结算参数后仍需验证应用及商户权限,不能据此认定 `40006 / isv.insufficient-isv-permissions` 已解除。
## 发布顺序与兼容
1. 先确认统一支付基线迁移 `db/20260825_third_payment_schema.sql` 已完成;已完成的环境无需重跑。双场景升级单独执行新文件 [`db/20261020_add_aggregate_pay_scene.sql`](../../db/20261020_add_aggregate_pay_scene.sql),不得只靠修改历史迁移升级现有库。
@@ -10,12 +10,14 @@ import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateMerchantCreateVo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
/** 只开放签名商户码查询与线下收款创建,不放开原有商户详情或内部支付接口。 */
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/aggregate-merchant-pay")
@@ -52,6 +54,8 @@ public class ThirdAggregateMerchantPayController {
} catch (IllegalArgumentException exception) {
body = R.failed(exception.getMessage());
} catch (RuntimeException exception) {
// 公开响应保持统一提示,原始异常只在服务端按付款请求定位,不记录签名或完整请求体。
log.error("商户H5付款创建失败,tenantId:{},mchId:{},requestId:{}", dto.getTenantId(), dto.getId(), dto.getRequestId(), exception);
body = R.failed("付款暂未能发起,请稍后重试或重新扫码");
}
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body);
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatu
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
import java.net.URI;
/** 聚合扫码与 OAuth 匿名入口;保留旧路径,收银展示统一通过 302 转入独立 H5。 */
@Slf4j
@RestController
@RequiredArgsConstructor
@Validated
@@ -38,6 +40,8 @@ public class ThirdAggregateOrderPayController {
h5Support.baseUrl();
return redirect(aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent));
} catch (RuntimeException exception) {
// 错误回跳不携带内部异常,服务端按阶段保留堆栈,不记录访问令牌或完整 URL。
log.error("聚合收银入口处理失败", exception);
return unavailable();
}
}
@@ -53,6 +57,8 @@ public class ThirdAggregateOrderPayController {
ThirdAggregateCashierPageData data = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state);
return redirect(data.getH5Url());
} catch (RuntimeException exception) {
// 授权码和 state 仅参与服务端处理,不能作为诊断字段输出到日志。
log.error("聚合收银微信授权回调处理失败", exception);
return unavailable();
}
}
@@ -67,6 +73,8 @@ public class ThirdAggregateOrderPayController {
ThirdAggregateCashierPageData data = aggregateOrderPayService.handleAlipayOauth(authCode, state);
return redirect(data.getH5Url());
} catch (RuntimeException exception) {
// 固定回调没有商户上下文,使用阶段日志定位,避免记录授权凭据。
log.error("聚合收银支付宝授权回调处理失败", exception);
return unavailable();
}
}
@@ -12,6 +12,8 @@ import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.domain.SettleDetailInfo;
import com.alipay.api.domain.SettleInfo;
import com.alipay.api.domain.SubMerchant;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
@@ -42,6 +44,7 @@ import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -63,6 +66,16 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
*/
private static final String PRODUCT_CODE = "JSAPI_PAY";
/**
* 沿用现有 App 直付通的自动确认结算账期;仅在商户签约账期结算模式时生效。
*/
private static final String DIRECT_PAY_SETTLE_PERIOD_TIME = "1d";
/**
* 使用二级商户进件时的默认结算账户;该类型不得同时指定 trans_in 收款账号。
*/
private static final String DEFAULT_SETTLE_TRANS_IN_TYPE = "defaultSettle";
/**
* 支付宝账单固定安全标题,避免传递内部订单信息。
*/
@@ -152,8 +165,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
AlipayTradeCreateModel model = new AlipayTradeCreateModel();
String totalAmount = fenToYuan(context.getAmount());
model.setOutTradeNo(context.getPayNo());
model.setTotalAmount(fenToYuan(context.getAmount()));
model.setTotalAmount(totalAmount);
model.setSubject(SUBJECT);
model.setProductCode(PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN));
@@ -162,6 +176,14 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
// 与 App 直付通保持相同结算模型,关闭平台抽佣也必须保留二级商户的全额结算明细。
SettleDetailInfo defaultSettleDetailInfo = new SettleDetailInfo();
defaultSettleDetailInfo.setTransInType(DEFAULT_SETTLE_TRANS_IN_TYPE);
defaultSettleDetailInfo.setAmount(totalAmount);
SettleInfo directPaySettleInfo = new SettleInfo();
directPaySettleInfo.setSettlePeriodTime(DIRECT_PAY_SETTLE_PERIOD_TIME);
directPaySettleInfo.setSettleDetailInfos(List.of(defaultSettleDetailInfo));
model.setSettleInfo(directPaySettleInfo);
if (StrUtil.isNotBlank(identity.getOpenId())) {
model.setBuyerOpenId(identity.getOpenId());
} else {
@@ -1,12 +1,15 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.internal.util.json.JSONWriter;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
@@ -135,7 +138,7 @@ class ThirdAggregateAliPayServiceImplTest {
}
/**
* 验证 trade.create 使用 JSAPI_PAY、分转元、付款人 open_id、授权 Token 和统一过期时间。
* 验证 trade.create 使用 JSAPI_PAY、全额直付通结算、付款人 open_id 和授权 Token。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@@ -161,6 +164,7 @@ class ThirdAggregateAliPayServiceImplTest {
Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId());
Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"));
Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO"));
assertDirectPaySettlement(request, "12.34");
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
@@ -194,6 +198,61 @@ class ThirdAggregateAliPayServiceImplTest {
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token"));
assertDirectPaySettlement(request, "12.34");
}
/**
* 线下收款关闭抽佣时仍按一分钱交易全额结算,且不发送平台抽佣专属授权。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void offlinePaymentWithoutCommissionShouldKeepFullDirectSettlement() throws Exception {
context.setBizType(ThirdPayOrderBizTypeEnum.MCH_OFFLINE_PAY);
context.setBizId("OFFLINE:88:12345678-1234-4234-8234-123456789abc");
context.setAggregateScene("OFFLINE");
context.setAmount(1);
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000004");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity(null, "alipay-user-id"));
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradeCreateRequest request = requestCaptor.getValue();
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId());
Assertions.assertEquals("alipay-user-id", model.getBuyerId());
Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token"));
assertDirectPaySettlement(request, "0.01");
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals("OFFLINE", orderCaptor.getValue().getAggregateScene());
Assertions.assertEquals(1, orderCaptor.getValue().getPrice());
Assertions.assertEquals(YesNoEnum.NO.getCode(), orderCaptor.getValue().getEnableFz());
}
/**
* 校验 SDK 实际序列化的结算参数,确保结算金额等于交易全额且默认账户不携带 trans_in。
*
* @param request 已捕获且尚未实际发送的支付宝 SDK 请求
* @param amount 人民币元字符串,必须与 total_amount 及结算明细同时一致
*/
private void assertDirectPaySettlement(AlipayTradeCreateRequest request, String amount) {
JSONObject content = JSONUtil.parseObj(new JSONWriter().write(request.getBizModel(), true));
JSONObject settleInfo = content.getJSONObject("settle_info");
Assertions.assertEquals(amount, content.getStr("total_amount"));
Assertions.assertNotNull(settleInfo);
Assertions.assertEquals("1d", settleInfo.getStr("settle_period_time"));
Assertions.assertEquals(1, settleInfo.getJSONArray("settle_detail_infos").size());
JSONObject detail = settleInfo.getJSONArray("settle_detail_infos").getJSONObject(0);
Assertions.assertEquals("defaultSettle", detail.getStr("trans_in_type"));
Assertions.assertEquals(amount, detail.getStr("amount"));
Assertions.assertFalse(detail.containsKey("trans_in"));
}
/**