基于WXPayUtility调通第一个品牌API

更新时间:2025.11.11

一、概述

本文档提供基于官方提供的工具类 WXPayUtility调用“设置商品券事件通知地址”接口的示例。 

二、准备开发参数

商户调用接口前需要需要先准备开发必要参数:brand_id(品牌ID)、品牌API证书以及证书序列号、微信支付公钥以及公钥ID等信息,具体可参考:开发必要参数说明

三、接口调用示例

1、引入依赖

工具类 WXPayUtility 依赖以下第三方库:

  1. Google Gson:用于 JSON 数据的序列化和反序列化。

  2. OkHttp:用于 HTTP 请求处理。

你可以通过 Maven 或 Gradle 来引入这些依赖。其中依赖的版本号可自行选择版本进行填写,本文仅为示例。

如果你使用的 Gradle,请在 build.gradle 中加入:

1implementation 'com.google.code.gson:gson:2.13.2'
2implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14'

如果你使用的 Maven,请在 pom.xml 中加入:

1<!-- Google Gson -->
2<dependency>
3    <groupId>com.google.code.gson</groupId>
4    <artifactId>gson</artifactId>
5    <version>2.13.2</version>
6</dependency>
7<!-- OkHttp -->
8<dependency>
9    <groupId>com.squareup.okhttp3</groupId>
10    <artifactId>okhttp</artifactId>
11    <version>5.0.0-alpha.14</version>
12</dependency>

2、编写工具类代码

创建WXPayUtility类,复制以下代码内容进去并补充当前类的包路径:

