微信服务号模板消息发送和自动回复

微信服务号常见三大配置区别

        配置项             用途                  作用对象        是否必须公网          是否限制80/443
    API IP白名单       后端调用微信API       服务器IP               是               否
    JS接口安全域名        微信网页JS-SDK          前端网页域名      是               域名即可
    消息推送URL         微信事件/消息回调       后端接口地址          是               必须80/443

开发时候发送模板消息修改 必须设置 ip白名单

注意开启消息推送后,自定义菜单会被关闭。需要自己生成一下

package cn.hgsl.pp.hgslwxservice;

import cn.hgsl.pp.hgslwxservice.configuration.WechatParamConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Scanner;

@SpringBootTest
public class WechatTestMenu {

    @Resource
    WechatParamConfig wechatParamConfig;

    /**
     * 1. 获取 Access Token
     */
    public String getAccessToken() throws Exception {
        String urlStr = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + wechatParamConfig.getAppId() + "&secret=" + wechatParamConfig.getAppSecret();
        URL url = new URL(urlStr);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("GET");

        Scanner scanner = new Scanner(conn.getInputStream(), "UTF-8");
        String response = scanner.useDelimiter("\\A").next();
        scanner.close();

        // 解析 JSON 获取 access_token
        ObjectMapper mapper = new ObjectMapper();
        return mapper.readTree(response).get("access_token").asText();
    }

    @Test
    public void testToken() throws Exception {
        System.out.println("当前accessToken:" +  getAccessToken());
    }

