微信服务号常见三大配置区别
配置项 用途 作用对象 是否必须公网 是否限制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();
}
}