feat:商户结账
This commit is contained in:
1 parent
47680b65c9
commit
0470f27bec
17 files changed
+479
-143
No files matched your search
+6
@@ -5,6 +5,7 @@ import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.feign.annotation.NoToken;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferCreateDto;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverQuery;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverLookup;
|
||||
import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdMerchantTransferReceiverVo;
|
||||
import java.util.List;
|
||||
import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdTransferOrderVo;
|
||||
@@ -34,6 +35,11 @@ public interface RemoteThirdTransferService {
|
||||
@PostMapping("/transfer-remoter/merchant-receivers/resolve")
|
||||
R<List<ThirdMerchantTransferReceiverVo>> resolveReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverQuery query);
|
||||
|
||||
/** 只读解析默认身份用于展示,完整账号仅供内部服务生成脱敏视图,结果不能作为付款授权。 */
|
||||
@NoToken
|
||||
@PostMapping("/transfer-remoter/merchant-receivers/lookup")
|
||||
R<List<ThirdMerchantTransferReceiverVo>> lookupReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverLookup query);
|
||||
|
||||
/** 恢复原付款指令的查单任务并同步核对,不创建新的付款指令。 */
|
||||
@NoToken
|
||||
@PostMapping("/transfer-remoter/{transferNo}/recheck")
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.bsd.pjcloud.thirdparty.dto.transfer;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/** 内部收款配置只读请求,展示不依赖付款金额、证书初始化及业务通知登记。 */
|
||||
@Data
|
||||
public class ThirdMerchantTransferReceiverLookup {
|
||||
/** 已授权的商户及当前支付配置,最多1000项,空项会被参数校验拒绝。 */
|
||||
@NotEmpty @Size(max = 1000) @Valid
|
||||
private List<@NotNull Item> items;
|
||||
|
||||
/** 一家商户的明确配置归属,服务端再次校验默认关系所有权。 */
|
||||
@Data
|
||||
public static class Item {
|
||||
/** 原业务完成数据权限检查后的商户主键。 */
|
||||
@NotNull @Positive
|
||||
private Long mchId;
|
||||
/** 当前逻辑有效的支付宝支付配置主键。 */
|
||||
@NotNull @Positive
|
||||
private Long paySettingId;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ public class ThirdMerchantTransferReceiverVo {
|
||||
private String accountMasked;
|
||||
/** 可展示的脱敏实名。 */
|
||||
private String nameMasked;
|
||||
/** 此项是否通过配置及渠道约束校验。 */
|
||||
/** lookup表示基础身份有效,resolve表示真实付款预检通过,只有后者可生成付款授权。 */
|
||||
private boolean eligible;
|
||||
/** 安全可展示的失败原因。 */
|
||||
private String message;
|
||||
|
||||
+7
@@ -4,6 +4,7 @@ import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.security.annotation.Inner;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferCreateDto;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverQuery;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverLookup;
|
||||
import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdMerchantTransferReceiverVo;
|
||||
import com.bsd.pjcloud.thirdparty.service.ThirdMerchantTransferReceiverService;
|
||||
import java.util.List;
|
||||
@@ -52,6 +53,12 @@ public class ThirdTransferRemoterController {
|
||||
return R.ok(receivers.resolve(query));
|
||||
}
|
||||
|
||||
/** 展示专用只读查询,业务配置校验由正式付款预检负责。 */
|
||||
@PostMapping("/merchant-receivers/lookup")
|
||||
public R<List<ThirdMerchantTransferReceiverVo>> lookupReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverLookup query) {
|
||||
return R.ok(receivers.lookup(query));
|
||||
}
|
||||
|
||||
/** 同步核对原单并恢复原任务,结果未知时明确拒绝作为再次发款依据。 */
|
||||
@PostMapping("/{transferNo}/recheck")
|
||||
public R<ThirdTransferOrderVo> recheck(@PathVariable String transferNo) {
|
||||
|
||||
+85
-58
@@ -5,8 +5,7 @@ import cn.hutool.crypto.SecureUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.thirdparty.config.ThirdTransferProperties;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverQuery;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferCreateDto;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.*;
|
||||
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
|
||||
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper;
|
||||
import com.bsd.pjcloud.thirdparty.support.transfer.ThirdTransferAccess;
|
||||
@@ -17,75 +16,103 @@ import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 转账专用默认收款人解析,读取身份事实,分账开关、绑定结果及比例不参与转账判断。 */
|
||||
/** 转账默认收款人读取和预检,展示与实际付款校验分离,分账状态不参与身份判断。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ThirdMerchantTransferReceiverService {
|
||||
/** 默认关系数据,平台自动按当前租户及逻辑删除过滤。 */
|
||||
/** 默认关系事实,平台自动处理租户及逻辑删除。 */
|
||||
private final ThirdMchFzRelationMapper relations;
|
||||
/** 已登记的渠道实现,统一复用金额、场景及证书校验。 */
|
||||
/** 正式付款预检使用已登记渠道执行产品及证书校验。 */
|
||||
private final List<ThirdTransferChannel> channels;
|
||||
/** 结果通知登记配置,预检时即发现缺失接收服务。 */
|
||||
/** 实际付款必须登记的内部结果接收服务。 */
|
||||
private final ThirdTransferProperties properties;
|
||||
/** 账号与实名的安全展示工具。 */
|
||||
/** 管理端身份的统一脱敏能力。 */
|
||||
private final ThirdTransferRequestSupport requests;
|
||||
|
||||
/** 一次读取全部明确支付配置,逐项返回失败原因,配置异常保留为预检失败。 */
|
||||
public List<ThirdMerchantTransferReceiverVo> resolve(ThirdMerchantTransferReceiverQuery query) {
|
||||
/** 只读展示身份,基础身份有效不代表已经取得付款授权,不初始化支付客户端。 */
|
||||
public List<ThirdMerchantTransferReceiverVo> lookup(ThirdMerchantTransferReceiverLookup query) {
|
||||
ThirdTransferAccess.requireTenant();
|
||||
properties.requireNotifyService(query.getBizType());
|
||||
ThirdTransferChannel channel = channels.stream().filter(item -> "20".equals(item.platformType())).findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("支付宝转账渠道尚未注册"));
|
||||
List<Long> settingIds = query.getItems().stream().map(ThirdMerchantTransferReceiverQuery.Item::getPaySettingId).distinct().toList();
|
||||
Map<Long, List<ThirdMchFzRelationEntity>> bySetting = relations.selectList(Wrappers.<ThirdMchFzRelationEntity>lambdaQuery()
|
||||
.eq(ThirdMchFzRelationEntity::getPlatformType, "20").eq(ThirdMchFzRelationEntity::getIsDefault, "1")
|
||||
.in(ThirdMchFzRelationEntity::getPaySettingId, settingIds)).stream().collect(Collectors.groupingBy(ThirdMchFzRelationEntity::getPaySettingId));
|
||||
return query.getItems().stream().map(item -> resolveItem(query, item, bySetting.getOrDefault(item.getPaySettingId(), List.of()), channel)).toList();
|
||||
Map<Long, List<ThirdMchFzRelationEntity>> defaults = defaults(query.getItems().stream().map(ThirdMerchantTransferReceiverLookup.Item::getPaySettingId).toList());
|
||||
return query.getItems().stream().map(item -> read(item.getMchId(), item.getPaySettingId(), defaults.getOrDefault(item.getPaySettingId(), List.of()))).toList();
|
||||
}
|
||||
|
||||
/** 仅将安全的业务校验错误逐项返回,存储或未知系统错误仍交由调用方失败处理。 */
|
||||
private ThirdMerchantTransferReceiverVo resolveItem(ThirdMerchantTransferReceiverQuery query, ThirdMerchantTransferReceiverQuery.Item item, List<ThirdMchFzRelationEntity> defaults, ThirdTransferChannel channel) {
|
||||
/** 先读取每家收款人,再逐项执行真实付款校验,配置失败也保留可见身份和明确原因。 */
|
||||
public List<ThirdMerchantTransferReceiverVo> resolve(ThirdMerchantTransferReceiverQuery query) {
|
||||
ThirdTransferAccess.requireTenant();
|
||||
Map<Long, List<ThirdMchFzRelationEntity>> defaults = defaults(query.getItems().stream().map(ThirdMerchantTransferReceiverQuery.Item::getPaySettingId).toList());
|
||||
return query.getItems().stream().map(item -> {
|
||||
ThirdMerchantTransferReceiverVo result = read(item.getMchId(), item.getPaySettingId(), defaults.getOrDefault(item.getPaySettingId(), List.of()));
|
||||
if (!result.isEligible()) return result;
|
||||
result.setEligible(false);
|
||||
try {
|
||||
properties.requireNotifyService(query.getBizType());
|
||||
ThirdTransferChannel channel = channels.stream().filter(provider -> "20".equals(provider.platformType())).findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("支付宝转账渠道尚未注册"));
|
||||
ThirdTransferCreateDto request = new ThirdTransferCreateDto();
|
||||
request.setAmountFen(item.getAmountFen());
|
||||
request.setTitle(query.getTitle());
|
||||
request.setRemark(query.getRemark());
|
||||
request.setScene(query.getScene());
|
||||
request.setReceiver(result.getReceiver());
|
||||
String appId = channel.validate(request);
|
||||
ThirdMchFzRelationEntity relation = defaults.get(item.getPaySettingId()).get(0);
|
||||
if ("ALIPAY_OPEN_ID".equals(result.getReceiver().getIdentityType()) && !Objects.equals(appId, relation.getChannelAppId())) {
|
||||
throw new IllegalArgumentException("默认接收方OpenID所属应用与本次付款应用不一致");
|
||||
}
|
||||
result.setPayerAppId(appId);
|
||||
result.setFingerprint(SecureUtil.sha256(JSONUtil.toJsonStr(Arrays.asList(item.getMchId(), item.getPaySettingId(), result.getRelationId(), appId,
|
||||
result.getReceiver().getIdentityType(), result.getReceiver().getIdentity(), result.getReceiver().getName()))));
|
||||
result.setEligible(true);
|
||||
result.setMessage("预检通过");
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
// 此处只返回本组件和渠道校验定义的安全业务说明,原始SDK异常仍由渠道内部转换。
|
||||
result.setMessage(exception.getMessage());
|
||||
}
|
||||
return result;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取明确支付配置的默认关系,返回按配置分组的完整事实以识别歧义。 */
|
||||
private Map<Long, List<ThirdMchFzRelationEntity>> defaults(List<Long> settingIds) {
|
||||
return relations.selectList(Wrappers.<ThirdMchFzRelationEntity>lambdaQuery().eq(ThirdMchFzRelationEntity::getPlatformType, "20")
|
||||
.eq(ThirdMchFzRelationEntity::getIsDefault, "1").in(ThirdMchFzRelationEntity::getPaySettingId, settingIds.stream().distinct().toList()))
|
||||
.stream().collect(Collectors.groupingBy(ThirdMchFzRelationEntity::getPaySettingId));
|
||||
}
|
||||
|
||||
/** 身份先投影脱敏信息再校验完整性,缺姓名、类型不支持等异常仍能展示已保存的收款信息。 */
|
||||
private ThirdMerchantTransferReceiverVo read(Long merchantId, Long settingId, List<ThirdMchFzRelationEntity> defaults) {
|
||||
ThirdMerchantTransferReceiverVo result = new ThirdMerchantTransferReceiverVo();
|
||||
result.setMchId(item.getMchId());
|
||||
result.setPaySettingId(item.getPaySettingId());
|
||||
try {
|
||||
if (defaults.size() != 1 || !Objects.equals(defaults.get(0).getMchId(), item.getMchId())) {
|
||||
throw new IllegalArgumentException("请配置唯一且属于该商户的支付宝默认接收方");
|
||||
}
|
||||
ThirdMchFzRelationEntity relation = defaults.get(0);
|
||||
String type = switch (Objects.toString(relation.getReceiverType(), "")) {
|
||||
case "userId" -> "ALIPAY_USER_ID";
|
||||
case "openId" -> "ALIPAY_OPEN_ID";
|
||||
case "loginName" -> "ALIPAY_LOGON_ID";
|
||||
default -> throw new IllegalArgumentException("默认接收方类型不支持转账,请选择支付宝用户ID、OpenID或登录号");
|
||||
};
|
||||
String account = StrUtil.trimToNull(relation.getReceiverAccount());
|
||||
if (account == null || account.length() > 128) throw new IllegalArgumentException("默认接收方标识缺失或超过128字");
|
||||
ThirdTransferCreateDto.Receiver receiver = new ThirdTransferCreateDto.Receiver();
|
||||
receiver.setIdentityType(type);
|
||||
receiver.setIdentity(account);
|
||||
receiver.setName(StrUtil.trimToNull(relation.getReceiverName()));
|
||||
if (receiver.getName() != null && receiver.getName().length() > 128) throw new IllegalArgumentException("接收方实名超过128字");
|
||||
ThirdTransferCreateDto request = new ThirdTransferCreateDto();
|
||||
request.setAmountFen(item.getAmountFen());
|
||||
request.setTitle(query.getTitle());
|
||||
request.setRemark(query.getRemark());
|
||||
request.setScene(query.getScene());
|
||||
request.setReceiver(receiver);
|
||||
String appId = channel.validate(request);
|
||||
if ("ALIPAY_OPEN_ID".equals(type) && !Objects.equals(appId, relation.getChannelAppId())) {
|
||||
throw new IllegalArgumentException("默认接收方OpenID所属应用与本次付款应用不一致");
|
||||
}
|
||||
result.setRelationId(relation.getId());
|
||||
result.setPayerAppId(appId);
|
||||
result.setReceiver(receiver);
|
||||
result.setAccountMasked(requests.maskAccount(account));
|
||||
result.setNameMasked(requests.maskName(receiver.getName()));
|
||||
result.setFingerprint(SecureUtil.sha256(JSONUtil.toJsonStr(Arrays.asList(item.getMchId(), item.getPaySettingId(), relation.getId(), appId, type, account, receiver.getName()))));
|
||||
result.setMchId(merchantId);
|
||||
result.setPaySettingId(settingId);
|
||||
if (defaults.size() != 1 || !Objects.equals(defaults.get(0).getMchId(), merchantId)) {
|
||||
result.setMessage("请配置唯一且属于该商户的支付宝默认接收方");
|
||||
return result;
|
||||
}
|
||||
ThirdMchFzRelationEntity relation = defaults.get(0);
|
||||
result.setRelationId(relation.getId());
|
||||
ThirdTransferCreateDto.Receiver receiver = new ThirdTransferCreateDto.Receiver();
|
||||
receiver.setIdentityType(switch (Objects.toString(relation.getReceiverType(), "")) {
|
||||
case "userId" -> "ALIPAY_USER_ID";
|
||||
case "openId" -> "ALIPAY_OPEN_ID";
|
||||
case "loginName" -> "ALIPAY_LOGON_ID";
|
||||
default -> relation.getReceiverType();
|
||||
});
|
||||
receiver.setIdentity(StrUtil.trimToNull(relation.getReceiverAccount()));
|
||||
receiver.setName(StrUtil.trimToNull(relation.getReceiverName()));
|
||||
result.setReceiver(receiver);
|
||||
result.setAccountMasked(receiver.getIdentity() == null ? null : requests.maskAccount(receiver.getIdentity()));
|
||||
result.setNameMasked(requests.maskName(receiver.getName()));
|
||||
if (!Set.of("ALIPAY_USER_ID", "ALIPAY_OPEN_ID", "ALIPAY_LOGON_ID").contains(Objects.toString(receiver.getIdentityType(), ""))) {
|
||||
result.setMessage("默认接收方类型不支持转账,请选择支付宝用户ID、OpenID或登录号");
|
||||
} else if (receiver.getIdentity() == null || receiver.getIdentity().length() > 128) {
|
||||
result.setMessage("默认接收方标识缺失或超过128字");
|
||||
} else if (receiver.getName() != null && receiver.getName().length() > 128) {
|
||||
result.setMessage("接收方实名超过128字");
|
||||
} else if ("ALIPAY_LOGON_ID".equals(receiver.getIdentityType()) && receiver.getName() == null) {
|
||||
result.setMessage("支付宝登录号转账须提供收款实名");
|
||||
} else {
|
||||
result.setEligible(true);
|
||||
result.setMessage("预检通过");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
result.setMessage(exception.getMessage());
|
||||
result.setMessage("收款信息已读取");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+36
-3
@@ -31,6 +31,8 @@ class ThirdMerchantTransferReceiverServiceTest {
|
||||
TenantContextHolder.setTenantId(9L);
|
||||
when(channel.platformType()).thenReturn("20");
|
||||
when(channel.validate(any())).thenReturn("payer-app");
|
||||
when(requests.maskAccount(anyString())).thenReturn("tes****123");
|
||||
when(requests.maskName(anyString())).thenReturn("测*");
|
||||
}
|
||||
|
||||
/** 防止测试租户上下文泄漏。 */
|
||||
@@ -79,12 +81,43 @@ class ThirdMerchantTransferReceiverServiceTest {
|
||||
assertFalse(service.resolve(query()).get(0).isEligible());
|
||||
}
|
||||
|
||||
/** 渠道配置失败必须向调用方抛出,不能返回一组假成功的空接收方。 */
|
||||
/** 渠道配置失败仍保留已读取身份,明确阻止转账并给出逐项原因。 */
|
||||
@Test
|
||||
void channelConfigurationFailurePropagates() {
|
||||
void channelConfigurationFailurePreservesReceiverAndBlocksPayment() {
|
||||
when(relations.selectList(any())).thenReturn(List.of(relation("userId")));
|
||||
when(channel.validate(any())).thenThrow(new IllegalStateException("证书不可用"));
|
||||
assertThrows(IllegalStateException.class, () -> service.resolve(query()));
|
||||
var result = service.resolve(query()).get(0);
|
||||
assertFalse(result.isEligible());
|
||||
assertEquals("证书不可用", result.getMessage());
|
||||
assertEquals("tes****123", result.getAccountMasked());
|
||||
assertEquals("测*", result.getNameMasked());
|
||||
}
|
||||
|
||||
/** 展示收款人无需构建支付客户端或登记通知服务,原始身份只存在于内部响应。 */
|
||||
@Test
|
||||
void lookupDoesNotRequirePaymentConfiguration() {
|
||||
when(relations.selectList(any())).thenReturn(List.of(relation("userId")));
|
||||
ThirdMerchantTransferReceiverLookup query = new ThirdMerchantTransferReceiverLookup();
|
||||
ThirdMerchantTransferReceiverLookup.Item item = new ThirdMerchantTransferReceiverLookup.Item();
|
||||
item.setMchId(1L);
|
||||
item.setPaySettingId(2L);
|
||||
query.setItems(List.of(item));
|
||||
var result = service.lookup(query).get(0);
|
||||
assertTrue(result.isEligible());
|
||||
assertEquals("tes****123", result.getAccountMasked());
|
||||
verifyNoInteractions(channel, properties);
|
||||
}
|
||||
|
||||
/** 缺少登录号实名时仍可展示已保存账号,预检必须拒绝付款。 */
|
||||
@Test
|
||||
void missingRealNameStillShowsConfiguredAccount() {
|
||||
ThirdMchFzRelationEntity relation = relation("loginName");
|
||||
relation.setReceiverName(null);
|
||||
when(relations.selectList(any())).thenReturn(List.of(relation));
|
||||
var result = service.resolve(query()).get(0);
|
||||
assertFalse(result.isEligible());
|
||||
assertEquals("tes****123", result.getAccountMasked());
|
||||
assertTrue(result.getMessage().contains("实名"));
|
||||
}
|
||||
|
||||
/** 独立测试关系,与真实用户账号无关。 */
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
# 酒店结账全部结算接入
|
||||
|
||||
## 默认启用和发布
|
||||
## 启用和发布
|
||||
|
||||
全部结算默认启用,预检、受理及后台执行均无需额外配置开关。完成迁移、第三方及钱包全部实例部署、前端和角色授权后即可使用。
|
||||
|
||||
需要暂停新的结算受理时,可显式配置以下可选开关。
|
||||
|
||||
```yaml
|
||||
wallet:
|
||||
mch-checkout:
|
||||
transfer:
|
||||
enabled: false
|
||||
```
|
||||
全部结算直接启用,预检、受理及后台执行由业务流程和权限控制。完成迁移、第三方及钱包全部实例部署、前端和角色授权后即可使用。
|
||||
|
||||
第三方配置将已申报的酒店业务结算结果发送至钱包。当前支付宝付款应用使用 `ali-pay-config` 的证书配置。
|
||||
|
||||
@@ -29,8 +20,6 @@ app-plus:
|
||||
|
||||
本次迁移文件为 `db/20261110_add_mch_checkout_bill_transfer.sql`,创建批次及付款尝试两张表,并兼容原商户付款表的 `ck_checkout_merchant_paid` 约束。仅固定 `HCP+雪花ID` 转账指令在已确认实付等于应付时允许渠道时间暂缺,原付款登记继续要求付款时间。脚本支持重复执行。编写迁移与执行数据库分开安排。
|
||||
|
||||
关闭开关会停止新的授权和钱包受理扫描。已被第三方受理的付款及结果通知继续按原指令执行,历史查询和结果回写持续可用。
|
||||
|
||||
## 默认收款配置
|
||||
|
||||
使用商户当前支付宝支付配置的唯一默认关系。默认关系中的接收方类型、完整标识及可选实名作为转账依据。关闭分账时仍可维护收款字段,实际转账前统一校验。
|
||||
|
||||
+11
@@ -24,6 +24,17 @@ public class MchCheckoutBillMerchantVo {
|
||||
/** 发起时的部门名称快照。 */
|
||||
private String deptName;
|
||||
|
||||
/** 收款身份类型,当前配置或实际转账快照提供。 */
|
||||
private String receiverType;
|
||||
/** 收款标识脱敏值,原始账号不会返回管理端。 */
|
||||
private String receiverAccountMasked;
|
||||
/** 收款实名脱敏值,未填写时为空。 */
|
||||
private String receiverNameMasked;
|
||||
/** CURRENT_CONFIG当前配置、TRANSFER_SNAPSHOT实际付款快照、NO_TRANSFER无需转账。 */
|
||||
private String receiverSource;
|
||||
/** 收款配置或读取异常的安全提示,正常时为空。 */
|
||||
private String receiverMessage;
|
||||
|
||||
/** 本次选择的订单笔数,零表示无结账订单。 */
|
||||
private Integer orderCount;
|
||||
|
||||
|
||||
+4
@@ -70,6 +70,10 @@ public class MchCheckoutBillTransferVo {
|
||||
private String receiverAccountMasked;
|
||||
/** 脱敏实名,零元结清可空。 */
|
||||
private String receiverNameMasked;
|
||||
/** CURRENT_CONFIG为预检当前配置,TRANSFER_SNAPSHOT为已授权快照,NO_TRANSFER为零元。 */
|
||||
private String receiverSource;
|
||||
/** 收款读取异常提示,预检时也会在逐项原因中展示。 */
|
||||
private String receiverMessage;
|
||||
/** 此商户是否通过本次预检。 */
|
||||
private boolean eligible;
|
||||
/** 预检结论或资金执行状态。 */
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.bsd.pjcloud.wallet.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 酒店结账实际转账开关,默认启用,可通过显式配置暂停受理和后台执行。 */
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "wallet.mch-checkout.transfer")
|
||||
public class MchCheckoutTransferProperties {
|
||||
/** 默认开启,新付款授权与后台受理均受此开关约束。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 仅在显式停用时阻止新的付款授权并提示暂停状态。 */
|
||||
public void requireEnabled() {
|
||||
if (!enabled) throw new IllegalStateException("全部结算已暂停,请联系管理员恢复");
|
||||
}
|
||||
}
|
||||
-4
@@ -4,7 +4,6 @@ import com.bsd.pjcloud.admin.api.entity.SysTenant;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.wallet.config.MchCheckoutTransferProperties;
|
||||
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutBillTransferItemMapper;
|
||||
import com.bsd.pjcloud.wallet.service.MchCheckoutBillTransferService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -26,8 +25,6 @@ public class MchCheckoutBillTransferJob {
|
||||
private static final int WORKERS = 4;
|
||||
/** 后台扫描固定间隔,单位秒。 */
|
||||
private static final int SCAN_INTERVAL_SECONDS = 5;
|
||||
/** 独立开关,默认扫描并受理已经授权的付款指令。 */
|
||||
private final MchCheckoutTransferProperties properties;
|
||||
/** 现有有效租户目录。 */
|
||||
private final RemoteTenantService tenants;
|
||||
/** 自动租户过滤的到期付款任务。 */
|
||||
@@ -54,7 +51,6 @@ public class MchCheckoutBillTransferJob {
|
||||
|
||||
/** 每轮按租户读取到期原任务,线程忙碌时留待下轮,不修改资金状态。 */
|
||||
private void scan() {
|
||||
if (!properties.isEnabled()) return;
|
||||
try {
|
||||
R<List<SysTenant>> response = tenants.list();
|
||||
if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("结账转账租户目录读取失败");
|
||||
|
||||
+36
-23
@@ -14,7 +14,6 @@ import com.bsd.pjcloud.thirdparty.vo.transfer.*;
|
||||
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillTransferRequest;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.*;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.*;
|
||||
import com.bsd.pjcloud.wallet.config.MchCheckoutTransferProperties;
|
||||
import com.bsd.pjcloud.wallet.mapper.mch.*;
|
||||
import com.bsd.pjcloud.wallet.support.mch.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -31,8 +30,6 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MchCheckoutBillTransferService {
|
||||
/** 默认开启的实际付款开关,可显式暂停新的授权和后台受理。 */
|
||||
private final MchCheckoutTransferProperties properties;
|
||||
/** 角色数据范围。 */
|
||||
private final MchCheckoutRelationAccess access;
|
||||
/** 整单历史权限与审计。 */
|
||||
@@ -45,8 +42,8 @@ public class MchCheckoutBillTransferService {
|
||||
private final RemoteDeptService departments;
|
||||
/** 不可变原订单事实。 */
|
||||
private final MchCheckoutBillOrderMapper orders;
|
||||
/** 当前支付宝支付配置,不读取分账开关。 */
|
||||
private final MchPaySettingMapper settings;
|
||||
/** 当前支付宝收款配置及只读展示回退,实际付款仍强制完整预检。 */
|
||||
private final MchCheckoutBillReceiverSupport receiverSupport;
|
||||
/** 受理批次查询。 */
|
||||
private final MchCheckoutBillTransferBatchMapper batches;
|
||||
/** 付款尝试及到期任务。 */
|
||||
@@ -66,7 +63,6 @@ public class MchCheckoutBillTransferService {
|
||||
|
||||
/** 全量预检所有待付款明细,只有全部通过才能生成真实付款确认令牌。 */
|
||||
public MchCheckoutBillTransferVo preview(Long billId) {
|
||||
properties.requireEnabled();
|
||||
MchCheckoutBillTransferSnapshot snapshot = build(billId);
|
||||
if (snapshot.getView().isEligible()) cache.save(snapshot);
|
||||
return snapshot.getView();
|
||||
@@ -79,7 +75,6 @@ public class MchCheckoutBillTransferService {
|
||||
String hash = source.hash(Arrays.asList(billId, SecurityUtils.getUser().getId(), request.getPreviewToken()));
|
||||
MchCheckoutBillTransferBatchEntity previous = store.byRequest(request.getRequestId());
|
||||
if (previous != null) return batch(billId, store.replay(previous, billId, hash));
|
||||
properties.requireEnabled();
|
||||
MchCheckoutBillTransferSnapshot confirmed = cache.require(billId, request.getPreviewToken());
|
||||
MchCheckoutBillTransferSnapshot fresh = build(billId);
|
||||
if (!fresh.getView().isEligible() || !Objects.equals(fresh.getContentHash(), confirmed.getContentHash())) {
|
||||
@@ -95,7 +90,7 @@ public class MchCheckoutBillTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询最近批次,关闭新付款开关后仍可读取历史真实资金结果。 */
|
||||
/** 查询最近批次的完整历史资金结果。 */
|
||||
public MchCheckoutBillTransferVo latest(Long billId) {
|
||||
ledger.requireVisible(billId, access.requireScope());
|
||||
MchCheckoutBillTransferBatchEntity row = batches.selectOne(Wrappers.<MchCheckoutBillTransferBatchEntity>lambdaQuery()
|
||||
@@ -129,7 +124,6 @@ public class MchCheckoutBillTransferService {
|
||||
|
||||
/** 人工核对原转账并恢复同号任务,逐项失败保留原资金状态及提示。 */
|
||||
public MchCheckoutBillTransferVo recheck(Long billId, Long batchId) {
|
||||
properties.requireEnabled();
|
||||
MchCheckoutRelationScope scope = access.requireScope();
|
||||
batch(billId, batchId);
|
||||
int unconfirmed = 0;
|
||||
@@ -152,7 +146,6 @@ public class MchCheckoutBillTransferService {
|
||||
|
||||
/** Worker在数据库租约保护下执行原指令,网络或本地写入异常只安排原任务恢复。 */
|
||||
public void execute(Long itemId) {
|
||||
if (!properties.isEnabled()) return;
|
||||
MchCheckoutBillTransferItemEntity item = store.claim(itemId);
|
||||
if (item == null) return;
|
||||
try {
|
||||
@@ -251,9 +244,17 @@ public class MchCheckoutBillTransferService {
|
||||
total = Math.addExact(total, merchant.getPayableAmountFen());
|
||||
if (merchant.getPayableAmountFen() == 0) {
|
||||
zero++;
|
||||
row.setReceiverSource("NO_TRANSFER");
|
||||
row.setReceiverMessage("零元结清,无需实际转账");
|
||||
} else {
|
||||
ThirdMerchantTransferReceiverVo receiver = receivers.get(merchant.getMerchantId());
|
||||
List<MchPaySettingEntity> configs = paySettings.getOrDefault(merchant.getMerchantId(), List.of());
|
||||
row.setReceiverSource("CURRENT_CONFIG");
|
||||
if (receiver != null) {
|
||||
if (receiver.getReceiver() != null) row.setReceiverType(receiver.getReceiver().getIdentityType());
|
||||
row.setReceiverAccountMasked(receiver.getAccountMasked());
|
||||
row.setReceiverNameMasked(receiver.getNameMasked());
|
||||
}
|
||||
if (configs.size() != 1) error = first(error, "商户需要唯一的有效支付宝支付配置");
|
||||
else if (receiver == null || !receiver.isEligible()) error = first(error, receiver == null ? "接收方读取失败" : Objects.toString(receiver.getMessage(), "接收方预检未通过"));
|
||||
else fillReceiver(bill, snapshot, member, receiver);
|
||||
@@ -285,8 +286,7 @@ public class MchCheckoutBillTransferService {
|
||||
private Map<Long, List<MchPaySettingEntity>> paymentSettings(List<MchCheckoutBillMerchantEntity> members) {
|
||||
List<Long> ids = members.stream().filter(row -> row.getPayableAmountFen() != null && row.getPayableAmountFen() > 0).map(MchCheckoutBillMerchantEntity::getMerchantId).toList();
|
||||
if (ids.isEmpty()) return Map.of();
|
||||
return settings.selectList(Wrappers.<MchPaySettingEntity>lambdaQuery().eq(MchPaySettingEntity::getPlatformType, "20").in(MchPaySettingEntity::getMchId, ids))
|
||||
.stream().collect(Collectors.groupingBy(MchPaySettingEntity::getMchId));
|
||||
return receiverSupport.settings(ids);
|
||||
}
|
||||
|
||||
/** 通过专用内部契约读取原始身份,漏项、重复或额外响应均拒绝整个预检。 */
|
||||
@@ -307,19 +307,30 @@ public class MchCheckoutBillTransferService {
|
||||
}
|
||||
if (inputs.isEmpty()) return Map.of();
|
||||
query.setItems(inputs);
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> result = new LinkedHashMap<>();
|
||||
Map<Long, Long> expected = inputs.stream().collect(Collectors.toMap(ThirdMerchantTransferReceiverQuery.Item::getMchId, ThirdMerchantTransferReceiverQuery.Item::getPaySettingId));
|
||||
for (ThirdMerchantTransferReceiverVo receiver : require(transfers.resolveReceivers(query), "支付宝接收方读取失败,请检查转账配置")) {
|
||||
if (receiver == null || !Objects.equals(expected.remove(receiver.getMchId()), receiver.getPaySettingId()) || result.put(receiver.getMchId(), receiver) != null) throw new IllegalStateException("接收方批量解析返回异常");
|
||||
if (receiver.isEligible() && (receiver.getRelationId() == null || receiver.getPayerAppId() == null || receiver.getPayerAppId().isBlank()
|
||||
|| receiver.getFingerprint() == null || !receiver.getFingerprint().matches("[0-9a-f]{64}") || receiver.getReceiver() == null
|
||||
|| receiver.getReceiver().getIdentity() == null || receiver.getReceiver().getIdentity().isBlank()
|
||||
|| !Set.of("ALIPAY_USER_ID", "ALIPAY_OPEN_ID", "ALIPAY_LOGON_ID").contains(Objects.toString(receiver.getReceiver().getIdentityType(), "")))) {
|
||||
throw new IllegalStateException("接收方预检返回的固定付款身份不完整,请重试");
|
||||
try {
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> result = new LinkedHashMap<>();
|
||||
Map<Long, Long> expected = inputs.stream().collect(Collectors.toMap(ThirdMerchantTransferReceiverQuery.Item::getMchId, ThirdMerchantTransferReceiverQuery.Item::getPaySettingId));
|
||||
for (ThirdMerchantTransferReceiverVo receiver : require(transfers.resolveReceivers(query), "支付宝接收方读取失败,请检查转账配置")) {
|
||||
if (receiver == null || !expected.containsKey(receiver.getMchId()) || !Objects.equals(expected.remove(receiver.getMchId()), receiver.getPaySettingId()) || result.put(receiver.getMchId(), receiver) != null) throw new IllegalStateException("接收方批量解析返回异常");
|
||||
if (receiver.isEligible() && (receiver.getRelationId() == null || receiver.getPayerAppId() == null || receiver.getPayerAppId().isBlank()
|
||||
|| receiver.getFingerprint() == null || !receiver.getFingerprint().matches("[0-9a-f]{64}") || receiver.getReceiver() == null
|
||||
|| receiver.getReceiver().getIdentity() == null || receiver.getReceiver().getIdentity().isBlank()
|
||||
|| !Set.of("ALIPAY_USER_ID", "ALIPAY_OPEN_ID", "ALIPAY_LOGON_ID").contains(Objects.toString(receiver.getReceiver().getIdentityType(), "")))) {
|
||||
throw new IllegalStateException("接收方预检返回的固定付款身份不完整,请重试");
|
||||
}
|
||||
}
|
||||
if (!expected.isEmpty()) throw new IllegalStateException("接收方批量解析缺少商户");
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
List<Long> ids = inputs.stream().map(ThirdMerchantTransferReceiverQuery.Item::getMchId).toList();
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> display = receiverSupport.current(ids, configs);
|
||||
for (ThirdMerchantTransferReceiverVo row : display.values()) {
|
||||
String reason = row.isEligible() ? "支付宝转账预检暂不可用,请重试或检查第三方服务" : Objects.toString(row.getMessage(), "收款人读取失败");
|
||||
row.setEligible(false);
|
||||
row.setMessage(reason);
|
||||
}
|
||||
return display;
|
||||
}
|
||||
if (!expected.isEmpty()) throw new IllegalStateException("接收方批量解析缺少商户");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 精确核对每笔原金额、归属和状态,同时检查商户级金额恒等式及订单数量。 */
|
||||
@@ -403,6 +414,8 @@ public class MchCheckoutBillTransferService {
|
||||
row.setReceiverType(item.getReceiverType());
|
||||
row.setReceiverAccountMasked(item.getReceiverAccountMasked());
|
||||
row.setReceiverNameMasked(item.getReceiverNameMasked());
|
||||
row.setReceiverSource(item.getAmountFen() == 0 ? "NO_TRANSFER" : "TRANSFER_SNAPSHOT");
|
||||
if (item.getAmountFen() == 0) row.setReceiverMessage("零元结清,无需实际转账");
|
||||
row.setStatus(item.getStatus());
|
||||
row.setMessage(item.getErrorMessage());
|
||||
row.setTransferNo(item.getTransferNo());
|
||||
|
||||
+3
@@ -48,6 +48,8 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService {
|
||||
private final MchCheckoutBillLedger ledger;
|
||||
/** 独立源事实复核及差异持久化。 */
|
||||
private final MchCheckoutBillRecheckService rechecks;
|
||||
/** 一页商户的安全收款信息,展示与付款渠道校验分离。 */
|
||||
private final MchCheckoutBillReceiverSupport receivers;
|
||||
/** Spring本地事务,覆盖主体、明细、占用和事件。 */
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
@@ -80,6 +82,7 @@ public class MchCheckoutBillServiceImpl implements MchCheckoutBillService {
|
||||
row.setCommissionMessage("本次未选择订单");
|
||||
}
|
||||
}
|
||||
receivers.fill(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.bsd.pjcloud.wallet.support.mch;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdTransferService;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdMerchantTransferReceiverLookup;
|
||||
import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdMerchantTransferReceiverVo;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.*;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillMerchantVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.mch.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 账单收款人安全展示,当前配置和历史实际转账快照分别标明来源,调用方先校验整单权限。 */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MchCheckoutBillReceiverSupport {
|
||||
/** 当前有效支付宝支付配置,自动应用租户和逻辑删除。 */
|
||||
private final MchPaySettingMapper settings;
|
||||
/** 已授权付款尝试中的固定脱敏身份。 */
|
||||
private final MchCheckoutBillTransferItemMapper items;
|
||||
/** 第三方默认身份只读接口,不执行渠道请求。 */
|
||||
private final RemoteThirdTransferService remote;
|
||||
|
||||
/** 批量读取明确商户的支付宝配置,保留完整分组以识别重复配置。 */
|
||||
public Map<Long, List<MchPaySettingEntity>> settings(List<Long> merchantIds) {
|
||||
if (merchantIds.isEmpty()) return Map.of();
|
||||
return settings.selectList(Wrappers.<MchPaySettingEntity>lambdaQuery().eq(MchPaySettingEntity::getPlatformType, "20")
|
||||
.in(MchPaySettingEntity::getMchId, merchantIds)).stream().collect(Collectors.groupingBy(MchPaySettingEntity::getMchId));
|
||||
}
|
||||
|
||||
/** 返回每家当前收款配置;网络、业务或协议失败逐项明确展示,不能假装没有配置。 */
|
||||
public Map<Long, ThirdMerchantTransferReceiverVo> current(List<Long> merchantIds, Map<Long, List<MchPaySettingEntity>> configs) {
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> result = new LinkedHashMap<>();
|
||||
List<ThirdMerchantTransferReceiverLookup.Item> inputs = new ArrayList<>();
|
||||
for (Long id : merchantIds.stream().distinct().toList()) {
|
||||
ThirdMerchantTransferReceiverVo row = new ThirdMerchantTransferReceiverVo();
|
||||
row.setMchId(id);
|
||||
row.setMessage("商户需要唯一的有效支付宝支付配置");
|
||||
result.put(id, row);
|
||||
List<MchPaySettingEntity> matches = configs.getOrDefault(id, List.of());
|
||||
if (matches.size() != 1) continue;
|
||||
ThirdMerchantTransferReceiverLookup.Item input = new ThirdMerchantTransferReceiverLookup.Item();
|
||||
input.setMchId(id);
|
||||
input.setPaySettingId(matches.get(0).getId());
|
||||
inputs.add(input);
|
||||
}
|
||||
if (inputs.isEmpty()) return result;
|
||||
ThirdMerchantTransferReceiverLookup query = new ThirdMerchantTransferReceiverLookup();
|
||||
query.setItems(inputs);
|
||||
try {
|
||||
R<List<ThirdMerchantTransferReceiverVo>> response = remote.lookupReceivers(query);
|
||||
if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("收款人读取失败");
|
||||
Map<Long, Long> expected = inputs.stream().collect(Collectors.toMap(ThirdMerchantTransferReceiverLookup.Item::getMchId, ThirdMerchantTransferReceiverLookup.Item::getPaySettingId));
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> verified = new LinkedHashMap<>();
|
||||
for (ThirdMerchantTransferReceiverVo row : response.getData()) {
|
||||
if (row == null || !expected.containsKey(row.getMchId()) || !Objects.equals(expected.remove(row.getMchId()), row.getPaySettingId()) || verified.put(row.getMchId(), row) != null) throw new IllegalStateException("收款配置返回异常");
|
||||
if (row.isEligible() && (row.getReceiver() == null || row.getReceiver().getIdentity() == null || row.getReceiver().getIdentity().isBlank()
|
||||
|| row.getAccountMasked() == null || row.getAccountMasked().isBlank())) throw new IllegalStateException("收款配置展示字段不完整");
|
||||
}
|
||||
if (!expected.isEmpty()) throw new IllegalStateException("收款配置缺少商户");
|
||||
result.putAll(verified);
|
||||
} catch (RuntimeException exception) {
|
||||
for (ThirdMerchantTransferReceiverLookup.Item input : inputs) result.get(input.getMchId()).setMessage("收款人读取失败,请重试或检查第三方服务");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 已授权的一页商户补齐展示信息,优先使用实际付款快照,其余读取当前默认配置。 */
|
||||
public void fill(List<MchCheckoutBillMerchantVo> rows) {
|
||||
if (rows.isEmpty()) return;
|
||||
List<Long> memberIds = rows.stream().map(MchCheckoutBillMerchantVo::getId).toList();
|
||||
Map<Long, List<MchCheckoutBillTransferItemEntity>> snapshots = items.selectList(Wrappers.<MchCheckoutBillTransferItemEntity>lambdaQuery()
|
||||
.select(MchCheckoutBillTransferItemEntity::getBillMerchantId, MchCheckoutBillTransferItemEntity::getMerchantId, MchCheckoutBillTransferItemEntity::getAmountFen,
|
||||
MchCheckoutBillTransferItemEntity::getAttemptNo, MchCheckoutBillTransferItemEntity::getStatus, MchCheckoutBillTransferItemEntity::getReceiverType,
|
||||
MchCheckoutBillTransferItemEntity::getReceiverAccountMasked, MchCheckoutBillTransferItemEntity::getReceiverNameMasked)
|
||||
.in(MchCheckoutBillTransferItemEntity::getBillMerchantId, memberIds).orderByDesc(MchCheckoutBillTransferItemEntity::getAttemptNo))
|
||||
.stream().collect(Collectors.groupingBy(MchCheckoutBillTransferItemEntity::getBillMerchantId));
|
||||
List<MchCheckoutBillMerchantVo> pending = new ArrayList<>();
|
||||
for (MchCheckoutBillMerchantVo row : rows) {
|
||||
if (row.getOrderCount() == 0 || Objects.equals(row.getPayableAmountFen(), 0L)) {
|
||||
row.setReceiverSource("NO_TRANSFER");
|
||||
row.setReceiverMessage(row.getOrderCount() == 0 ? "无结账订单" : "零元结清,无需实际转账");
|
||||
continue;
|
||||
}
|
||||
List<MchCheckoutBillTransferItemEntity> history = snapshots.getOrDefault(row.getId(), List.of());
|
||||
MchCheckoutBillTransferItemEntity saved = null;
|
||||
if ("PAYING".equals(row.getState()) && !history.isEmpty()) saved = history.get(0);
|
||||
if ("PAID".equals(row.getState())) saved = history.stream().filter(item -> "SUCCESS".equals(item.getStatus())).findFirst().orElse(null);
|
||||
if (saved == null) {
|
||||
pending.add(row);
|
||||
} else {
|
||||
if (!Objects.equals(row.getMerchantId(), saved.getMerchantId()) || !Objects.equals(row.getPayableAmountFen(), saved.getAmountFen())) {
|
||||
row.setReceiverSource("TRANSFER_SNAPSHOT");
|
||||
row.setReceiverMessage("原转账归属或金额与账单不一致,请核对原流水");
|
||||
continue;
|
||||
}
|
||||
row.setReceiverType(saved.getReceiverType());
|
||||
row.setReceiverAccountMasked(saved.getReceiverAccountMasked());
|
||||
row.setReceiverNameMasked(saved.getReceiverNameMasked());
|
||||
row.setReceiverSource("TRANSFER_SNAPSHOT");
|
||||
if (saved.getReceiverAccountMasked() == null) row.setReceiverMessage("原转账缺少收款快照,请核对转账流水");
|
||||
}
|
||||
}
|
||||
List<Long> ids = pending.stream().map(MchCheckoutBillMerchantVo::getMerchantId).distinct().toList();
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> current = current(ids, settings(ids));
|
||||
for (MchCheckoutBillMerchantVo row : pending) {
|
||||
ThirdMerchantTransferReceiverVo receiver = current.get(row.getMerchantId());
|
||||
row.setReceiverSource("CURRENT_CONFIG");
|
||||
if (receiver.getReceiver() != null) row.setReceiverType(receiver.getReceiver().getIdentityType());
|
||||
row.setReceiverAccountMasked(receiver.getAccountMasked());
|
||||
row.setReceiverNameMasked(receiver.getNameMasked());
|
||||
if (!receiver.isEligible()) row.setReceiverMessage(Objects.toString(receiver.getMessage(), "收款配置不完整"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-21
@@ -12,7 +12,6 @@ import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdMerchantTransferReceiverVo;
|
||||
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutBillTransferRequest;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.*;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillVo;
|
||||
import com.bsd.pjcloud.wallet.config.MchCheckoutTransferProperties;
|
||||
import com.bsd.pjcloud.wallet.mapper.MchManageMapper;
|
||||
import com.bsd.pjcloud.wallet.mapper.mch.*;
|
||||
import com.bsd.pjcloud.wallet.support.mch.*;
|
||||
@@ -28,8 +27,6 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 经正常预检入口覆盖全量金额、整批阻断及原请求恢复,未访问真实渠道和数据库。 */
|
||||
class MchCheckoutBillTransferServiceTest {
|
||||
/** 真实功能开关,测试按用例启用。 */
|
||||
private final MchCheckoutTransferProperties properties = new MchCheckoutTransferProperties();
|
||||
/** 原账单历史授权替身。 */
|
||||
private final MchCheckoutRelationAccess access = mock(MchCheckoutRelationAccess.class);
|
||||
/** 财务视图及摘要替身。 */
|
||||
@@ -42,8 +39,8 @@ class MchCheckoutBillTransferServiceTest {
|
||||
private final RemoteDeptService departments = mock(RemoteDeptService.class);
|
||||
/** 原始订单快照替身。 */
|
||||
private final MchCheckoutBillOrderMapper orders = mock(MchCheckoutBillOrderMapper.class);
|
||||
/** 支付配置目录替身。 */
|
||||
private final MchPaySettingMapper settings = mock(MchPaySettingMapper.class);
|
||||
/** 当前收款配置与只读展示替身。 */
|
||||
private final MchCheckoutBillReceiverSupport receiverSupport = mock(MchCheckoutBillReceiverSupport.class);
|
||||
/** 原受理批次替身。 */
|
||||
private final MchCheckoutBillTransferBatchMapper batches = mock(MchCheckoutBillTransferBatchMapper.class);
|
||||
/** 原付款尝试替身。 */
|
||||
@@ -61,12 +58,11 @@ class MchCheckoutBillTransferServiceTest {
|
||||
/** 真实序列化器覆盖固定指令的结构。 */
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
/** 被测全量预检及受理编排。 */
|
||||
private final MchCheckoutBillTransferService service = new MchCheckoutBillTransferService(properties, access, ledger, merchants, currentMerchants, departments, orders, settings, batches, items, store, cache, source, transfers, encryptor, json);
|
||||
private final MchCheckoutBillTransferService service = new MchCheckoutBillTransferService(access, ledger, merchants, currentMerchants, departments, orders, receiverSupport, batches, items, store, cache, source, transfers, encryptor, json);
|
||||
|
||||
/** 管理端用户、整单数据权限及只读酒店事实。 */
|
||||
@BeforeEach
|
||||
void initialize() {
|
||||
properties.setEnabled(true);
|
||||
PjcloudUser user = mock(PjcloudUser.class);
|
||||
when(user.getId()).thenReturn(9L);
|
||||
when(user.getUsername()).thenReturn("test-operator");
|
||||
@@ -88,16 +84,6 @@ class MchCheckoutBillTransferServiceTest {
|
||||
@AfterEach
|
||||
void clearUser() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
/** 未配置时直接启用全部结算,显式关闭仍可暂停新付款授权。 */
|
||||
@Test
|
||||
void defaultConfigurationEnablesTransferAndExplicitDisableStillWorks() {
|
||||
MchCheckoutTransferProperties defaults = new MchCheckoutTransferProperties();
|
||||
assertTrue(defaults.isEnabled());
|
||||
assertDoesNotThrow(defaults::requireEnabled);
|
||||
defaults.setEnabled(false);
|
||||
assertThrows(IllegalStateException.class, defaults::requireEnabled);
|
||||
}
|
||||
|
||||
/** 超过一页的商户应付全部纳入服务端合计,零元商户同时确认结清。 */
|
||||
@Test
|
||||
void previewIncludesAllPagesAndZeroPayable() {
|
||||
@@ -128,14 +114,39 @@ class MchCheckoutBillTransferServiceTest {
|
||||
assertFalse(result.isEligible());
|
||||
assertEquals(3, result.getItems().size());
|
||||
assertTrue(result.getItems().get(0).getMessage().contains("0.10"));
|
||||
assertEquals("tes****1234", result.getItems().get(0).getReceiverAccountMasked());
|
||||
verifyNoInteractions(cache);
|
||||
verify(transfers, never()).create(any());
|
||||
}
|
||||
|
||||
/** 同号受理结果不依赖过期缓存和当前开关,可从已持久批次安全恢复。 */
|
||||
/** 上游预检失败时保留只读身份及全部商户,不能生成真实付款令牌。 */
|
||||
@Test
|
||||
void replayRestoresAcceptedBatchBeforeCacheAndSwitchChecks() {
|
||||
properties.setEnabled(false);
|
||||
void unavailablePrecheckKeepsMerchantRowsAndReceiverDisplay() {
|
||||
prepare(2);
|
||||
when(transfers.resolveReceivers(any())).thenThrow(new IllegalStateException("预检读取失败"));
|
||||
when(receiverSupport.current(anyList(), anyMap())).thenAnswer(call -> {
|
||||
List<Long> ids = call.getArgument(0);
|
||||
Map<Long, List<MchPaySettingEntity>> configs = call.getArgument(1);
|
||||
Map<Long, ThirdMerchantTransferReceiverVo> values = new LinkedHashMap<>();
|
||||
for (Long id : ids) {
|
||||
ThirdMerchantTransferReceiverQuery.Item input = new ThirdMerchantTransferReceiverQuery.Item();
|
||||
input.setMchId(id);
|
||||
input.setPaySettingId(configs.get(id).get(0).getId());
|
||||
values.put(id, receiver(input));
|
||||
}
|
||||
return values;
|
||||
});
|
||||
var result = service.preview(42L);
|
||||
assertEquals(3, result.getItems().size());
|
||||
assertFalse(result.isEligible());
|
||||
assertEquals("tes****1234", result.getItems().get(0).getReceiverAccountMasked());
|
||||
assertTrue(result.getItems().get(0).getMessage().contains("预检暂不可用"));
|
||||
verifyNoInteractions(cache);
|
||||
}
|
||||
|
||||
/** 同号受理结果不依赖过期预检缓存,可从已持久批次安全恢复。 */
|
||||
@Test
|
||||
void replayRestoresAcceptedBatchBeforeCacheChecks() {
|
||||
MchCheckoutBillTransferBatchEntity previous = new MchCheckoutBillTransferBatchEntity();
|
||||
previous.setId(7L);
|
||||
previous.setBillId(42L);
|
||||
@@ -193,7 +204,7 @@ class MchCheckoutBillTransferServiceTest {
|
||||
}
|
||||
when(merchants.selectList(any())).thenReturn(members);
|
||||
when(currentMerchants.selectList(any())).thenReturn(merchantFacts);
|
||||
when(settings.selectList(any())).thenReturn(paymentSettings);
|
||||
when(receiverSupport.settings(anyList())).thenReturn(paymentSettings.stream().collect(java.util.stream.Collectors.groupingBy(MchPaySettingEntity::getMchId)));
|
||||
when(orders.selectList(any())).thenReturn(original);
|
||||
when(source.fetch(anyList())).thenReturn(facts);
|
||||
SysDept department = new SysDept();
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class MchCheckoutBillPreviewTest {
|
||||
when(user.getId()).thenReturn(9L);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", List.of()));
|
||||
try {
|
||||
MchCheckoutBillService service = new MchCheckoutBillServiceImpl(bills, merchants, orders, claims, mock(MchCheckoutBillEventMapper.class), previews, source, context, access, ledger, mock(MchCheckoutBillRecheckService.class), transaction);
|
||||
MchCheckoutBillService service = new MchCheckoutBillServiceImpl(bills, merchants, orders, claims, mock(MchCheckoutBillEventMapper.class), previews, source, context, access, ledger, mock(MchCheckoutBillRecheckService.class), mock(MchCheckoutBillReceiverSupport.class), transaction);
|
||||
MchCheckoutBillRequests.Create request = new MchCheckoutBillRequests.Create();
|
||||
request.setPreviewToken("preview");
|
||||
request.setRequestId("request-one");
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.bsd.pjcloud.wallet.support.mch;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.thirdparty.api.feign.RemoteThirdTransferService;
|
||||
import com.bsd.pjcloud.thirdparty.dto.transfer.ThirdTransferCreateDto;
|
||||
import com.bsd.pjcloud.thirdparty.vo.transfer.ThirdMerchantTransferReceiverVo;
|
||||
import com.bsd.pjcloud.wallet.api.entity.mch.*;
|
||||
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutBillMerchantVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.mch.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 账单收款展示回归,当前配置、历史收款人和远程故障必须保持可区分。 */
|
||||
class MchCheckoutBillReceiverSupportTest {
|
||||
/** 当前支付配置替身。 */
|
||||
private final MchPaySettingMapper settings = mock(MchPaySettingMapper.class);
|
||||
/** 固定付款身份替身。 */
|
||||
private final MchCheckoutBillTransferItemMapper items = mock(MchCheckoutBillTransferItemMapper.class);
|
||||
/** 展示专用Feign替身。 */
|
||||
private final RemoteThirdTransferService remote = mock(RemoteThirdTransferService.class);
|
||||
/** 被测安全投影。 */
|
||||
private final MchCheckoutBillReceiverSupport support = new MchCheckoutBillReceiverSupport(settings, items, remote);
|
||||
|
||||
/** 显式列投影需要实体列元信息,沿用现有Mapper替身测试初始化方式。 */
|
||||
@BeforeAll
|
||||
static void initializeColumns() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "checkout-receiver-test"), MchCheckoutBillTransferItemEntity.class);
|
||||
}
|
||||
|
||||
/** 未付款商户从当前唯一默认配置展示姓名、类型及脱敏标识。 */
|
||||
@Test
|
||||
void waitingMerchantShowsCurrentDefaultReceiver() {
|
||||
prepareCurrent();
|
||||
MchCheckoutBillMerchantVo row = merchant("WAIT_PAY");
|
||||
support.fill(List.of(row));
|
||||
assertEquals("CURRENT_CONFIG", row.getReceiverSource());
|
||||
assertEquals("ALIPAY_USER_ID", row.getReceiverType());
|
||||
assertEquals("208****1234", row.getReceiverAccountMasked());
|
||||
assertEquals("测*", row.getReceiverNameMasked());
|
||||
assertNull(row.getReceiverMessage());
|
||||
}
|
||||
|
||||
/** 已付款商户使用实际付款快照,后续默认配置变化不改变历史展示。 */
|
||||
@Test
|
||||
void paidMerchantUsesSnapshotWithoutReadingCurrentConfig() {
|
||||
MchCheckoutBillTransferItemEntity saved = new MchCheckoutBillTransferItemEntity();
|
||||
saved.setBillMerchantId(101L);
|
||||
saved.setMerchantId(1L);
|
||||
saved.setAmountFen(21000L);
|
||||
saved.setStatus("SUCCESS");
|
||||
saved.setReceiverType("ALIPAY_LOGON_ID");
|
||||
saved.setReceiverAccountMasked("old****5678");
|
||||
saved.setReceiverNameMasked("原*");
|
||||
when(items.selectList(any())).thenReturn(List.of(saved));
|
||||
MchCheckoutBillMerchantVo row = merchant("PAID");
|
||||
support.fill(List.of(row));
|
||||
assertEquals("TRANSFER_SNAPSHOT", row.getReceiverSource());
|
||||
assertEquals("old****5678", row.getReceiverAccountMasked());
|
||||
assertEquals("原*", row.getReceiverNameMasked());
|
||||
verifyNoInteractions(settings, remote);
|
||||
}
|
||||
|
||||
/** 服务读取失败保留商户和金额,并展示明确原因,不误报成未配置。 */
|
||||
@Test
|
||||
void remoteFailureIsVisibleOnTheOriginalMerchant() {
|
||||
prepareCurrent();
|
||||
when(remote.lookupReceivers(any())).thenReturn(R.failed("服务不可用"));
|
||||
MchCheckoutBillMerchantVo row = merchant("WAIT_PAY");
|
||||
support.fill(List.of(row));
|
||||
assertEquals(21000L, row.getPayableAmountFen());
|
||||
assertEquals("CURRENT_CONFIG", row.getReceiverSource());
|
||||
assertTrue(row.getReceiverMessage().contains("读取失败"));
|
||||
}
|
||||
|
||||
/** 基础配置与受保护内部身份,测试使用虚构账号。 */
|
||||
private void prepareCurrent() {
|
||||
when(items.selectList(any())).thenReturn(List.of());
|
||||
MchPaySettingEntity config = new MchPaySettingEntity();
|
||||
config.setId(21L);
|
||||
config.setMchId(1L);
|
||||
when(settings.selectList(any())).thenReturn(List.of(config));
|
||||
ThirdMerchantTransferReceiverVo receiver = new ThirdMerchantTransferReceiverVo();
|
||||
receiver.setMchId(1L);
|
||||
receiver.setPaySettingId(21L);
|
||||
receiver.setEligible(true);
|
||||
receiver.setAccountMasked("208****1234");
|
||||
receiver.setNameMasked("测*");
|
||||
ThirdTransferCreateDto.Receiver identity = new ThirdTransferCreateDto.Receiver();
|
||||
identity.setIdentityType("ALIPAY_USER_ID");
|
||||
identity.setIdentity("test-private-identity");
|
||||
receiver.setReceiver(identity);
|
||||
when(remote.lookupReceivers(any())).thenReturn(R.ok(List.of(receiver)));
|
||||
}
|
||||
|
||||
/** 正金额商户的账单安全视图。 */
|
||||
private MchCheckoutBillMerchantVo merchant(String state) {
|
||||
MchCheckoutBillMerchantVo row = new MchCheckoutBillMerchantVo();
|
||||
row.setId(101L);
|
||||
row.setMerchantId(1L);
|
||||
row.setOrderCount(2);
|
||||
row.setPayableAmountFen(21000L);
|
||||
row.setState(state);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user