diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdTransferService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdTransferService.java index d8b10f9fe..1ad193799 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdTransferService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteThirdTransferService.java @@ -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> resolveReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverQuery query); + /** 只读解析默认身份用于展示,完整账号仅供内部服务生成脱敏视图,结果不能作为付款授权。 */ + @NoToken + @PostMapping("/transfer-remoter/merchant-receivers/lookup") + R> lookupReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverLookup query); + /** 恢复原付款指令的查单任务并同步核对,不创建新的付款指令。 */ @NoToken @PostMapping("/transfer-remoter/{transferNo}/recheck") diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/transfer/ThirdMerchantTransferReceiverLookup.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/transfer/ThirdMerchantTransferReceiverLookup.java new file mode 100644 index 000000000..25d27c269 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/transfer/ThirdMerchantTransferReceiverLookup.java @@ -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; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdMerchantTransferReceiverVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdMerchantTransferReceiverVo.java index d636700b6..32df37520 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdMerchantTransferReceiverVo.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/transfer/ThirdMerchantTransferReceiverVo.java @@ -24,7 +24,7 @@ public class ThirdMerchantTransferReceiverVo { private String accountMasked; /** 可展示的脱敏实名。 */ private String nameMasked; - /** 此项是否通过配置及渠道约束校验。 */ + /** lookup表示基础身份有效,resolve表示真实付款预检通过,只有后者可生成付款授权。 */ private boolean eligible; /** 安全可展示的失败原因。 */ private String message; diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdTransferRemoterController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdTransferRemoterController.java index 59dfe0d59..22631760a 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdTransferRemoterController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appplus/ThirdTransferRemoterController.java @@ -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> lookupReceivers(@Valid @RequestBody ThirdMerchantTransferReceiverLookup query) { + return R.ok(receivers.lookup(query)); + } + /** 同步核对原单并恢复原任务,结果未知时明确拒绝作为再次发款依据。 */ @PostMapping("/{transferNo}/recheck") public R recheck(@PathVariable String transferNo) { diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverService.java index 64652115b..a41a99603 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverService.java @@ -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 channels; - /** 结果通知登记配置,预检时即发现缺失接收服务。 */ + /** 实际付款必须登记的内部结果接收服务。 */ private final ThirdTransferProperties properties; - /** 账号与实名的安全展示工具。 */ + /** 管理端身份的统一脱敏能力。 */ private final ThirdTransferRequestSupport requests; - /** 一次读取全部明确支付配置,逐项返回失败原因,配置异常保留为预检失败。 */ - public List resolve(ThirdMerchantTransferReceiverQuery query) { + /** 只读展示身份,基础身份有效不代表已经取得付款授权,不初始化支付客户端。 */ + public List lookup(ThirdMerchantTransferReceiverLookup query) { ThirdTransferAccess.requireTenant(); - properties.requireNotifyService(query.getBizType()); - ThirdTransferChannel channel = channels.stream().filter(item -> "20".equals(item.platformType())).findFirst() - .orElseThrow(() -> new IllegalStateException("支付宝转账渠道尚未注册")); - List settingIds = query.getItems().stream().map(ThirdMerchantTransferReceiverQuery.Item::getPaySettingId).distinct().toList(); - Map> bySetting = relations.selectList(Wrappers.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> 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 defaults, ThirdTransferChannel channel) { + /** 先读取每家收款人,再逐项执行真实付款校验,配置失败也保留可见身份和明确原因。 */ + public List resolve(ThirdMerchantTransferReceiverQuery query) { + ThirdTransferAccess.requireTenant(); + Map> 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> defaults(List settingIds) { + return relations.selectList(Wrappers.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 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; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverServiceTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverServiceTest.java index d530a51e8..8982102ab 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverServiceTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdMerchantTransferReceiverServiceTest.java @@ -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("实名")); } /** 独立测试关系,与真实用户账号无关。 */ diff --git a/pjcloud-wallet/docs/checkout-transfer.md b/pjcloud-wallet/docs/checkout-transfer.md index b30966340..8be47f984 100644 --- a/pjcloud-wallet/docs/checkout-transfer.md +++ b/pjcloud-wallet/docs/checkout-transfer.md @@ -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` 转账指令在已确认实付等于应付时允许渠道时间暂缺,原付款登记继续要求付款时间。脚本支持重复执行。编写迁移与执行数据库分开安排。 -关闭开关会停止新的授权和钱包受理扫描。已被第三方受理的付款及结果通知继续按原指令执行,历史查询和结果回写持续可用。 - ## 默认收款配置 使用商户当前支付宝支付配置的唯一默认关系。默认关系中的接收方类型、完整标识及可选实名作为转账依据。关闭分账时仍可维护收款字段,实际转账前统一校验。 diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java index 31bcc7fc7..f9e94d0ff 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillMerchantVo.java @@ -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; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillTransferVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillTransferVo.java index 6ef21aa1c..281e22a96 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillTransferVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutBillTransferVo.java @@ -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; /** 预检结论或资金执行状态。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/config/MchCheckoutTransferProperties.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/config/MchCheckoutTransferProperties.java deleted file mode 100644 index 6fdb4ef75..000000000 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/config/MchCheckoutTransferProperties.java +++ /dev/null @@ -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("全部结算已暂停,请联系管理员恢复"); - } -} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillTransferJob.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillTransferJob.java index f9812e3b0..6fe715399 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillTransferJob.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/job/MchCheckoutBillTransferJob.java @@ -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> response = tenants.list(); if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("结账转账租户目录读取失败"); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferService.java index d39998845..85a82eb25 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferService.java @@ -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.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 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> paymentSettings(List members) { List 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.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 result = new LinkedHashMap<>(); - Map 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 result = new LinkedHashMap<>(); + Map 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 ids = inputs.stream().map(ThirdMerchantTransferReceiverQuery.Item::getMchId).toList(); + Map 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()); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java index 36e049773..4e54eef51 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutBillServiceImpl.java @@ -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; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupport.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupport.java new file mode 100644 index 000000000..67e4e9ae9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupport.java @@ -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> settings(List merchantIds) { + if (merchantIds.isEmpty()) return Map.of(); + return settings.selectList(Wrappers.lambdaQuery().eq(MchPaySettingEntity::getPlatformType, "20") + .in(MchPaySettingEntity::getMchId, merchantIds)).stream().collect(Collectors.groupingBy(MchPaySettingEntity::getMchId)); + } + + /** 返回每家当前收款配置;网络、业务或协议失败逐项明确展示,不能假装没有配置。 */ + public Map current(List merchantIds, Map> configs) { + Map result = new LinkedHashMap<>(); + List inputs = new ArrayList<>(); + for (Long id : merchantIds.stream().distinct().toList()) { + ThirdMerchantTransferReceiverVo row = new ThirdMerchantTransferReceiverVo(); + row.setMchId(id); + row.setMessage("商户需要唯一的有效支付宝支付配置"); + result.put(id, row); + List 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> response = remote.lookupReceivers(query); + if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("收款人读取失败"); + Map expected = inputs.stream().collect(Collectors.toMap(ThirdMerchantTransferReceiverLookup.Item::getMchId, ThirdMerchantTransferReceiverLookup.Item::getPaySettingId)); + Map 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 rows) { + if (rows.isEmpty()) return; + List memberIds = rows.stream().map(MchCheckoutBillMerchantVo::getId).toList(); + Map> snapshots = items.selectList(Wrappers.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 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 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 ids = pending.stream().map(MchCheckoutBillMerchantVo::getMerchantId).distinct().toList(); + Map 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(), "收款配置不完整")); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferServiceTest.java index ba841adeb..d9c1d8376 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferServiceTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/MchCheckoutBillTransferServiceTest.java @@ -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 ids = call.getArgument(0); + Map> configs = call.getArgument(1); + Map 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(); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java index cfffda590..bad851ed9 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillPreviewTest.java @@ -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"); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupportTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupportTest.java new file mode 100644 index 000000000..5b5415005 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutBillReceiverSupportTest.java @@ -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; + } +}