From 6512b7184ea9c441d9ac1650362699762ff46a16 Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Tue, 13 Oct 2026 16:56:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(pay):=20=E6=94=AF=E6=8C=81=E8=BF=9E?= =?UTF-8?q?=E9=94=81=E5=93=81=E7=89=8C=E5=85=A5=E9=A9=BB=E3=80=81=E9=97=A8?= =?UTF-8?q?=E5=BA=97=E7=AE=A1=E7=90=86=E5=92=8C=E5=88=86=E8=B4=A6=20#3972?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md | 56 +++++ .../BrandApplymentCancelRequest.java | 19 ++ .../BrandApplymentQueryResult.java | 39 ++++ .../brandapplyment/BrandApplymentRequest.java | 162 ++++++++++++++ .../brandapplyment/BrandApplymentResult.java | 19 ++ .../BrandProfitSharingAmountResult.java | 26 +++ .../BrandProfitSharingFinishRequest.java | 34 +++ .../BrandProfitSharingFinishResult.java | 34 +++ .../BrandProfitSharingQueryRequest.java | 30 +++ .../BrandProfitSharingRatioResult.java | 26 +++ ...andProfitSharingReceiverDeleteRequest.java | 38 ++++ .../BrandProfitSharingReceiverRequest.java | 46 ++++ .../BrandProfitSharingReceiverResult.java | 30 +++ .../BrandProfitSharingRequest.java | 83 +++++++ .../BrandProfitSharingResult.java | 94 ++++++++ .../BrandProfitSharingReturnQueryRequest.java | 34 +++ .../BrandProfitSharingReturnRequest.java | 46 ++++ .../BrandProfitSharingReturnResult.java | 58 +++++ .../bean/brandstore/BrandStoreAddress.java | 32 +++ .../bean/brandstore/BrandStoreBasics.java | 20 ++ .../BrandStoreBindRecipientRequest.java | 24 +++ .../BrandStoreBindRecipientResult.java | 32 +++ .../bean/brandstore/BrandStoreBusiness.java | 20 ++ .../bean/brandstore/BrandStoreRecipient.java | 24 +++ .../bean/brandstore/BrandStoreRequest.java | 28 +++ .../bean/brandstore/BrandStoreResult.java | 49 +++++ .../BrandStoreUnbindRecipientRequest.java | 20 ++ .../BrandStoreUnbindRecipientResult.java | 16 ++ .../wxpay/service/BrandApplymentService.java | 49 +++++ .../service/BrandProfitSharingService.java | 113 ++++++++++ .../wxpay/service/BrandStoreService.java | 63 ++++++ .../wxpay/service/WxPayService.java | 24 +++ .../service/impl/BaseWxPayServiceImpl.java | 9 + .../impl/BrandApplymentServiceImpl.java | 49 +++++ .../impl/BrandProfitSharingServiceImpl.java | 114 ++++++++++ .../service/impl/BrandStoreServiceImpl.java | 83 +++++++ .../service/BrandApplymentServiceTest.java | 135 ++++++++++++ .../impl/BrandProfitSharingServiceTest.java | 204 ++++++++++++++++++ .../service/impl/BrandServiceEntryTest.java | 23 ++ .../service/impl/BrandStoreServiceTest.java | 138 ++++++++++++ 40 files changed, 2143 insertions(+) create mode 100644 docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentCancelRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentQueryResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingAmountResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingQueryRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRatioResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverDeleteRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnQueryRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreAddress.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBasics.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBusiness.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRecipient.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientRequest.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientResult.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandApplymentService.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandProfitSharingService.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandStoreService.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandApplymentServiceImpl.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java create mode 100644 weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceImpl.java create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/BrandApplymentServiceTest.java create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceTest.java diff --git a/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md b/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md new file mode 100644 index 000000000..07f53a9c7 --- /dev/null +++ b/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md @@ -0,0 +1,56 @@ +# 微信支付连锁品牌 + +`weixin-java-pay` 提供服务商模式下的品牌入驻、品牌门店和连锁品牌分账。它们与品牌红包转账、普通商户分账分别使用独立服务和接口。 + +## 服务入口 + +```java +BrandApplymentService applyments = wxPayService.getBrandApplymentService(); +BrandStoreService stores = wxPayService.getBrandStoreService(); +BrandProfitSharingService profitSharing = wxPayService.getBrandProfitSharingService(); +``` + +三个服务位于 `com.github.binarywang.wxpay.service`,请求与响应模型分别位于 `bean.brandapplyment`、`bean.brandstore`、`bean.brandprofitsharing`。 + +| 服务 | 支持操作 | +| --- | --- | +| 品牌入驻 | 提交、撤销、按业务编号查询、按申请单编号查询 | +| 品牌门店 | 创建、查询、更新、删除、绑定与解绑收款商户号 | +| 品牌分账 | 请求、查询、回退、查询回退、完结、查询剩余金额、查询最大比例、添加与删除接收方 | + +## 使用示例 + +查询品牌入驻申请: + +```java +BrandApplymentQueryResult application = applyments.queryApplymentByBusinessCode("brand-application-001"); +``` + +查询或删除品牌门店: + +```java +BrandStoreResult store = stores.query("BRAND_ID", "STORE_ID"); +stores.delete("BRAND_ID", "STORE_ID"); +``` + +查询连锁品牌分账: + +```java +BrandProfitSharingQueryRequest request = new BrandProfitSharingQueryRequest(); +request.setSubMchId("SUB_MCH_ID"); +request.setTransactionId("WECHAT_TRANSACTION_ID"); +request.setOutOrderNo("profit-sharing-001"); +BrandProfitSharingResult result = profitSharing.query(request); +``` + +入驻与门店管理使用 `brand_id`,连锁品牌分账相关接口按文档使用 `brand_mchid` 与 `sub_mchid`,不要混用这些标识。分账是异步处理,应通过查询或通知确认最终状态,不能将受理成功等同于资金划拨完成。 + +## 接入条件 + +配置已有 APIv3 商户私钥、APIv3 密钥及微信支付平台证书或公钥。品牌入驻管理员姓名和证件号等敏感字段由 SDK 加密,请传入明文,不要预先加密。 + +SDK 不代替业务授权和审核:品牌入驻需要管理员授权、主体确认及审核;门店收款号需要先建立品牌关系;品牌分账需要申请连锁品牌工具箱并配置比例,支付下单按官方要求启用分账。 + +离线测试验证接口协议、序列化与加密;真实接口联调需要业务方提供已开通权限的测试账号。 + +官方文档:[品牌入驻](https://pay.weixin.qq.com/doc/v3/partner/4016985537)、[品牌门店](https://pay.weixin.qq.com/doc/v3/partner/4016628135)、[连锁品牌分账](https://pay.weixin.qq.com/doc/v3/partner/4012072637)。 diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentCancelRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentCancelRequest.java new file mode 100644 index 000000000..dd1a74d9c --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentCancelRequest.java @@ -0,0 +1,19 @@ +package com.github.binarywang.wxpay.bean.brandapplyment; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 撤销品牌入驻申请,业务申请编号与微信支付申请单号至少填写一个。 */ +@Data +public class BrandApplymentCancelRequest implements Serializable { + private static final long serialVersionUID = 1L; + /** 业务申请编号。 */ + @SerializedName("business_code") + private String businessCode; + + /** 微信支付申请单号。 */ + @SerializedName("applyment_id") + private String applymentId; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentQueryResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentQueryResult.java new file mode 100644 index 000000000..110370fbc --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentQueryResult.java @@ -0,0 +1,39 @@ +package com.github.binarywang.wxpay.bean.brandapplyment; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 品牌入驻申请状态应答。 */ +@Data +public class BrandApplymentQueryResult implements Serializable { + private static final long serialVersionUID = 1L; + /** 微信支付申请单号。 */ + @SerializedName("applyment_id") + private String applymentId; + + /** 业务申请编号。 */ + @SerializedName("business_code") + private String businessCode; + + /** 申请状态,使用微信支付 APPLYMENT_STATE_* 值。 */ + @SerializedName("applyment_state") + private String applymentState; + + /** 申请状态说明,包含并行环节状态。 */ + @SerializedName("applyment_state_desc") + private String applymentStateDesc; + + /** 授权确认二维码链接,管理员使用微信扫码。 */ + @SerializedName("authorization_confirmation_qr_code") + private String authorizationConfirmationQrCode; + + /** 审核驳回原因。 */ + @SerializedName("reject_reason") + private String rejectReason; + + /** 品牌 ID,入驻完成时返回。 */ + @SerializedName("brand_id") + private String brandId; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentRequest.java new file mode 100644 index 000000000..db1d613df --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentRequest.java @@ -0,0 +1,162 @@ +package com.github.binarywang.wxpay.bean.brandapplyment; + +import com.github.binarywang.wxpay.v3.SpecEncrypt; +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** 品牌入驻申请资料;图片资料填写预先上传的 MediaID。 */ +@Data +public class BrandApplymentRequest implements Serializable { + private static final long serialVersionUID = 1L; + /** 服务商自定义业务申请编号,同编号可修改被驳回的申请。 */ + @SerializedName("business_code") + private String businessCode; + /** 品牌管理员信息。 */ + @SpecEncrypt + @SerializedName("admin_info") + private AdminInfo adminInfo; + /** 所属企业主体信息。 */ + @SerializedName("subject_info") + private SubjectInfo subjectInfo; + /** 品牌基础信息。 */ + @SerializedName("brand_basic_info") + private BrandBasicInfo brandBasicInfo; + /** 品牌商标资料。 */ + @SerializedName("trademark") + private Trademark trademark; + + /** 品牌管理员信息。 */ + @Data + public static class AdminInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 管理员姓名。 */ + @SpecEncrypt + @SerializedName("admin_name") + private String adminName; + + /** 管理员证件类型。 */ + @SerializedName("id_doc_type") + private String idDocType; + + /** 管理员证件号。 */ + @SpecEncrypt + @SerializedName("id_card_number") + private String idCardNumber; + } + + /** 企业主体信息。 */ + @Data + public static class SubjectInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 主体类型:企业或个体工商户。 */ + @SerializedName("subject_type") + private String subjectType; + + /** 主体名称。 */ + @SerializedName("subject_name") + private String subjectName; + + /** 统一社会信用代码。 */ + @SerializedName("unified_social_credit_code") + private String unifiedSocialCreditCode; + } + + /** 品牌基础信息。 */ + @Data + public static class BrandBasicInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌名称。 */ + @SerializedName("brand_name") + private String brandName; + + /** 品牌 Logo 图片 MediaID。 */ + @SerializedName("brand_logo") + private String brandLogo; + } + + /** 品牌商标资料。 */ + @Data + public static class Trademark implements Serializable { + private static final long serialVersionUID = 1L; + + /** 有无商标:TRADEMARK_EXISTS 或 TRADEMARK_NONE。 */ + @SerializedName("trademark_exists") + private String trademarkExists; + + /** 商标注册证信息。 */ + @SerializedName("trademark_registration_certificate") + private TrademarkRegistrationCertificate trademarkRegistrationCertificate; + + /** Logo 商标注册证信息。 */ + @SerializedName("logo_trademark_registration_certificate") + private TrademarkRegistrationCertificate logoTrademarkRegistrationCertificate; + + /** 无商标额外证明材料 MediaID,存量服务商字段,不可与列表同时填写。 */ + @SerializedName("no_trademark_addition_prove") + private String noTrademarkAdditionProve; + + /** 无商标额外证明材料 MediaID 列表,1 至 5 张。 */ + @SerializedName("no_trademark_addition_prove_list") + private List noTrademarkAdditionProveList; + } + + /** 商标或 Logo 商标注册证信息。 */ + @Data + public static class TrademarkRegistrationCertificate implements Serializable { + private static final long serialVersionUID = 1L; + + /** 商标注册证 MediaID,存量服务商字段,不可与列表同时填写。 */ + @SerializedName("certificate") + private String certificate; + + /** 商标名称。 */ + @SerializedName("name") + private String name; + + /** 商标注册号。 */ + @SerializedName("number") + private String number; + + /** 商标有效期开始日期 YYYY-MM-DD,存量服务商字段。 */ + @SerializedName("valid_begin_time") + private String validBeginTime; + + /** 商标有效期结束日期 YYYY-MM-DD。 */ + @SerializedName("valid_end_time") + private String validEndTime; + + /** 商标国际分类,1 至 45。 */ + @SerializedName("international_class") + private String internationalClass; + + /** 商标注册人。 */ + @SerializedName("holder") + private String holder; + + /** 商标许可使用授权书 MediaID,存量服务商字段,不可与列表同时填写。 */ + @SerializedName("license") + private String license; + + /** 授权有效期开始日期 YYYY-MM-DD,存量服务商字段。 */ + @SerializedName("authorization_begin_time") + private String authorizationBeginTime; + + /** 授权有效期结束日期 YYYY-MM-DD,不得超过商标有效期。 */ + @SerializedName("authorization_end_time") + private String authorizationEndTime; + + /** 商标注册证 MediaID 列表,1 至 5 张。 */ + @SerializedName("certificate_list") + private List certificateList; + + /** 许可使用授权书 MediaID 列表,1 至 5 张。 */ + @SerializedName("license_list") + private List licenseList; + } +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentResult.java new file mode 100644 index 000000000..b146e967c --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandapplyment/BrandApplymentResult.java @@ -0,0 +1,19 @@ +package com.github.binarywang.wxpay.bean.brandapplyment; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 提交或撤销品牌入驻申请应答。 */ +@Data +public class BrandApplymentResult implements Serializable { + private static final long serialVersionUID = 1L; + /** 微信支付申请单号。 */ + @SerializedName("applyment_id") + private String applymentId; + + /** 业务申请编号。 */ + @SerializedName("business_code") + private String businessCode; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingAmountResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingAmountResult.java new file mode 100644 index 000000000..96e5766d7 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingAmountResult.java @@ -0,0 +1,26 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 订单剩余待分账金额。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingAmountResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 剩余待分账金额,单位为分。 */ + @SerializedName("unsplit_amount") + private Integer unsplitAmount; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishRequest.java new file mode 100644 index 000000000..9d12f52c6 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishRequest.java @@ -0,0 +1,34 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 完结分账并解冻剩余资金,需使用新的商户分账单号。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingFinishRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 分账或回退原因描述。 */ + @SerializedName("description") + private String description; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishResult.java new file mode 100644 index 000000000..d5b7bf1e8 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingFinishResult.java @@ -0,0 +1,34 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 完结分账受理结果,最终结果通过查询分账接口获取。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingFinishResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 微信分账单号,与商户分账单号二选一。 */ + @SerializedName("order_id") + private String orderId; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingQueryRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingQueryRequest.java new file mode 100644 index 000000000..c42ca8482 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingQueryRequest.java @@ -0,0 +1,30 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 查询连锁品牌分账结果。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingQueryRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRatioResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRatioResult.java new file mode 100644 index 000000000..824b5a8dc --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRatioResult.java @@ -0,0 +1,26 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 品牌最大分账比例。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingRatioResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 最大分账比例,单位为万分之一。 */ + @SerializedName("max_ratio") + private Integer maxRatio; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverDeleteRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverDeleteRequest.java new file mode 100644 index 000000000..0a5275cd0 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverDeleteRequest.java @@ -0,0 +1,38 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 删除品牌分账接收方。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReceiverDeleteRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 服务商应用 ID。 */ + @SerializedName("appid") + private String appid; + + /** 品牌主应用 ID,PERSONAL_SUB_OPENID 时必填。 */ + @SerializedName("sub_appid") + private String subAppid; + + /** 接收方类型:MERCHANT_ID、PERSONAL_OPENID 或 PERSONAL_SUB_OPENID。 */ + @SerializedName("type") + private String type; + + /** 接收方账号。 */ + @SerializedName("account") + private String account; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverRequest.java new file mode 100644 index 000000000..f442c4826 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverRequest.java @@ -0,0 +1,46 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 添加品牌分账接收方,MERCHANT_ID 的名称必填,名称按此接口契约明文传输。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReceiverRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 服务商应用 ID。 */ + @SerializedName("appid") + private String appid; + + /** 品牌主应用 ID,PERSONAL_SUB_OPENID 时必填。 */ + @SerializedName("sub_appid") + private String subAppid; + + /** 接收方类型:MERCHANT_ID、PERSONAL_OPENID 或 PERSONAL_SUB_OPENID。 */ + @SerializedName("type") + private String type; + + /** 接收方账号。 */ + @SerializedName("account") + private String account; + + /** 接收方名称。 */ + @SerializedName("name") + private String name; + + /** 与品牌主的关系:SUPPLIER、DISTRIBUTOR、SERVICE_PROVIDER、PLATFORM、STAFF、OTHERS。 */ + @SerializedName("relation_type") + private String relationType; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverResult.java new file mode 100644 index 000000000..f68440582 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReceiverResult.java @@ -0,0 +1,30 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 添加或删除分账接收方结果。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReceiverResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 接收方类型:MERCHANT_ID、PERSONAL_OPENID 或 PERSONAL_SUB_OPENID。 */ + @SerializedName("type") + private String type; + + /** 接收方账号。 */ + @SerializedName("account") + private String account; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRequest.java new file mode 100644 index 000000000..f5bca162d --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingRequest.java @@ -0,0 +1,83 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.github.binarywang.wxpay.v3.SpecEncrypt; +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** 请求连锁品牌分账,商户单号唯一,同一单号重复请求等同一次。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 服务商应用 ID。 */ + @SerializedName("appid") + private String appid; + + /** 品牌主应用 ID,PERSONAL_SUB_OPENID 时必填。 */ + @SerializedName("sub_appid") + private String subAppid; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 分账接收方列表。 */ + @SpecEncrypt + @SerializedName("receivers") + private List receivers; + + /** 是否完结分账并解冻剩余资金。 */ + @SerializedName("finish") + private Boolean finish; + + /** 分账接收方;姓名由 SDK 在发送时加密。 */ + @Data + @Builder(builderMethodName = "newBuilder") + @NoArgsConstructor + @AllArgsConstructor + public static class Receiver implements Serializable { + private static final long serialVersionUID = 1L; + + /** 接收方类型:MERCHANT_ID、PERSONAL_OPENID 或 PERSONAL_SUB_OPENID。 */ + @SerializedName("type") + private String type; + + /** 接收方账号。 */ + @SerializedName("account") + private String account; + + /** 金额,单位为分。 */ + @SerializedName("amount") + private Integer amount; + + /** 分账或回退原因描述。 */ + @SerializedName("description") + private String description; + + /** 接收方名称。 */ + @SpecEncrypt + @SerializedName("name") + private String name; + } +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingResult.java new file mode 100644 index 000000000..b9f7c2be2 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingResult.java @@ -0,0 +1,94 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** 分账请求或查询结果。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌主商户号。 */ + @SerializedName("brand_mchid") + private String brandMchId; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信支付订单号。 */ + @SerializedName("transaction_id") + private String transactionId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 微信分账单号,与商户分账单号二选一。 */ + @SerializedName("order_id") + private String orderId; + + /** 分账状态:PROCESSING 或 FINISHED。 */ + @SerializedName("status") + private String status; + + /** 分账接收方列表。 */ + @SerializedName("receivers") + private List receivers; + + /** 完结解冻金额,单位为分。 */ + @SerializedName("finish_amount") + private Integer finishAmount; + + /** 完结原因描述。 */ + @SerializedName("finish_description") + private String finishDescription; + + /** 接收方分账结果,FINISHED 仍需逐项检查 result。 */ + @Data + @NoArgsConstructor + public static class Receiver implements Serializable { + private static final long serialVersionUID = 1L; + + /** 接收方类型:MERCHANT_ID、PERSONAL_OPENID 或 PERSONAL_SUB_OPENID。 */ + @SerializedName("type") + private String type; + + /** 接收方账号。 */ + @SerializedName("account") + private String account; + + /** 金额,单位为分。 */ + @SerializedName("amount") + private Integer amount; + + /** 分账或回退原因描述。 */ + @SerializedName("description") + private String description; + + /** 分账或回退处理结果。 */ + @SerializedName("result") + private String result; + + /** 完成时间,RFC3339 格式。 */ + @SerializedName("finish_time") + private String finishTime; + + /** 失败原因。 */ + @SerializedName("fail_reason") + private String failReason; + + /** 微信分账明细单号。 */ + @SerializedName("detail_id") + private String detailId; + } +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnQueryRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnQueryRequest.java new file mode 100644 index 000000000..ef2aebe14 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnQueryRequest.java @@ -0,0 +1,34 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 查询回退,orderId 与 outOrderNo 二选一。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReturnQueryRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 商户回退单号。 */ + @SerializedName("out_return_no") + private String outReturnNo; + + /** 微信分账单号,与商户分账单号二选一。 */ + @SerializedName("order_id") + private String orderId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnRequest.java new file mode 100644 index 000000000..4c1aef72c --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnRequest.java @@ -0,0 +1,46 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 请求分账回退,仅支持商户接收方,orderId 与 outOrderNo 二选一。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReturnRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信分账单号,与商户分账单号二选一。 */ + @SerializedName("order_id") + private String orderId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 商户回退单号。 */ + @SerializedName("out_return_no") + private String outReturnNo; + + /** 原分账接收方的商户号。 */ + @SerializedName("return_mchid") + private String returnMchId; + + /** 金额,单位为分。 */ + @SerializedName("amount") + private Integer amount; + + /** 分账或回退原因描述。 */ + @SerializedName("description") + private String description; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnResult.java new file mode 100644 index 000000000..5a8139dea --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandprofitsharing/BrandProfitSharingReturnResult.java @@ -0,0 +1,58 @@ +package com.github.binarywang.wxpay.bean.brandprofitsharing; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** 分账回退请求或查询结果。 */ +@Data +@Builder(builderMethodName = "newBuilder") +@NoArgsConstructor +@AllArgsConstructor +public class BrandProfitSharingReturnResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 订单收款商户号,可为品牌主或门店商户号。 */ + @SerializedName("sub_mchid") + private String subMchId; + + /** 微信分账单号,与商户分账单号二选一。 */ + @SerializedName("order_id") + private String orderId; + + /** 商户分账单号。 */ + @SerializedName("out_order_no") + private String outOrderNo; + + /** 商户回退单号。 */ + @SerializedName("out_return_no") + private String outReturnNo; + + /** 微信回退单号。 */ + @SerializedName("return_no") + private String returnNo; + + /** 原分账接收方的商户号。 */ + @SerializedName("return_mchid") + private String returnMchId; + + /** 金额,单位为分。 */ + @SerializedName("amount") + private Integer amount; + + /** 分账或回退处理结果。 */ + @SerializedName("result") + private String result; + + /** 失败原因。 */ + @SerializedName("fail_reason") + private String failReason; + + /** 完成时间,RFC3339 格式。 */ + @SerializedName("finish_time") + private String finishTime; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreAddress.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreAddress.java new file mode 100644 index 000000000..90094186d --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreAddress.java @@ -0,0 +1,32 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 品牌门店地址信息。 */ +@Data +public class BrandStoreAddress implements Serializable { + private static final long serialVersionUID = 1L; + + /** 必填,微信支付省市区编码。 */ + @SerializedName("address_code") + private String addressCode; + + /** 必填,精确到门牌号的地址,不重复省市区。 */ + @SerializedName("address_detail") + private String addressDetail; + + /** 地址辅助描述。 */ + @SerializedName("address_complements") + private String addressComplements; + + /** 腾讯地图经度,范围[-180,180]。 */ + @SerializedName("longitude") + private String longitude; + + /** 腾讯地图纬度,范围[-90,90]。 */ + @SerializedName("latitude") + private String latitude; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBasics.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBasics.java new file mode 100644 index 000000000..a2b7f8ff3 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBasics.java @@ -0,0 +1,20 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 品牌门店基础信息。 */ +@Data +public class BrandStoreBasics implements Serializable { + private static final long serialVersionUID = 1L; + + /** 商家内部唯一门店编号,最长32字符。 */ + @SerializedName("store_reference_id") + private String storeReferenceId; + + /** 纯分店名称,最长50字符,不包含品牌名或辅助符号。 */ + @SerializedName("branch_name") + private String branchName; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientRequest.java new file mode 100644 index 000000000..d879784c4 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientRequest.java @@ -0,0 +1,24 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 绑定品牌门店收款商户请求。 */ +@Data +public class BrandStoreBindRecipientRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 必填,品牌ID。 */ + @SerializedName("brand_id") + private String brandId; + + /** 必填,品牌下已有收款商户号。 */ + @SerializedName("mchid") + private String mchid; + + /** 必填,收款商户主体名称。 */ + @SerializedName("company_name") + private String companyName; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientResult.java new file mode 100644 index 000000000..a272e4123 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBindRecipientResult.java @@ -0,0 +1,32 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 绑定品牌门店收款商户响应。 */ +@Data +public class BrandStoreBindRecipientResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌ID。 */ + @SerializedName("brand_id") + private String brandId; + + /** 门店ID。 */ + @SerializedName("store_id") + private String storeId; + + /** 收款商户号。 */ + @SerializedName("mchid") + private String mchid; + + /** 收款商户主体名称。 */ + @SerializedName("company_name") + private String companyName; + + /** 绑定状态:CONFIRMED、ADMIN_REJECTED、CONFIRMING、TIMEOUT_REJECTED。 */ + @SerializedName("recipient_state") + private String recipientState; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBusiness.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBusiness.java new file mode 100644 index 000000000..d777fa008 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreBusiness.java @@ -0,0 +1,20 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 品牌门店经营信息。 */ +@Data +public class BrandStoreBusiness implements Serializable { + private static final long serialVersionUID = 1L; + + /** 服务电话,最多两个,以英文竖线分隔。 */ + @SerializedName("service_phone") + private String servicePhone; + + /** 经营时间,最多七个时间段,以英文竖线分隔。 */ + @SerializedName("business_hours") + private String businessHours; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRecipient.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRecipient.java new file mode 100644 index 000000000..d8c90cc50 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRecipient.java @@ -0,0 +1,24 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 品牌门店绑定收款商户。 */ +@Data +public class BrandStoreRecipient implements Serializable { + private static final long serialVersionUID = 1L; + + /** 收款商户号。 */ + @SerializedName("mchid") + private String mchid; + + /** 收款商户主体名称。 */ + @SerializedName("company_name") + private String companyName; + + /** 绑定状态:CONFIRMED、ADMIN_REJECTED、CONFIRMING、TIMEOUT_REJECTED。 */ + @SerializedName("recipient_state") + private String recipientState; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRequest.java new file mode 100644 index 000000000..582dc76ce --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreRequest.java @@ -0,0 +1,28 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 创建或更新品牌门店的请求。 */ +@Data +public class BrandStoreRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 必填,品牌ID。 */ + @SerializedName("brand_id") + private String brandId; + + /** 门店基础信息。 */ + @SerializedName("store_basics") + private BrandStoreBasics storeBasics; + + /** 门店地址信息;创建时必填。 */ + @SerializedName("store_address") + private BrandStoreAddress storeAddress; + + /** 门店经营信息。 */ + @SerializedName("store_business") + private BrandStoreBusiness storeBusiness; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreResult.java new file mode 100644 index 000000000..57b0e888e --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreResult.java @@ -0,0 +1,49 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** 品牌门店创建、查询或更新响应。 */ +@Data +public class BrandStoreResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 品牌ID。 */ + @SerializedName("brand_id") + private String brandId; + + /** 微信支付门店ID。 */ + @SerializedName("store_id") + private String storeId; + + /** 门店状态:OPEN、CREATING、CLOSED。 */ + @SerializedName("store_state") + private String storeState; + + /** 审核状态:SUCCESS、PROCESSING、REJECTED。 */ + @SerializedName("audit_state") + private String auditState; + + /** 审核驳回原因。 */ + @SerializedName("review_reject_reason") + private String reviewRejectReason; + + /** 门店基础信息。 */ + @SerializedName("store_basics") + private BrandStoreBasics storeBasics; + + /** 门店地址信息。 */ + @SerializedName("store_address") + private BrandStoreAddress storeAddress; + + /** 门店经营信息。 */ + @SerializedName("store_business") + private BrandStoreBusiness storeBusiness; + + /** 绑定收款商户列表。 */ + @SerializedName("store_recipient") + private List storeRecipient; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientRequest.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientRequest.java new file mode 100644 index 000000000..f7187af08 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientRequest.java @@ -0,0 +1,20 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 解绑品牌门店收款商户请求。 */ +@Data +public class BrandStoreUnbindRecipientRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** 必填,品牌ID。 */ + @SerializedName("brand_id") + private String brandId; + + /** 必填,收款商户号。 */ + @SerializedName("mchid") + private String mchid; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientResult.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientResult.java new file mode 100644 index 000000000..307cd0175 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/bean/brandstore/BrandStoreUnbindRecipientResult.java @@ -0,0 +1,16 @@ +package com.github.binarywang.wxpay.bean.brandstore; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.io.Serializable; + +/** 解绑品牌门店收款商户响应。 */ +@Data +public class BrandStoreUnbindRecipientResult implements Serializable { + private static final long serialVersionUID = 1L; + + /** 解绑失败原因;成功时可为空。 */ + @SerializedName("failed_reason") + private String failedReason; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandApplymentService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandApplymentService.java new file mode 100644 index 000000000..2ca833085 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandApplymentService.java @@ -0,0 +1,49 @@ +package com.github.binarywang.wxpay.service; + +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentCancelRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentQueryResult; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentResult; +import com.github.binarywang.wxpay.exception.WxPayException; + +/** + * 连锁品牌入驻申请 API。 + * 官方接入文档 + */ +public interface BrandApplymentService { + /** + * 提交入驻申请;SDK 自动加密管理员姓名和证件号,不修改传入资料。 + * + * @param request 品牌入驻资料,敏感字段填写明文,图片填写预先上传的 MediaID + * @return 微信支付申请单号及业务申请编号 + * @throws WxPayException 接口调用或敏感信息加密失败 + */ + BrandApplymentResult createApplyment(BrandApplymentRequest request) throws WxPayException; + + /** + * 撤销尚未完成开号的入驻申请。 + * + * @param request 业务申请编号与微信支付申请单号至少填写一个 + * @return 被撤销的申请单号及业务申请编号 + * @throws WxPayException 接口调用失败 + */ + BrandApplymentResult cancelApplyment(BrandApplymentCancelRequest request) throws WxPayException; + + /** + * 根据业务申请编号查询申请状态。 + * + * @param businessCode 服务商自定义的业务申请编号,仅包含数字、字母或下划线 + * @return 申请状态及管理员授权链接 + * @throws WxPayException 接口调用失败 + */ + BrandApplymentQueryResult queryApplymentByBusinessCode(String businessCode) throws WxPayException; + + /** + * 根据微信支付申请单号查询申请状态。 + * + * @param applymentId 微信支付分配的申请单号 + * @return 申请状态及管理员授权链接 + * @throws WxPayException 接口调用失败 + */ + BrandApplymentQueryResult queryApplymentByApplymentId(String applymentId) throws WxPayException; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandProfitSharingService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandProfitSharingService.java new file mode 100644 index 000000000..346dbabb1 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandProfitSharingService.java @@ -0,0 +1,113 @@ +package com.github.binarywang.wxpay.service; + +import com.github.binarywang.wxpay.bean.brandprofitsharing.*; +import com.github.binarywang.wxpay.bean.notify.SignatureHeader; +import com.github.binarywang.wxpay.bean.profitsharing.notify.ProfitSharingNotifyV3Result; +import com.github.binarywang.wxpay.exception.WxPayException; + +/** + * 连锁品牌分账 API v3,区别于普通服务商分账接口。 + * @see 官方开发指引 + */ +public interface BrandProfitSharingService { + /** + * 请求分账;异步受理,最终结果需查询。 + * + * @param request 接口请求参数 + * @return 分账受理结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingResult profitSharing(BrandProfitSharingRequest request) throws WxPayException; + + /** + * 查询分账或完结分账结果。 + * + * @param request 接口请求参数 + * @return 分账执行结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingResult query(BrandProfitSharingQueryRequest request) throws WxPayException; + + /** + * 请求回退已分给商户接收方的资金。 + * + * @param request 接口请求参数 + * @return 回退受理结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingReturnResult returnOrder(BrandProfitSharingReturnRequest request) throws WxPayException; + + /** + * 查询分账回退结果。 + * + * @param request 接口请求参数 + * @return 回退执行结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingReturnResult queryReturn(BrandProfitSharingReturnQueryRequest request) throws WxPayException; + + /** + * 完结分账,解冻订单剩余资金。 + * + * @param request 接口请求参数 + * @return 完结受理结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingFinishResult finish(BrandProfitSharingFinishRequest request) throws WxPayException; + + /** + * 查询订单剩余待分账金额(分)。 + * + * @param transactionId 微信支付订单号 + * @return 剩余待分账金额 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingAmountResult queryRemainingAmount(String transactionId) throws WxPayException; + + /** + * 查询品牌最大分账比例(万分之一)。 + * + * @param brandMchId 品牌主商户号 + * @return 品牌最大分账比例 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingRatioResult queryMaxRatio(String brandMchId) throws WxPayException; + + /** + * 添加品牌接收方。 + * + * @param request 接口请求参数 + * @return 接收方添加结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingReceiverResult addReceiver(BrandProfitSharingReceiverRequest request) throws WxPayException; + + /** + * 删除品牌接收方。 + * + * @param request 接口请求参数 + * @return 接收方删除结果 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + BrandProfitSharingReceiverResult deleteReceiver(BrandProfitSharingReceiverDeleteRequest request) throws WxPayException; + + /** + * 验签并解密分账动账通知;请传入完整通知签名头。 + * + * @param notifyData 原始通知 JSON + * @param header 微信支付通知签名头,不能为空 + * @return 解密后的分账动账通知 + * @throws WxPayException 微信支付接口、加密或验签异常 + * @see 官方接口文档 + */ + ProfitSharingNotifyV3Result parseNotifyResult(String notifyData, SignatureHeader header) throws WxPayException; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandStoreService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandStoreService.java new file mode 100644 index 000000000..80d9b749d --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/BrandStoreService.java @@ -0,0 +1,63 @@ +package com.github.binarywang.wxpay.service; + +import com.github.binarywang.wxpay.bean.brandstore.*; +import com.github.binarywang.wxpay.exception.WxPayException; + +/** + * 品牌门店(服务商模式)。 + *