    @Test
    public void createMenu() {
        try {
            System.out.println("正在获取 Access Token...");
            String accessToken = getAccessToken();
            System.out.println("获取成功, Token: " + accessToken);
            org.springframework.core.io.ClassPathResource resource =
                    new org.springframework.core.io.ClassPathResource("wechat-menu.json");
            String menuJson = org.springframework.util.StreamUtils.copyToString(
                    resource.getInputStream(),
                    java.nio.charset.StandardCharsets.UTF_8
            );
            String urlStr = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=" + accessToken;
            URL url = new URL(urlStr);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");

            try (OutputStream os = conn.getOutputStream()) {
                byte[] input = menuJson.getBytes("utf-8");
                os.write(input, 0, input.length);
            }

            Scanner scanner = new Scanner(conn.getInputStream(), "UTF-8");
            String response = scanner.useDelimiter("\\A").next();
            scanner.close();
            System.out.println("微信创建菜单响应结果: " + response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 3. 根据文章标题查询已发布文章的 media_id
     */
    @Test
    public void getMediaIdByTitle() {
        String targetTitle = "深蓝数字科技有限公司";
//        String targetTitle = "成功案例";
        try {
            String accessToken = getAccessToken();
            String urlStr = "https://api.weixin.qq.com/cgi-bin/freepublish/batchget?access_token=" + accessToken;
            URL url = new URL(urlStr);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setDoOutput(true);
            conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");

            String requestBody = "{\"offset\": 0, \"count\": 20, \"no_content\": 1}";
            try (OutputStream os = conn.getOutputStream()) {
                byte[] input = requestBody.getBytes("utf-8");
                os.write(input, 0, input.length);
            }

            Scanner scanner = new Scanner(conn.getInputStream(), "UTF-8");
            String response = scanner.useDelimiter("\\A").next();
            scanner.close();

            ObjectMapper mapper = new ObjectMapper();
            com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(response);
            com.fasterxml.jackson.databind.JsonNode itemArray = rootNode.get("item");
            if (itemArray != null && itemArray.isArray()) {
                boolean found = false;
                for (com.fasterxml.jackson.databind.JsonNode item : itemArray) {
                    com.fasterxml.jackson.databind.JsonNode newsItem = item.get("content").get("news_item");
                    if (newsItem != null && newsItem.isArray() && newsItem.size() > 0) {
                        String title = newsItem.get(0).get("title").asText();
                        if (targetTitle.equals(title) || title.contains(targetTitle)) {
                            String article_id = item.get("article_id").asText();
                            System.out.println("==================================================");
                            System.out.println("🎉 成功找到匹配的文章!");
                            System.out.println("文章标题: " + title);
                            System.out.println("文章链接: " + newsItem.get(0).get("url").asText());
                            System.out.println("🎯 该文章的 article_id: " + article_id);
                            System.out.println("==================================================");
                            found = true;
                            break;
                        }
                    }
                }
                if (!found) {
                    System.out.println("❌ 在最近发布的20篇文章中,未找到包含标题【" + targetTitle + "】的文章。");
                }
            } else {
                System.out.println("微信响应异常或没有任何发布记录: " + response);
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

如果要监听消息,自动回复消息:

微信开发者平台
1。启用消息推送,需要配置 URL ,Token,EncodingAESKey

URL:需要微信认证过的域名,有公网ip。

开发者用来接收微信消息和事件的接口 URL,必须以 http:// 或 https:// 开头,分别支持 80 端口和 443 端口

例如:http://baidu.com/wx/wx-event
其中 wx 可以自定义。为了不影响主站http://baidu.com的使用,http://baidu.com/wx 通过nginx指向部署的服务地址。wx-event 是监听的和接受微信消息的地址

@RequestMapping("/wx-event")
public class WechatEventController {

Token

Token令牌:用于签名处理

EncodingAESKey

将用作消息体加解密密钥。
消息加解密方式:
明文模式:不使用消息加解密,明文发送,安全系数较低,不建议使用。
兼容模式:明文、密文共存,不建议使用。
安全模式:使用消息加解密,纯密文,安全系数高,强烈推荐使用。
数据格式:消息体的格式,仅支持 XML
开发者可选择消息加解密方式:明文模式、兼容模式和安全模式。

模式的选择与服务器配置在提交后都会立即生效,请开发者谨慎填写及选择

消息加密方式
安全模式

package cn.hgsl.pp.hgslwxservice.controller;

import cn.hgsl.pp.hgslwxservice.configuration.WechatParamConfig;
import cn.hgsl.pp.hgslwxservice.utils.TempDbService;
import cn.hgsl.pp.hgslwxservice.wx.WeChatCryptoUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.io.File;
import java.security.MessageDigest;
import java.util.Arrays;

@RestController
@RequestMapping("/wx-event")
public class WechatEventController {
    static {
        try {
            Class<?> jceSecurity = Class.forName("javax.crypto.JceSecurity");
            java.lang.reflect.Field isRestrictedField = jceSecurity.getDeclaredField("isRestricted");
            isRestrictedField.setAccessible(true);
            if (java.lang.reflect.Modifier.isFinal(isRestrictedField.getModifiers())) {
                java.lang.reflect.Field modifiersField = java.lang.reflect.Field.class.getDeclaredField("modifiers");
                modifiersField.setAccessible(true);
                modifiersField.setInt(isRestrictedField, isRestrictedField.getModifiers() & ~java.lang.reflect.Modifier.FINAL);
            }
            isRestrictedField.set(null, java.lang.Boolean.FALSE);
            System.out.println("====== 成功通过反射解除 JDK AES-256 加密限制 ======");
        } catch (Exception e) {
            System.err.println("通过反射解除 AES 限制失败: " + e.getMessage());
        }
    }

    @Resource
    private TempDbService tempDbService;

    @Resource
    private WechatParamConfig wechatParamConfig;

    @GetMapping
    public String checkWxServer(String signature, String timestamp, String nonce, String echostr) {
        String[] array = new String[]{wechatParamConfig.getMsgToken(), timestamp, nonce};
        Arrays.sort(array);
        String concatStr = String.join("", array);
        String sha1Hex = sha1Encode(concatStr);
        if (sha1Hex.equals(signature)) {
            return echostr;
        }
        return "校验失败";
    }

    /**
     * 接收微信推送消息
     */
    @PostMapping
    public String listenUserMessage(
            @RequestBody String xmlData,
            @RequestParam(value = "msg_signature", required = false) String msgSignature,
            @RequestParam(value = "timestamp", required = false) String timestamp,
            @RequestParam(value = "nonce", required = false) String nonce,
            @RequestParam(value = "encrypt_type", required = false) String encryptType) {
        System.out.println("========= 接收微信原始数据 =========");
        System.out.println(xmlData);
        boolean isAesMode = "aes".equals(encryptType);
        String plainXml = xmlData;
        if (isAesMode) {
            try {
                plainXml = WeChatCryptoUtil.decryptMsg(
                        wechatParamConfig.getMsgToken(),
                        wechatParamConfig.getEncodingAesKey(),
                        wechatParamConfig.getAppId(),
                        msgSignature, timestamp, nonce, xmlData
                );
                System.out.println("========= 解密后的明文 XML =========");
                System.out.println(plainXml);
            } catch (Exception e) {
                System.err.println("微信消息解密失败: " + e.getMessage());
                return "fail";
            }
        }
        String openid = getXmlValue(plainXml, "FromUserName");
        String toUser = getXmlValue(plainXml, "ToUserName");
        String msgType = getXmlValue(plainXml, "MsgType");
        if (!"text".equals(msgType)) {
            return "success";
        }
        String content = getXmlValue(plainXml, "Content");
        String time = DateUtil.now();
        String logLine = openid + " | " + time + " | " + msgType + " | " + content + "\n";
        String userDir = System.getProperty("user.dir");
        FileUtil.appendUtf8String(logLine, userDir + File.separator + "wx-msg.log");
        content = content.trim();
        System.out.println("toUser:" + toUser);
        System.out.println("openid:" + openid);
        System.out.println("内容:" + content);
        String replyContent;
        if (content.startsWith("绑定")) {
            String phone = content.replaceFirst("^绑定", "").trim();
            String phoneRegex = "^1[3-9]\\d{9}$";
            if (phone.matches(phoneRegex)) {
                tempDbService.put(phone, openid);
                replyContent = "绑定成功 ✅\n\n手机号:" + phone + "\n绑定时间:" + time;
            } else {
                replyContent = "手机号格式错误 ❌\n\n正确格式:绑定13800138000";
            }
        } else {
            replyContent = "地址:XXXXX-529";
        }

        String replyXml = buildTextMessage(openid, toUser, replyContent);
        if (isAesMode) {
            try {
                return WeChatCryptoUtil.encryptMsg(
                        wechatParamConfig.getMsgToken(),
                        wechatParamConfig.getEncodingAesKey(),
                        wechatParamConfig.getAppId(),
                        replyXml, timestamp, nonce
                );
            } catch (Exception e) {
                System.err.println("微信消息加密失败: " + e.getMessage());
                return "success";
            }
        }
        return replyXml;
    }

    private String getXmlValue(String xml, String tag) {
        String startTag = "<" + tag + "><![CDATA[";
        String endTag = "]]></" + tag + ">";
        int startIndex = xml.indexOf(startTag);
        int endIndex = xml.indexOf(endTag);

        if (startIndex == -1 || endIndex == -1) {
            return "";
        }
        startIndex += startTag.length();
        if (startIndex > endIndex) {
            return "";
        }
        return xml.substring(startIndex, endIndex);
    }

    private String sha1Encode(String data) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            md.update(data.getBytes());
            byte[] digest = md.digest();
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                String hex = Integer.toHexString(b & 0xFF);
                if (hex.length() == 1) sb.append("0");
                sb.append(hex);
            }
            return sb.toString();
        } catch (Exception e) {
            return "";
        }
    }

    private String buildTextMessage(String toUser, String fromUser, String content) {
        return "<xml>" +
                "<ToUserName><![CDATA[" + toUser + "]]></ToUserName>" +
                "<FromUserName><![CDATA[" + fromUser + "]]></FromUserName>" +
                "<CreateTime>" + System.currentTimeMillis() / 1000 + "</CreateTime>" +
                "<MsgType><![CDATA[text]]></MsgType>" +
                "<Content><![CDATA[" + content + "]]></Content>" +
                "</xml>";
    }
}
package cn.hgsl.pp.hgslwxservice.wx;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.ThreadLocalRandom;

public class WeChatCryptoUtil {

    /**
     * 解密微信消息
     */
    public static String decryptMsg(String token, String encodingAesKey, String appId,
                                    String msgSignature, String timestamp, String nonce, String postData) throws Exception {

        String encrypt = getXmlValue(postData, "Encrypt");
        if (encrypt.isEmpty()) {
            throw new IllegalArgumentException("密文内容不存在");
        }
        String[] array = new String[]{token, timestamp, nonce, encrypt};
        Arrays.sort(array);
        String sha1Hex = sha1Encode(String.join("", array));
        if (!sha1Hex.equals(msgSignature)) {
            throw new SecurityException("微信签名验证失败");
        }
        byte[] aesKey = Base64.getDecoder().decode(encodingAesKey + "=");
        byte[] iv = Arrays.copyOfRange(aesKey, 0, 16);
        Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
        SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);

        byte[] encryptedBytes = Base64.getDecoder().decode(encrypt);
        byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
        byte[] bytes = decodePkcs7(decryptedBytes);

        byte[] networkOrderUuidBytes = Arrays.copyOfRange(bytes, 16, 20);
        int xmlLength = recoverNetworkBytesOrderToInt(networkOrderUuidBytes);

        String xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), StandardCharsets.UTF_8);
        String fromAppId = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), StandardCharsets.UTF_8);

        if (!fromAppId.equals(appId)) {
            throw new SecurityException("AppId 校验不匹配,可能遭受非法请求");
        }
        return xmlContent;
    }

