diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java index 75a03910b..0f7adc135 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java @@ -248,10 +248,13 @@ public class CanteenSpuController { } /** - * 获取菜品详情(含详情富文本、套餐组成) + * 查询收银终端使用的每日菜品详情,并补充富文本与套餐组成。 + * + * @param id 每日菜品 SPU ID + * @return 每日菜品详情;商品不存在时返回失败响应 */ @GetMapping("/ad/getCanteenSpuDetails") - public R getCanteenSpuDetails(@RequestParam("id") Long id) { + public R getCanteenSpuDetails(@RequestParam("id") Long id) { CanteenSpuEntity detail = canteenSpuService.getDetail(id); if (detail == null) { return R.failed("商品不存在"); @@ -271,6 +274,19 @@ public class CanteenSpuController { return R.ok(vo); } + /** + * 按每日菜品 SPU ID 查询 App 商品详情。 + * + * @param id 套餐组成返回的 childSpuId,关联 canteen_spu 主键 + * @return App 可展示的每日菜品详情;商品不存在时返回失败响应 + */ + @ApiCryptoAes + @Operation(summary = "查询每日菜品详情", description = "通过每日菜品 SPU ID 查询富文本和套餐组成") + @GetMapping("/app/getCanteenSpuDetails") + public R getAppCanteenSpuDetails(@RequestParam("id") Long id) { + return getCanteenSpuDetails(id); + } + @PostMapping("/ad/updateSkuStock") @SysLog("更新菜品库存") 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/job/ThirdAggregateOrderPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java index b27ed93b5..047c17608 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAggregateOrderPayJob.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; import java.util.List; /** @@ -27,6 +28,21 @@ public class ThirdAggregateOrderPayJob { */ private static final int BATCH_SIZE = 100; + /** + * 未到期聚合会话的服务端查单间隔,单位毫秒。 + */ + private static final long PENDING_POLL_INTERVAL_MILLIS = 5000L; + + /** + * 到期聚合子订单的服务端关单补偿间隔,单位毫秒,避免继续占用五秒高频任务。 + */ + private static final long CLOSE_COMPENSATION_INTERVAL_MILLIS = 30000L; + + /** + * 到期后的关单补偿窗口,单位分钟;超过该窗口不再后台调用第三方支付平台。 + */ + private static final long CLOSE_COMPENSATION_MINUTES = 10L; + /** * 聚合会话服务,用于跨租户分批读取仍可能支付的会话。 */ @@ -43,7 +59,7 @@ public class ThirdAggregateOrderPayJob { *

调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文; * Redis 键仍由公共基础设施自动附加租户前缀。

*/ - @Scheduled(fixedDelay = 5000L, initialDelay = 5000L) + @Scheduled(fixedDelay = PENDING_POLL_INTERVAL_MILLIS, initialDelay = PENDING_POLL_INTERVAL_MILLIS) public void handlePendingSessions() { List sessions = aggregatePaySessionService.list( new LambdaQueryWrapper() @@ -68,4 +84,38 @@ public class ThirdAggregateOrderPayJob { } } } + + /** + * 每三十秒补偿处理已到期但子订单尚未确认关闭的聚合会话。 + * + *

补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时, + * 服务端仍会独立完成有限次查单和关单,同时不会对一天前的未支付订单永久轮询。

+ */ + @Scheduled(fixedDelay = CLOSE_COMPENSATION_INTERVAL_MILLIS, initialDelay = CLOSE_COMPENSATION_INTERVAL_MILLIS) + public void handleExpiredSessionCompensation() { + LocalDateTime now = LocalDateTime.now(); + List sessions = aggregatePaySessionService.list( + new LambdaQueryWrapper() + .eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()) + .isNotNull(ThirdAggregatePaySessionEntity::getPayNo) + .isNotNull(ThirdAggregatePaySessionEntity::getExpireTime) + .between(ThirdAggregatePaySessionEntity::getExpireTime, + now.minusMinutes(CLOSE_COMPENSATION_MINUTES), now) + .orderByDesc(ThirdAggregatePaySessionEntity::getExpireTime) + .last("limit " + BATCH_SIZE)); + if (ObjectUtil.isEmpty(sessions)) { + return; + } + for (ThirdAggregatePaySessionEntity session : sessions) { + if (session.getTenantId() == null) { + log.warn("到期聚合支付会话缺少持久化租户ID,sessionId:{}", session.getId()); + continue; + } + try { + TenantBroker.runAs(session.getTenantId(), tenantId -> aggregateOrderPayService.processPendingSession(session.getId())); + } catch (Exception exception) { + log.error("补偿关闭聚合支付会话异常,sessionId:{},tenantId:{}", session.getId(), session.getTenantId(), exception); + } + } + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java index 491d5c74b..43e9b2dbe 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/job/ThirdAppPlusPayJob.java @@ -32,6 +32,16 @@ public class ThirdAppPlusPayJob { */ private static final int ORDER_CODE_BATCH_SIZE = 100; + /** + * 原生订单码的服务端查单间隔,单位毫秒。 + */ + private static final long ORDER_CODE_POLL_INTERVAL_MILLIS = 5000L; + + /** + * 原生订单码到期后的关单补偿窗口,单位分钟;超过窗口后停止后台查询。 + */ + private static final long ORDER_CODE_CLOSE_COMPENSATION_MINUTES = 10L; + /** * 支付订单服务,用于分批读取仍未支付的订单码记录。 */ @@ -66,19 +76,21 @@ public class ThirdAppPlusPayJob { } /** - * 每五秒分批处理支付宝和微信云消费机订单码。 + * 每五秒分批处理支付宝和微信云消费机订单码,最长处理到订单过期后十分钟。 * *

初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker * 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。

*/ - @Scheduled(fixedDelay = 5000L, initialDelay = 5000L) + @Scheduled(fixedDelay = ORDER_CODE_POLL_INTERVAL_MILLIS, initialDelay = ORDER_CODE_POLL_INTERVAL_MILLIS) public void handleOrderCodeOrders() { + LocalDateTime earliestExpireTime = LocalDateTime.now().minusMinutes(ORDER_CODE_CLOSE_COMPENSATION_MINUTES); List orders = thirdPayOrderService.list(new LambdaQueryWrapper() .in(ThirdPayOrderEntity::getPayPlatformType, AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType()) .eq(ThirdPayOrderEntity::getBizType, ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType()) .eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()) .isNotNull(ThirdPayOrderEntity::getExpireTime) + .ge(ThirdPayOrderEntity::getExpireTime, earliestExpireTime) .notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'") .orderByAsc(ThirdPayOrderEntity::getCreateTime) .last("limit " + ORDER_CODE_BATCH_SIZE)); 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..f031b4bd5 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 查询当前租户聚合支付状态。 @@ -76,7 +75,7 @@ public interface ThirdAggregateOrderPayService { ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket); /** - * 查询、关闭并同步一条待处理聚合会话,供定时任务和创建互斥逻辑复用。 + * 查询、关闭并同步一条活动会话或处于有限关单补偿窗口内的过期会话。 * * @param sessionId 聚合支付会话主键 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java index 6d93aef40..1d474c61a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdAggregatePaySessionService.java @@ -75,7 +75,7 @@ public interface ThirdAggregatePaySessionService extends IService后台补偿超过十分钟后会停止,但创建新订单时仍会同步执行一次查单和关单;只有旧子订单已经成功、 + * 关闭、失败或不存在时才允许继续,避免旧单与新单同时可支付。

+ * + * @param session 同业务最新聚合会话,允许为空或非过期状态 + */ + private void assertExpiredChildOrderSettled(ThirdAggregatePaySessionEntity session) { + if (session == null || ObjectUtil.notEqual( + session.getStatus(), ThirdAggregatePaySessionStatusEnum.EXPIRED.getType())) { + return; + } + try { + processPendingSession(session.getId()); + } catch (Exception exception) { + log.warn("|{}|创建新订单前确认过期子订单失败,sessionId:{}", TAG, session.getId()); + } + ThirdAggregatePaySessionEntity latestSession = aggregatePaySessionService.getById(session.getId()); + ThirdPayOrderEntity payOrder = StrUtil.isBlank(session.getPayNo()) + ? null : thirdPayOrderService.getByPayNo(session.getPayNo()); + if (payOrder != null) { + aggregatePaySessionService.syncFromPayOrder(payOrder); + } + if ((latestSession != null && ObjectUtil.equals(latestSession.getStatus(), + ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())) + || (payOrder != null && ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()))) { + throw new RuntimeException("该业务订单已支付,请勿重复下单"); + } + if (payOrder != null && ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) { + throw new RuntimeException("上一笔聚合订单尚未确认关闭,请稍后重试"); + } + } + /** * 将未支付第三方子订单原子推进为支付成功并记录平台交易号。 * @@ -766,6 +846,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/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java index c7d4bd1e5..9e39989a2 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdAggregatePaySessionServiceImpl.java @@ -153,7 +153,10 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl过期只允许从活动状态写入;支付成功或平台关闭还允许覆盖过期状态,用于接收关单补偿期间 + * 查询到的真实平台终态,但不会覆盖已经确认的成功或关闭结果。

* * @param sessionId 聚合会话主键 * @param status 目标终态 @@ -170,16 +173,23 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl sourceStatuses = ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED) + ? List.of( + ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType()) + : List.of( + ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), + ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), + ThirdAggregatePaySessionStatusEnum.PAYING.getType(), + ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()); return update(update, new LambdaUpdateWrapper() .eq(ThirdAggregatePaySessionEntity::getId, sessionId) - .in(ThirdAggregatePaySessionEntity::getStatus, - ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), - ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(), - ThirdAggregatePaySessionStatusEnum.PAYING.getType())); + .in(ThirdAggregatePaySessionEntity::getStatus, sourceStatuses)); } /** - * 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖既有终态。 + * 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖已确认的成功或关闭终态。 * * @param payOrder 最新第三方支付子订单 */ 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..effef4c5d 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 @@ -8,8 +8,10 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; +import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; @@ -129,8 +131,8 @@ class ThirdAggregateOrderPayServiceImplTest { aggregateWechatPayService, thirdPayOrderService, remoteMchService, remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor); - when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); SysTenant tenant = new SysTenant(); tenant.setId(1L); @@ -207,6 +209,46 @@ 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.")); + } + + /** + * 验证第三方查单异常不能让已到期会话永久停留在五秒高频轮询队列。 + */ + @Test + void expiredSessionShouldBecomeExpiredWhenAlipayQueryFails() { + ThirdAggregatePaySessionEntity session = reusableSession(); + session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType()); + session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + session.setPayNo("aggregate-child-pay-no"); + session.setExpireTime(LocalDateTime.now().minusSeconds(1)); + ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity(); + payOrder.setId(101L); + payOrder.setPayNo(session.getPayNo()); + payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType()); + payOrder.setMchPaySettingJson("{}"); + when(aggregatePaySessionService.getById(session.getId())).thenReturn(session); + when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder); + when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) + .thenThrow(new RuntimeException("mock network error")); + + Assertions.assertThrows(RuntimeException.class, () -> service.processPendingSession(session.getId())); + + verify(aggregatePaySessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED); + } + /** * 构建支付宝和微信全局应用配置,公网地址固定使用 HTTPS。 *