diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java index 217004474..8dca08bcb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdAggregateOrderPayController.java @@ -86,20 +86,20 @@ public class ThirdAggregateOrderPayController { } /** - * 接收支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。 + * 接收固定地址的支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。 + * + *

租户编码由服务端一次性 state 恢复,确保所有租户共用同一个支付宝授权回调白名单地址。

* - * @param tenantCode 聚合 URL 中的租户唯一编码 * @param authCode 支付宝一次性授权码 - * @param state 服务端创建的一次性 OAuth state + * @param state 包含租户编码且由服务端创建的一次性 OAuth state * @return 带 CSP nonce 的固定支付宝收银页 */ - @GetMapping(value = "/oauth/alipay/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE) + @GetMapping(value = "/oauth/alipay", produces = MediaType.TEXT_HTML_VALUE) @Inner(value = false) - public ResponseEntity alipayOauth(@NotBlank @PathVariable String tenantCode, - @NotBlank @RequestParam("auth_code") String authCode, + public ResponseEntity alipayOauth(@NotBlank @RequestParam("auth_code") String authCode, @NotBlank @RequestParam("state") String state) { try { - ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(tenantCode, authCode, state); + ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(authCode, state); return payPageResponse(pageData); } catch (RuntimeException exception) { return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java index 7de1944a5..0139a943f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregateOrderPayService.java @@ -57,14 +57,13 @@ public interface ThirdAggregateOrderPayService { ThirdAggregateCashierPageData handleWechatOauth(String tenantCode, String authCode, String state); /** - * 处理支付宝 OAuth 回调并准备 AlipayJSBridge 收银页。 + * 处理固定地址的支付宝 OAuth 回调,并从一次性 state 恢复租户上下文。 * - * @param tenantCode 聚合 URL 中的租户唯一编码 * @param authCode 支付宝一次性授权码 - * @param state 服务端创建的一次性 OAuth state + * @param state 包含租户编码且由服务端创建的一次性 OAuth state * @return 支付宝 JSBridge 调起数据和短期状态查询地址 */ - ThirdAggregateCashierPageData handleAlipayOauth(String tenantCode, String authCode, String state); + ThirdAggregateCashierPageData handleAlipayOauth(String authCode, String state); /** * 使用短期 ticket 查询当前租户聚合支付状态。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java index 38e4525de..aba46850f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImpl.java @@ -55,6 +55,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -151,6 +152,16 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay */ private static final String WECHAT_OAUTH_SCOPE = "snsapi_base"; + /** + * 支付宝固定 OAuth 回调路径,避免租户编码进入回调 URL 后要求逐租户配置平台白名单。 + */ + private static final String ALIPAY_OAUTH_CALLBACK_PATH = "/aggregate-order-pay/oauth/alipay"; + + /** + * 支付宝 OAuth state 中租户编码与随机 state 的分隔符。 + */ + private static final String OAUTH_STATE_TENANT_SEPARATOR = "."; + /** * 支付宝撤销接口要求稍后重试的标记值。 */ @@ -292,9 +303,13 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay AppPlusPlatformTypeEnum platform = resolveClientPlatform(userAgent); ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getByAccessTokenHash(sha256(token)); validateCashierSession(session, platform); - String state = createOauthState(session.getId(), platform); - String callbackUrl = buildPublicUrl(getPlatformBaseUrl(platform), "/aggregate-order-pay/oauth/" - + platformPath(platform) + "/" + tenantCode); + String storedState = createOauthState(session.getId(), platform); + String state = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY) + ? buildAlipayOauthState(tenantCode, storedState) : storedState; + String callbackPath = ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY) + ? ALIPAY_OAUTH_CALLBACK_PATH + : "/aggregate-order-pay/oauth/" + platformPath(platform) + "/" + tenantCode; + String callbackUrl = buildPublicUrl(getPlatformBaseUrl(platform), callbackPath); if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.WECHAT)) { return WECHAT_OAUTH_URL + "?appid=" + encode(appPlusPayConfig.getWechatConfig().getGzhAppId()) + "&redirect_uri=" + encode(callbackUrl) @@ -329,18 +344,31 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay } /** - * 消费一次性支付宝 OAuth state,换取付款人身份并创建或复用 JSAPI 子订单。 + * 处理固定支付宝 OAuth 回调,从一次性 state 中恢复租户编码后进入对应租户上下文。 * - * @param tenantCode 聚合 URL 中的租户唯一编码 * @param authCode 支付宝一次性授权码 - * @param state 服务端创建的一次性 OAuth state + * @param state 包含租户编码和 Redis 一次性随机值的 OAuth state * @return 支付宝 JSBridge 调起数据和短期状态查询地址 */ @Override - public ThirdAggregateCashierPageData handleAlipayOauth(String tenantCode, String authCode, String state) { + public ThirdAggregateCashierPageData handleAlipayOauth(String authCode, String state) { + String[] stateParts = parseAlipayOauthState(state); + return handleAlipayOauthInTenant(stateParts[0], authCode, stateParts[1]); + } + + /** + * 在指定租户上下文中消费支付宝 OAuth state 并创建或复用 JSAPI 子订单。 + * + * @param tenantCode 已由固定回调 state 确认的租户唯一编码 + * @param authCode 支付宝一次性授权码 + * @param storedState Redis 中保存的一次性随机 state + * @return 支付宝 JSBridge 调起数据和短期状态查询地址 + */ + private ThirdAggregateCashierPageData handleAlipayOauthInTenant(String tenantCode, String authCode, + String storedState) { SysTenant tenant = resolveTenant(tenantCode); return TenantBroker.applyAs(tenant.getId(), ignored -> { - ThirdAggregatePaySessionEntity session = consumeOauthState(state, AppPlusPlatformTypeEnum.ALI_PAY); + ThirdAggregatePaySessionEntity session = consumeOauthState(storedState, AppPlusPlatformTypeEnum.ALI_PAY); ThirdAggregateAlipayOauthIdentity identity = aggregateAliPayService.exchangeOauthCode(authCode); ThirdAggregateCashierLaunch launch = createOrReuseChildOrder(session, AppPlusPlatformTypeEnum.ALI_PAY, identity.getStableIdentity(), identity); @@ -766,6 +794,49 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay return state; } + /** + * 将租户编码绑定到支付宝 OAuth state,租户编码仅用于回调恢复上下文,随机部分仍由 Redis 一次性消费。 + * + * @param tenantCode 聚合二维码中的租户唯一编码 + * @param storedState 已保存到当前租户 Redis 命名空间的一次性随机 state + * @return 可安全放入 OAuth URL 的租户绑定 state + */ + private String buildAlipayOauthState(String tenantCode, String storedState) { + if (StrUtil.isBlank(tenantCode) || StrUtil.isBlank(storedState)) { + throw new RuntimeException("支付宝授权状态生成失败"); + } + String encodedTenantCode = Base64.getUrlEncoder().withoutPadding() + .encodeToString(tenantCode.getBytes(StandardCharsets.UTF_8)); + return encodedTenantCode + OAUTH_STATE_TENANT_SEPARATOR + storedState; + } + + /** + * 解析固定支付宝回调携带的租户绑定 state,并拒绝缺段、非法编码或空值。 + * + * @param state 支付宝原样回传的 OAuth state + * @return 长度为二的数组,依次为租户唯一编码和 Redis 一次性随机 state + */ + private String[] parseAlipayOauthState(String state) { + if (StrUtil.isBlank(state)) { + throw new RuntimeException("支付授权状态不能为空"); + } + int separatorIndex = state.indexOf(OAUTH_STATE_TENANT_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex >= state.length() - OAUTH_STATE_TENANT_SEPARATOR.length()) { + throw new RuntimeException("支付宝授权状态格式错误,请重新扫码"); + } + String encodedTenantCode = state.substring(0, separatorIndex); + String storedState = state.substring(separatorIndex + OAUTH_STATE_TENANT_SEPARATOR.length()); + try { + String tenantCode = new String(Base64.getUrlDecoder().decode(encodedTenantCode), StandardCharsets.UTF_8); + if (StrUtil.isBlank(tenantCode) || StrUtil.isBlank(storedState)) { + throw new RuntimeException("支付宝授权状态格式错误,请重新扫码"); + } + return new String[]{tenantCode, storedState}; + } catch (IllegalArgumentException exception) { + throw new RuntimeException("支付宝授权状态格式错误,请重新扫码"); + } + } + /** * 校验聚合会话可继续扫码,并阻止另一平台在首个平台已选定后继续授权。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index 8f5273d89..c9e0ec362 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -207,6 +207,21 @@ class ThirdAggregateOrderPayServiceImplTest { verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class)); } + /** + * 验证支付宝扫码授权统一使用不含租户编码的固定回调地址,租户信息由一次性 state 承载。 + */ + @Test + void alipayOauthShouldUseFixedCallbackUrl() { + ThirdAggregatePaySessionEntity session = reusableSession(); + when(aggregatePaySessionService.getByAccessTokenHash(anyString())).thenReturn(session); + + String oauthUrl = service.getCashierOauthUrl("tenant-code", "cashier-access-token", "AlipayClient/10.6.80"); + + Assertions.assertTrue(oauthUrl.contains("redirect_uri=https%3A%2F%2Fpay.example.com%2Fthird%2Faggregate-order-pay%2Foauth%2Falipay")); + Assertions.assertFalse(oauthUrl.contains("oauth%2Falipay%2Ftenant-code")); + Assertions.assertTrue(oauthUrl.contains("state=dGVuYW50LWNvZGU.")); + } + /** * 构建支付宝和微信全局应用配置,公网地址固定使用 HTTPS。 *