feat:支付宝H5直付通支付适配
This commit is contained in:
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
@@ -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),不得只靠修改历史迁移升级现有库。
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+8
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -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 {
|
||||
|
||||
+60
-1
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user