feat(gateway): 聚合锁定前置提示 + 收银台锁定项标记 + DIRECT 幂等修复

聚合 meta 进入时检测订单已锁定且当前环境 method 不匹配直接抛 channelLocked; 收银台 items 返回 locked 标记供前端禁用切换; 修复 DIRECT 模式 item.method 为空时 handle 锁定校验误判切换导致幂等返回失效
This commit is contained in:
DaxPay Dev
2026-07-20 23:00:00 +08:00
parent 1374a6c7a4
commit d2d08b4cc6
4 changed files with 44 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum;
import cn.daxpay.open.payment.merchant.service.gateway.ClientEnvPayResolveService;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.unipay.param.gateway.AggregateQrPayParam;
@@ -52,6 +53,15 @@ public class AggregatePayService {
ClientRuntimeEnum runtime = ClientRuntimeEnum.ofOrDefault(runtimeCode);
var resolved = clientEnvPayResolveService.resolveRequired(order.getAppId(), clientEnv, runtime);
// 订单已发起支付(支付中)时, 支付方式已锁定到容器: 当前环境解析出的 method 与锁定值不一致视为换端, 提前拒绝
// 与 GatewayPayHandleService#handle 的 method 比较语义一致(聚合 product 传 null, 实际只比 method)
if (Objects.equals(order.getStatus(), GatewayOrderStatusEnum.PAYING.getCode())
&& StrUtil.isNotBlank(order.getMethod())
&& !Objects.equals(order.getMethod(), resolved.method())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.channelLocked");
}
GatewayAggregateConfig config = aggregateConfigManager.findByAppId(order.getAppId())
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.aggregateConfigMissing"));

View File

@@ -7,6 +7,7 @@ import cn.daxpay.open.payment.merchant.enums.CashierItemResolveModeEnum;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.GatewayCashierTypeEnum;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.payment.trade.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.unipay.param.gateway.CashierPayParam;
@@ -62,12 +63,33 @@ public class CashierPayService {
GatewayCashierTypeEnum typeEnum = GatewayCashierTypeEnum.findByCode(cashierType);
String bucketClientEnv = normalizeClientEnvForBucket(typeEnum, clientEnv);
ClientEnvEnum envEnum = StrUtil.isBlank(bucketClientEnv) ? null : ClientEnvEnum.findByCode(bucketClientEnv);
// 订单已发起支付(支付中)且 method 有值时, 支付方式已锁定, 需标记匹配的支付项供前端禁用切换
boolean orderLocked = Objects.equals(order.getStatus(), GatewayOrderStatusEnum.PAYING.getCode())
&& StrUtil.isNotBlank(order.getMethod());
return gatewayCashierItemManager.listByAppAndBucket(order.getAppId(), typeEnum.getCode(), bucketClientEnv)
.stream()
.map(item -> this.toPublicResult(item, envEnum))
.map(item -> this.toPublicResult(item, envEnum)
// 命中锁定的支付项置 locked=true, 前端据此自动选中并禁用其他项
.setLocked(orderLocked && this.isLockedItem(item, order)))
.toList();
}
/// 判断收银台支付项是否为订单已锁定的支付方式
///
/// - METHOD 模式: 比较支付方式编码
/// - DIRECT 模式: 比较通道商户号 + 支付能力(均为路由回填到容器的权威值)
private boolean isLockedItem(GatewayCashierItem item, GatewayPayOrder order) {
CashierItemResolveModeEnum resolveMode = CashierItemResolveModeEnum.findByCode(item.getResolveMode());
if (resolveMode == CashierItemResolveModeEnum.METHOD) {
return Objects.equals(item.getMethod(), order.getMethod());
}
if (resolveMode == CashierItemResolveModeEnum.DIRECT) {
return Objects.equals(item.getChannelMchNo(), order.getChannelMchNo())
&& Objects.equals(item.getCapability(), order.getCapability());
}
return false;
}
/// 收银台发起支付
public NormalPayResult pay(CashierPayParam param) {
GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(param.getOrderNo());

View File

@@ -108,8 +108,14 @@ public class GatewayPayHandleService {
// 通道/方式不一致则拒绝换端(product/method 在容器上)
// 聚合支付 product 传 null(由路由解析填充) 是合法设计, 用容器已持久化值补位避免误判切换支付方式
String effectiveProduct = StrUtil.blankToDefault(product, current.getProduct());
// 收银台 DIRECT 模式 item.method 可能为空(由路由反推), 容器上的 method 是首次支付时路由反推后的值;
// 这里先反推对齐, 避免与容器已持久化值口径不一致而误判切换支付方式
String effectiveMethod = method;
if (StrUtil.isBlank(effectiveMethod) && StrUtil.isNotBlank(channelMchNo)) {
effectiveMethod = payRouteService.inferMethodForCapability(channelMchNo, capability);
}
if (!Objects.equals(current.getProduct(), effectiveProduct)
|| !Objects.equals(current.getMethod(), method)) {
|| !Objects.equals(current.getMethod(), effectiveMethod)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.channelLocked");
}

View File

@@ -30,4 +30,8 @@ public class CashierItemPublicResult {
@Schema(description = "是否需要先走 OAuth 获取 openId前端据此跳授权")
private Boolean needOpenId;
// 是否为订单已锁定的支付项(订单支付中且与已锁定的支付方式匹配)
@Schema(description = "是否为订单已锁定的支付项")
private Boolean locked;
}