From c11d70ccacb5c78219308d3dc278bd465085c953 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Sat, 29 Aug 2026 17:29:51 +0800 Subject: [PATCH] 11 --- .../dto/pay/ThirdAlipayAliasModifyDto.java | 167 +++++ .../vo/pay/ThirdAlipayAliasModifyVo.java | 17 + .../pay/ThirdMchFzRelationController.java | 50 ++ .../service/ThirdMchFzRelationService.java | 24 +- .../pay/ThirdMchFzRelationServiceImpl.java | 572 ++++++++++++++++++ .../ThirdMchFzRelationServiceImplTest.java | 156 +++++ .../recharge/WalletRechargeServiceImpl.java | 18 +- 7 files changed, 996 insertions(+), 8 deletions(-) create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdAlipayAliasModifyDto.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdAlipayAliasModifyVo.java diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdAlipayAliasModifyDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdAlipayAliasModifyDto.java new file mode 100644 index 000000000..e18d01ce0 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdAlipayAliasModifyDto.java @@ -0,0 +1,167 @@ +package com.bsd.pjcloud.thirdparty.dto.pay; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 支付宝直付通二级商户全量资料修改参数。 + * + *

支付宝的简称修改接口实际会重新校验二级商户资料,因此请求不能只携带 alias_name。 + * 图片字段填写支付宝 {@code ant.merchant.expand.indirect.image.upload} 返回的 image_id。