1import com.google.gson.ExclusionStrategy;
2import com.google.gson.FieldAttributes;
3import com.google.gson.Gson;
4import com.google.gson.GsonBuilder;
5import com.google.gson.JsonElement;
6import com.google.gson.JsonObject;
7import com.google.gson.JsonSyntaxException;
8import com.google.gson.annotations.Expose;
9import com.google.gson.annotations.SerializedName;
10import okhttp3.Headers;
11import okhttp3.Response;
12import okio.BufferedSource;
13
14import javax.crypto.BadPaddingException;
15import javax.crypto.Cipher;
16import javax.crypto.IllegalBlockSizeException;
17import javax.crypto.NoSuchPaddingException;
18import javax.crypto.spec.GCMParameterSpec;
19import javax.crypto.spec.SecretKeySpec;
20import java.io.IOException;
21import java.io.UncheckedIOException;
22import java.io.UnsupportedEncodingException;
23import java.net.URLEncoder;
24import java.nio.charset.StandardCharsets;
25import java.nio.file.Files;
26import java.nio.file.Paths;
27import java.security.InvalidAlgorithmParameterException;
28import java.security.InvalidKeyException;
29import java.security.KeyFactory;
30import java.security.NoSuchAlgorithmException;
31import java.security.PrivateKey;
32import java.security.PublicKey;
33import java.security.SecureRandom;
34import java.security.Signature;
35import java.security.SignatureException;
36import java.security.spec.InvalidKeySpecException;
37import java.security.spec.PKCS8EncodedKeySpec;
38import java.security.spec.X509EncodedKeySpec;
39import java.time.DateTimeException;
40import java.time.Duration;
41import java.time.Instant;
42import java.util.Base64;
43import java.util.Map;
44import java.util.Objects;
45
46public class WXPayBrandUtility {
47    private static final Gson gson = new GsonBuilder()
48            .disableHtmlEscaping()
49            .addSerializationExclusionStrategy(new ExclusionStrategy() {
50                @Override
51                public boolean shouldSkipField(FieldAttributes fieldAttributes) {
52                    final Expose expose = fieldAttributes.getAnnotation(Expose.class);
53                    return expose != null && !expose.serialize();
54                }
55
56                @Override
57                public boolean shouldSkipClass(Class<?> aClass) {
58                    return false;
59                }
60            })
61            .addDeserializationExclusionStrategy(new ExclusionStrategy() {
62                @Override
63                public boolean shouldSkipField(FieldAttributes fieldAttributes) {
64                    final Expose expose = fieldAttributes.getAnnotation(Expose.class);
65                    return expose != null && !expose.deserialize();
66                }
67
68                @Override
69                public boolean shouldSkipClass(Class<?> aClass) {
70                    return false;
71                }
72            })
73            .create();
74    private static final char[] SYMBOLS =
75            "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
76    private static final SecureRandom random = new SecureRandom();
77
78    /**
79     * 将 Object 转换为 JSON 字符串
80     */
81    public static String toJson(Object object) {
82        return gson.toJson(object);
83    }
84
85    /**
86     * 将 JSON 字符串解析为特定类型的实例
87     */
88    public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
89        return gson.fromJson(json, classOfT);
90    }
91
92    /**
93     * 从公私钥文件路径中读取文件内容
94     *
95     * @param keyPath 文件路径
96     * @return 文件内容
97     */
98    private static String readKeyStringFromPath(String keyPath) {
99        try {
100            return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
101        } catch (IOException e) {
102            throw new UncheckedIOException(e);
103        }
104    }
105
106    /**
107     * 读取 PKCS#8 格式的私钥字符串并加载为私钥对象
108     *
109     * @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头
110     * @return PrivateKey 对象
111     */
112    public static PrivateKey loadPrivateKeyFromString(String keyString) {
113        try {
114            keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
115                    .replace("-----END PRIVATE KEY-----", "")
116                    .replaceAll("\\s+", "");
117            return KeyFactory.getInstance("RSA").generatePrivate(
118                    new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
119        } catch (NoSuchAlgorithmException e) {
120            throw new UnsupportedOperationException(e);
121        } catch (InvalidKeySpecException e) {
122            throw new IllegalArgumentException(e);
123        }
124    }
125
126    /**
127     * 从 PKCS#8 格式的私钥文件中加载私钥
128     *
129     * @param keyPath 私钥文件路径
130     * @return PrivateKey 对象
131     */
132    public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
133        return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
134    }
135
136    /**
137     * 读取 PKCS#8 格式的公钥字符串并加载为公钥对象
138     *
139     * @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头
140     * @return PublicKey 对象
141     */
142    public static PublicKey loadPublicKeyFromString(String keyString) {
143        try {
144            keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
145                    .replace("-----END PUBLIC KEY-----", "")
146                    .replaceAll("\\s+", "");
147            return KeyFactory.getInstance("RSA").generatePublic(
148                    new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
149        } catch (NoSuchAlgorithmException e) {
150            throw new UnsupportedOperationException(e);
151        } catch (InvalidKeySpecException e) {
152            throw new IllegalArgumentException(e);
153        }
154    }
155
156    /**
157     * 从 PKCS#8 格式的公钥文件中加载公钥
158     *
159     * @param keyPath 公钥文件路径
160     * @return PublicKey 对象
161     */
162    public static PublicKey loadPublicKeyFromPath(String keyPath) {
163        return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
164    }
165
166    /**
167     * 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途
168     */
169    public static String createNonce(int length) {
170        char[] buf = new char[length];
171        for (int i = 0; i < length; ++i) {
172            buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
173        }
174        return new String(buf);
175    }
176
177    /**
178     * 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密
179     *
180     * @param publicKey 加密用公钥对象
181     * @param plaintext 待加密明文
182     * @return 加密后密文
183     */
184    public static String encrypt(PublicKey publicKey, String plaintext) {
185        final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
186
187        try {
188            Cipher cipher = Cipher.getInstance(transformation);
189            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
190            return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
191        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
192            throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
193        } catch (InvalidKeyException e) {
194            throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
195        } catch (BadPaddingException | IllegalBlockSizeException e) {
196            throw new IllegalArgumentException("Plaintext is too long", e);
197        }
198    }
199
200    public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
201                                        byte[] ciphertext) {
202        final String transformation = "AES/GCM/NoPadding";
203        final String algorithm = "AES";
204        final int tagLengthBit = 128;
205
206        try {
207            Cipher cipher = Cipher.getInstance(transformation);
208            cipher.init(
209                    Cipher.DECRYPT_MODE,
210                    new SecretKeySpec(key, algorithm),
211                    new GCMParameterSpec(tagLengthBit, nonce));
212            if (associatedData != null) {
213                cipher.updateAAD(associatedData);
214            }
215            return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
216        } catch (InvalidKeyException
217                 | InvalidAlgorithmParameterException
218                 | BadPaddingException
219                 | IllegalBlockSizeException
220                 | NoSuchAlgorithmException
221                 | NoSuchPaddingException e) {
222            throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
223                    transformation), e);
224        }
225    }
226
227    /**
228     * 使用私钥按照指定算法进行签名
229     *
230     * @param message    待签名串
231     * @param algorithm  签名算法,如 SHA256withRSA
232     * @param privateKey 签名用私钥对象
233     * @return 签名结果
234     */
235    public static String sign(String message, String algorithm, PrivateKey privateKey) {
236        byte[] sign;
237        try {
238            Signature signature = Signature.getInstance(algorithm);
239            signature.initSign(privateKey);
240            signature.update(message.getBytes(StandardCharsets.UTF_8));
241            sign = signature.sign();
242        } catch (NoSuchAlgorithmException e) {
243            throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
244        } catch (InvalidKeyException e) {
245            throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
246        } catch (SignatureException e) {
247            throw new RuntimeException("An error occurred during the sign process.", e);
248        }
249        return Base64.getEncoder().encodeToString(sign);
250    }
251
252    /**
253     * 使用公钥按照特定算法验证签名
254     *
255     * @param message   待签名串
256     * @param signature 待验证的签名内容
257     * @param algorithm 签名算法,如:SHA256withRSA
258     * @param publicKey 验签用公钥对象
259     * @return 签名验证是否通过
260     */
261    public static boolean verify(String message, String signature, String algorithm,
262                                 PublicKey publicKey) {
263        try {
264            Signature sign = Signature.getInstance(algorithm);
265            sign.initVerify(publicKey);
266            sign.update(message.getBytes(StandardCharsets.UTF_8));
267            return sign.verify(Base64.getDecoder().decode(signature));
268        } catch (SignatureException e) {
269            return false;
270        } catch (InvalidKeyException e) {
271            throw new IllegalArgumentException("verify uses an illegal publickey.", e);
272        } catch (NoSuchAlgorithmException e) {
273            throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
274        }
275    }
276
277    /**
278     * 根据品牌API请求签名规则构造 Authorization 签名
279     *
280     * @param brand_id            品牌ID
281     * @param certificateSerialNo 品牌API证书序列号
282     * @param privateKey          品牌API证书私钥
283     * @param method              请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE
284     * @param uri                 请求接口的URL
285     * @param body                请求接口的Body
286     * @return 构造好的品牌API Authorization 头
287     */
288    public static String buildAuthorization(String brand_id, String certificateSerialNo,
289                                            PrivateKey privateKey,
290                                            String method, String uri, String body) {
291        String nonce = createNonce(32);
292        long timestamp = Instant.now().getEpochSecond();
293
294        String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
295                body == null ? "" : body);
296
297        String signature = sign(message, "SHA256withRSA", privateKey);
298
299        return String.format(
300                "WECHATPAY-BRAND-SHA256-RSA2048 brand_id=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
301                        "timestamp=\"%d\",serial_no=\"%s\"",
302                brand_id, nonce, signature, timestamp, certificateSerialNo);
303    }
304
305    /**
306     * 对参数进行 URL 编码
307     *
308     * @param content 参数内容
309     * @return 编码后的内容
310     */
311    public static String urlEncode(String content) {
312        try {
313            return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
314        } catch (UnsupportedEncodingException e) {
315            throw new RuntimeException(e);
316        }
317    }
318
319    /**
320     * 对参数Map进行 URL 编码,生成 QueryString
321     *
322     * @param params Query参数Map
323     * @return QueryString
324     */
325    public static String urlEncode(Map<String, Object> params) {
326        if (params == null || params.isEmpty()) {
327            return "";
328        }
329
330        int index = 0;
331        StringBuilder result = new StringBuilder();
332        for (Map.Entry<String, Object> entry : params.entrySet()) {
333            result.append(entry.getKey())
334                    .append("=")
335                    .append(urlEncode(entry.getValue().toString()));
336            index++;
337            if (index < params.size()) {
338                result.append("&");
339            }
340        }
341        return result.toString();
342    }
343
344    /**
345     * 从应答中提取 Body
346     *
347     * @param response HTTP 请求应答对象
348     * @return 应答中的Body内容,Body为空时返回空字符串
349     */
350    public static String extractBody(Response response) {
351        if (response.body() == null) {
352            return "";
353        }
354
355        try {
356            BufferedSource source = response.body().source();
357            return source.readUtf8();
358        } catch (IOException e) {
359            throw new RuntimeException(String.format("An error occurred during reading response body. " +
360                    "Status: %d", response.code()), e);
361        }
362    }
363
364    /**
365     * 根据品牌API应答验签规则对应答签名进行验证,验证不通过时抛出异常
366     *
367     * @param wechatpayPublicKeyId 微信支付公钥ID
368     * @param wechatpayPublicKey   微信支付公钥对象
369     * @param headers              微信支付应答 Header 列表
370     * @param body                 微信支付应答 Body
371     */
372    public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
373                                        Headers headers,
374                                        String body) {
375        String timestamp = headers.get("Wechatpay-Timestamp");
376        String requestId = headers.get("Request-ID");
377        try {
378            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
379            // 拒绝过期请求
380            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
381                throw new IllegalArgumentException(
382                        String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
383                                timestamp, requestId));
384            }
385        } catch (DateTimeException | NumberFormatException e) {
386            throw new IllegalArgumentException(
387                    String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
388                            timestamp, requestId));
389        }
390        String serialNumber = headers.get("Wechatpay-Serial");
391        if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
392            throw new IllegalArgumentException(
393                    String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
394                            "%s", wechatpayPublicKeyId, serialNumber));
395        }
396
397        String signature = headers.get("Wechatpay-Signature");
398        String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
399                body == null ? "" : body);
400
401        boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
402        if (!success) {
403            throw new IllegalArgumentException(
404                    String.format("Validate response failed,the WechatPay signature is incorrect.%n"
405                                    + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
406                            headers.get("Request-ID"), headers, body));
407        }
408    }
409
410    /**
411     * 根据品牌API通知验签规则对通知签名进行验证,验证不通过时抛出异常
412     * @param wechatpayPublicKeyId 微信支付公钥ID
413     * @param wechatpayPublicKey 微信支付公钥对象
414     * @param headers 微信支付通知 Header 列表
415     * @param body 微信支付通知 Body
416     */
417    public static void validateNotification(String wechatpayPublicKeyId,
418                                            PublicKey wechatpayPublicKey, Headers headers,
419                                            String body) {
420        String timestamp = headers.get("Wechatpay-Timestamp");
421        try {
422            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
423            // 拒绝过期请求
424            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
425                throw new IllegalArgumentException(
426                        String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
427            }
428        } catch (DateTimeException | NumberFormatException e) {
429            throw new IllegalArgumentException(
430                    String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
431        }
432        String serialNumber = headers.get("Wechatpay-Serial");
433        if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
434            throw new IllegalArgumentException(
435                    String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
436                                    "Remote: %s",
437                            wechatpayPublicKeyId,
438                            serialNumber));
439        }
440
441        String signature = headers.get("Wechatpay-Signature");
442        String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
443                body == null ? "" : body);
444
445        boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
446        if (!success) {
447            throw new IllegalArgumentException(
448                    String.format("Validate notification failed, WechatPay signature is incorrect.\n"
449                                    + "responseHeader[%s]\tresponseBody[%.1024s]",
450                            headers, body));
451        }
452    }
453
454    /**
455     * 对微信支付通知进行签名验证、解析,同时将业务数据解密。验签名失败、解析失败、解密失败时抛出异常
456     * @param apiv3Key 品牌API密钥
457     * @param wechatpayPublicKeyId 微信支付公钥ID
458     * @param wechatpayPublicKey   微信支付公钥对象
459     * @param headers              微信支付应答 Header 列表
460     * @param body                 微信支付应答 Body
461     * @return 解析后的通知内容,解密后的业务数据可以使用 Notification.getPlaintext() 访问
462     */
463    public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
464                                                 PublicKey wechatpayPublicKey, Headers headers,
465                                                 String body) {
466        validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
467        Notification notification = gson.fromJson(body, Notification.class);
468        notification.decrypt(apiv3Key);
469        return notification;
470    }
471
472    /**
473     * 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常
474     */
475    public static class ApiException extends RuntimeException {
476        private static final long serialVersionUID = 2261086748874802175L;
477
478        private final int statusCode;
479        private final String body;
480        private final Headers headers;
481        private final String errorCode;
482        private final String errorMessage;
483
484        public ApiException(int statusCode, String body, Headers headers) {
485            super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
486                    body, headers));
487            this.statusCode = statusCode;
488            this.body = body;
489            this.headers = headers;
490
491            if (body != null && !body.isEmpty()) {
492                JsonElement code;
493                JsonElement message;
494
495                try {
496                    JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
497                    code = jsonObject.get("code");
498                    message = jsonObject.get("message");
499                } catch (JsonSyntaxException ignored) {
500                    code = null;
501                    message = null;
502                }
503                this.errorCode = code == null ? null : code.getAsString();
504                this.errorMessage = message == null ? null : message.getAsString();
505            } else {
506                this.errorCode = null;
507                this.errorMessage = null;
508            }
509        }
510
511        /**
512         * 获取 HTTP 应答状态码
513         */
514        public int getStatusCode() {
515            return statusCode;
516        }
517
518        /**
519         * 获取 HTTP 应答包体内容
520         */
521        public String getBody() {
522            return body;
523        }
524
525        /**
526         * 获取 HTTP 应答 Header
527         */
528        public Headers getHeaders() {
529            return headers;
530        }
531
532        /**
533         * 获取 错误码 (错误应答中的 code 字段)
534         */
535        public String getErrorCode() {
536            return errorCode;
537        }
538
539        /**
540         * 获取 错误消息 (错误应答中的 message 字段)
541         */
542        public String getErrorMessage() {
543            return errorMessage;
544        }
545    }
546
547    public static class Notification {
548        @SerializedName("id")
549        private String id;
550        @SerializedName("create_time")
551        private String createTime;
552        @SerializedName("event_type")
553        private String eventType;
554        @SerializedName("resource_type")
555        private String resourceType;
556        @SerializedName("summary")
557        private String summary;
558        @SerializedName("resource")
559        private Resource resource;
560        private String plaintext;
561
562        public String getId() {
563            return id;
564        }
565
566        public String getCreateTime() {
567            return createTime;
568        }
569
570        public String getEventType() {
571            return eventType;
572        }
573
574        public String getResourceType() {
575            return resourceType;
576        }
577
578        public String getSummary() {
579            return summary;
580        }
581
582        public Resource getResource() {
583            return resource;
584        }
585
586        /**
587         * 获取解密后的业务数据(JSON字符串,需要自行解析)
588         */
589        public String getPlaintext() {
590            return plaintext;
591        }
592
593        private void validate() {
594            if (resource == null) {
595                throw new IllegalArgumentException("Missing required field `resource` in notification");
596            }
597            resource.validate();
598        }
599
600        /**
601         * 使用 APIv3Key 对通知中的业务数据解密,解密结果可以通过 getPlainText 访问。
602         * 外部拿到的 Notification 一定是解密过的,因此本方法没有设置为 public
603         * @param apiv3Key 品牌API密钥
604         */
605        private void decrypt(String apiv3Key) {
606            validate();
607
608            plaintext = aesAeadDecrypt(
609                    apiv3Key.getBytes(StandardCharsets.UTF_8),
610                    resource.associatedData.getBytes(StandardCharsets.UTF_8),
611                    resource.nonce.getBytes(StandardCharsets.UTF_8),
612                    Base64.getDecoder().decode(resource.ciphertext)
613            );
614        }
615
616        public static class Resource {
617            @SerializedName("algorithm")
618            private String algorithm;
619
620            @SerializedName("ciphertext")
621            private String ciphertext;
622
623            @SerializedName("associated_data")
624            private String associatedData;
625
626            @SerializedName("nonce")
627            private String nonce;
628
629            @SerializedName("original_type")
630            private String originalType;
631
632            public String getAlgorithm() {
633                return algorithm;
634            }
635
636            public String getCiphertext() {
637                return ciphertext;
638            }
639
640            public String getAssociatedData() {
641                return associatedData;
642            }
643
644            public String getNonce() {
645                return nonce;
646            }
647
648            public String getOriginalType() {
649                return originalType;
650            }
651
652            private void validate() {
653                if (algorithm == null || algorithm.isEmpty()) {
654                    throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
655                            ".Resource");
656                }
657                if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
658                    throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
659                            "Notification.Resource", algorithm));
660                }
661
662                if (ciphertext == null || ciphertext.isEmpty()) {
663                    throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
664                            ".Resource");
665                }
666
667                if (associatedData == null || associatedData.isEmpty()) {
668                    throw new IllegalArgumentException("Missing required field `associatedData` in " +
669                            "Notification.Resource");
670                }
671
672                if (nonce == null || nonce.isEmpty()) {
673                    throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
674                            ".Resource");
675                }
676
677                if (originalType == null || originalType.isEmpty()) {
678                    throw new IllegalArgumentException("Missing required field `originalType` in " +
679                            "Notification.Resource");
680                }
681            }
682        }
683    }
684}