    /**
     * 加密回复消息并组装成回复 XML
     */
    public static String encryptMsg(String token, String encodingAesKey, String appId,
                                    String replyXml, String timestamp, String nonce) throws Exception {
        byte[] aesKey = Base64.getDecoder().decode(encodingAesKey + "=");
        byte[] iv = Arrays.copyOfRange(aesKey, 0, 16);
        byte[] randomBytes = new byte[16];
        ThreadLocalRandom.current().nextBytes(randomBytes);

        byte[] xmlBytes = replyXml.getBytes(StandardCharsets.UTF_8);
        byte[] networkOrderUuidBytes = getNetworkBytesOrder(xmlBytes.length);
        byte[] appIdBytes = appId.getBytes(StandardCharsets.UTF_8);

        int totalLength = randomBytes.length + networkOrderUuidBytes.length + xmlBytes.length + appIdBytes.length;
        byte[] plainBytes = new byte[totalLength];

        System.arraycopy(randomBytes, 0, plainBytes, 0, 16);
        System.arraycopy(networkOrderUuidBytes, 0, plainBytes, 16, 4);
        System.arraycopy(xmlBytes, 0, plainBytes, 20, xmlBytes.length);
        System.arraycopy(appIdBytes, 0, plainBytes, 20 + xmlBytes.length, appIdBytes.length);

        byte[] paddedBytes = encodePkcs7(plainBytes);
        Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
        SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);

