Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-07 11:49:38 +08:00
commit 27a7a293e1
9 files changed
+295 -43

No files matched your search

@@ -248,10 +248,13 @@ public class CanteenSpuController {
}
/**
* 获取菜品详情(含详情富文本、套餐组成)
* 查询收银终端使用的每日菜品详情,并补充富文本与套餐组成。
*
* @param id 每日菜品 SPU ID
* @return 每日菜品详情;商品不存在时返回失败响应
*/
@GetMapping("/ad/getCanteenSpuDetails")
public R getCanteenSpuDetails(@RequestParam("id") Long id) {
public R<ADCanteenSpuListVO> 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<ADCanteenSpuListVO> getAppCanteenSpuDetails(@RequestParam("id") Long id) {
return getCanteenSpuDetails(id);
}
@PostMapping("/ad/updateSkuStock")
@SysLog("更新菜品库存")
@@ -86,20 +86,20 @@ public class ThirdAggregateOrderPayController {
}
/**
* 接收支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。
* 接收固定地址的支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。
*
* <p>租户编码由服务端一次性 state 恢复,确保所有租户共用同一个支付宝授权回调白名单地址。</p>
*
* @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<String> alipayOauth(@NotBlank @PathVariable String tenantCode,
@NotBlank @RequestParam("auth_code") String authCode,
public ResponseEntity<String> 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);
@@ -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 {
* <p>调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文;
* Redis 键仍由公共基础设施自动附加租户前缀。</p>
*/
@Scheduled(fixedDelay = 5000L, initialDelay = 5000L)
@Scheduled(fixedDelay = PENDING_POLL_INTERVAL_MILLIS, initialDelay = PENDING_POLL_INTERVAL_MILLIS)
public void handlePendingSessions() {
List<ThirdAggregatePaySessionEntity> sessions = aggregatePaySessionService.list(
new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
@@ -68,4 +84,38 @@ public class ThirdAggregateOrderPayJob {
}
}
}
/**
* 每三十秒补偿处理已到期但子订单尚未确认关闭的聚合会话。
*
* <p>补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时,
* 服务端仍会独立完成有限次查单和关单,同时不会对一天前的未支付订单永久轮询。</p>
*/
@Scheduled(fixedDelay = CLOSE_COMPENSATION_INTERVAL_MILLIS, initialDelay = CLOSE_COMPENSATION_INTERVAL_MILLIS)
public void handleExpiredSessionCompensation() {
LocalDateTime now = LocalDateTime.now();
List<ThirdAggregatePaySessionEntity> sessions = aggregatePaySessionService.list(
new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.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);
}
}
}
}
@@ -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 {
}
/**
* 每五秒分批处理支付宝和微信云消费机订单码。
* 每五秒分批处理支付宝和微信云消费机订单码,最长处理到订单过期后十分钟。
*
* <p>初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker
* 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。</p>
*/
@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<ThirdPayOrderEntity> orders = thirdPayOrderService.list(new LambdaQueryWrapper<ThirdPayOrderEntity>()
.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));
@@ -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 聚合支付会话主键
*/
@@ -75,7 +75,7 @@ public interface ThirdAggregatePaySessionService extends IService<ThirdAggregate
boolean markCreateFailed(Long sessionId, String payNo);
/**
* 在会话尚未进入终态时原子写入指定终态。
* 原子写入指定终态;成功或关闭可纠正补偿窗口内先落库的过期状态。
*
* @param sessionId 聚合会话主键
* @param status 允许写入的成功、关闭或过期状态
@@ -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 = ".";
/**
* 支付宝撤销接口要求稍后重试的标记值。
*/
@@ -249,7 +260,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session == null) {
throw new RuntimeException("聚合支付会话不存在");
}
syncSessionFromChildOrder(session);
if (isActive(session) && isExpired(session)) {
processPendingSession(session.getId());
} else {
syncSessionFromChildOrder(session);
}
return buildStatusVo(aggregatePaySessionService.getById(session.getId()));
}
@@ -275,6 +290,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (session != null && isActive(session)) {
throw new RuntimeException("该业务订单已有待支付的聚合订单码");
}
assertExpiredChildOrderSettled(session);
}
/**
@@ -292,9 +308,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 +349,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);
@@ -371,14 +404,19 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 处理一条待确认聚合会话,平台网络异常或可重试结果保留当前状态供下轮处理。
* 处理一条待确认聚合会话或处于有限补偿窗口内的过期会话。
*
* <p>活动会话到期后即使第三方查询或关单异常,也会在本轮结束时推进为过期状态,退出五秒高频队列;
* 后续仅由服务端低频补偿任务在限定窗口内重试,不依赖客户端继续查询。</p>
*
* @param sessionId 聚合支付会话主键
*/
@Override
public void processPendingSession(Long sessionId) {
ThirdAggregatePaySessionEntity session = aggregatePaySessionService.getById(sessionId);
if (session == null || !isActive(session)) {
boolean expiredCompensation = session != null && ObjectUtil.equals(
session.getStatus(), ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
if (session == null || (!isActive(session) && !expiredCompensation)) {
return;
}
if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())) {
@@ -395,8 +433,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(session.getPayNo());
if (payOrder == null) {
if (isExpired(session)) {
aggregatePaySessionService.markCreateFailed(session.getId(), session.getPayNo());
if (!expiredCompensation && isExpired(session)) {
aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
}
return;
}
@@ -404,10 +442,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
return;
}
if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
processAlipayOrder(session, payOrder);
} else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
processWechatOrder(session, payOrder);
boolean reachedExpireTime = isExpired(session);
try {
if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
processAlipayOrder(session, payOrder);
} else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
processWechatOrder(session, payOrder);
}
} finally {
if (!expiredCompensation && reachedExpireTime) {
aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
}
}
}
@@ -455,6 +500,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
throw new RuntimeException("上一笔聚合订单正在确认关闭,请稍后重试");
}
}
assertExpiredChildOrderSettled(latest);
assertNoBlockingNativeOrder(dto);
MchManageVo merchant = getMerchant(dto.getMchId());
@@ -621,6 +667,40 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
aggregatePaySessionService.syncFromPayOrder(thirdPayOrderService.getByPayNo(session.getPayNo()));
}
/**
* 新建支付订单前确认已过期会话绑定的第三方子订单不再处于可支付状态。
*
* <p>后台补偿超过十分钟后会停止,但创建新订单时仍会同步执行一次查单和关单;只有旧子订单已经成功、
* 关闭、失败或不存在时才允许继续,避免旧单与新单同时可支付。</p>
*
* @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("支付宝授权状态格式错误,请重新扫码");
}
}
/**
* 校验聚合会话可继续扫码,并阻止另一平台在首个平台已选定后继续授权。
*
@@ -153,7 +153,10 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
}
/**
* 将尚未进入终态的会话推进为成功、关闭或过期状态。
* 将会话推进为成功、关闭或过期状态。
*
* <p>过期只允许从活动状态写入;支付成功或平台关闭还允许覆盖过期状态,用于接收关单补偿期间
* 查询到的真实平台终态,但不会覆盖已经确认的成功或关闭结果。</p>
*
* @param sessionId 聚合会话主键
* @param status 目标终态
@@ -170,16 +173,23 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(status.getType());
update.setResolvedTime(LocalDateTime.now());
List<String> 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<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.in(ThirdAggregatePaySessionEntity::getStatus,
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType()));
.in(ThirdAggregatePaySessionEntity::getStatus, sourceStatuses));
}
/**
* 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖既有终态。
* 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖已确认的成功或关闭终态。
*
* @param payOrder 最新第三方支付子订单
*/
@@ -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。
*