工具类中已包含签名、验签操作,若需了解具体步骤可参考:

3、调用接口示例

创建SetNotifyConfig类,复制以下代码内容进去并补充当前类的包路径,最后根据注释替换对应参数:

1import com.google.gson.annotations.SerializedName;
2import com.google.gson.annotations.Expose;
3import okhttp3.MediaType;
4import okhttp3.OkHttpClient;
5import okhttp3.Request;
6import okhttp3.RequestBody;
7import okhttp3.Response;
8
9import java.io.IOException;
10import java.io.UncheckedIOException;
11import java.security.PrivateKey;
12import java.security.PublicKey;
13import java.util.ArrayList;
14import java.util.HashMap;
15import java.util.List;
16import java.util.Map;
17
18/**
19 * 设置商品券事件通知地址
20 */
21public class SetNotifyConfig {
22  private static String HOST = "https://api.mch.weixin.qq.com";
23  private static String METHOD = "POST";
24  private static String PATH = "/brand/marketing/product-coupon/notify-configs";
25
26  public static void main(String[] args) {
27    // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/brand/4015415289
28    SetNotifyConfig client = new SetNotifyConfig(
29      "xxxxxxxx",                    // 品牌ID,是由微信支付系统生成并分配给每个品牌方的唯一标识符,品牌ID获取方式参考 https://pay.weixin.qq.com/doc/brand/4015415289
30      "1DDE55AD98Exxxxxxxxxx",         // 品牌API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/brand/4015407570
31      "/path/to/apiclient_key.pem",     // 品牌API证书私钥文件路径,本地文件路径
32      "PUB_KEY_ID_xxxxxxxxxxxxx",      // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/brand/4015453439
33      "/path/to/wxp_pub.pem"           // 微信支付公钥文件路径,本地文件路径
34    );
35
36    SetNotifyConfigRequest request = new SetNotifyConfigRequest();
37    request.notifyUrl = "https://www.xxxxxxx.com/notify";	//需要设置的回调地址,回调地址设置规范请参考 https://pay.weixin.qq.com/doc/brand/4015407551
38    try {
39      SetNotifyConfigResponse response = client.run(request);
40        // TODO: 请求成功,继续业务逻辑
41        System.out.println(response);
42    } catch (WXPayBrandUtility.ApiException e) {
43        // TODO: 请求失败,根据状态码执行不同的逻辑
44        e.printStackTrace();
45    }
46  }
47
48  public SetNotifyConfigResponse run(SetNotifyConfigRequest request) {
49    String uri = PATH;
50    String reqBody = WXPayBrandUtility.toJson(request);
51
52    Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
53    reqBuilder.addHeader("Accept", "application/json");
54    reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
55    reqBuilder.addHeader("Authorization", WXPayBrandUtility.buildAuthorization(brand_id, certificateSerialNo,privateKey, METHOD, uri, reqBody));
56    reqBuilder.addHeader("Content-Type", "application/json");
57    RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
58    reqBuilder.method(METHOD, requestBody);
59    Request httpRequest = reqBuilder.build();
60
61    // 发送HTTP请求
62    OkHttpClient client = new OkHttpClient.Builder().build();
63    try (Response httpResponse = client.newCall(httpRequest).execute()) {
64      String respBody = WXPayBrandUtility.extractBody(httpResponse);
65      if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
66        // 2XX 成功,验证应答签名
67        WXPayBrandUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
68            httpResponse.headers(), respBody);
69
70        // 从HTTP应答报文构建返回数据
71        return WXPayBrandUtility.fromJson(respBody, SetNotifyConfigResponse.class);
72      } else {
73        throw new WXPayBrandUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
74      }
75    } catch (IOException e) {
76      throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
77    }
78  }
79
80  private final String brand_id;
81  private final String certificateSerialNo;
82  private final PrivateKey privateKey;
83  private final String wechatPayPublicKeyId;
84  private final PublicKey wechatPayPublicKey;
85
86  public SetNotifyConfig(String brand_id, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
87    this.brand_id = brand_id;
88    this.certificateSerialNo = certificateSerialNo;
89    this.privateKey = WXPayBrandUtility.loadPrivateKeyFromPath(privateKeyFilePath);
90    this.wechatPayPublicKeyId = wechatPayPublicKeyId;
91    this.wechatPayPublicKey = WXPayBrandUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
92  }
93
94  public static class SetNotifyConfigRequest {
95    @SerializedName("notify_url")
96    public String notifyUrl;
97  }
98  
99  public static class SetNotifyConfigResponse {
100    @SerializedName("notify_url")
101    public String notifyUrl;
102  
103    @SerializedName("update_time")
104    public String updateTime;
105  }
106  
107}

注意事项:

1、点击查看notify_url填写注意事项

2、调用接口后,建议在日志中保留应答的HTTP头Request-ID值,Request-ID作为请求的唯一标识,在调用接口遇到问题时,可向微信侧提供该值用于快速定位到请求记录,协助排查问题原因。

若请求成功,请求主体将返回以下信息:

1{"notify_url" : "你的回调地址","update_time" : "更新时间"}

若请求失败,请求主体将返回报错,将返回以下格式的信息,请根据具体的错误描述进行排查。

1{"code":"错误码","message":"错误描述"} 

为了信息安全,请求成功后还需使用公钥对返回信息进行验签,确认返回信息来自微信支付。

应答验签validateResponse()若无报错即为验签通过。