        byte[] encryptedBytes = cipher.doFinal(paddedBytes);
        String encryptBase64 = Base64.getEncoder().encodeToString(encryptedBytes);

        String[] array = new String[]{token, timestamp, nonce, encryptBase64};
        Arrays.sort(array);
        String sha1Hex = sha1Encode(String.join("", array));

        return "<xml>" +
                "<Encrypt><![CDATA[" + encryptBase64 + "]]></Encrypt>" +
                "<MsgSignature><![CDATA[" + sha1Hex + "]]></MsgSignature>" +
                "<TimeStamp>" + timestamp + "</TimeStamp>" +
                "<Nonce><![CDATA[" + nonce + "]]></Nonce>" +
                "</xml>";
    }

    private static byte[] decodePkcs7(byte[] decrypted) {
        int pad = decrypted[decrypted.length - 1];
        if (pad < 1 || pad > 32) pad = 0;
        return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
    }

    private static byte[] encodePkcs7(byte[] count) {
        int blockSize = 32;
        int amountToPad = blockSize - (count.length % blockSize);
        if (amountToPad == 0) amountToPad = blockSize;
        byte padChr = (byte) (amountToPad & 0xFF);
        byte[] padding = new byte[amountToPad];
        Arrays.fill(padding, padChr);
        byte[] updated = new byte[count.length + amountToPad];
        System.arraycopy(count, 0, updated, 0, count.length);
        System.arraycopy(padding, 0, updated, count.length, amountToPad);
        return updated;
    }

    private static int recoverNetworkBytesOrderToInt(byte[] orderBytes) {
        int sourceNumber = 0;
        for (int i = 0; i < 4; i++) {
            sourceNumber <<= 8;
            sourceNumber |= (orderBytes[i] & 0xff);
        }
        return sourceNumber;
    }

    private static byte[] getNetworkBytesOrder(int sourceNumber) {
        byte[] orderBytes = new byte[4];
        orderBytes[3] = (byte) (sourceNumber & 0xFF);
        orderBytes[2] = (byte) ((sourceNumber >> 8) & 0xFF);
        orderBytes[1] = (byte) ((sourceNumber >> 16) & 0xFF);
        orderBytes[0] = (byte) ((sourceNumber >> 24) & 0xFF);
        return orderBytes;
    }

    private static String getXmlValue(String xml, String tag) {
        String startTag = "<" + tag + "><![CDATA[";
        String endTag = "]]></" + tag + ">";
        int startIndex = xml.indexOf(startTag);
        int endIndex = xml.indexOf(endTag);
        if (startIndex == -1 || endIndex == -1) return "";
        return xml.substring(startIndex + startTag.length(), endIndex);
    }

    private static String sha1Encode(String data) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-1");
        byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();
        for (byte b : digest) {
            String hex = Integer.toHexString(b & 0xFF);
            if (hex.length() == 1) sb.append("0");
            sb.append(hex);
        }
        return sb.toString();
    }
}
上一篇
下一篇