fix(ums): 修复时间解析必崩(OffsetDateTime缺时区) + billDate透传 + 移除直连商户实体

This commit is contained in:
DaxPay Dev
2026-07-06 12:53:33 +08:00
parent 285ddbd6a7
commit b97a5a4cab
21 changed files with 175 additions and 264 deletions

View File

@@ -4,6 +4,8 @@ import cn.daxpay.open.channel.ums.client.credential.UmsSdkCredential;
import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import lombok.Data;
import java.time.OffsetDateTime;
/// # 银联商务通道退款请求
@Data
public class UmsRefundReq {
@@ -11,8 +13,8 @@ public class UmsRefundReq {
/// 原商户订单号
private String outTradeNo;
/// 原订单日期(yyyy-MM-dd, 扫码退款必填)
private String billDate;
/// 原订单创建时间(UTC), 由子应用按通道时区转换为银联商务 billDate(yyyy-MM-dd)
private OffsetDateTime billDate;
/// 退款单号
private String outRefundNo;

View File

@@ -4,6 +4,8 @@ import cn.daxpay.open.channel.ums.client.credential.UmsSdkCredential;
import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import lombok.Data;
import java.time.OffsetDateTime;
/// # 银联商务通道退款同步请求
@Data
public class UmsRefundSyncReq {
@@ -14,8 +16,8 @@ public class UmsRefundSyncReq {
/// 原商户订单号
private String outTradeNo;
/// 原订单日期(yyyy-MM-dd, 扫码退款查询必填)
private String billDate;
/// 原订单创建时间(UTC), 由子应用按通道时区转换为银联商务 billDate(yyyy-MM-dd)
private OffsetDateTime billDate;
/// 支付方式(区分扫码/H5 退款查询)
private UmsPayMethod method;

View File

@@ -4,6 +4,8 @@ import cn.daxpay.open.channel.ums.client.credential.UmsSdkCredential;
import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import lombok.Data;
import java.time.OffsetDateTime;
/// # 银联商务通道支付同步请求
@Data
public class UmsSyncReq {
@@ -11,8 +13,8 @@ public class UmsSyncReq {
/// 商户订单号
private String outTradeNo;
/// 订单日期(yyyy-MM-dd, 扫码查询必填)
private String billDate;
/// 订单创建时间(UTC), 由子应用按通道时区转换为银联商务 billDate(yyyy-MM-dd)
private OffsetDateTime billDate;
/// 支付方式(区分扫码/H5 查询)
private UmsPayMethod method;

View File

@@ -2,7 +2,6 @@ package cn.daxpay.open.channel.ums.controller.direct;
import cn.daxpay.open.channel.ums.param.direct.UmsDirectChannelMerchantCreateParam;
import cn.daxpay.open.channel.ums.param.direct.UmsDirectKeyConfigParam;
import cn.daxpay.open.channel.ums.result.direct.UmsDirectChannelMerchantResult;
import cn.daxpay.open.channel.ums.result.direct.UmsDirectKeyConfigResult;
import cn.daxpay.open.channel.ums.service.direct.UmsDirectChannelMerchantService;
import cn.daxpay.open.channel.ums.service.direct.UmsDirectKeyConfigService;
@@ -22,8 +21,8 @@ import org.springframework.web.bind.annotation.RestController;
/// # 银联商务直连通道商户管理
///
/// 提供通道商户创建、查询和密钥配置管理。
/// 银联商务签名无证书, 密钥配置为 umsAppId/appKey/secretKey 三件套
/// 提供通道商户创建和密钥配置管理。
/// 商户身份(mid)创建时录入, 应用ID/终端号(tid)/应用密钥/通讯密钥由密钥配置维护
@PermCode(menuCode = "channel:merchant")
@Validated
@Tag(name = "银联商务直连通道商户管理")
@@ -35,14 +34,6 @@ public class UmsDirectChannelMerchantController {
private final UmsDirectChannelMerchantService umsDirectChannelMerchantService;
private final UmsDirectKeyConfigService umsDirectKeyConfigService;
@PermCode(code = "view", nameCn = "通道商户查看", nameEn = "Channel Merchant View")
@Operation(summary = "根据通道商户号查询银联商务直连通道商户配置")
@GetMapping("/find-by-channel-mch-no")
public Result<UmsDirectChannelMerchantResult> findByChannelMchNo(
@NotBlank(message = "{validation.field.channelMerchantNo.notBlank}") String channelMchNo) {
return Res.ok(umsDirectChannelMerchantService.findByChannelMchNo(channelMchNo));
}
@PermCode(code = "manage", nameCn = "通道商户管理", nameEn = "Channel Merchant Manage")
@Operation(summary = "创建银联商务直连通道商户")
@PostMapping("/create")

View File

@@ -1,14 +0,0 @@
package cn.daxpay.open.channel.ums.convert.direct;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectChannelMerchant;
import cn.daxpay.open.channel.ums.result.direct.UmsDirectChannelMerchantResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 银联商务直连通道商户绑定转换
@Mapper
public interface UmsDirectChannelMerchantConvert {
UmsDirectChannelMerchantConvert CONVERT = Mappers.getMapper(UmsDirectChannelMerchantConvert.class);
UmsDirectChannelMerchantResult toResult(UmsDirectChannelMerchant entity);
}

View File

@@ -1,19 +0,0 @@
package cn.daxpay.open.channel.ums.dao.direct;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectChannelMerchant;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/// # 银联商务直连通道商户绑定
@Repository
public class UmsDirectChannelMerchantManager extends BaseManager<UmsDirectChannelMerchantMapper, UmsDirectChannelMerchant> {
/// 根据通道商户号查询
public Optional<UmsDirectChannelMerchant> findByChannelMchNo(String channelMchNo) {
return lambdaQuery()
.eq(UmsDirectChannelMerchant::getChannelMchNo, channelMchNo)
.oneOpt();
}
}

View File

@@ -1,10 +0,0 @@
package cn.daxpay.open.channel.ums.dao.direct;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectChannelMerchant;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 银联商务直连通道商户绑定
@Mapper
public interface UmsDirectChannelMerchantMapper extends MPJBaseMapper<UmsDirectChannelMerchant> {
}

View File

@@ -1,45 +0,0 @@
package cn.daxpay.open.channel.ums.entity.direct;
import cn.daxpay.open.channel.ums.convert.direct.UmsDirectChannelMerchantConvert;
import cn.daxpay.open.channel.ums.result.direct.UmsDirectChannelMerchantResult;
import cn.daxpay.open.payment.common.entity.merchant.MchBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 银联商务直连通道商户绑定
///
/// 一个银联商务商户号(merchantNo)对应一个 channelMchNo, 包含终端号与订单前缀。
/// 银联商务为聚合支付, 一个商户号同时支持支付宝/微信/银联的扫码与 H5。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName(value = "ums_direct_channel_merchant", autoResultMap = true)
public class UmsDirectChannelMerchant extends MchBaseEntity implements ToResult<UmsDirectChannelMerchantResult> {
/// 通道商户号(系统生成雪花号)
private String channelMchNo;
/// 所属支付产品
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 银联商务商户号(mid)
private String merchantNo;
/// 终端号(tid)
private String terminalNo;
/// 订单号前缀(用于生成关联订单号)
private String orderPrefix;
/// 是否沙箱环境
private boolean sandbox;
@Override
public UmsDirectChannelMerchantResult toResult() {
return UmsDirectChannelMerchantConvert.CONVERT.toResult(this);
}
}

View File

@@ -12,9 +12,10 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/// # 银联商务直连密钥配置
/// # 银联商务直连配置
///
/// 直连商户维度的密钥配置, 敏感字段(appKey/secretKey)加密存储。
/// 直连商户维度的通道配置, 含商户身份(mid/tid)与签名密钥, 敏感字段(appKey/secretKey)加密存储。
/// merchantNo(mid) 创建时录入不可修改, terminalNo(tid)/umsAppId/appKey/secretKey 由密钥配置维护。
/// 银联商务签名无需证书, 仅依赖 appKey(HmacSHA256) 与 secretKey(回调验签)。
@EqualsAndHashCode(callSuper = true)
@Data
@@ -26,6 +27,13 @@ public class UmsDirectKeyConfig extends MchBaseEntity implements ToResult<UmsDir
@TableField(updateStrategy = FieldStrategy.NEVER)
private String channelMchNo;
/// 银联商务商户号(mid, 创建时录入不可修改)
@TableField(updateStrategy = FieldStrategy.NEVER)
private String merchantNo;
/// 终端号(tid)
private String terminalNo;
/// 银联商务应用 AppId
private String umsAppId;

View File

@@ -7,8 +7,9 @@ import lombok.experimental.Accessors;
/// # 银联商务直连通道商户绑定创建参数
///
/// 银联商务在创建通道商户时需要同时配置密钥(umsAppId/appKey/secretKey),
/// 因为银联商务签名无证书, 密钥是支付调用的前置条件。
/// 创建时仅录入商户身份(merchantNo mid)与商户名称,
/// 应用ID(umsAppId)/终端号(tid)/应用密钥(appKey)/通讯密钥(secretKey)由密钥配置单独维护,
/// 沙箱环境由支付产品配置(pay_md_product_config.activeEnv)决定。
@Data
@Accessors(chain = true)
@Schema(title = "银联商务直连通道商户绑定创建参数")
@@ -22,32 +23,11 @@ public class UmsDirectChannelMerchantCreateParam {
@Schema(description = "通道商户名称")
private String channelMerchantName;
@NotBlank(message = "{validation.field.merchantNo.notBlank}")
@Schema(description = "银联商务商户号")
private String merchantNo;
@Schema(description = "终端号")
private String terminalNo;
@Schema(description = "订单号前缀")
private String orderPrefix;
@Schema(description = "是否沙箱环境")
private boolean sandbox;
@NotBlank(message = "{validation.field.product.notBlank}")
@Schema(description = "所属支付产品")
private String product;
@NotBlank(message = "{validation.field.umsAppId.notBlank}")
@Schema(description = "银联商务应用 AppId")
private String umsAppId;
@NotBlank(message = "{validation.field.appKey.notBlank}")
@Schema(description = "应用密钥(HmacSHA256 签名密钥)")
private String appKey;
@NotBlank(message = "{validation.field.secretKey.notBlank}")
@Schema(description = "通讯密钥(回调验签密钥)")
private String secretKey;
@NotBlank(message = "{validation.field.merchantNo.notBlank}")
@Schema(description = "银联商务商户号(mid)")
private String merchantNo;
}

View File

@@ -6,6 +6,9 @@ import lombok.Data;
import lombok.experimental.Accessors;
/// # 银联商务直连密钥配置保存参数
///
/// 以 channelMchNo(通道商户号) 作为唯一标识定位记录,
/// mchNo(平台商户号)/merchantNo(银联 mid) 为不可变身份字段, 创建时写入后永不可改, 不参与保存。
@Data
@Accessors(chain = true)
@Schema(title = "银联商务直连密钥配置保存参数")
@@ -15,9 +18,8 @@ public class UmsDirectKeyConfigParam {
@Schema(description = "通道商户号")
private String channelMchNo;
@NotBlank(message = "{validation.field.mchNo.notBlank}")
@Schema(description = "商户号")
private String mchNo;
@Schema(description = "终端号(tid)")
private String terminalNo;
@Schema(description = "银联商务应用 AppId")
private String umsAppId;

View File

@@ -1,28 +0,0 @@
package cn.daxpay.open.channel.ums.result.direct;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 银联商务直连通道商户绑定返回结果
@Data
@Accessors(chain = true)
public class UmsDirectChannelMerchantResult {
/// 通道商户号
private String channelMchNo;
/// 所属支付产品
private String product;
/// 银联商务商户号
private String merchantNo;
/// 终端号
private String terminalNo;
/// 订单号前缀
private String orderPrefix;
/// 是否沙箱环境
private boolean sandbox;
}

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.channel.ums.result.direct;
import cn.daxpay.open.platform.common.json.sensitive.SensitiveInfo;
import lombok.Data;
import lombok.experimental.Accessors;
@@ -11,13 +12,21 @@ public class UmsDirectKeyConfigResult {
/// 通道商户号
private String channelMchNo;
/// 银联商务商户号(mid)
private String merchantNo;
/// 终端号(tid)
private String terminalNo;
/// 银联商务应用 AppId
private String umsAppId;
/// 应用密钥(脱敏)
/// 应用密钥(脱敏返回, 保留前后各4位)
@SensitiveInfo(front = 4, end = 4)
private String appKey;
/// 通讯密钥(脱敏)
/// 通讯密钥(脱敏返回, 保留前后各4位)
@SensitiveInfo(front = 4, end = 4)
private String secretKey;
/// 应用密钥是否已配置

View File

@@ -6,6 +6,7 @@ import cn.daxpay.open.channel.ums.client.req.UmsCallbackParseReq;
import cn.daxpay.open.channel.ums.client.resp.UmsCallbackParseResp;
import cn.daxpay.open.channel.ums.code.UmsCode;
import cn.daxpay.open.channel.ums.service.direct.UmsDirectConfigAssembler;
import cn.daxpay.open.channel.ums.util.UmsDateUtil;
import cn.daxpay.open.payment.common.callback.CallbackData;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.core.trade.service.PayCallbackService;
@@ -19,8 +20,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@@ -40,8 +39,6 @@ public class UmsPayCallbackService {
private final UmsDirectConfigAssembler configAssembler;
private final PayCallbackService payCallbackService;
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/// 支付回调处理
public String payHandle(String mchNo, String channelMchNo, HttpServletRequest request) {
// 1. 提取回调参数(JSON body → Map<String,String>, 嵌套对象转 JSON 字符串)
@@ -103,10 +100,8 @@ public class UmsPayCallbackService {
data.setTradeNo(resp.getOutTradeNo());
// resp.targetOrderId 是第三方交易号
data.setOutTradeNo(resp.getTargetOrderId());
// 完成时间
if (StrUtil.isNotBlank(resp.getFinishTime())) {
data.setFinishTime(OffsetDateTime.parse(resp.getFinishTime(), TIME_FORMAT));
}
// 完成时间(银联商务返回东八区本地时间, 由 UmsDateUtil 解析为带偏移的 OffsetDateTime)
data.setFinishTime(UmsDateUtil.parseCst(resp.getFinishTime()));
// 交易状态映射
data.setTradeStatus(this.mapStatus(resp.getTradeStatus()));
return data;

View File

@@ -1,14 +1,11 @@
package cn.daxpay.open.channel.ums.service.direct;
import cn.daxpay.open.channel.ums.dao.direct.UmsDirectChannelMerchantManager;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectChannelMerchant;
import cn.daxpay.open.channel.ums.dao.direct.UmsDirectKeyConfigManager;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectKeyConfig;
import cn.daxpay.open.channel.ums.param.direct.UmsDirectChannelMerchantCreateParam;
import cn.daxpay.open.channel.ums.param.direct.UmsDirectKeyConfigParam;
import cn.daxpay.open.channel.ums.result.direct.UmsDirectChannelMerchantResult;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.platform.core.enums.channel.ChannelMerchantSourceEnum;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.hutool.core.util.IdUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,20 +14,22 @@ import org.springframework.transaction.annotation.Transactional;
/// # 银联商务直连通道商户管理
///
/// 管理通道商户绑定的创建/查询/删除。
/// 银联商务签名无证书, 创建时需同时写入密钥配置(umsAppId/appKey/secretKey)。
/// 管理通道商户绑定的创建/删除。
/// 商户身份(mid)在创建时录入直连配置表,
/// 应用ID(umsAppId)/终端号(tid)/应用密钥(appKey)/通讯密钥(secretKey)由密钥配置单独维护,
/// 沙箱环境运行时读取支付产品配置(pay_md_product_config.activeEnv)。
@Slf4j
@Service
@RequiredArgsConstructor
public class UmsDirectChannelMerchantService {
private final ChannelMerchantManager channelMerchantManager;
private final UmsDirectChannelMerchantManager umsDirectChannelMerchantManager;
private final UmsDirectKeyConfigService umsDirectKeyConfigService;
private final UmsDirectKeyConfigManager umsDirectKeyConfigManager;
/// 创建通道商户绑定
///
/// 同时写入通用通道商户主表银联商务直连绑定表和密钥配置表。
/// 同时写入通用通道商户主表银联商务直连配置表(含商户号 mid)
/// umsAppId/terminalNo/appKey/secretKey 由密钥配置单独维护。
@Transactional(rollbackFor = Exception.class)
public void create(UmsDirectChannelMerchantCreateParam param) {
// 生成通道商户号(雪花号)
@@ -44,40 +43,18 @@ public class UmsDirectChannelMerchantService {
channelMerchant.setSource(ChannelMerchantSourceEnum.MANUAL.getCode());
channelMerchant.setEnable(true);
channelMerchantManager.save(channelMerchant);
// 写银联商务直连绑定表
var entity = new UmsDirectChannelMerchant();
entity.setMchNo(param.getMchNo());
entity.setChannelMchNo(channelMchNo);
entity.setProduct(param.getProduct());
entity.setMerchantNo(param.getMerchantNo());
entity.setTerminalNo(param.getTerminalNo());
entity.setOrderPrefix(param.getOrderPrefix());
entity.setSandbox(param.isSandbox());
umsDirectChannelMerchantManager.save(entity);
// 同时保存密钥配置(umsAppId/appKey/secretKey)
var keyParam = new UmsDirectKeyConfigParam();
keyParam.setChannelMchNo(channelMchNo);
keyParam.setMchNo(param.getMchNo());
keyParam.setUmsAppId(param.getUmsAppId());
keyParam.setAppKey(param.getAppKey());
keyParam.setSecretKey(param.getSecretKey());
umsDirectKeyConfigService.save(keyParam);
// 写银联商务直连配置(仅商户身份 mid, umsAppId/terminalNo/appKey/secretKey 由密钥配置单独维护)
var keyConfig = new UmsDirectKeyConfig()
.setChannelMchNo(channelMchNo)
.setMerchantNo(param.getMerchantNo());
// mchNo 继承自 MchBaseEntity, 链式返回父类型, 单独赋值
keyConfig.setMchNo(param.getMchNo());
umsDirectKeyConfigManager.save(keyConfig);
}
/// 根据通道商户号查询银联商务直连通道商户配置
public UmsDirectChannelMerchantResult findByChannelMchNo(String channelMchNo) {
return umsDirectChannelMerchantManager.findByChannelMchNo(channelMchNo)
.map(UmsDirectChannelMerchant::toResult)
// 通道: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
}
/// 根据通道商户号删除(级联删除密钥)
/// 根据通道商户号删除(级联删除直连配置)
@Transactional(rollbackFor = Exception.class)
public void deleteByChannelMchNo(String channelMchNo) {
umsDirectKeyConfigService.deleteByChannelMchNo(channelMchNo);
umsDirectChannelMerchantManager.lambdaUpdate()
.eq(UmsDirectChannelMerchant::getChannelMchNo, channelMchNo)
.remove();
umsDirectKeyConfigManager.deleteByChannelMchNo(channelMchNo);
}
}

View File

@@ -1,9 +1,12 @@
package cn.daxpay.open.channel.ums.service.direct;
import cn.daxpay.open.channel.ums.client.credential.UmsSdkCredential;
import cn.daxpay.open.channel.ums.dao.direct.UmsDirectChannelMerchantManager;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectChannelMerchant;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectKeyConfig;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import cn.daxpay.open.payment.channel.entity.mch.ChannelMerchant;
import cn.daxpay.open.payment.masterdata.constants.product.dao.PayProductConfigManager;
import cn.daxpay.open.payment.masterdata.constants.product.entity.PayProductConfig;
import cn.daxpay.open.platform.core.enums.pay.config.PayEnvEnum;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -11,8 +14,10 @@ import org.springframework.stereotype.Service;
/// # 银联商务直连通道凭证组装器
///
/// 从进件实体([UmsDirectChannelMerchant] + [UmsDirectKeyConfig])读取
/// appId / appKey / 商户号 / 终端号 / 通讯密钥, 组装为下发给子应用的通道调用凭证 [UmsSdkCredential]。
/// 从通用通道商户主表([ChannelMerchant])读取 product, 从直连配置([UmsDirectKeyConfig])读取
/// 商户身份(mid/tid) 与签名密钥(appId/appKey/secretKey), 组装为下发给子应用的通道调用凭证 [UmsSdkCredential]。
///
/// 沙箱状态不挂在商户配置上, 运行时读取支付产品配置([PayProductConfig] 的 activeEnv)判断。
///
/// 银联商务为聚合支付, 不区分应用能力(capability 参数保留但不使用)。
@Slf4j
@@ -21,30 +26,38 @@ import org.springframework.stereotype.Service;
public class UmsDirectConfigAssembler {
private final UmsDirectKeyConfigService keyConfigService;
private final UmsDirectChannelMerchantManager channelMerchantManager;
private final ChannelMerchantManager channelMerchantManager;
private final PayProductConfigManager payProductConfigManager;
/// 组装直连商户的通道调用凭证(下发给子应用)
///
/// @param mchNo 商户号(兜底定位)
/// @param channelMchNo 通道商户号(定位密钥/商户绑定)
/// @param mchNo 商户号(定位通用通道商户主表)
/// @param channelMchNo 通道商户号(定位直连配置)
/// @param capability 支付能力编码(UMS 不使用, 保留对齐签名)
/// @return 银联商务 SDK 凭证
public UmsSdkCredential buildConfig(String mchNo, String channelMchNo, String capability) {
// 1. 读取通道商户绑定(获取商户号/终端号/沙箱标志)
UmsDirectChannelMerchant merchant = channelMerchantManager.findByChannelMchNo(channelMchNo)
.orElseThrow(() -> new DataNotExistException("error.channel.ums.mchNotFound"));
// 1. 通用通道商户主表(获取 product, 用于查支付产品配置的沙箱环境)
ChannelMerchant channelMerchant = channelMerchantManager.findByMchNoAndChannelMchNo(mchNo, channelMchNo)
// 通道: 通道商户配置不存在
.orElseThrow(() -> new DataNotExistException("error.payment.channel.channelMerchantNotExist"));
// 2. 读取密钥配置(appId/appKey/secretKey)
// 2. 直连配置(商户身份 mid/tid + 密钥 appId/appKey/secretKey)
UmsDirectKeyConfig keyConfig = keyConfigService.findByChannelMchNo(channelMchNo);
// 3. 组装凭证
// 3. 沙箱状态读取支付产品配置的生效环境(不挂在商户配置上)
boolean sandbox = payProductConfigManager.findByProduct(channelMerchant.getProduct())
.map(PayProductConfig::getActiveEnv)
.map(PayEnvEnum.SANDBOX.getCode()::equals)
.orElse(false);
// 4. 组装凭证
var credential = new UmsSdkCredential();
credential.setUmsAppId(keyConfig.getUmsAppId());
credential.setAppKey(keyConfig.getAppKey());
credential.setMerchantNo(merchant.getMerchantNo());
credential.setTerminalNo(merchant.getTerminalNo());
credential.setMerchantNo(keyConfig.getMerchantNo());
credential.setTerminalNo(keyConfig.getTerminalNo());
credential.setSecretKey(keyConfig.getSecretKey());
credential.setSandbox(merchant.isSandbox());
credential.setSandbox(sandbox);
return credential;
}
}

View File

@@ -1,10 +1,10 @@
package cn.daxpay.open.channel.ums.service.direct;
import cn.daxpay.open.channel.ums.convert.direct.UmsDirectKeyConfigConvert;
import cn.daxpay.open.channel.ums.dao.direct.UmsDirectChannelMerchantManager;
import cn.daxpay.open.channel.ums.dao.direct.UmsDirectKeyConfigManager;
import cn.daxpay.open.channel.ums.entity.direct.UmsDirectKeyConfig;
import cn.daxpay.open.channel.ums.param.direct.UmsDirectKeyConfigParam;
import cn.daxpay.open.payment.channel.dao.mch.ChannelMerchantManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -19,7 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
public class UmsDirectKeyConfigService {
private final UmsDirectKeyConfigManager umsDirectKeyConfigManager;
private final UmsDirectChannelMerchantManager umsDirectChannelMerchantManager;
private final ChannelMerchantManager channelMerchantManager;
/// 根据通道商户号查询密钥配置, 不存在则创建默认记录
@Transactional(rollbackFor = Exception.class)
@@ -30,18 +30,20 @@ public class UmsDirectKeyConfigService {
}
var config = new UmsDirectKeyConfig()
.setChannelMchNo(channelMchNo);
// 查询通道商户记录填充商户号
umsDirectChannelMerchantManager.findByChannelMchNo(channelMchNo)
// 查询通用通道商户主表填充商户号
channelMerchantManager.findByChannelMchNo(channelMchNo)
.ifPresent(mch -> config.setMchNo(mch.getMchNo()));
umsDirectKeyConfigManager.save(config);
return config;
}
/// 保存密钥配置(更新)
///
/// 以 channelMchNo 定位记录, 仅更新可编辑字段(terminalNo/umsAppId/appKey/secretKey);
/// mchNo/merchantNo 为不可变身份字段(实体 FieldStrategy.NEVER), 由 findByChannelMchNo 从 DB 加载后保持不变。
@Transactional(rollbackFor = Exception.class)
public void save(UmsDirectKeyConfigParam param) {
var config = this.findByChannelMchNo(param.getChannelMchNo());
config.setMchNo(param.getMchNo());
UmsDirectKeyConfigConvert.CONVERT.copy(param, config);
umsDirectKeyConfigManager.updateById(config);
}

View File

@@ -6,19 +6,19 @@ import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import cn.daxpay.open.channel.ums.client.req.UmsRefundReq;
import cn.daxpay.open.channel.ums.client.resp.UmsRefundResp;
import cn.daxpay.open.channel.ums.code.UmsCode;
import cn.daxpay.open.channel.ums.util.UmsDateUtil;
import cn.daxpay.open.payment.common.enums.RefundOrderStatusEnum;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.core.trade.bo.RefundResultBo;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
/// # 银联商务退款业务服务
///
/// 通过 [UmsChannelClient] 调用子应用发起银联商务退款。
@@ -30,9 +30,7 @@ public class UmsRefundService {
private final UmsChannelClient umsChannelClient;
private final PlatformUrlConfigService platformUrlConfigService;
/// 银联商务退款时间格式
private static final DateTimeFormatter REFUND_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final PayTradeManager payTradeManager;
/// 执行银联商务退款
public RefundResultBo refund(PayRefundOrder refundOrder, UmsSdkCredential credential) {
@@ -46,6 +44,13 @@ public class UmsRefundService {
req.setMethod(UmsPayMethod.QRCODE);
req.setCredential(credential);
// 银联商务扫码退款需要 billDate(原订单创建日, 子应用按通道时区转换)
payTradeManager.findByTradeNo(refundOrder.getOrderNo())
.ifPresentOrElse(
t -> req.setBillDate(t.getCreateTime()),
() -> log.warn("银联商务退款未查到原交易({}), billDate 未填充, 银商可能拒绝",
refundOrder.getOrderNo()));
DaxResult<UmsRefundResp> result = umsChannelClient.refund(req);
if (result.getCode() != 0) {
log.error("银联商务通道退款失败: refundNo={}, msg={}", refundOrder.getRefundNo(), result.getMsg());
@@ -73,10 +78,8 @@ public class UmsRefundService {
private RefundResultBo toRefundResult(UmsRefundResp resp) {
RefundResultBo bo = new RefundResultBo();
bo.setOutRefundNo(resp.getOutRefundNo());
// 退款完成时间(yyyy-MM-dd HH:mm:ss → OffsetDateTime)
if (StrUtil.isNotBlank(resp.getFinishTime())) {
bo.setFinishTime(OffsetDateTime.parse(resp.getFinishTime(), REFUND_TIME_FORMAT));
}
// 退款完成时间(银联商务返回东八区本地时间, 由 UmsDateUtil 解析为带偏移的 OffsetDateTime)
bo.setFinishTime(UmsDateUtil.parseCst(resp.getFinishTime()));
// SUCCESS 退款即时成功; 其他状态需同步查询确认
if (UmsCode.REFUND_STATUS_SUCCESS.equals(resp.getRefundStatus())) {
bo.setComplete(true)

View File

@@ -6,18 +6,18 @@ import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import cn.daxpay.open.channel.ums.client.req.UmsRefundSyncReq;
import cn.daxpay.open.channel.ums.client.resp.UmsRefundSyncResp;
import cn.daxpay.open.channel.ums.code.UmsCode;
import cn.daxpay.open.channel.ums.util.UmsDateUtil;
import cn.daxpay.open.payment.common.enums.RefundOrderStatusEnum;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.core.trade.bo.RefundResultBo;
import cn.daxpay.open.payment.core.trade.dao.PayTradeManager;
import cn.daxpay.open.payment.core.trade.entity.PayRefundOrder;
import cn.daxpay.open.payment.core.trade.entity.PayTrade;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
/// # 银联商务退款同步业务服务
///
/// 通过 [UmsChannelClient] 调用子应用查询银联商务退款状态。
@@ -27,8 +27,7 @@ import java.time.format.DateTimeFormatter;
public class UmsRefundSyncService {
private final UmsChannelClient umsChannelClient;
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final PayTradeManager payTradeManager;
/// 执行银联商务退款同步查询
public RefundResultBo sync(PayRefundOrder refundOrder, UmsSdkCredential credential) {
@@ -39,6 +38,13 @@ public class UmsRefundSyncService {
req.setMethod(UmsPayMethod.QRCODE);
req.setCredential(credential);
// 银联商务扫码退款查询需要 billDate(原订单创建日, 子应用按通道时区转换)
payTradeManager.findByTradeNo(refundOrder.getOrderNo())
.ifPresentOrElse(
t -> req.setBillDate(t.getCreateTime()),
() -> log.warn("银联商务退款同步未查到原交易({}), billDate 未填充, 银商可能拒绝",
refundOrder.getOrderNo()));
DaxResult<UmsRefundSyncResp> result = umsChannelClient.refundSync(req);
if (result.getCode() != 0) {
log.error("银联商务通道退款同步失败: refundNo={}, msg={}", refundOrder.getRefundNo(), result.getMsg());
@@ -54,10 +60,8 @@ public class UmsRefundSyncService {
private RefundResultBo toSyncResult(UmsRefundSyncResp resp) {
RefundResultBo bo = new RefundResultBo();
bo.setOutRefundNo(resp.getOutRefundNo());
// 退款完成时间
if (StrUtil.isNotBlank(resp.getFinishTime())) {
bo.setFinishTime(OffsetDateTime.parse(resp.getFinishTime(), TIME_FORMAT));
}
// 退款完成时间(银联商务返回东八区本地时间, 由 UmsDateUtil 解析为带偏移的 OffsetDateTime)
bo.setFinishTime(UmsDateUtil.parseCst(resp.getFinishTime()));
// 退款金额
bo.setRefundAmount(resp.getRefundAmount());

View File

@@ -6,6 +6,7 @@ import cn.daxpay.open.channel.ums.client.enums.UmsPayMethod;
import cn.daxpay.open.channel.ums.client.req.UmsSyncReq;
import cn.daxpay.open.channel.ums.client.resp.UmsSyncResp;
import cn.daxpay.open.channel.ums.code.UmsCode;
import cn.daxpay.open.channel.ums.util.UmsDateUtil;
import cn.daxpay.open.payment.common.enums.PayFundStatusEnum;
import cn.daxpay.open.payment.common.result.DaxResult;
import cn.daxpay.open.payment.core.trade.bo.PaySyncResultBo;
@@ -15,9 +16,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
/// # 银联商务支付同步业务服务
///
/// 通过 [UmsChannelClient] 调用子应用查询银联商务订单状态,
@@ -29,13 +27,12 @@ public class UmsSyncService {
private final UmsChannelClient umsChannelClient;
/// 银联商务时间格式
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/// 执行银联商务支付同步
public PaySyncResultBo sync(PayTrade trade, UmsSdkCredential credential) {
UmsSyncReq req = new UmsSyncReq();
req.setOutTradeNo(trade.getTradeNo());
// 原订单创建时间(UTC), 子应用按通道时区转换为银联商务 billDate(yyyy-MM-dd)
req.setBillDate(trade.getCreateTime());
// 首期默认扫码查询
req.setMethod(UmsPayMethod.QRCODE);
req.setCredential(credential);
@@ -59,10 +56,8 @@ public class UmsSyncService {
bo.setRealAmount(resp.getTotalAmount());
// 买家标识
bo.setBuyerId(resp.getBuyerId());
// 支付成功时间
if (StrUtil.isNotBlank(resp.getPayTime())) {
bo.setFinishTime(OffsetDateTime.parse(resp.getPayTime(), TIME_FORMAT));
}
// 支付成功时间(银联商务返回东八区本地时间, 由 UmsDateUtil 解析为带偏移的 OffsetDateTime)
bo.setFinishTime(UmsDateUtil.parseCst(resp.getPayTime()));
String tradeStatus = resp.getTradeStatus();
if (StrUtil.isBlank(tradeStatus)) {

View File

@@ -0,0 +1,42 @@
package cn.daxpay.open.channel.ums.util;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/// # 银联商务通道日期工具(主应用侧)
///
/// 银联商务属国内通道, 接口返回的时间字段(payTime / finishTime 等)
/// 一律为东八区(GMT+8)本地时间字面量(yyyy-MM-dd HH:mm:ss), 无时区后缀。
///
/// 历史问题: 早期直接用 `OffsetDateTime.parse(str, ofPattern("yyyy-MM-dd HH:mm:ss"))`,
/// 因 formatter 不含时区字段、解析出的 TemporalAccessor 缺 OFFSET_SECONDS,
/// 触发 `Unable to obtain OffsetDateTime from TemporalAccessor` 异常。
///
/// 本工具显式按东八区(+08:00)将本地时间字面量转换为带偏移的绝对时间 [OffsetDateTime],
/// 与子应用(channel-one)侧 [UmsDateUtil] 的时区处理姿势一致, 不再依赖运行环境时区。
public final class UmsDateUtil {
/// 银联商务时间格式(yyyy-MM-dd HH:mm:ss)
private static final DateTimeFormatter DATETIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/// 东八区偏移(北京时间)
private static final ZoneOffset CST = ZoneOffset.ofHours(8);
private UmsDateUtil() {
}
/// 解析银联商务东八区时间字面量为带偏移的 [OffsetDateTime]
///
/// 通道返回的 `yyyy-MM-dd HH:mm:ss` 是东八区本地时间, 先按 [LocalDateTime] 解析,
/// 再附加 +08:00 偏移得到绝对时间点, 入库(timestamptz)时区正确。
/// 传入 null 或空白字符串时返回 null。
public static OffsetDateTime parseCst(String text) {
if (text == null || text.isBlank()) {
return null;
}
return LocalDateTime.parse(text, DATETIME_FORMATTER).atOffset(CST);
}
}