服务商须获得品牌授权,门店信息创建和更新可能进入审核流程。

+ * @see 开发指引 + */ +public interface BrandStoreService { + /** + * 创建门店并获取微信支付门店ID。 + * @param request 品牌ID和门店信息,地址必填 + * @return 门店信息和审核状态 + * @throws WxPayException 微信支付接口调用失败 + */ + BrandStoreResult create(BrandStoreRequest request) throws WxPayException; + + /** + * 查询品牌门店及审核状态。 + * @param brandId 品牌ID + * @param storeId 微信支付门店ID + * @return 门店信息、审核状态和收款商户 + * @throws WxPayException 微信支付接口调用失败 + */ + BrandStoreResult query(String brandId, String storeId) throws WxPayException; + + /** + * 更新门店信息,生效门店的营业状态不受审核影响。 + * @param storeId 微信支付门店ID + * @param request 品牌ID和待更新的门店信息 + * @return 更新后的门店信息和审核状态 + * @throws WxPayException 微信支付接口调用失败 + */ + BrandStoreResult update(String storeId, BrandStoreRequest request) throws WxPayException; + + /** + * 删除已关闭的品牌门店。 + * @param brandId 品牌ID + * @param storeId 微信支付门店ID + * @throws WxPayException 微信支付接口调用失败 + */ + void delete(String brandId, String storeId) throws WxPayException; + + /** + * 绑定品牌下已有的收款商户号。 + * @param storeId 微信支付门店ID + * @param request 品牌ID、商户号和主体名称 + * @return 收款商户绑定状态 + * @throws WxPayException 微信支付接口调用失败 + */ + BrandStoreBindRecipientResult bindRecipient(String storeId, BrandStoreBindRecipientRequest request) throws WxPayException; + + /** + * 解绑收款商户号。 + * @param storeId 微信支付门店ID + * @param request 品牌ID和商户号 + * @return 解绑结果,失败时包含失败原因 + * @throws WxPayException 微信支付接口调用失败 + */ + BrandStoreUnbindRecipientResult unbindRecipient(String storeId, BrandStoreUnbindRecipientRequest request) throws WxPayException; +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java index d0d43285d..89f66efed 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/WxPayService.java @@ -387,6 +387,30 @@ default WxPayService switchoverTo(String mchIdOrConfigKey) { */ BrandMerchantTransferService getBrandMerchantTransferService(); + /** + * 获取连锁品牌入驻服务。 + * @return 品牌入驻服务 + */ + default BrandApplymentService getBrandApplymentService() { + return new com.github.binarywang.wxpay.service.impl.BrandApplymentServiceImpl(this); + } + + /** + * 获取连锁品牌门店服务。 + * @return 品牌门店服务 + */ + default BrandStoreService getBrandStoreService() { + return new com.github.binarywang.wxpay.service.impl.BrandStoreServiceImpl(this); + } + + /** + * 获取连锁品牌分账服务。 + * @return 品牌分账服务 + */ + default BrandProfitSharingService getBrandProfitSharingService() { + return new com.github.binarywang.wxpay.service.impl.BrandProfitSharingServiceImpl(this); + } + /** * 获取微信支付预约扣费服务类 (连续包月功能) * diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java index 18e9b05b1..2dcb418cd 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BaseWxPayServiceImpl.java @@ -133,6 +133,15 @@ public abstract class BaseWxPayServiceImpl implements WxPayService { @Getter private final BrandMerchantTransferService brandMerchantTransferService = new BrandMerchantTransferServiceImpl(this); + @Getter + private final BrandApplymentService brandApplymentService = new BrandApplymentServiceImpl(this); + + @Getter + private final BrandStoreService brandStoreService = new BrandStoreServiceImpl(this); + + @Getter + private final BrandProfitSharingService brandProfitSharingService = new BrandProfitSharingServiceImpl(this); + @Getter private final SubscriptionBillingService subscriptionBillingService = new SubscriptionBillingServiceImpl(this); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandApplymentServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandApplymentServiceImpl.java new file mode 100644 index 000000000..f6864c706 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandApplymentServiceImpl.java @@ -0,0 +1,49 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentCancelRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentQueryResult; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentResult; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.BrandApplymentService; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.v3.util.RsaCryptoUtil; +import com.google.gson.Gson; +import lombok.RequiredArgsConstructor; + +/** 连锁品牌入驻申请 API 实现。 */ +@RequiredArgsConstructor +public class BrandApplymentServiceImpl implements BrandApplymentService { + private static final Gson GSON = new Gson(); + private final WxPayService payService; + + @Override + public BrandApplymentResult createApplyment(BrandApplymentRequest request) throws WxPayException { + BrandApplymentRequest encrypted = GSON.fromJson(GSON.toJson(request), BrandApplymentRequest.class); + RsaCryptoUtil.encryptFields(encrypted, this.payService.getConfig().getVerifier().getValidCertificate()); + String response = this.payService.postV3WithWechatpaySerial( + this.payService.getPayBaseUrl() + "/v3/brand/applyments", GSON.toJson(encrypted)); + return GSON.fromJson(response, BrandApplymentResult.class); + } + + @Override + public BrandApplymentResult cancelApplyment(BrandApplymentCancelRequest request) throws WxPayException { + String response = this.payService.postV3(this.payService.getPayBaseUrl() + + "/v3/brand/applyments/cancel-applyment", GSON.toJson(request)); + return GSON.fromJson(response, BrandApplymentResult.class); + } + + @Override + public BrandApplymentQueryResult queryApplymentByBusinessCode(String businessCode) throws WxPayException { + String response = this.payService.getV3(this.payService.getPayBaseUrl() + + "/v3/brand/applyments/business-code/" + businessCode); + return GSON.fromJson(response, BrandApplymentQueryResult.class); + } + + @Override + public BrandApplymentQueryResult queryApplymentByApplymentId(String applymentId) throws WxPayException { + String response = this.payService.getV3(this.payService.getPayBaseUrl() + + "/v3/brand/applyments/applyment-id/" + applymentId); + return GSON.fromJson(response, BrandApplymentQueryResult.class); + } +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java new file mode 100644 index 000000000..d4d799f02 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java @@ -0,0 +1,114 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.bean.brandprofitsharing.*; +import com.github.binarywang.wxpay.bean.notify.SignatureHeader; +import com.github.binarywang.wxpay.bean.profitsharing.notify.ProfitSharingNotifyV3Result; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.BrandProfitSharingService; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.v3.util.RsaCryptoUtil; +import com.google.gson.Gson; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; + +/** 连锁品牌分账接口实现。 */ +public class BrandProfitSharingServiceImpl implements BrandProfitSharingService { + private static final Gson GSON = new Gson(); + private static final String PATH = "/v3/brand/profitsharing"; + private final WxPayService payService; + + /** + * 创建连锁品牌分账服务。 + * @param payService 微信支付服务,提供 API v3 配置和 HTTP 执行能力 + */ + public BrandProfitSharingServiceImpl(WxPayService payService) { + this.payService = payService; + } + + @Override + public BrandProfitSharingResult profitSharing(BrandProfitSharingRequest request) throws WxPayException { + // 保留原始姓名,允许调用方在暂时失败后使用同一请求对象重试。 + BrandProfitSharingRequest encrypted = GSON.fromJson(GSON.toJson(request), BrandProfitSharingRequest.class); + RsaCryptoUtil.encryptFields(encrypted, this.payService.getConfig().getVerifier().getValidCertificate()); + return GSON.fromJson(this.payService.postV3WithWechatpaySerial(url("/orders"), GSON.toJson(encrypted)), + BrandProfitSharingResult.class); + } + + @Override + public BrandProfitSharingResult query(BrandProfitSharingQueryRequest request) throws WxPayException { + String path = "/orders?sub_mchid=" + encode(request.getSubMchId()) + + "&transaction_id=" + encode(request.getTransactionId()) + + "&out_order_no=" + encode(request.getOutOrderNo()); + return GSON.fromJson(this.payService.getV3(url(path)), BrandProfitSharingResult.class); + } + + @Override + public BrandProfitSharingReturnResult returnOrder(BrandProfitSharingReturnRequest request) throws WxPayException { + return GSON.fromJson(this.payService.postV3(url("/returnorders"), GSON.toJson(request)), + BrandProfitSharingReturnResult.class); + } + + @Override + public BrandProfitSharingReturnResult queryReturn(BrandProfitSharingReturnQueryRequest request) throws WxPayException { + String path = "/returnorders?sub_mchid=" + encode(request.getSubMchId()) + + "&out_return_no=" + encode(request.getOutReturnNo()); + if (request.getOrderId() != null) { + path += "&order_id=" + encode(request.getOrderId()); + } + if (request.getOutOrderNo() != null) { + path += "&out_order_no=" + encode(request.getOutOrderNo()); + } + return GSON.fromJson(this.payService.getV3(url(path)), BrandProfitSharingReturnResult.class); + } + + @Override + public BrandProfitSharingFinishResult finish(BrandProfitSharingFinishRequest request) throws WxPayException { + return GSON.fromJson(this.payService.postV3(url("/finish-order"), GSON.toJson(request)), + BrandProfitSharingFinishResult.class); + } + + @Override + public BrandProfitSharingAmountResult queryRemainingAmount(String transactionId) throws WxPayException { + return GSON.fromJson(this.payService.getV3(url("/orders/" + encode(transactionId) + "/amounts")), + BrandProfitSharingAmountResult.class); + } + + @Override + public BrandProfitSharingRatioResult queryMaxRatio(String brandMchId) throws WxPayException { + return GSON.fromJson(this.payService.getV3(url("/brand-configs/" + encode(brandMchId))), + BrandProfitSharingRatioResult.class); + } + + @Override + public BrandProfitSharingReceiverResult addReceiver(BrandProfitSharingReceiverRequest request) throws WxPayException { + return GSON.fromJson(this.payService.postV3(url("/receivers/add"), GSON.toJson(request)), + BrandProfitSharingReceiverResult.class); + } + + @Override + public BrandProfitSharingReceiverResult deleteReceiver(BrandProfitSharingReceiverDeleteRequest request) throws WxPayException { + return GSON.fromJson(this.payService.postV3(url("/receivers/delete"), GSON.toJson(request)), + BrandProfitSharingReceiverResult.class); + } + + @Override + public ProfitSharingNotifyV3Result parseNotifyResult(String notifyData, SignatureHeader header) throws WxPayException { + if (header == null) { + throw new WxPayException("通知签名头不能为空"); + } + return new ProfitSharingServiceImpl(this.payService).parseProfitSharingNotifyResult(notifyData, header); + } + + private String url(String path) { + return this.payService.getPayBaseUrl() + PATH + path; + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20").replace("*", "%2A"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceImpl.java new file mode 100644 index 000000000..1aeb76d91 --- /dev/null +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceImpl.java @@ -0,0 +1,83 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.bean.brandstore.*; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.BrandStoreService; +import com.github.binarywang.wxpay.service.WxPayService; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import lombok.RequiredArgsConstructor; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** 品牌门店接口实现。 */ +@RequiredArgsConstructor +public class BrandStoreServiceImpl implements BrandStoreService { + private static final Gson GSON = new Gson(); + private static final String PATH = "/v3/brand/partner/store/brandstores"; + private final WxPayService wxPayService; + + @Override + public BrandStoreResult create(BrandStoreRequest request) throws WxPayException { + return GSON.fromJson(wxPayService.postV3(wxPayService.getPayBaseUrl() + PATH, GSON.toJson(request)), BrandStoreResult.class); + } + + @Override + public BrandStoreResult query(String brandId, String storeId) throws WxPayException { + return GSON.fromJson(wxPayService.getV3(storeUrl(storeId) + "?brand_id=" + encode(brandId)), BrandStoreResult.class); + } + + @Override + public BrandStoreResult update(String storeId, BrandStoreRequest request) throws WxPayException { + return GSON.fromJson(wxPayService.patchV3(storeUrl(storeId), GSON.toJson(request)), BrandStoreResult.class); + } + + @Override + public void delete(String brandId, String storeId) throws WxPayException { + String url = storeUrl(storeId); + // 删除接口的 brand_id 位于 JSON 请求体,普通 HttpDelete 不支持实体。 + HttpEntityEnclosingRequestBase request = new HttpEntityEnclosingRequestBase() { + @Override + public String getMethod() { + return "DELETE"; + } + }; + request.setURI(URI.create(url)); + request.setHeader("Accept", "application/json"); + JsonObject body = new JsonObject(); + body.addProperty("brand_id", brandId); + request.setEntity(new StringEntity(GSON.toJson(body), ContentType.APPLICATION_JSON)); + wxPayService.requestV3(url, request); + } + + @Override + public BrandStoreBindRecipientResult bindRecipient(String storeId, BrandStoreBindRecipientRequest request) throws WxPayException { + return GSON.fromJson(wxPayService.postV3(storeUrl(storeId) + "/bindrecipient", GSON.toJson(request)), BrandStoreBindRecipientResult.class); + } + + @Override + public BrandStoreUnbindRecipientResult unbindRecipient(String storeId, BrandStoreUnbindRecipientRequest request) throws WxPayException { + return GSON.fromJson(wxPayService.postV3(storeUrl(storeId) + "/unbindrecipient", GSON.toJson(request)), BrandStoreUnbindRecipientResult.class); + } + + private String storeUrl(String storeId) { + return wxPayService.getPayBaseUrl() + PATH + "/" + encode(storeId); + } + + private static String encode(String value) { + if (value == null) { + throw new IllegalArgumentException("微信支付接口必填参数不能为空"); + } + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/BrandApplymentServiceTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/BrandApplymentServiceTest.java new file mode 100644 index 000000000..f475ad1ca --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/BrandApplymentServiceTest.java @@ -0,0 +1,135 @@ +package com.github.binarywang.wxpay.service; + +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentCancelRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentRequest; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentQueryResult; +import com.github.binarywang.wxpay.bean.brandapplyment.BrandApplymentResult; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.impl.BrandApplymentServiceImpl; +import com.github.binarywang.wxpay.v3.auth.PublicCertificateVerifier; +import com.github.binarywang.wxpay.v3.util.RsaCryptoUtil; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Proxy; +import java.security.KeyPair; +import java.security.KeyPairGenerator; + +/** 品牌入驻申请离线契约测试。 */ +public class BrandApplymentServiceTest { + private static final String BASE = "https://api.mch.weixin.qq.com"; + private static final Gson GSON = new Gson(); + + @Test + public void shouldEncryptAdminAndPreserveReusableRequest() throws Exception { + Capture capture = new Capture(); + BrandApplymentService service = new BrandApplymentServiceImpl(capture.service()); + BrandApplymentRequest request = GSON.fromJson("{\"business_code\":\"brand_1\"," + + "\"admin_info\":{\"admin_name\":\"测试管理员\",\"id_doc_type\":\"IDENTIFICATION_TYPE_MAINLAND_ID_CARD\",\"id_card_number\":\"test-id\"}," + + "\"subject_info\":{\"subject_type\":\"SUBJECT_TYPE_ENTERPRISE\",\"subject_name\":\"测试企业\",\"unified_social_credit_code\":\"test-code\"}," + + "\"brand_basic_info\":{\"brand_name\":\"测试品牌\",\"brand_logo\":\"logo-media\"}," + + "\"trademark\":{\"trademark_exists\":\"TRADEMARK_EXISTS\",\"trademark_registration_certificate\":{\"name\":\"商标\",\"certificate_list\":[\"cert-1\",\"cert-2\"],\"license_list\":[\"license-1\"]}," + + "\"logo_trademark_registration_certificate\":{\"number\":\"logo-1\"},\"no_trademark_addition_prove_list\":[\"proof-1\"]}}", BrandApplymentRequest.class); + BrandApplymentResult result = service.createApplyment(request); + Assert.assertEquals(capture.method, "postV3WithWechatpaySerial"); + Assert.assertEquals(capture.url, BASE + "/v3/brand/applyments"); + JsonObject body = GSON.fromJson(capture.body, JsonObject.class); + JsonObject admin = body.getAsJsonObject("admin_info"); + Assert.assertEquals(RsaCryptoUtil.decryptOAEP(admin.get("admin_name").getAsString(), capture.keys.getPrivate()), "测试管理员"); + Assert.assertEquals(RsaCryptoUtil.decryptOAEP(admin.get("id_card_number").getAsString(), capture.keys.getPrivate()), "test-id"); + Assert.assertEquals(admin.get("id_doc_type").getAsString(), "IDENTIFICATION_TYPE_MAINLAND_ID_CARD"); + Assert.assertEquals(body.getAsJsonObject("trademark").getAsJsonObject("trademark_registration_certificate").getAsJsonArray("certificate_list").size(), 2); + Assert.assertEquals(result.getApplymentId(), "1234567890123456"); + Assert.assertEquals(request.getAdminInfo().getAdminName(), "测试管理员"); + service.createApplyment(request); + admin = GSON.fromJson(capture.body, JsonObject.class).getAsJsonObject("admin_info"); + Assert.assertEquals(RsaCryptoUtil.decryptOAEP(admin.get("admin_name").getAsString(), capture.keys.getPrivate()), "测试管理员"); + } + + @Test + public void shouldCancelByEitherIdentifier() throws Exception { + Capture capture = new Capture(); + BrandApplymentService service = new BrandApplymentServiceImpl(capture.service()); + BrandApplymentCancelRequest request = new BrandApplymentCancelRequest(); + request.setBusinessCode("brand_1"); + Assert.assertEquals(service.cancelApplyment(request).getBusinessCode(), "brand_1"); + Assert.assertEquals(capture.method, "postV3"); + Assert.assertEquals(capture.url, BASE + "/v3/brand/applyments/cancel-applyment"); + Assert.assertEquals(GSON.fromJson(capture.body, JsonObject.class).get("business_code").getAsString(), "brand_1"); + request.setBusinessCode(null); + request.setApplymentId("1234567890123456"); + service.cancelApplyment(request); + Assert.assertFalse(GSON.fromJson(capture.body, JsonObject.class).has("business_code")); + } + + @Test + public void shouldQueryBothIdentifiersAndReadState() throws Exception { + Capture capture = new Capture(); + capture.response = "{\"applyment_id\":\"1234567890123456\",\"business_code\":\"brand_1\",\"applyment_state\":\"APPLYMENT_STATE_FINISH\",\"applyment_state_desc\":\"入驻完成\",\"authorization_confirmation_qr_code\":\"https://example.com/auth\",\"reject_reason\":\"测试原因\",\"brand_id\":\"12345678901234567890\"}"; + BrandApplymentService service = new BrandApplymentServiceImpl(capture.service()); + BrandApplymentQueryResult result = service.queryApplymentByBusinessCode("brand_1"); + Assert.assertEquals(result.getApplymentState(), "APPLYMENT_STATE_FINISH"); + Assert.assertEquals(result.getApplymentStateDesc(), "入驻完成"); + Assert.assertEquals(result.getAuthorizationConfirmationQrCode(), "https://example.com/auth"); + Assert.assertEquals(result.getRejectReason(), "测试原因"); + Assert.assertEquals(result.getBrandId(), "12345678901234567890"); + Assert.assertEquals(capture.method, "getV3"); + Assert.assertEquals(capture.url, BASE + "/v3/brand/applyments/business-code/brand_1"); + Assert.assertEquals(service.queryApplymentByApplymentId("1234567890123456").getApplymentId(), "1234567890123456"); + Assert.assertEquals(capture.url, BASE + "/v3/brand/applyments/applyment-id/1234567890123456"); + } + + @Test + public void shouldPropagateWechatErrors() throws Exception { + Capture capture = new Capture(); + capture.error = new WxPayException("RESOURCE_NOT_EXISTS"); + BrandApplymentService service = new BrandApplymentServiceImpl(capture.service()); + try { + service.queryApplymentByBusinessCode("brand_1"); + Assert.fail("Expected WeChat error"); + } catch (WxPayException e) { + Assert.assertSame(e, capture.error); + } + } + + private static class Capture { + final KeyPair keys; + final WxPayConfig config = new WxPayConfig(); + String method; + String url; + String body; + String response = "{\"applyment_id\":\"1234567890123456\",\"business_code\":\"brand_1\"}"; + WxPayException error; + + Capture() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + keys = generator.generateKeyPair(); + config.setVerifier(new PublicCertificateVerifier(keys.getPublic(), "PUB_KEY_ID_TEST")); + } + + WxPayService service() { + return (WxPayService) Proxy.newProxyInstance(WxPayService.class.getClassLoader(), new Class[]{WxPayService.class}, (proxy, called, args) -> { + if (called.getName().equals("getPayBaseUrl")) { + return BASE; + } + if (called.getName().equals("getConfig")) { + return config; + } + if (called.getName().equals("getV3") || called.getName().equals("postV3") || called.getName().equals("postV3WithWechatpaySerial")) { + method = called.getName(); + url = (String) args[0]; + body = args.length > 1 ? (String) args[1] : null; + if (error != null) { + throw error; + } + return response; + } + throw new UnsupportedOperationException(called.getName()); + }); + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java new file mode 100644 index 000000000..aaa8df44a --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java @@ -0,0 +1,204 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.bean.brandprofitsharing.*; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.BrandProfitSharingService; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.v3.auth.PublicCertificateVerifier; +import com.github.binarywang.wxpay.v3.util.RsaCryptoUtil; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Proxy; +import java.security.KeyPair; +import java.security.KeyPairGenerator; + +/** 连锁品牌分账接口契约测试,无需商户凭据。 */ +public class BrandProfitSharingServiceTest { + private static final Gson GSON = new Gson(); + private static final String BASE = "https://example.com/v3/brand/profitsharing"; + + @Test + public void shouldUseBrandQueryRouteAndEncodeOrderNumber() throws Exception { + Capture capture = new Capture(); + capture.response = "{\"status\":\"FINISHED\",\"finish_amount\":123,\"finish_description\":\"完成\"," + + "\"receivers\":[{\"detail_id\":\"detail\",\"result\":\"SUCCESS\",\"finish_time\":\"2026-10-13T10:00:00+08:00\"}]}"; + BrandProfitSharingResult result = capture.service().query(BrandProfitSharingQueryRequest.newBuilder() + .subMchId("store").transactionId("transaction").outOrderNo("order|*@").build()); + Assert.assertEquals(capture.url, BASE + "/orders?sub_mchid=store&transaction_id=transaction&out_order_no=order%7C%2A%40"); + Assert.assertEquals(capture.method, "getV3"); + Assert.assertEquals(result.getFinishAmount(), Integer.valueOf(123)); + Assert.assertEquals(result.getFinishDescription(), "完成"); + Assert.assertEquals(result.getReceivers().get(0).getDetailId(), "detail"); + } + + @Test + public void shouldRouteReturnAndFinishAndReadResults() throws Exception { + Capture capture = new Capture(); + BrandProfitSharingService service = capture.service(); + capture.response = "{\"return_no\":\"wechat-return\",\"result\":\"SUCCESS\",\"amount\":10}"; + BrandProfitSharingReturnResult result = service.returnOrder(BrandProfitSharingReturnRequest.newBuilder() + .subMchId("store").outOrderNo("order").outReturnNo("return").returnMchId("receiver") + .amount(10).description("退款").build()); + Assert.assertEquals(capture.url, BASE + "/returnorders"); + Assert.assertEquals(capture.method, "postV3"); + Assert.assertEquals(capture.json().get("return_mchid").getAsString(), "receiver"); + Assert.assertEquals(capture.json().get("amount").getAsInt(), 10); + Assert.assertEquals(result.getReturnNo(), "wechat-return"); + Assert.assertEquals(result.getResult(), "SUCCESS"); + service.queryReturn(BrandProfitSharingReturnQueryRequest.newBuilder() + .subMchId("store").outReturnNo("return").outOrderNo("order|*@").build()); + Assert.assertEquals(capture.url, BASE + "/returnorders?sub_mchid=store&out_return_no=return&out_order_no=order%7C%2A%40"); + service.queryReturn(BrandProfitSharingReturnQueryRequest.newBuilder() + .subMchId("store").outReturnNo("return").orderId("wechat-order").build()); + Assert.assertEquals(capture.url, BASE + "/returnorders?sub_mchid=store&out_return_no=return&order_id=wechat-order"); + capture.response = "{\"order_id\":\"finish-order\",\"sub_mchid\":\"store\"}"; + Assert.assertEquals(service.finish(BrandProfitSharingFinishRequest.newBuilder().subMchId("store") + .transactionId("transaction").outOrderNo("finish").description("完成").build()).getOrderId(), "finish-order"); + Assert.assertEquals(capture.url, BASE + "/finish-order"); + Assert.assertEquals(capture.json().get("sub_mchid").getAsString(), "store"); + } + + @Test + public void shouldRouteRemainingAmountAndBrandRatio() throws Exception { + Capture capture = new Capture(); + capture.response = "{\"transaction_id\":\"transaction\",\"unsplit_amount\":99}"; + Assert.assertEquals(capture.service().queryRemainingAmount("transaction").getUnsplitAmount(), Integer.valueOf(99)); + Assert.assertEquals(capture.url, BASE + "/orders/transaction/amounts"); + capture.response = "{\"brand_mchid\":\"brand\",\"max_ratio\":3000}"; + Assert.assertEquals(capture.service().queryMaxRatio("brand").getMaxRatio(), Integer.valueOf(3000)); + Assert.assertEquals(capture.url, BASE + "/brand-configs/brand"); + } + + @Test + public void shouldTransmitReceiverRegistrationNameAsPlaintextPerContract() throws Exception { + Capture capture = new Capture(); + BrandProfitSharingService service = capture.service(); + service.addReceiver(BrandProfitSharingReceiverRequest.newBuilder().brandMchId("brand") + .appid("app").subAppid("brand-app").type("MERCHANT_ID").account("receiver") + .name("测试名称").relationType("SUPPLIER").build()); + Assert.assertEquals(capture.url, BASE + "/receivers/add"); + Assert.assertEquals(capture.method, "postV3"); + Assert.assertEquals(capture.json().get("brand_mchid").getAsString(), "brand"); + Assert.assertEquals(capture.json().get("name").getAsString(), "测试名称"); + Assert.assertFalse(capture.json().has("sub_mchid")); + service.deleteReceiver(BrandProfitSharingReceiverDeleteRequest.newBuilder().brandMchId("brand") + .appid("app").subAppid("brand-app").type("MERCHANT_ID").account("receiver").build()); + Assert.assertEquals(capture.url, BASE + "/receivers/delete"); + Assert.assertEquals(capture.method, "postV3"); + Assert.assertFalse(capture.json().has("name")); + } + + @Test + public void shouldEncryptReceiverNameWithoutMutatingReusableRequest() throws Exception { + Capture capture = new Capture(); + BrandProfitSharingRequest request = GSON.fromJson("{\"brand_mchid\":\"brand\",\"sub_mchid\":\"store\"," + + "\"appid\":\"app\",\"sub_appid\":\"brand-app\",\"finish\":true," + + "\"receivers\":[{\"type\":\"MERCHANT_ID\",\"account\":\"receiver\",\"amount\":10,\"name\":\"测试名称\"}]}", BrandProfitSharingRequest.class); + BrandProfitSharingService service = capture.service(); + for (int n = 0; n < 2; n++) { + service.profitSharing(request); + Assert.assertEquals(capture.url, BASE + "/orders"); + Assert.assertEquals(capture.method, "postV3WithWechatpaySerial"); + String encrypted = capture.json().getAsJsonArray("receivers").get(0).getAsJsonObject().get("name").getAsString(); + Assert.assertEquals(RsaCryptoUtil.decryptOAEP(encrypted, capture.keys.getPrivate()), "测试名称"); + Assert.assertEquals(capture.json().get("brand_mchid").getAsString(), "brand"); + Assert.assertEquals(capture.json().get("sub_mchid").getAsString(), "store"); + Assert.assertTrue(capture.json().get("finish").getAsBoolean()); + Assert.assertEquals(request.getReceivers().get(0).getName(), "测试名称"); + } + } + + @Test + public void shouldPropagateWechatErrors() throws Exception { + Capture capture = new Capture(); + capture.error = new WxPayException("RESOURCE_NOT_EXISTS"); + try { + capture.service().queryMaxRatio("brand"); + Assert.fail("Expected WeChat error"); + } catch (WxPayException e) { + Assert.assertSame(e, capture.error); + } + } + + @Test + public void shouldVerifyAndDecryptBrandNotification() throws Exception { + Capture capture = new Capture(); + String key = "0123456789abcdef0123456789abcdef"; + capture.config.setApiV3Key(key); + String nonce = "0123456789ab"; + String associated = "profitsharing"; + String plaintext = "{\"sp_mchid\":\"provider\",\"sub_mchid\":\"store\",\"transaction_id\":\"transaction\"," + + "\"order_id\":\"order\",\"out_order_no\":\"out-order\",\"success_time\":\"2026-10-13T10:00:00+08:00\"," + + "\"receiver\":{\"type\":\"MERCHANT_ID\",\"account\":\"receiver\",\"amount\":10,\"description\":\"分账\"}}"; + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, + new javax.crypto.spec.SecretKeySpec(key.getBytes(java.nio.charset.StandardCharsets.UTF_8), "AES"), + new javax.crypto.spec.GCMParameterSpec(128, nonce.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + cipher.updateAAD(associated.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String encrypted = java.util.Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + String data = "{\"resource\":{\"algorithm\":\"AEAD_AES_256_GCM\",\"nonce\":\"" + nonce + + "\",\"associated_data\":\"" + associated + "\",\"ciphertext\":\"" + encrypted + "\"}}"; + com.github.binarywang.wxpay.bean.notify.SignatureHeader header = new com.github.binarywang.wxpay.bean.notify.SignatureHeader(); + header.setTimeStamp("123456"); + header.setNonce("signature-nonce"); + header.setSerial("PUB_KEY_ID_TEST"); + java.security.Signature signature = java.security.Signature.getInstance("SHA256withRSA"); + signature.initSign(capture.keys.getPrivate()); + signature.update((header.getTimeStamp() + "\n" + header.getNonce() + "\n" + data + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + header.setSignature(java.util.Base64.getEncoder().encodeToString(signature.sign())); + com.github.binarywang.wxpay.bean.profitsharing.notify.ProfitSharingNotifyV3Result result = capture.service().parseNotifyResult(data, header); + Assert.assertEquals(result.getSpMchid(), "provider"); + Assert.assertEquals(result.getSubMchid(), "store"); + Assert.assertEquals(result.getReceiver().getAmount(), Integer.valueOf(10)); + try { + capture.service().parseNotifyResult(data + " ", header); + Assert.fail("Expected signature rejection"); + } catch (WxPayException e) { + Assert.assertTrue(e.getMessage().contains("验证失败")); + } + } + + @Test(expectedExceptions = WxPayException.class, expectedExceptionsMessageRegExp = ".*签名头.*") + public void shouldRequireNotificationSignatureHeader() throws Exception { + new Capture().service().parseNotifyResult("{}", null); + } + + private static class Capture { + final KeyPair keys; + final WxPayConfig config = new WxPayConfig(); + String method; + String url; + String body; + String response = "{}"; + WxPayException error; + + Capture() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + keys = generator.generateKeyPair(); + config.setVerifier(new PublicCertificateVerifier(keys.getPublic(), "PUB_KEY_ID_TEST")); + } + + JsonObject json() { + return GSON.fromJson(body, JsonObject.class); + } + + BrandProfitSharingService service() { + WxPayService pay = (WxPayService) Proxy.newProxyInstance(WxPayService.class.getClassLoader(), + new Class[]{WxPayService.class}, (proxy, called, args) -> { + if (called.getName().equals("getPayBaseUrl")) return "https://example.com"; + if (called.getName().equals("getConfig")) return config; + method = called.getName(); + url = (String) args[0]; + body = args.length > 1 ? (String) args[1] : null; + if (error != null) throw error; + return response; + }); + return new BrandProfitSharingServiceImpl(pay); + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java new file mode 100644 index 000000000..75abd83f9 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java @@ -0,0 +1,23 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.service.WxPayService; +import org.testng.Assert; +import org.testng.annotations.Test; + +/** 连锁品牌服务入口兼容性测试。 */ +public class BrandServiceEntryTest { + @Test + public void shouldExposeBrandServicesOnAllHttpClients() throws Exception { + String[] getters = {"getBrandApplymentService", "getBrandStoreService", "getBrandProfitSharingService"}; + WxPayService[] clients = {new WxPayServiceImpl(), new WxPayServiceApacheHttpImpl(), + new WxPayServiceHttpComponentsImpl(), new WxPayServiceJoddHttpImpl()}; + for (String getter : getters) { + Assert.assertTrue(WxPayService.class.getMethod(getter).isDefault()); + for (WxPayService client : clients) { + Object service = WxPayService.class.getMethod(getter).invoke(client); + Assert.assertNotNull(service); + Assert.assertSame(WxPayService.class.getMethod(getter).invoke(client), service); + } + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceTest.java new file mode 100644 index 000000000..8ce03548b --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandStoreServiceTest.java @@ -0,0 +1,138 @@ +package com.github.binarywang.wxpay.service.impl; + +import com.github.binarywang.wxpay.bean.brandstore.*; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.util.EntityUtils; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Proxy; + +/** 品牌门店接口的离线契约测试。 */ +public class BrandStoreServiceTest { + private String method; + private String url; + private String body; + private String response; + private WxPayException error; + + private BrandStoreServiceImpl service(String response) { + this.response = response; + this.error = null; + return new BrandStoreServiceImpl((WxPayService) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{WxPayService.class}, (proxy, called, args) -> { + if ("getPayBaseUrl".equals(called.getName())) { + return "https://pay.example"; + } + this.method = called.getName(); + this.url = (String) args[0]; + this.body = args.length > 1 && args[1] instanceof String ? (String) args[1] : null; + if ("requestV3".equals(this.method)) { + HttpEntityEnclosingRequestBase request = (HttpEntityEnclosingRequestBase) args[1]; + Assert.assertEquals(request.getURI().toString(), this.url); + Assert.assertEquals(request.getFirstHeader("Accept").getValue(), "application/json"); + Assert.assertTrue(request.getEntity().getContentType().getValue().startsWith("application/json")); + this.method = request.getMethod(); + this.body = EntityUtils.toString(request.getEntity(), "UTF-8"); + } + if (this.error != null) { + throw this.error; + } + return this.response; + })); + } + + @Test + public void createSerializesNestedStoreAndParsesFullResponse() throws Exception { + BrandStoreServiceImpl service = service("{\"brand_id\":\"brand\",\"store_id\":\"store\",\"store_state\":\"OPEN\",\"audit_state\":\"REJECTED\",\"review_reject_reason\":\"地址错误\",\"store_basics\":{\"branch_name\":\"深圳店\"},\"store_address\":{\"longitude\":\"112.63484\"},\"store_business\":{\"service_phone\":\"010-12345678\"},\"store_recipient\":[{\"mchid\":\"mch\",\"company_name\":\"公司\",\"recipient_state\":\"CONFIRMING\"}]}"); + BrandStoreRequest request = new BrandStoreRequest(); + request.setBrandId("brand"); + BrandStoreBasics basics = new BrandStoreBasics(); + basics.setStoreReferenceId("ref"); + basics.setBranchName("深圳店"); + request.setStoreBasics(basics); + BrandStoreAddress address = new BrandStoreAddress(); + address.setAddressCode("440305"); + address.setAddressDetail("地址"); + address.setAddressComplements("地铁口"); + address.setLongitude("112.63484"); + address.setLatitude("37.75464"); + request.setStoreAddress(address); + BrandStoreBusiness business = new BrandStoreBusiness(); + business.setBusinessHours("09:00-20:00"); + business.setServicePhone("010-12345678"); + request.setStoreBusiness(business); + BrandStoreResult result = service.create(request); + Assert.assertEquals(method, "postV3"); + Assert.assertEquals(url, "https://pay.example/v3/brand/partner/store/brandstores"); + JsonObject json = new JsonParser().parse(body).getAsJsonObject(); + Assert.assertEquals(json.getAsJsonObject("store_basics").get("store_reference_id").getAsString(), "ref"); + Assert.assertEquals(json.getAsJsonObject("store_address").get("address_complements").getAsString(), "地铁口"); + Assert.assertEquals(json.getAsJsonObject("store_business").get("business_hours").getAsString(), "09:00-20:00"); + Assert.assertEquals(result.getStoreId(), "store"); + Assert.assertEquals(result.getAuditState(), "REJECTED"); + Assert.assertEquals(result.getStoreState(), "OPEN"); + Assert.assertEquals(result.getReviewRejectReason(), "地址错误"); + Assert.assertEquals(result.getStoreRecipient().get(0).getRecipientState(), "CONFIRMING"); + Assert.assertEquals(result.getStoreBasics().getBranchName(), "深圳店"); + Assert.assertEquals(result.getStoreAddress().getLongitude(), "112.63484"); + Assert.assertEquals(result.getStoreBusiness().getServicePhone(), "010-12345678"); + } + + @Test + public void queryEncodesPathAndQueryIdentifiers() throws Exception { + service("{\"store_id\":\"store\"}").query("brand &+", "store /中"); + Assert.assertEquals(method, "getV3"); + Assert.assertEquals(url, "https://pay.example/v3/brand/partner/store/brandstores/store%20%2F%E4%B8%AD?brand_id=brand%20%26%2B"); + } + + @Test + public void updateUsesPatchAndDeletePreservesRequiredJsonBody() throws Exception { + BrandStoreServiceImpl service = service("{}"); + BrandStoreRequest request = new BrandStoreRequest(); + request.setBrandId("brand"); + service.update("store", request); + Assert.assertEquals(method, "patchV3"); + Assert.assertEquals(url, "https://pay.example/v3/brand/partner/store/brandstores/store"); + Assert.assertEquals(body, "{\"brand_id\":\"brand\"}"); + service.delete("brand", "store"); + Assert.assertEquals(method, "DELETE"); + Assert.assertEquals(body, "{\"brand_id\":\"brand\"}"); + } + + @Test + public void bindsAndUnbindsRecipientWithTypedResponses() throws Exception { + BrandStoreServiceImpl service = service("{\"brand_id\":\"brand\",\"store_id\":\"store\",\"mchid\":\"mch\",\"company_name\":\"公司\",\"recipient_state\":\"CONFIRMED\"}"); + BrandStoreBindRecipientRequest request = new BrandStoreBindRecipientRequest(); + request.setBrandId("brand"); + request.setMchid("mch"); + request.setCompanyName("公司"); + Assert.assertEquals(service.bindRecipient("store", request).getRecipientState(), "CONFIRMED"); + Assert.assertEquals(method, "postV3"); + Assert.assertTrue(url.endsWith("/store/bindrecipient")); + Assert.assertEquals(new JsonParser().parse(body).getAsJsonObject().get("company_name").getAsString(), "公司"); + this.response = "{\"failed_reason\":\"解绑失败\"}"; + BrandStoreUnbindRecipientRequest unbind = new BrandStoreUnbindRecipientRequest(); + unbind.setBrandId("brand"); + unbind.setMchid("mch"); + Assert.assertEquals(service.unbindRecipient("store", unbind).getFailedReason(), "解绑失败"); + Assert.assertTrue(url.endsWith("/store/unbindrecipient")); + Assert.assertEquals(body, "{\"brand_id\":\"brand\",\"mchid\":\"mch\"}"); + } + + @Test + public void propagatesWechatErrors() throws Exception { + BrandStoreServiceImpl service = service("{}"); + this.error = new WxPayException("denied"); + try { + service.query("brand", "store"); + Assert.fail("Expected WxPayException"); + } catch (WxPayException actual) { + Assert.assertSame(actual, this.error); + } + } +} From c90abb7fd8ea8032f2136a89b96e29533b19b6ea Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Wed, 14 Oct 2026 17:58:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(pay):=20=E5=A4=84=E7=90=86=E5=93=81?= =?UTF-8?q?=E7=89=8C=E6=9C=8D=E5=8A=A1=E5=AE=A1=E6=9F=A5=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E6=95=8F=E6=84=9F=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md | 4 +- weixin-java-pay/pom.xml | 1 + .../impl/BrandProfitSharingServiceImpl.java | 16 ++- .../impl/WxPayServiceApacheHttpImpl.java | 28 +++- .../impl/WxPayServiceHttpComponentsImpl.java | 28 +++- .../impl/WxPayServiceJoddHttpImpl.java | 19 +++ .../wxpay/v3/auth/WxPayCredentials.java | 2 - .../impl/BrandProfitSharingServiceTest.java | 8 +- .../impl/BrandSensitiveLoggingTest.java | 130 ++++++++++++++++++ .../service/impl/BrandServiceEntryTest.java | 9 +- weixin-java-pay/src/test/resources/testng.xml | 5 + 11 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandSensitiveLoggingTest.java diff --git a/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md b/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md index 07f53a9c7..ae2dc7546 100644 --- a/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md +++ b/docs/WECHAT_PAY_CHAIN_BRAND_USAGE.md @@ -2,6 +2,8 @@ `weixin-java-pay` 提供服务商模式下的品牌入驻、品牌门店和连锁品牌分账。它们与品牌红包转账、普通商户分账分别使用独立服务和接口。 +品牌服务需要支持 APIv3 的 ApacheHttp 或 HttpComponents 客户端;Jodd 客户端的品牌服务入口会明确抛出 `UnsupportedOperationException`。 + ## 服务入口 ```java @@ -23,7 +25,7 @@ BrandProfitSharingService profitSharing = wxPayService.getBrandProfitSharingServ 查询品牌入驻申请: ```java -BrandApplymentQueryResult application = applyments.queryApplymentByBusinessCode("brand-application-001"); +BrandApplymentQueryResult application = applyments.queryApplymentByBusinessCode("brand_application_001"); ``` 查询或删除品牌门店: diff --git a/weixin-java-pay/pom.xml b/weixin-java-pay/pom.xml index 9bcc08284..65e243f36 100644 --- a/weixin-java-pay/pom.xml +++ b/weixin-java-pay/pom.xml @@ -61,6 +61,7 @@ ch.qos.logback logback-classic + ${logback-slf4j1-test.version} test diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java index d4d799f02..4d28b596a 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceImpl.java @@ -8,9 +8,13 @@ import com.github.binarywang.wxpay.service.WxPayService; import com.github.binarywang.wxpay.v3.util.RsaCryptoUtil; import com.google.gson.Gson; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; /** 连锁品牌分账接口实现。 */ public class BrandProfitSharingServiceImpl implements BrandProfitSharingService { @@ -82,13 +86,13 @@ public BrandProfitSharingRatioResult queryMaxRatio(String brandMchId) throws WxP @Override public BrandProfitSharingReceiverResult addReceiver(BrandProfitSharingReceiverRequest request) throws WxPayException { - return GSON.fromJson(this.payService.postV3(url("/receivers/add"), GSON.toJson(request)), + return GSON.fromJson(postReceiver("/receivers/add", request), BrandProfitSharingReceiverResult.class); } @Override public BrandProfitSharingReceiverResult deleteReceiver(BrandProfitSharingReceiverDeleteRequest request) throws WxPayException { - return GSON.fromJson(this.payService.postV3(url("/receivers/delete"), GSON.toJson(request)), + return GSON.fromJson(postReceiver("/receivers/delete", request), BrandProfitSharingReceiverResult.class); } @@ -100,6 +104,14 @@ public ProfitSharingNotifyV3Result parseNotifyResult(String notifyData, Signatur return new ProfitSharingServiceImpl(this.payService).parseProfitSharingNotifyResult(notifyData, header); } + // 使用通用执行器,保留接口要求的明文实体,同时避免记录收款人信息。 + private String postReceiver(String path, Object request) throws WxPayException { + String address = url(path); + HttpPost post = new HttpPost(address); + post.setEntity(new StringEntity(GSON.toJson(request), ContentType.create("application/json", StandardCharsets.UTF_8))); + return this.payService.requestV3(address, post); + } + private String url(String path) { return this.payService.getPayBaseUrl() + PATH + path; } diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceApacheHttpImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceApacheHttpImpl.java index 69a67ca0b..9772b9707 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceApacheHttpImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceApacheHttpImpl.java @@ -140,7 +140,11 @@ private String requestV3(String url, String requestStr, HttpRequestBase httpRequ } if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || HttpStatus.SC_ACCEPTED == statusCode) { - this.logRequestAndResponse(url, requestStr, responseString); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); + } else { + this.logRequestAndResponse(url, requestStr, responseString); + } return responseString; } @@ -148,7 +152,11 @@ private String requestV3(String url, String requestStr, HttpRequestBase httpRequ JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - this.logError(url, requestStr, e); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); + } else { + this.logError(url, requestStr, e); + } throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpRequestBase.releaseConnection(); @@ -180,7 +188,11 @@ public String postV3WithWechatpaySerial(String url, String requestStr) throws Wx if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || (HttpStatus.SC_ACCEPTED == statusCode && url.endsWith("/codepay"))) { - this.logRequestAndResponse(url, requestStr, responseString); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); + } else { + this.logRequestAndResponse(url, requestStr, responseString); + } return responseString; } @@ -188,7 +200,11 @@ public String postV3WithWechatpaySerial(String url, String requestStr) throws Wx JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - this.logError(url, requestStr, e); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); + } else { + this.logError(url, requestStr, e); + } throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpPost.releaseConnection(); @@ -215,7 +231,7 @@ public String requestV3(String url, HttpRequestBase httpRequest) throws WxPayExc } if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || HttpStatus.SC_ACCEPTED == statusCode) { - log.info("\n【请求地址】:{}\n【响应数据】:{}", url, responseString); + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); return responseString; } @@ -223,7 +239,7 @@ public String requestV3(String url, HttpRequestBase httpRequest) throws WxPayExc JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - log.error("\n【请求地址】:{}\n【异常信息】:{}", url, e.getMessage()); + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpRequest.releaseConnection(); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceHttpComponentsImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceHttpComponentsImpl.java index d28cbc890..d357beeee 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceHttpComponentsImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceHttpComponentsImpl.java @@ -137,7 +137,11 @@ private String requestV3(String url, String requestStr, HttpRequestBase httpRequ } if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || HttpStatus.SC_ACCEPTED == statusCode) { - this.logRequestAndResponse(url, requestStr, responseString); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); + } else { + this.logRequestAndResponse(url, requestStr, responseString); + } return responseString; } @@ -145,7 +149,11 @@ private String requestV3(String url, String requestStr, HttpRequestBase httpRequ JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - this.logError(url, requestStr, e); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); + } else { + this.logError(url, requestStr, e); + } throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpRequestBase.releaseConnection(); @@ -177,7 +185,11 @@ public String postV3WithWechatpaySerial(String url, String requestStr) throws Wx if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || (HttpStatus.SC_ACCEPTED == statusCode && url.endsWith("/codepay"))) { - this.logRequestAndResponse(url, requestStr, responseString); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); + } else { + this.logRequestAndResponse(url, requestStr, responseString); + } return responseString; } @@ -185,7 +197,11 @@ public String postV3WithWechatpaySerial(String url, String requestStr) throws Wx JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - this.logError(url, requestStr, e); + if (java.net.URI.create(url).getPath().contains("/v3/brand/")) { + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); + } else { + this.logError(url, requestStr, e); + } throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpPost.releaseConnection(); @@ -212,7 +228,7 @@ public String requestV3(String url, HttpRequestBase httpRequest) throws WxPayExc } if (HttpStatus.SC_OK == statusCode || HttpStatus.SC_NO_CONTENT == statusCode || HttpStatus.SC_ACCEPTED == statusCode) { - log.info("\n【请求地址】:{}\n【响应数据】:{}", url, responseString); + log.info("\n【请求地址】:{}\n【响应状态码】:{}", url, statusCode); return responseString; } @@ -220,7 +236,7 @@ public String requestV3(String url, HttpRequestBase httpRequest) throws WxPayExc JsonObject jsonObject = GsonParser.parse(responseString); throw convertException(jsonObject); } catch (Exception e) { - log.error("\n【请求地址】:{}\n【异常信息】:{}", url, e.getMessage()); + log.error("\n【请求地址】:{}\n【异常类型】:{}", url, e.getClass().getSimpleName()); throw (e instanceof WxPayException) ? (WxPayException) e : new WxPayException(e.getMessage(), e); } finally { httpRequest.releaseConnection(); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceJoddHttpImpl.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceJoddHttpImpl.java index 6c8bcec89..044278699 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceJoddHttpImpl.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/service/impl/WxPayServiceJoddHttpImpl.java @@ -2,6 +2,9 @@ import com.github.binarywang.wxpay.bean.WxPayApiData; import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.BrandApplymentService; +import com.github.binarywang.wxpay.service.BrandStoreService; +import com.github.binarywang.wxpay.service.BrandProfitSharingService; import jodd.http.HttpConnectionProvider; import jodd.http.HttpRequest; import jodd.http.HttpResponse; @@ -27,6 +30,22 @@ */ @Slf4j public class WxPayServiceJoddHttpImpl extends BaseWxPayServiceImpl { + /** Jodd 尚不支持 APIv3,品牌服务不能通过该客户端执行。 */ + @Override + public BrandApplymentService getBrandApplymentService() { + throw new UnsupportedOperationException("Jodd 客户端不支持 APIv3 品牌入驻,请使用 ApacheHttp 或 HttpComponents 客户端"); + } + + @Override + public BrandStoreService getBrandStoreService() { + throw new UnsupportedOperationException("Jodd 客户端不支持 APIv3 品牌门店,请使用 ApacheHttp 或 HttpComponents 客户端"); + } + + @Override + public BrandProfitSharingService getBrandProfitSharingService() { + throw new UnsupportedOperationException("Jodd 客户端不支持 APIv3 品牌分账,请使用 ApacheHttp 或 HttpComponents 客户端"); + } + @Override public byte[] postForBytes(String url, String requestStr, boolean useKey) throws WxPayException { try { diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java index 4b78a26f7..85506f82e 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayCredentials.java @@ -79,7 +79,6 @@ public final String getToken(HttpRequestWrapper request) throws IOException { long timestamp = generateTimestamp(); String message = buildMessage(nonceStr, timestamp, request); - log.debug("authorization message=[{}]", message); Signer.SignatureResult signature = signer.sign(message.getBytes(StandardCharsets.UTF_8)); @@ -88,7 +87,6 @@ public final String getToken(HttpRequestWrapper request) throws IOException { + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + signature.certificateSerialNumber + "\"," + "signature=\"" + signature.sign + "\""; - log.debug("authorization token=[{}]", token); return token; } diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java index aaa8df44a..6cafcccff 100644 --- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandProfitSharingServiceTest.java @@ -81,14 +81,14 @@ public void shouldTransmitReceiverRegistrationNameAsPlaintextPerContract() throw .appid("app").subAppid("brand-app").type("MERCHANT_ID").account("receiver") .name("测试名称").relationType("SUPPLIER").build()); Assert.assertEquals(capture.url, BASE + "/receivers/add"); - Assert.assertEquals(capture.method, "postV3"); + Assert.assertEquals(capture.method, "requestV3"); Assert.assertEquals(capture.json().get("brand_mchid").getAsString(), "brand"); Assert.assertEquals(capture.json().get("name").getAsString(), "测试名称"); Assert.assertFalse(capture.json().has("sub_mchid")); service.deleteReceiver(BrandProfitSharingReceiverDeleteRequest.newBuilder().brandMchId("brand") .appid("app").subAppid("brand-app").type("MERCHANT_ID").account("receiver").build()); Assert.assertEquals(capture.url, BASE + "/receivers/delete"); - Assert.assertEquals(capture.method, "postV3"); + Assert.assertEquals(capture.method, "requestV3"); Assert.assertFalse(capture.json().has("name")); } @@ -194,7 +194,9 @@ BrandProfitSharingService service() { if (called.getName().equals("getConfig")) return config; method = called.getName(); url = (String) args[0]; - body = args.length > 1 ? (String) args[1] : null; + body = args.length > 1 ? (args[1] instanceof org.apache.http.client.methods.HttpPost + ? org.apache.http.util.EntityUtils.toString(((org.apache.http.client.methods.HttpPost) args[1]).getEntity(), "UTF-8") + : (String) args[1]) : null; if (error != null) throw error; return response; }); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandSensitiveLoggingTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandSensitiveLoggingTest.java new file mode 100644 index 000000000..dde5bf089 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandSensitiveLoggingTest.java @@ -0,0 +1,130 @@ +package com.github.binarywang.wxpay.service.impl; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.github.binarywang.wxpay.bean.brandprofitsharing.BrandProfitSharingReceiverDeleteRequest; +import com.github.binarywang.wxpay.bean.brandprofitsharing.BrandProfitSharingReceiverRequest; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.v3.auth.Signer; +import com.github.binarywang.wxpay.v3.auth.WxPayCredentials; +import org.apache.http.*; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestWrapper; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicHttpResponse; +import org.apache.http.protocol.HttpContext; +import org.apache.http.util.EntityUtils; +import org.slf4j.LoggerFactory; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; + +/** 验证实际 HTTP 执行器保留明文传输且日志不泄露收款信息。 */ +public class BrandSensitiveLoggingTest { + private static final String NAME = "测试敏感姓名"; + private static final String ACCOUNT = "sensitive-receiver-account"; + + @Test + public void shouldKeepReceiverBodyOutOfTransportLogs() throws Exception { + for (WxPayService pay : new WxPayService[]{new WxPayServiceApacheHttpImpl(), new WxPayServiceHttpComponentsImpl()}) { + for (int route = 0; route < 4; route++) { + for (boolean failure : new boolean[]{false, true}) { + final String[] sent = new String[1]; + CloseableHttpClient client = new CloseableHttpClient() { + @Override + protected CloseableHttpResponse doExecute(HttpHost host, HttpRequest request, HttpContext context) throws IOException { + sent[0] = EntityUtils.toString(((HttpEntityEnclosingRequest) request).getEntity(), StandardCharsets.UTF_8); + BasicHttpResponse response = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), failure ? 400 : 200, "test"); + response.setEntity(new StringEntity(failure + ? "{\"code\":\"PARAM_ERROR\",\"message\":\"" + NAME + ACCOUNT + "\"}" + : "{\"name\":\"" + NAME + "\",\"account\":\"" + ACCOUNT + "\"}", ContentType.APPLICATION_JSON)); + return (CloseableHttpResponse) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{CloseableHttpResponse.class}, (proxy, method, args) -> + method.getName().equals("close") ? null : method.invoke(response, args)); + } + @Override public void close() { } + @Override public org.apache.http.params.HttpParams getParams() { return null; } + @Override public org.apache.http.conn.ClientConnectionManager getConnectionManager() { return null; } + }; + WxPayConfig config = new WxPayConfig(); + config.setApiV3HttpClient(client); + pay.setConfig(config); + Logger logger = (Logger) LoggerFactory.getLogger(pay.getClass()); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + try { + if (route == 0) pay.getBrandProfitSharingService().addReceiver(BrandProfitSharingReceiverRequest.newBuilder() + .brandMchId("brand").appid("app").type("MERCHANT_ID").account(ACCOUNT).name(NAME).build()); + else if (route == 1) pay.postV3("https://example.com/pay/v3/brand/profitsharing/returnorders", "{\"name\":\"" + NAME + "\",\"account\":\"" + ACCOUNT + "\"}"); + else if (route == 2) pay.postV3WithWechatpaySerial("https://example.com/pay/v3/brand/profitsharing/orders", "{\"name\":\"" + NAME + "\",\"account\":\"" + ACCOUNT + "\"}"); + else pay.getBrandProfitSharingService().deleteReceiver(BrandProfitSharingReceiverDeleteRequest.newBuilder() + .brandMchId("brand").appid("app").type("MERCHANT_ID").account(ACCOUNT).build()); + Assert.assertFalse(failure, "HTTP errors must propagate"); + } catch (WxPayException e) { + Assert.assertTrue(failure); + } + if (route != 3) Assert.assertTrue(sent[0].contains(NAME), "Signing transport must receive unchanged plaintext name"); + Assert.assertTrue(sent[0].contains(ACCOUNT)); + Assert.assertFalse(appender.list.isEmpty(), "Capture actual executor logs"); + for (ILoggingEvent event : appender.list) { + Assert.assertFalse(event.getFormattedMessage().contains(NAME), "Sensitive name leaked into logs"); + Assert.assertFalse(event.getFormattedMessage().contains(ACCOUNT), "Sensitive account leaked into logs"); + } + } finally { + logger.detachAppender(appender); + appender.stop(); + } + } + } + } + } + + @Test + public void shouldSignCompleteBodyWithoutDebugLoggingAuthorization() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(WxPayCredentials.class); + Level previous = logger.getLevel(); + logger.setLevel(Level.DEBUG); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + final String[] signed = new String[1]; + WxPayCredentials credentials = new WxPayCredentials("merchant", message -> { + signed[0] = new String(message, StandardCharsets.UTF_8); + return new Signer.SignatureResult("sensitive-signature", "serial"); + }) { + @Override protected long generateTimestamp() { return 123; } + @Override protected String generateNonceStr() { return "nonce"; } + }; + HttpPost request = new HttpPost("https://example.com/v3/brand/profitsharing/receivers/add"); + String body = "{\"name\":\"" + NAME + "\",\"account\":\"" + ACCOUNT + "\"}"; + request.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); + String token = credentials.getToken(HttpRequestWrapper.wrap(request)); + Assert.assertEquals(signed[0], "POST\n/v3/brand/profitsharing/receivers/add\n123\nnonce\n" + body + "\n"); + Assert.assertTrue(token.contains("signature=\"sensitive-signature\"")); + for (ILoggingEvent event : appender.list) { + String message = event.getFormattedMessage(); + Assert.assertFalse(message.contains(NAME)); + Assert.assertFalse(message.contains(ACCOUNT)); + Assert.assertFalse(message.contains("sensitive-signature")); + Assert.assertFalse(message.toLowerCase().contains("authorization")); + } + } finally { + logger.detachAppender(appender); + appender.stop(); + logger.setLevel(previous); + } + } +} diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java index 75abd83f9..ce0926196 100644 --- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/service/impl/BrandServiceEntryTest.java @@ -10,7 +10,7 @@ public class BrandServiceEntryTest { public void shouldExposeBrandServicesOnAllHttpClients() throws Exception { String[] getters = {"getBrandApplymentService", "getBrandStoreService", "getBrandProfitSharingService"}; WxPayService[] clients = {new WxPayServiceImpl(), new WxPayServiceApacheHttpImpl(), - new WxPayServiceHttpComponentsImpl(), new WxPayServiceJoddHttpImpl()}; + new WxPayServiceHttpComponentsImpl()}; for (String getter : getters) { Assert.assertTrue(WxPayService.class.getMethod(getter).isDefault()); for (WxPayService client : clients) { @@ -20,4 +20,11 @@ public void shouldExposeBrandServicesOnAllHttpClients() throws Exception { } } } + @Test + public void shouldRejectBrandServicesOnJodd() { + WxPayService client = new WxPayServiceJoddHttpImpl(); + Assert.expectThrows(UnsupportedOperationException.class, client::getBrandApplymentService); + Assert.expectThrows(UnsupportedOperationException.class, client::getBrandStoreService); + Assert.expectThrows(UnsupportedOperationException.class, client::getBrandProfitSharingService); + } } diff --git a/weixin-java-pay/src/test/resources/testng.xml b/weixin-java-pay/src/test/resources/testng.xml index f4f94f059..b44e6f387 100644 --- a/weixin-java-pay/src/test/resources/testng.xml +++ b/weixin-java-pay/src/test/resources/testng.xml @@ -4,6 +4,11 @@ + + + + +