+ */ +@Data +public class ThirdAlipayAliasModifyDto { + + @NotBlank(message = "支付宝商户简称不能为空") + @Size(max = 128, message = "支付宝商户简称不能超过128个字符") + private String aliasName; + + /** 进件时登记的商户名称,通常应与营业执照或身份证名称一致。 */ + @NotBlank(message = "支付宝商户名称不能为空") + @Size(max = 128, message = "支付宝商户名称不能超过128个字符") + private String name; + + /** 01企业、02事业单位、03民办非企业、04社会团体、05党政机关、06个人、07个体工商户。 */ + @NotBlank(message = "支付宝商户类型不能为空") + private String merchantType; + + @NotBlank(message = "支付宝商户类别码MCC不能为空") + private String mcc; + + @NotEmpty(message = "支付宝商户已开通服务不能为空") + private List<@NotBlank(message = "支付宝商户服务不能为空") String> services; + + @NotBlank(message = "支付宝客服电话不能为空") + private String servicePhone; + + /** yyyy-MM-dd。 */ + @NotBlank(message = "支付宝签约时间不能为空") + private String signTimeWithIsv; + + /** 201营业执照、218事业单位证书、204民办非企业证书、206社会团体证书、219机关文件、100身份证。 */ + @NotBlank(message = "支付宝商户证件类型不能为空") + private String certType; + + /** 非个人商户必填。 */ + private String legalName; + private String legalCertNo; + private String legalCertType; + + /** 需要协议确认时填写签约支付宝账号;结算到支付宝时同时填写 alipayLogonId。 */ + private String bindingAlipayLogonId; + private String alipayLogonId; + + @Valid + private ContactInfoDto contact; + + @Valid + private BusinessAddressDto businessAddress; + + @Valid + private List sites; + + @Valid + private SettleCardDto settleCard; + + @Valid + private DefaultSettleRuleDto defaultSettleRule; + + /** 当面付需要门头照、内景照;值为支付宝 image_id,多个值用列表传递。 */ + private List outDoorImages; + private List inDoorImages; + private String certImage; + private String certImageBack; + private String legalCertFrontImage; + private String legalCertBackImage; + private String licenseAuthLetterImage; + /** 个人商户当面付提额时可补充的证件三件套。 */ + private String additionalCertType; + private String additionalCertNo; + private String additionalCertImage; + + @Valid + private List qualifications; + + /** 交易场景,可选 TINY_APP、WAP、OFFLINE、APP、PC;WAP、APP、PC 对应站点必须有截图。 */ + private List tradeScene; + + @Data + public static class ContactInfoDto { + @NotBlank(message = "支付宝联系人姓名不能为空") + private String name; + @NotBlank(message = "支付宝联系人手机号不能为空") + private String mobile; + private String phone; + private String email; + private String idCardNo; + private List tag; + /** LEGAL_PERSON、CONTROLLER、AGENT、OTHER。 */ + private String type; + } + + @Data + public static class BusinessAddressDto { + private String provinceCode; + private String cityCode; + private String districtCode; + private String address; + private String poiid; + private String longitude; + private String latitude; + private String type; + } + + @Data + public static class SiteInfoDto { + private String siteType; + private String siteName; + private String siteUrl; + private String screenshotImage; + private String tinyAppId; + private String account; + private String password; + private String status; + private String market; + private String download; + private String authLetterImage; + private String remark; + private String remarkImage; + private String siteDomain; + private String icpServiceName; + private String icpNo; + private String icpOrgName; + } + + @Data + public static class SettleCardDto { + private String accountBranchName; + private String accountHolderName; + private String accountInstCity; + private String accountInstId; + private String accountInstName; + private String accountInstProvince; + private String accountNo; + /** DC借记卡、CC信用卡。 */ + private String accountType; + private String usageType; + private String bankCode; + } + + @Data + public static class DefaultSettleRuleDto { + /** bankCard 或 alipayAccount。 */ + private String defaultSettleType; + private String defaultSettleTarget; + } + + @Data + public static class QualificationDto { + private String industryQualificationType; + private String industryQualificationImage; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdAlipayAliasModifyVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdAlipayAliasModifyVo.java new file mode 100644 index 000000000..ddbd871da --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdAlipayAliasModifyVo.java @@ -0,0 +1,17 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import lombok.Data; + +/** + * 支付宝直付通二级商户简称修改结果。 + * + *

支付宝接口为异步资料修改流程,返回的申请单号用于渠道侧追踪;当前阶段不在本地保存申请记录。

+ */ +@Data +public class ThirdAlipayAliasModifyVo { + + /** + * 支付宝返回的资料修改申请单号。 + */ + private String orderId; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java index 3718e57e9..7bd4b8776 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdMchFzRelationController.java @@ -4,9 +4,11 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdAlipayAliasModifyDto; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdAlipayAliasModifyVo; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import io.swagger.v3.oas.annotations.Operation; @@ -21,7 +23,10 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -61,6 +66,51 @@ public class ThirdMchFzRelationController { return R.ok(relationService.saveForManage(dto)); } + /** + * 提交支付宝直付通二级商户简称修改申请;支付宝返回申请单号后由渠道异步处理。 + * + * @param id 分账关系主键;服务端据此读取支付宝 smid 和授权上下文 + * @param dto 本次申请的支付宝商户简称 + * @return 支付宝异步资料修改申请单号 + */ + @Operation(summary = "修改支付宝商户简称") + @PostMapping("/{id}/alipay-alias") + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R modifyAlipayAlias(@PathVariable Long id, + @Valid @RequestBody ThirdAlipayAliasModifyDto dto) { + ThirdMchFzRelationEntity relation = relationService.getById(id); + if (relation == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + requireMerchantAccess(relation.getMchId()); + // DTO含身份证、银行卡和站点测试密码,不能使用会记录完整请求体的 @SysLog。 + return R.ok(relationService.modifyAlipayAlias(id, dto)); + } + + /** 上传商户资料图片到支付宝,返回资料修改接口可直接使用的 image_id。 */ + @Operation(summary = "上传支付宝商户资料图片") + @PostMapping(value = "/{id}/alipay-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')") + public R uploadAlipayImage(@PathVariable Long id, @RequestPart("file") MultipartFile file) { + ThirdMchFzRelationEntity relation = relationService.getById(id); + if (relation == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + requireMerchantAccess(relation.getMchId()); + try { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("图片不能为空"); + } + if (file.getSize() > 10 * 1024 * 1024L) { + throw new IllegalArgumentException("图片不能超过10MB"); + } + return R.ok(relationService.uploadAlipayMerchantImage(id, file.getOriginalFilename(), + file.getBytes(), file.getContentType())); + } catch (java.io.IOException exception) { + throw new IllegalArgumentException("读取图片失败"); + } + } + /** 删除关系;已绑定关系由服务层保留为历史事实。 */ @Operation(summary = "删除分账关系") @SysLog("删除商户支付分账关系") diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java index 8c4619038..8957919c7 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdMchFzRelationService.java @@ -1,7 +1,9 @@ package com.bsd.pjcloud.thirdparty.service; -import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdAlipayAliasModifyDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdAlipayAliasModifyVo; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; @@ -30,6 +32,26 @@ public interface ThirdMchFzRelationService { */ ThirdMchFzRelationVo saveForManage(ThirdMchFzRelationSaveDto dto); + /** + * 提交支付宝直付通二级商户简称修改申请;申请由支付宝异步处理,不改变本地关系事实。 + * + * @param relationId 分账关系主键,用于解析并校验支付宝 smid、商户和平台账号 + * @param dto 商户简称及支付宝要求的全量资料 + * @return 支付宝返回的资料修改申请单号 + */ + ThirdAlipayAliasModifyVo modifyAlipayAlias(Long relationId, ThirdAlipayAliasModifyDto dto); + + /** + * 上传支付宝二级商户资料图片,返回后续资料接口所需的 image_id。 + * + * @param relationId 支付宝关系主键,用于解析平台账号和代调用授权 + * @param fileName 原始文件名 + * @param content 图片二进制,最大10MB + * @param mimeType 图片媒体类型 + * @return 支付宝图片标识 + */ + String uploadAlipayMerchantImage(Long relationId, String fileName, byte[] content, String mimeType); + /** * 删除关系;已绑定关系只标记为历史关系,删除默认关系后允许当前支付配置暂时没有默认关系。 * 支付宝直付通和微信服务商支付都需要默认关系提供二级商户路由,缺失期间新支付将被拒绝。 diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java index ccd73ee95..50ddb6ad8 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImpl.java @@ -7,13 +7,25 @@ import com.alipay.api.AlipayClient; import com.alipay.api.AlipayApiException; import com.alipay.api.AlipayConfig; import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.FileItem; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; +import com.alipay.api.domain.AntMerchantExpandIndirectZftModifyModel; +import com.alipay.api.domain.AddressInfo; +import com.alipay.api.domain.ContactInfo; +import com.alipay.api.domain.DefaultSettleRule; +import com.alipay.api.domain.IndustryQualificationInfo; import com.alipay.api.domain.RoyaltyEntity; +import com.alipay.api.domain.SettleCardInfo; +import com.alipay.api.domain.SiteInfo; import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest; import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest; +import com.alipay.api.request.AntMerchantExpandIndirectImageUploadRequest; +import com.alipay.api.request.AntMerchantExpandIndirectZftModifyRequest; import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse; import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse; +import com.alipay.api.response.AntMerchantExpandIndirectImageUploadResponse; +import com.alipay.api.response.AntMerchantExpandIndirectZftModifyResponse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -24,11 +36,13 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdAlipayAliasModifyDto; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdAlipayAliasModifyVo; import com.bsd.pjcloud.thirdparty.vo.pay.ThirdMchFzRelationVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -47,8 +61,14 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; @@ -88,6 +108,22 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService * 管理端允许配置的支付宝接收方类型,编码直接传给支付宝 SDK。 */ private static final Set ALIPAY_RECEIVER_TYPES = Set.of("userId", "loginName", "openId", "cardAliasNo"); + /** + * 支付宝二级商户资料接口允许提交的服务名称。 + */ + private static final Set ALIPAY_MERCHANT_SERVICES = Set.of( + "当面付", "jsapi支付", "app支付", "wap支付", "电脑支付", "预授权支付", "商户代扣", "小程序支付", "订单码支付"); + /** 支付宝直付通商户类型。 */ + private static final Set ALIPAY_MERCHANT_TYPES = Set.of("01", "02", "03", "04", "05", "06", "07"); + /** 各商户类型在直付通进件中唯一对应的主体证件类型。 */ + private static final Map ALIPAY_MERCHANT_CERT_TYPES = Map.of( + "01", "201", "02", "218", "03", "204", "04", "206", "05", "219", "06", "100", "07", "201"); + /** 支付宝联系人身份枚举。 */ + private static final Set ALIPAY_CONTACT_TYPES = Set.of("LEGAL_PERSON", "CONTROLLER", "AGENT", "OTHER"); + /** 支付宝站点类型:网站、APP、支付宝小程序。 */ + private static final Set ALIPAY_SITE_TYPES = Set.of("01", "02", "06"); + /** 支付宝资料接口支持的交易场景。 */ + private static final Set ALIPAY_TRADE_SCENES = Set.of("TINY_APP", "WAP", "OFFLINE", "APP", "PC"); /** * 管理端允许配置的微信接收方类型,编码对应微信 V3 分账 SDK 枚举。 */ @@ -213,6 +249,542 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService return toManageVo(latest == null ? relation : latest); } + /** + * 提交支付宝直付通二级商户全量资料修改申请。 + * + *

{@code ant.merchant.expand.indirect.zft.modify} 会按商户类型和已开通服务重新校验完整资料。 + * 本方法先做同等条件校验,再把管理端资料显式映射到支付宝 SDK;资料仅用于本次申请,不落库。

+ */ + @Override + public ThirdAlipayAliasModifyVo modifyAlipayAlias(Long relationId, ThirdAlipayAliasModifyDto dto) { + validateAlipayMerchantProfile(relationId, dto); + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + throw new IllegalStateException("当前租户不能为空"); + } + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, relationId) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); + if (relation == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + if (!ALIPAY_TYPE.equals(relation.getPlatformType())) { + throw new IllegalArgumentException("只有支付宝分账关系支持修改商户简称"); + } + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { + throw new IllegalArgumentException("历史分账关系不能修改支付宝商户简称,请使用当前关系"); + } + if (StrUtil.isBlank(relation.getChannelSubMchId())) { + throw new IllegalStateException("支付宝直付通二级商户标识未配置"); + } + MchManageVo merchant = requireMerchant(relation.getMchId()); + MchPaySettingVo setting = merchant.getPaySettingList() == null ? null + : merchant.getPaySettingList().stream() + .filter(item -> Objects.equals(relation.getPaySettingId(), item.getId())) + .findFirst().orElse(null); + if (setting == null || !ALIPAY_TYPE.equals(setting.getPlatformType()) + || !matchesCurrentSetting(relation, setting)) { + throw new IllegalStateException("支付宝关系与当前商户支付配置不一致"); + } + + AppPlusPayConfig.AliPayConfig config = requireAliPayConfig(); + AntMerchantExpandIndirectZftModifyModel model = buildAlipayMerchantModifyModel( + relation.getChannelSubMchId(), dto); + AntMerchantExpandIndirectZftModifyRequest request = new AntMerchantExpandIndirectZftModifyRequest(); + request.setBizModel(model); + if (StrUtil.isNotBlank(relation.getAppAuthToken())) { + request.putOtherTextParam("app_auth_token", relation.getAppAuthToken()); + } + AntMerchantExpandIndirectZftModifyResponse response; + try { + response = client(config).execute(request); + } catch (Exception exception) { + log.error("调用支付宝二级商户资料修改接口异常,relationId:{}, exceptionType:{}", + relationId, exception.getClass().getSimpleName()); + throw new IllegalStateException("支付宝商户简称修改申请提交失败,请稍后重试"); + } + if (response == null || !response.isSuccess() || StrUtil.isBlank(response.getOrderId())) { + String channelMessage = response == null ? null + : StrUtil.firstNonBlank(response.getSubMsg(), response.getMsg()); + throw new IllegalStateException(StrUtil.isBlank(channelMessage) + ? "支付宝商户简称修改申请提交失败" + : "支付宝商户简称修改申请提交失败:" + channelMessage); + } + ThirdAlipayAliasModifyVo result = new ThirdAlipayAliasModifyVo(); + result.setOrderId(response.getOrderId()); + log.info("支付宝二级商户资料修改申请已提交,relationId:{}, orderId:{}", relationId, response.getOrderId()); + return result; + } + + /** 上传支付宝资料图片并返回 image_id,服务端不保存图片内容。 */ + @Override + public String uploadAlipayMerchantImage(Long relationId, String fileName, byte[] content, String mimeType) { + if (relationId == null) { + throw new IllegalArgumentException("分账关系不能为空"); + } + if (content == null || content.length == 0) { + throw new IllegalArgumentException("图片不能为空"); + } + if (content.length > 10 * 1024 * 1024) { + throw new IllegalArgumentException("图片不能超过10MB"); + } + String extension = fileExtension(fileName); + if (!Set.of("bmp", "jpg", "jpeg", "png", "gif").contains(extension)) { + throw new IllegalArgumentException("图片格式只支持bmp、jpg、jpeg、png或gif"); + } + ThirdMchFzRelationEntity relation = requireAlipayRelation(relationId); + AppPlusPayConfig.AliPayConfig config = requireAliPayConfig(); + AntMerchantExpandIndirectImageUploadRequest request = new AntMerchantExpandIndirectImageUploadRequest(); + request.setImageType(extension); + request.setImageContent(new FileItem(StrUtil.blankToDefault(fileName, "merchant." + extension), content, + StrUtil.blankToDefault(mimeType, "image/" + extension))); + if (StrUtil.isNotBlank(relation.getAppAuthToken())) { + request.putOtherTextParam("app_auth_token", relation.getAppAuthToken()); + } + try { + AntMerchantExpandIndirectImageUploadResponse response = client(config).execute(request); + if (response == null || !response.isSuccess() || StrUtil.isBlank(response.getImageId())) { + String message = response == null ? null : StrUtil.firstNonBlank(response.getSubMsg(), response.getMsg()); + throw new IllegalStateException(StrUtil.isBlank(message) ? "支付宝图片上传失败" : "支付宝图片上传失败:" + message); + } + return response.getImageId(); + } catch (AlipayApiException exception) { + log.error("调用支付宝商户资料图片上传接口异常,relationId:{}, exceptionType:{}", + relationId, exception.getClass().getSimpleName()); + throw new IllegalStateException("支付宝图片上传失败,请稍后重试"); + } + } + + /** 统一校验并读取支付宝关系,供资料和图片接口复用。 */ + private ThirdMchFzRelationEntity requireAlipayRelation(Long relationId) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + throw new IllegalStateException("当前租户不能为空"); + } + ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper() + .eq(ThirdMchFzRelationEntity::getId, relationId) + .eq(ThirdMchFzRelationEntity::getTenantId, tenantId) + .eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE) + .eq(ThirdMchFzRelationEntity::getDelFlag, "0") + .last("LIMIT 1")); + if (relation == null) { + throw new IllegalArgumentException("分账关系不存在"); + } + if (!ALIPAY_TYPE.equals(relation.getPlatformType())) { + throw new IllegalArgumentException("只有支付宝分账关系支持商户资料操作"); + } + if (ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) { + throw new IllegalArgumentException("历史分账关系不能修改支付宝商户资料"); + } + if (StrUtil.isBlank(relation.getChannelSubMchId())) { + throw new IllegalStateException("支付宝直付通二级商户标识未配置"); + } + return relation; + } + + private String fileExtension(String fileName) { + String normalized = StrUtil.blankToDefault(fileName, "").trim().toLowerCase(Locale.ROOT); + int dot = normalized.lastIndexOf('.'); + return dot >= 0 && dot < normalized.length() - 1 ? normalized.substring(dot + 1) : ""; + } + + /** 按支付宝文档中的商户类型、服务和结算条件提前检查全量资料。 */ + private void validateAlipayMerchantProfile(Long relationId, ThirdAlipayAliasModifyDto dto) { + if (relationId == null) { + throw new IllegalArgumentException("分账关系不能为空"); + } + if (dto == null) { + throw new IllegalArgumentException("支付宝商户资料不能为空"); + } + requireAlipayField(dto.getName(), "支付宝商户名称不能为空"); + String aliasName = requireAlipayField(dto.getAliasName(), "支付宝商户简称不能为空"); + if (aliasName.length() > 128) { + throw new IllegalArgumentException("支付宝商户简称不能超过128个字符"); + } + String merchantType = requireAlipayField(dto.getMerchantType(), "支付宝商户类型不能为空"); + if (!ALIPAY_MERCHANT_TYPES.contains(merchantType)) { + throw new IllegalArgumentException("支付宝商户类型不受支持"); + } + requireAlipayField(dto.getMcc(), "支付宝商户类别码MCC不能为空"); + String certType = requireAlipayField(dto.getCertType(), "支付宝商户证件类型不能为空"); + if (!certType.equals(ALIPAY_MERCHANT_CERT_TYPES.get(merchantType))) { + throw new IllegalArgumentException("支付宝商户类型与证件类型不匹配"); + } + requireAlipayField(dto.getServicePhone(), "支付宝客服电话不能为空"); + String signDate = requireAlipayField(dto.getSignTimeWithIsv(), "支付宝签约时间不能为空"); + try { + LocalDate.parse(signDate); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException("支付宝签约时间格式必须为yyyy-MM-dd"); + } + + List services = normalizedAlipayList(dto.getServices()); + if (services.isEmpty()) { + throw new IllegalArgumentException("支付宝商户已开通服务不能为空"); + } + if (!ALIPAY_MERCHANT_SERVICES.containsAll(services)) { + throw new IllegalArgumentException("支付宝商户服务包含不支持的值"); + } + if (!"06".equals(merchantType)) { + requireAlipayField(dto.getLegalName(), "非个人商户的法人名称不能为空"); + requireAlipayField(dto.getLegalCertNo(), "非个人商户的法人证件号不能为空"); + } + validateAlipayContact(dto.getContact()); + validateAlipaySettlement(dto); + + if (services.contains("当面付")) { + validateAlipayFaceToFaceProfile(dto, merchantType); + } + validateAlipaySites(dto, services); + validateAlipayQualifications(dto.getQualifications()); + if (dto.getQualifications() != null && dto.getQualifications().stream().filter(Objects::nonNull) + .anyMatch(item -> StrUtil.isNotBlank(item.getIndustryQualificationType()) + && StrUtil.isNotBlank(item.getIndustryQualificationImage()))) { + requireAlipayField(dto.getLegalCertFrontImage(), "特殊行业必须上传法人证件正面照"); + requireAlipayField(dto.getLegalCertBackImage(), "特殊行业必须上传法人证件反面照"); + } + boolean hasAdditionalType = StrUtil.isNotBlank(dto.getAdditionalCertType()); + boolean hasAdditionalNo = StrUtil.isNotBlank(dto.getAdditionalCertNo()); + boolean hasAdditionalImage = StrUtil.isNotBlank(dto.getAdditionalCertImage()); + if ((hasAdditionalType || hasAdditionalNo || hasAdditionalImage) + && !(hasAdditionalType && hasAdditionalNo && hasAdditionalImage)) { + throw new IllegalArgumentException("补充证件类型、证件号和图片必须同时填写"); + } + if ((hasAdditionalType || hasAdditionalNo || hasAdditionalImage) && !"06".equals(merchantType)) { + throw new IllegalArgumentException("只有个人商户可以填写补充证件"); + } + } + + /** 联系人对象中的姓名和手机号是支付宝明确要求的必填项。 */ + private void validateAlipayContact(ThirdAlipayAliasModifyDto.ContactInfoDto contact) { + if (contact == null) { + throw new IllegalArgumentException("支付宝联系人信息不能为空"); + } + requireAlipayField(contact.getName(), "支付宝联系人姓名不能为空"); + requireAlipayField(contact.getMobile(), "支付宝联系人手机号不能为空"); + if (StrUtil.isNotBlank(contact.getType()) && !ALIPAY_CONTACT_TYPES.contains(StrUtil.trim(contact.getType()))) { + throw new IllegalArgumentException("支付宝联系人类型不受支持"); + } + } + + /** 修改全量资料时必须明确一个支付宝账号或一张银行卡作为结算目标。 */ + private void validateAlipaySettlement(ThirdAlipayAliasModifyDto dto) { + boolean settleToAlipay = StrUtil.isNotBlank(dto.getAlipayLogonId()); + boolean settleToCard = hasSettleCardValue(dto.getSettleCard()); + if (settleToAlipay == settleToCard) { + throw new IllegalArgumentException("支付宝结算账号和结算银行卡必须且只能填写一种"); + } + if (settleToCard) { + ThirdAlipayAliasModifyDto.SettleCardDto card = dto.getSettleCard(); + requireAlipayField(card.getAccountBranchName(), "结算卡开户支行名不能为空"); + requireAlipayField(card.getAccountHolderName(), "结算卡户名不能为空"); + requireAlipayField(card.getAccountInstCity(), "结算卡开户行所在市不能为空"); + requireAlipayField(card.getAccountInstId(), "结算卡开户行简称不能为空"); + requireAlipayField(card.getAccountInstName(), "结算卡银行名称不能为空"); + requireAlipayField(card.getAccountInstProvince(), "结算卡开户行所在省不能为空"); + requireAlipayField(card.getAccountNo(), "结算卡号不能为空"); + if (!Set.of("DC", "CC").contains(requireAlipayField(card.getAccountType(), "结算卡类型不能为空"))) { + throw new IllegalArgumentException("结算卡类型只能是DC或CC"); + } + if (!Set.of("01", "02").contains(requireAlipayField(card.getUsageType(), "结算卡使用类型不能为空"))) { + throw new IllegalArgumentException("结算卡使用类型只能是01或02"); + } + if (!StrUtil.trim(dto.getName()).equals(StrUtil.trim(card.getAccountHolderName()))) { + requireAlipayField(dto.getLicenseAuthLetterImage(), "结算卡户名与商户名称不一致时授权函不能为空"); + } + } + if (!"06".equals(dto.getMerchantType()) && StrUtil.isNotBlank(dto.getLegalCertType()) + && !"100".equals(StrUtil.trim(dto.getLegalCertType()))) { + requireAlipayField(dto.getLicenseAuthLetterImage(), "外籍或非大陆身份证法人必须上传身份说明材料"); + } + ThirdAlipayAliasModifyDto.DefaultSettleRuleDto rule = dto.getDefaultSettleRule(); + if (rule != null && (StrUtil.isNotBlank(rule.getDefaultSettleType()) + || StrUtil.isNotBlank(rule.getDefaultSettleTarget()))) { + String type = requireAlipayField(rule.getDefaultSettleType(), "默认结算类型不能为空"); + String target = requireAlipayField(rule.getDefaultSettleTarget(), "默认结算目标不能为空"); + if (!Set.of("bankCard", "alipayAccount").contains(type)) { + throw new IllegalArgumentException("默认结算类型不受支持"); + } + if ("bankCard".equals(type) && (!settleToCard + || !target.equals(StrUtil.trim(dto.getSettleCard().getAccountNo())))) { + throw new IllegalArgumentException("默认银行卡结算目标必须等于本次填写的结算卡号"); + } + if ("alipayAccount".equals(type) && (!settleToAlipay + || !target.equals(StrUtil.trim(dto.getAlipayLogonId())))) { + throw new IllegalArgumentException("默认支付宝结算目标必须等于本次填写的结算支付宝账号"); + } + } + } + + /** 当面付额外要求经营地址及门店、证件图片。 */ + private void validateAlipayFaceToFaceProfile(ThirdAlipayAliasModifyDto dto, String merchantType) { + ThirdAlipayAliasModifyDto.BusinessAddressDto address = dto.getBusinessAddress(); + if (address == null) { + throw new IllegalArgumentException("当面付商户的经营地址不能为空"); + } + requireAlipayField(address.getProvinceCode(), "当面付经营地址省份编码不能为空"); + requireAlipayField(address.getCityCode(), "当面付经营地址城市编码不能为空"); + requireAlipayField(address.getDistrictCode(), "当面付经营地址区县编码不能为空"); + requireAlipayField(address.getAddress(), "当面付经营详细地址不能为空"); + if (normalizedAlipayList(dto.getOutDoorImages()).isEmpty()) { + throw new IllegalArgumentException("当面付商户门头照不能为空"); + } + if (normalizedAlipayList(dto.getInDoorImages()).isEmpty()) { + throw new IllegalArgumentException("当面付商户内景照不能为空"); + } + if (!"06".equals(merchantType) || hasSettleCardValue(dto.getSettleCard())) { + requireAlipayField(dto.getCertImage(), "当前商户的证件照不能为空"); + } + if ("06".equals(merchantType)) { + requireAlipayField(dto.getCertImageBack(), "个人商户使用当面付时证件反面照不能为空"); + } + } + + /** 已开通网页、APP或小程序类服务时校验对应站点,避免支付宝逐项返回缺失错误。 */ + private void validateAlipaySites(ThirdAlipayAliasModifyDto dto, List services) { + List sites = dto.getSites() == null + ? List.of() : dto.getSites().stream().filter(Objects::nonNull).toList(); + for (ThirdAlipayAliasModifyDto.SiteInfoDto site : sites) { + if (!ALIPAY_SITE_TYPES.contains(requireAlipayField(site.getSiteType(), "支付宝站点类型不能为空"))) { + throw new IllegalArgumentException("支付宝站点类型不受支持"); + } + requireAlipayField(site.getSiteName(), "支付宝站点名称不能为空"); + } + if (services.contains("电脑支付") || services.contains("wap支付")) { + ThirdAlipayAliasModifyDto.SiteInfoDto website = findAlipaySite(sites, "01"); + if (website == null) { + throw new IllegalArgumentException("电脑支付或wap支付必须填写网站资料"); + } + requireAlipayField(website.getSiteUrl(), "支付宝网站地址不能为空"); + } + if (services.contains("app支付") && sites.stream().noneMatch(site -> Set.of("02", "06").contains(site.getSiteType()))) { + throw new IllegalArgumentException("app支付必须填写APP或支付宝小程序资料"); + } + if (services.contains("jsapi支付") || services.contains("小程序支付")) { + ThirdAlipayAliasModifyDto.SiteInfoDto miniProgram = findAlipaySite(sites, "06"); + if (miniProgram == null) { + throw new IllegalArgumentException("jsapi支付或小程序支付必须填写支付宝小程序资料"); + } + requireAlipayField(miniProgram.getTinyAppId(), "支付宝小程序AppID不能为空"); + requireAlipayField(miniProgram.getSiteUrl(), "支付宝小程序站点地址不能为空"); + } + List tradeScenes = normalizedAlipayList(dto.getTradeScene()); + if (!ALIPAY_TRADE_SCENES.containsAll(tradeScenes)) { + throw new IllegalArgumentException("支付宝交易场景包含不支持的值"); + } + for (String scene : tradeScenes) { + if (!Set.of("WAP", "APP", "PC").contains(scene)) { + continue; + } + Set targetTypes = "APP".equals(scene) ? Set.of("02", "06") : Set.of("01"); + boolean screenshotPresent = sites.stream().anyMatch(site -> targetTypes.contains(site.getSiteType()) + && StrUtil.isNotBlank(site.getScreenshotImage())); + if (!screenshotPresent) { + throw new IllegalArgumentException(scene + "交易场景必须上传对应站点截图"); + } + } + } + + /** 特殊行业资质必须成对填写资质类型和支付宝图片ID。 */ + private void validateAlipayQualifications(List qualifications) { + if (qualifications == null) { + return; + } + for (ThirdAlipayAliasModifyDto.QualificationDto qualification : qualifications) { + if (qualification == null) { + continue; + } + boolean hasType = StrUtil.isNotBlank(qualification.getIndustryQualificationType()); + boolean hasImage = StrUtil.isNotBlank(qualification.getIndustryQualificationImage()); + if (hasType != hasImage) { + throw new IllegalArgumentException("行业资质类型和资质图片必须同时填写"); + } + } + } + + /** 把已校验 DTO 显式映射到支付宝 SDK,空的可选字段不发送。 */ + private AntMerchantExpandIndirectZftModifyModel buildAlipayMerchantModifyModel( + String smid, ThirdAlipayAliasModifyDto dto) { + AntMerchantExpandIndirectZftModifyModel model = new AntMerchantExpandIndirectZftModifyModel(); + model.setSmid(smid); + model.setAliasName(trimToNull(dto.getAliasName())); + model.setName(trimToNull(dto.getName())); + model.setMerchantType(trimToNull(dto.getMerchantType())); + model.setMcc(trimToNull(dto.getMcc())); + model.setService(normalizedAlipayList(dto.getServices())); + model.setServicePhone(trimToNull(dto.getServicePhone())); + model.setSignTimeWithIsv(trimToNull(dto.getSignTimeWithIsv())); + model.setCertType(trimToNull(dto.getCertType())); + model.setLegalName(trimToNull(dto.getLegalName())); + model.setLegalCertNo(trimToNull(dto.getLegalCertNo())); + model.setLegalCertType(StrUtil.isBlank(dto.getLegalCertType()) && !"06".equals(dto.getMerchantType()) + ? "100" : trimToNull(dto.getLegalCertType())); + model.setBindingAlipayLogonId(trimToNull(dto.getBindingAlipayLogonId())); + model.setAlipayLogonId(trimToNull(dto.getAlipayLogonId())); + model.setContactInfos(List.of(toAlipayContact(dto.getContact()))); + model.setBusinessAddress(toAlipayAddress(dto.getBusinessAddress())); + model.setSites(toAlipaySites(dto.getSites())); + model.setBizCards(hasSettleCardValue(dto.getSettleCard()) + ? List.of(toAlipaySettleCard(dto.getSettleCard())) : null); + model.setDefaultSettleRule(toAlipaySettleRule(dto.getDefaultSettleRule())); + model.setOutDoorImages(emptyToNull(normalizedAlipayList(dto.getOutDoorImages()))); + model.setInDoorImages(emptyToNull(normalizedAlipayList(dto.getInDoorImages()))); + model.setCertImage(trimToNull(dto.getCertImage())); + model.setCertImageBack(trimToNull(dto.getCertImageBack())); + model.setLegalCertFrontImage(trimToNull(dto.getLegalCertFrontImage())); + model.setLegalCertBackImage(trimToNull(dto.getLegalCertBackImage())); + model.setLicenseAuthLetterImage(trimToNull(dto.getLicenseAuthLetterImage())); + model.setAdditionalCertType(trimToNull(dto.getAdditionalCertType())); + model.setAdditionalCertNo(trimToNull(dto.getAdditionalCertNo())); + model.setAdditionalCertImage(trimToNull(dto.getAdditionalCertImage())); + model.setQualifications(toAlipayQualifications(dto.getQualifications())); + model.setTradeScene(emptyToNull(normalizedAlipayList(dto.getTradeScene()))); + return model; + } + + private ContactInfo toAlipayContact(ThirdAlipayAliasModifyDto.ContactInfoDto dto) { + ContactInfo result = new ContactInfo(); + result.setName(trimToNull(dto.getName())); + result.setMobile(trimToNull(dto.getMobile())); + result.setPhone(trimToNull(dto.getPhone())); + result.setEmail(trimToNull(dto.getEmail())); + result.setIdCardNo(trimToNull(dto.getIdCardNo())); + result.setTag(emptyToNull(normalizedAlipayList(dto.getTag()))); + result.setType(trimToNull(dto.getType())); + return result; + } + + private AddressInfo toAlipayAddress(ThirdAlipayAliasModifyDto.BusinessAddressDto dto) { + if (dto == null || !hasAnyText(dto.getProvinceCode(), dto.getCityCode(), dto.getDistrictCode(), + dto.getAddress(), dto.getPoiid(), dto.getLongitude(), dto.getLatitude())) { + return null; + } + AddressInfo result = new AddressInfo(); + result.setProvinceCode(trimToNull(dto.getProvinceCode())); + result.setCityCode(trimToNull(dto.getCityCode())); + result.setDistrictCode(trimToNull(dto.getDistrictCode())); + result.setAddress(trimToNull(dto.getAddress())); + result.setPoiid(trimToNull(dto.getPoiid())); + result.setLongitude(trimToNull(dto.getLongitude())); + result.setLatitude(trimToNull(dto.getLatitude())); + result.setType(StrUtil.blankToDefault(trimToNull(dto.getType()), "BUSINESS_ADDRESS")); + return result; + } + + private List toAlipaySites(List sites) { + if (sites == null) { + return null; + } + List result = new ArrayList<>(); + for (ThirdAlipayAliasModifyDto.SiteInfoDto dto : sites) { + if (dto == null || !hasAnyText(dto.getSiteType(), dto.getSiteName(), dto.getSiteUrl(), dto.getTinyAppId())) { + continue; + } + SiteInfo site = new SiteInfo(); + site.setSiteType(trimToNull(dto.getSiteType())); + site.setSiteName(trimToNull(dto.getSiteName())); + site.setSiteUrl(trimToNull(dto.getSiteUrl())); + site.setScreenshotImage(trimToNull(dto.getScreenshotImage())); + site.setTinyAppId(trimToNull(dto.getTinyAppId())); + site.setAccount(trimToNull(dto.getAccount())); + site.setPassword(trimToNull(dto.getPassword())); + site.setStatus(trimToNull(dto.getStatus())); + site.setMarket(trimToNull(dto.getMarket())); + site.setDownload(trimToNull(dto.getDownload())); + site.setAuthLetterImage(trimToNull(dto.getAuthLetterImage())); + site.setRemark(trimToNull(dto.getRemark())); + site.setRemarkImage(trimToNull(dto.getRemarkImage())); + site.setSiteDomain(trimToNull(dto.getSiteDomain())); + site.setIcpServiceName(trimToNull(dto.getIcpServiceName())); + site.setIcpNo(trimToNull(dto.getIcpNo())); + site.setIcpOrgName(trimToNull(dto.getIcpOrgName())); + result.add(site); + } + return emptyToNull(result); + } + + private SettleCardInfo toAlipaySettleCard(ThirdAlipayAliasModifyDto.SettleCardDto dto) { + SettleCardInfo result = new SettleCardInfo(); + result.setAccountBranchName(trimToNull(dto.getAccountBranchName())); + result.setAccountHolderName(trimToNull(dto.getAccountHolderName())); + result.setAccountInstCity(trimToNull(dto.getAccountInstCity())); + result.setAccountInstId(trimToNull(dto.getAccountInstId())); + result.setAccountInstName(trimToNull(dto.getAccountInstName())); + result.setAccountInstProvince(trimToNull(dto.getAccountInstProvince())); + result.setAccountNo(trimToNull(dto.getAccountNo())); + result.setAccountType(trimToNull(dto.getAccountType())); + result.setUsageType(trimToNull(dto.getUsageType())); + result.setBankCode(trimToNull(dto.getBankCode())); + return result; + } + + private DefaultSettleRule toAlipaySettleRule(ThirdAlipayAliasModifyDto.DefaultSettleRuleDto dto) { + if (dto == null || !hasAnyText(dto.getDefaultSettleType(), dto.getDefaultSettleTarget())) { + return null; + } + DefaultSettleRule result = new DefaultSettleRule(); + result.setDefaultSettleType(trimToNull(dto.getDefaultSettleType())); + result.setDefaultSettleTarget(trimToNull(dto.getDefaultSettleTarget())); + return result; + } + + private List toAlipayQualifications( + List qualifications) { + if (qualifications == null) { + return null; + } + List result = new ArrayList<>(); + for (ThirdAlipayAliasModifyDto.QualificationDto dto : qualifications) { + if (dto == null || !hasAnyText(dto.getIndustryQualificationType(), dto.getIndustryQualificationImage())) { + continue; + } + IndustryQualificationInfo item = new IndustryQualificationInfo(); + item.setIndustryQualificationType(trimToNull(dto.getIndustryQualificationType())); + item.setIndustryQualificationImage(trimToNull(dto.getIndustryQualificationImage())); + result.add(item); + } + return emptyToNull(result); + } + + private ThirdAlipayAliasModifyDto.SiteInfoDto findAlipaySite( + List sites, String type) { + return sites.stream().filter(site -> type.equals(StrUtil.trim(site.getSiteType()))).findFirst().orElse(null); + } + + private boolean hasSettleCardValue(ThirdAlipayAliasModifyDto.SettleCardDto card) { + return card != null && hasAnyText(card.getAccountNo(), card.getAccountHolderName(), card.getAccountInstName(), + card.getAccountBranchName(), card.getBankCode()); + } + + private String requireAlipayField(String value, String message) { + String normalized = StrUtil.trim(value); + if (StrUtil.isBlank(normalized)) { + throw new IllegalArgumentException(message); + } + return normalized; + } + + private String trimToNull(String value) { + String normalized = StrUtil.trim(value); + return StrUtil.isBlank(normalized) ? null : normalized; + } + + private List normalizedAlipayList(List values) { + return values == null ? List.of() : values.stream().map(StrUtil::trim) + .filter(StrUtil::isNotBlank).distinct().toList(); + } + + private boolean hasAnyText(String... values) { + return values != null && Arrays.stream(values).anyMatch(StrUtil::isNotBlank); + } + + private List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; + } + /** * 保存已有关系。渠道身份改变时生成新版本,展示名称或微信关系类型改变时重置原关系等待重绑。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java index 11e6132d3..ed5f314d1 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -3,11 +3,16 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; import cn.hutool.json.JSONUtil; import com.alipay.api.AlipayClient; import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; +import com.alipay.api.domain.AntMerchantExpandIndirectZftModifyModel; import com.alipay.api.domain.RoyaltyEntity; import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest; import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest; +import com.alipay.api.request.AntMerchantExpandIndirectImageUploadRequest; +import com.alipay.api.request.AntMerchantExpandIndirectZftModifyRequest; import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse; import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse; +import com.alipay.api.response.AntMerchantExpandIndirectImageUploadResponse; +import com.alipay.api.response.AntMerchantExpandIndirectZftModifyResponse; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; @@ -18,6 +23,7 @@ import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdAlipayAliasModifyDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; @@ -265,6 +271,113 @@ class ThirdMchFzRelationServiceImplTest { () -> Assertions.assertNull(receiver.getLoginName())); } + /** 支付宝商户简称修改提交 smid、alias_name 和完整服务列表,并透传关系内的代调用授权 token。 */ + @Test + void modifyAlipayAliasShouldSubmitSmidAndAliasName() throws Exception { + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7016L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(alipaySetting(102L, 5D, + "legacy-setting-token"))))); + AntMerchantExpandIndirectZftModifyResponse response = new AntMerchantExpandIndirectZftModifyResponse(); + response.setCode("10000"); + response.setOrderId("zft-order-7016"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AntMerchantExpandIndirectZftModifyRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setTradeScene(List.of("TINY_APP", "OFFLINE")); + + var result = service.modifyAlipayAlias(relation.getId(), dto); + + AntMerchantExpandIndirectZftModifyRequest request = requestCaptor.getValue(); + AntMerchantExpandIndirectZftModifyModel model = + (AntMerchantExpandIndirectZftModifyModel) request.getBizModel(); + Assertions.assertAll( + () -> Assertions.assertEquals("zft-order-7016", result.getOrderId()), + () -> Assertions.assertEquals("smid-1001", model.getSmid()), + () -> Assertions.assertEquals("青鸟同文", model.getAliasName()), + () -> Assertions.assertEquals("青鸟同文", model.getName()), + () -> Assertions.assertEquals("01", model.getMerchantType()), + () -> Assertions.assertEquals(List.of("当面付", "app支付"), model.getService()), + () -> Assertions.assertEquals(List.of("TINY_APP", "OFFLINE"), model.getTradeScene()), + () -> Assertions.assertEquals("5812", model.getMcc()), + () -> Assertions.assertEquals("13800000000", model.getServicePhone()), + () -> Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"))); + } + + /** 支付宝线上会强制校验商户服务,服务列表为空时应在本地直接拒绝。 */ + @Test + void modifyAlipayAliasShouldRejectEmptyServices() throws Exception { + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7018L, fullAlipayModifyDtoWithServices(List.of()))); + + Assertions.assertTrue(exception.getMessage().contains("已开通服务不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 商户名称是全量资料的核心字段,应在调用支付宝之前返回明确错误。 */ + @Test + void modifyAlipayAliasShouldRejectEmptyMerchantName() throws Exception { + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setName(" "); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7019L, dto)); + + Assertions.assertTrue(exception.getMessage().contains("商户名称不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 当面付缺少门头照时不提交,避免支付宝按字段逐个报错。 */ + @Test + void modifyAlipayAliasShouldRejectMissingFaceToFaceImages() throws Exception { + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setOutDoorImages(List.of()); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7020L, dto)); + + Assertions.assertTrue(exception.getMessage().contains("门头照不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 商户材料图片上传必须透传关系授权,并返回支付宝 image_id。 */ + @Test + void uploadAlipayMerchantImageShouldReturnImageId() throws Exception { + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7021L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + AntMerchantExpandIndirectImageUploadResponse response = new AntMerchantExpandIndirectImageUploadResponse(); + response.setCode("10000"); + response.setImageId("A*merchant-image-id"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AntMerchantExpandIndirectImageUploadRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + String imageId = service.uploadAlipayMerchantImage(relation.getId(), "license.PNG", + new byte[]{1, 2, 3}, "image/png"); + + AntMerchantExpandIndirectImageUploadRequest request = requestCaptor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("A*merchant-image-id", imageId), + () -> Assertions.assertEquals("png", request.getImageType()), + () -> Assertions.assertArrayEquals(new byte[]{1, 2, 3}, request.getImageContent().getContent()), + () -> Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"))); + } + + /** 非支付宝关系不能调用二级商户资料修改接口。 */ + @Test + void modifyAlipayAliasShouldRejectNonAlipayRelation() throws Exception { + ThirdMchFzRelationEntity relation = pendingWechatRelation(7017L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(relation.getId(), fullAlipayModifyDtoWithServices(List.of("app支付")))); + + Assertions.assertTrue(exception.getMessage().contains("只有支付宝")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + /** 修改接收方账号必须创建新关系版本,旧关系仅退出默认候选并保留给历史订单。 */ @Test void saveForManageShouldVersionRelationWhenReceiverAccountChanges() { @@ -607,6 +720,49 @@ class ThirdMchFzRelationServiceImplTest { return relation; } + /** 构造接口要求的最小完整支付宝资料,模拟企业当面付+APP商户。 */ + private ThirdAlipayAliasModifyDto fullAlipayModifyDto() { + return fullAlipayModifyDtoWithServices(List.of("当面付", "app支付")); + } + + private ThirdAlipayAliasModifyDto fullAlipayModifyDtoWithServices(List services) { + ThirdAlipayAliasModifyDto dto = new ThirdAlipayAliasModifyDto(); + dto.setAliasName("青鸟同文"); + dto.setName("青鸟同文"); + dto.setMerchantType("01"); + dto.setMcc("5812"); + dto.setServices(services); + dto.setServicePhone("13800000000"); + dto.setSignTimeWithIsv("2026-01-01"); + dto.setCertType("201"); + dto.setLegalName("张斌"); + dto.setLegalCertNo("330000000000000000"); + dto.setLegalCertType("100"); + dto.setAlipayLogonId("merchant@example.com"); + + ThirdAlipayAliasModifyDto.ContactInfoDto contact = new ThirdAlipayAliasModifyDto.ContactInfoDto(); + contact.setName("张斌"); + contact.setMobile("13800000000"); + contact.setType("LEGAL_PERSON"); + dto.setContact(contact); + + ThirdAlipayAliasModifyDto.BusinessAddressDto address = new ThirdAlipayAliasModifyDto.BusinessAddressDto(); + address.setProvinceCode("330000"); + address.setCityCode("330100"); + address.setDistrictCode("330106"); + address.setAddress("浙江省杭州市西湖区测试路1号"); + dto.setBusinessAddress(address); + dto.setOutDoorImages(List.of("oss-door")); + dto.setInDoorImages(List.of("oss-inside")); + dto.setCertImage("oss-cert"); + + ThirdAlipayAliasModifyDto.SiteInfoDto site = new ThirdAlipayAliasModifyDto.SiteInfoDto(); + site.setSiteType("02"); + site.setSiteName("青鸟同文APP"); + dto.setSites(List.of(site)); + return dto; + } + /** 执行一次支付宝绑定并返回 SDK 请求中的接收方模型。 */ private RoyaltyEntity bindAlipayAndCaptureReceiver(String receiverType, String receiverAccount) throws Exception { ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index c9237f857..d5f1bf29a 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -626,18 +626,22 @@ public class WalletRechargeServiceImpl extends ServiceImpl identityTypes) { - if (StrUtil.isBlank(wallet.getCardNumber())) { - return "饭卡未绑定实体卡"; - } - if (!ObjectUtil.equals(wallet.getStatus(), WalleCardStatusEnum.ACTIVE.getCode())) { - return cardStatusName(wallet.getStatus()) + ",暂不可充值"; - } if (ObjectUtil.equals(wallet.getIsFreeze(), YesNoEnum.YES.getCode())) { return "饭卡已冻结"; } + boolean cardStatusSupportsRecharge = ObjectUtil.equals(wallet.getStatus(), WalleCardStatusEnum.NONE_ACTIVE.getCode()) + || ObjectUtil.equals(wallet.getStatus(), WalleCardStatusEnum.ACTIVE.getCode()); + if (!cardStatusSupportsRecharge) { + return cardStatusName(wallet.getStatus()) + ",暂不可充值"; + } MchManageVo merchant = mchManageService.getViewsCanteenMchByDeptId(wallet.getDeptId()); if (merchant == null) { return "饭卡所属食堂不存在";