微信公众号开发
一、注册公众号
公众号分类:公众号分为订阅号,服务号和企业号
个人用户大多数选择订阅号,而企业用户一般选择服务号和企业号
-
我们平常大多数关注的都是订阅号,他们统一都放置在微信应用的订阅号消息列表中,没有微信支付等高级功能,只是用于发布文章等基础功能。
-
而服务号和企业号都在会话列表,和我们的微信好友是同级别的位置,具备微信支付等高级功能,一般是某个企业品牌的对外操作窗口,如海底捞火锅、顺丰速运等。
我们前期开发测试只需要注册个人订阅号即可,真正开发使用的是开发者工具里的测试号
二、公众号管理页面
功能模块
作为开发人员,首先应该关注的是设置、开发模块;而作为产品运营人员,关注的是功能、管理、推广模块;作为数据分析人员,关注的是统计模块。
这里有一点需要注意,如果我们决定技术人员开发公众号,必须启用服务器配置,而这将导致UI界面设置的自动回复和自定义菜单失效!
我们在 开发 - 基本配置 - 服务器配置 中点击启用:
三、开发工具
1. 工具
在开发-> 开发工具中
2. 工具登录地址
-
测试公众号后台配置地址 https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login
-
开发文档 https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html
-
在线接口调试工具地址 https://mp.weixin.qq.com/debug
3. 配置
4. 配置介绍
-
测试账号信息:APPID,APPSecret是重要的信息,在签名和授权时,会用到
-
接口配置信息:URL需要返回指定的Token验证值,测试时需要用netapp生成一个临时公网地址映射到自己IP,及实现能在公网返回一个验证值,Token是自己填写的,会在验证中用到。
-
JS接口安全域名:只有在这个域名下面,才能使用你调用的微信js接口
5. 参数介绍
-
无论是在真实公众号的 开发 - 基本配置 - 服务器配置,还是在 测试号管理 中,我们都可以看到这几个基本参数:
开发者ID(AppID)、开发者密码(AppSecret)、服务器地址(URL)、令牌(Token) -
AppSecret 是校验公众号开发者身份的密码,具有极高的安全性。切记勿把密码直接交给第三方开发者或直接存储在代码中。如需第三方代开发公众号,请使用授权方式接入。其中获取accessToken就需要同时传入AppID和AppSecret获取。
-
URL 是开发者用来接收微信消息和事件的接口URL,也就是我们服务后端的入口地址,需要注意的是该地址必须以域名形式填写,且必须以http 或 https 开头,分别支持80端口和443端口。如:http://kp6yy3.natappfree.cc/hzll/wechat/chcekToken。
-
Token 可由开发者可以任意填写,用作生成签名(该Token会和接口URL中包含的Token进行比对,从而验证安全性),也就是我们项目和微信服务端进行通信时,必须保证公众平台配置的Token和我们后台代码配置的Token保持一致,这样微信就能验证我们身份。
-
JS接口安全域名,它的意思就是只有在这个域名下面,才能使用你调用的微信js接口,因为你配置的appid参数,已经证明了你使用哪个公众号,所以当访问你写的程序时,微信会自动验证安全域名,如果不是,就使用不了微信的js接口。
四、开发重点
由于系统框架不同,注意一下代码并不能直接复制使用,只能作为参考(虽说如此,但理解微信开发流程之后,以下大部分代码可以复用)
1. 验证Token
这里的验证Token及接口配置信息需要配置的接口地址,但是对于发送微信消息推送模板,并不需要
验证token,对于自定义菜单栏才需要,并且正式的服务号启用了服务器配置(参见二、公众号管理页面),会自动禁止UI中已经存在的菜单栏。
@Value("${wechat_configToken}")
private String token;
@RequestMapping(value = "chcekToken", method = RequestMethod.GET, produces = "application/json;charset=UTF-8")
@ResponseBody
public String wxSignatureCheck(
@RequestParam(value = "signature") String signature,
@RequestParam(value = "timestamp") String timestamp,
@RequestParam(value = "nonce") String nonce,
@RequestParam(value = "echostr") String echostr
){
return weChatService.wxSignatureCheck(signature, timestamp, nonce, echostr);
}
public String wxSignatureCheck(String signature, String timestamp, String nonce, String echostr) {
//排序
String sortString = sort(token, timestamp, nonce);
//加密
String mySignature = encryption(sortString);
//校验签名
if (StringUtils.hasText(mySignature) && mySignature.equals(signature)) {
return echostr; //如果检验成功输出echostr,微信服务器接收到此输出,才会确认检验完成。
} else {
logger.info("========================== 签名校验失败 ==========================");
return null;
}
}
/**
* 排序方法
* @param token
* @param timestamp
* @param nonce
* @return
*/
private static String sort(String token, String timestamp, String nonce) {
String[] strArray = { token, timestamp, nonce };
Arrays.sort(strArray);
StringBuilder sbuilder = new StringBuilder();
for (String str : strArray) {
sbuilder.append(str);
}
return sbuilder.toString();
}
/**
*
* @Description: 对 cryptString 进行 SHA-1 加密
* @param cryptString
* @return
*/
private static String encryption(String cryptString){
String res = "";
try {
MessageDigest crypt = MessageDigest.getInstance("SHA-1");
crypt.reset();
crypt.update(cryptString.getBytes("UTF-8"));
res = byteToHex(crypt.digest());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return res;
}
/**
*
* @Description: 字节转16进制
* @param hash
* @return
*/
public String byteToHex(final byte[] hash) {
Formatter formatter = new Formatter();
for (byte b : hash) {
formatter.format("%02x", b);
}
String result = formatter.toString();
formatter.close();
return result;
}
2. 存取access_token,ticket参数
access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时(7200秒),需定时刷新,重复获取将导致上次获取的access_token失效。
access_token这个参数非常重要,几乎贯穿整个微信公关号项目开发,我们如何在有效期内定时刷新获取呢?
如果我们的微信公众号项目是单服务架构,可以直接作为静态变量存储在内存里;如果是多服务,可以用中间件存储,Redis、数据库都可以。Spring项目内部可以通过@Scheduled注解,执行定时任务,既然access_token有效期是2小时,那我们可以一小时刷新获取一次,将其存入Redis,覆盖之前的access_token。
/**
*
* @Description: 定时刷新全局token
*/
@Scheduled(cron="${token.cron}")
public void refreshToken(){
saveGlobalToken();
}
@PostConstruct
public boolean saveGlobalToken() {
//HTTP请求微信接口,获取全局token
String gToken = getGlobalToken();
logger.info("token====================="+gToken);
if (StringUtils.hasText(gToken)) {
//暂时将数据保存至数据库中,不启用redis
Map<String, Object> insertMap = new HashMap<>();
insertMap.put(SysAccessToken.RECID, UUID.randomUUID().toString());
insertMap.put(SysAccessToken.ACCESSTOKEN, gToken);
insertMap.put(SysAccessToken.CREATETIME, new Date());
int result = daoExecuteCenter.Insert(insertMap, SysAccessToken.TABLENAME, new StringBuilder());
if (result > 0) {
//获取ticket,ticket在签名时用到
return saveJsapiTicket();
}
return result > 0;
}
return false;
}
public boolean saveJsapiTicket() {
String accessToken = getToken();
//HTTP请求微信接口,获取Ticket
String ticket = getJsapiTicket(accessToken);
logger.info("ticket====================="+ticket);
if (StringUtils.hasText(ticket)) {
//暂时将数据保存至数据库中,不启用redis
Map<String, Object> updateMap = new HashMap<>();
updateMap.put(SysAccessToken.JSAPITICKET, ticket);
Map<String, Object> conditionMap = new HashMap<>();
conditionMap.put(SysAccessToken.ACCESSTOKEN, accessToken);
int result = daoExecuteCenter.Update(updateMap, conditionMap, SysAccessToken.TABLENAME, new StringBuilder());
return result > 0;
}
return false;
}
/**
*
* @Description: 获取全局token
* @return
*/
private String getGlobalToken(){
//https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
String reqUrl = "https://api.weixin.qq.com/cgi-bin/token";
Map<String, String> params = new HashMap<>();
params.put("grant_type", "client_credential");
params.put("appid", appid);
params.put("secret", secret);
String res = HttpClient.Get(reqUrl, params);
return JSONObject.parseObject(res).getString("access_token");
}
/**
*
* @Description: 获取全局ticket
* @return
*/
private String getJsapiTicket(String accessToken) {
String reqUrl = "https://api.weixin.qq.com/cgi-bin/ticket/getticket";
Map<String, String> params = new HashMap<>();
params.put("access_token", accessToken);
params.put("type", "jsapi");
String res = HttpClient.Get(reqUrl, params);
return JSONObject.parseObject(res).getString("ticket");
}
/**
获取Ticket
*/
@Override
public String getToken() {
String accessToken = "";
Map<String, Object> map = getAccessToken();
if (map.size() > 0) {
accessToken = map.get(SysAccessToken.ACCESSTOKEN).toString();
}
return accessToken;
}
/**
获取Ticket
*/
@Override
public String getTicket() {
String jsapiTicket = "";
Map<String, Object> map = getAccessToken();
if (map.size() > 0) {
jsapiTicket = map.get(SysAccessToken.JSAPITICKET).toString();
}
return jsapiTicket;
}
/**
获取存在数据库中的sysaccesstoken
*/
private Map<String, Object> getAccessToken(){
String sql = String.format("select * from sysaccesstoken order by createTime desc limit 1 ", SysAccessToken.TABLENAME);
List<Map<String, Object>> list = daoExecuteCenter.ExeQueryBySQL(sql, new Object[]{});
Map<String, Object> map = new HashMap<>();
if (list.size() > 0) {
map = list.get(0);
}
return map;
}
3. 获取签名
@RequestMapping(value = "signature", method = RequestMethod.GET)
@ApiOperation(value = "获取签名", notes = "获取签名", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public JSONResponse getSignature(HttpServletRequest request,
@ApiParam(required = true, name = "url", value = "请求地址") @RequestParam(value = "url", required = true) String url,
@ApiParam(required = false, name = "state", value = "请求码") @RequestParam(value = "state", required = false) String state){
//拼接参数,形成新的URL
String reqUrl = url;
if (StringUtils.hasText(state)) {
reqUrl = url + "&state=" + state;
// reqUrl = url + "&state=" + state+"#/login";
}
Map<String, Object> result = weChatService.getSignature(reqUrl);
return super.toJSONString(0, "操作成功", result);
}
@Override
public Map<String, Object> getSignature(String url) {
int len = 10;//指定随机字符串长度
Map<String, Object> ret = new HashMap<>();
String nonce_str = RandomStringUtils.random(len, true, true);
String timestamp = String.valueOf(System.currentTimeMillis()).substring(0, 10);
String joinString = "";
String signature = "";
//注意这里参数名必须全部小写,且必须有序
joinString = "jsapi_ticket=" + getTicket() + "&noncestr=" + nonce_str + "×tamp=" + timestamp + "&url=" + url;
logger.info("请求签名时的参数=========================" + joinString);
//对拼接的参数进行SHA-1加密,得到签名
signature = encryption(joinString);
logger.info("签名=========================" + signature);
ret.put("url", url);
ret.put("nonceStr", nonce_str);
ret.put("timestamp", timestamp);
ret.put("signature", signature);
ret.put("appid",appid);
return ret;
}
/**
*
* @Description: 对 cryptString 进行 SHA-1 加密
* @param cryptString
* @return
*/
private static String encryption(String cryptString){
//和验证Token的encryption是同一个
return null;
}
4. 前端授权
前端传递code, 通过微信端授权,获取到openid,再通过openid,获取到用户详细信息,如头像地址,昵称等
@RequestMapping(value = "userAuthtoken", method = RequestMethod.GET, produces = "application/json;charset=UTF-8")
@ApiOperation(value = "授权时,获取授权用户信息", notes = "授权时,获取授权用户信息", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public JSONResponse userAuthtoken(HttpServletRequest request,
@ApiParam(required = true, name = "code", value = "前端微信唯一标识") @RequestParam(value = "code", required = true) String code
){
Map<String, Object> result = weChatService.userAuthtoken(code);
return super.toJSONString(0, "操作成功", result);
}
@Override
public Map<String, Object> userAuthtoken(String code) {
Map<String, Object> rMap = new HashMap<>();
//1.通过code获取openid,access_token,refresh_token
Map<String, Object> map = getOpenIDByCode(code);
//判断是否获取成功,如果map为null,返回null
if (map == null) {
return rMap;
}
String openID = map.get("openID").toString();
String accessToken = map.get("accessToken").toString();
//2.将用户信息存入数据库,用openid关联
if (StringUtils.hasText(openID)) {
List<Map<String, Object>> list = getUserInfo(openID);
if (list.size() > 0) {
rMap = list.get(0);
} else {
//3.通过openid获取用户信息
rMap = getWeChatUserInfo(openID, accessToken);
daoExecuteCenter.Insert(rMap, SysWeChatUser.TABLENAME, new StringBuilder());
}
}
return rMap;
}
/**
*
* @Description: 获取静默授权时的access_token,和refresh_token
* @return
*/
private Map<String, Object> getOpenIDByCode(String code){
//https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
String reqUrl = "https://api.weixin.qq.com/sns/oauth2/access_token";
Map<String, String> params = new HashMap<String, String>();
params.put("appid", appid);
params.put("secret", secret);
params.put("CODE", code);
params.put("grant_type", "authorization_code");
String res = HttpClient.Get(reqUrl, params);
JSONObject jsonObject = JSONObject.parseObject(res);
//判断openid是否有值,如果为null,返回null
if (jsonObject.getString("openid") == null) {
return null;
}
Map<String, Object> rMap = new HashMap<>();
rMap.put("accessToken", jsonObject.getString("access_token"));
rMap.put("openID", jsonObject.getString("openid"));
rMap.put("refreshToken", jsonObject.getString("refresh_token"));
rMap.put("scope", jsonObject.getString("scope"));//静默授权snsapi_base 或者 网页授权snsapi_userinfo
return rMap;
}
/**
*
* @Description: 通过openid获取用户信息
* @return
*/
private Map<String, Object> getWeChatUserInfo(String openid, String accessToken){
//https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
String reqUrl = "https://api.weixin.qq.com/sns/userinfo";
Map<String, String> params = new HashMap<String, String>();
params.put("access_token", accessToken);
params.put("openid", openid);
params.put("lang", "zh_CN");
String res = HttpClient.Get(reqUrl, params);
JSONObject jsonObject = JSONObject.parseObject(res);
Map<String, Object> rMap = new HashMap<>();
rMap.put("openID", jsonObject.getString("openid"));
rMap.put("nickName", jsonObject.getString("nickname"));
rMap.put("sex", jsonObject.getString("sex"));
rMap.put("city", jsonObject.getString("city"));
rMap.put("province", jsonObject.getString("province"));
rMap.put("country", jsonObject.getString("country"));
rMap.put("headimgUrl", jsonObject.getString("headimgurl"));
return rMap;
}
5. 微信消息推送
5.1 引入Wxjava工具包
<!-- WxJava公众号 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>3.6.0</version>
</dependency>
5.2 配置全局appid等信息
主要用于构建wxMpService, 通过它实现微信消息推送,当然,自定义菜单栏,图片消息等,都可以通过它完成
package com.depart.config.configuration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
import me.chanjar.weixin.mp.config.WxMpConfigStorage;
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
/**
*
* @ClassName: WxConfig
* @Description:
* @author wangzhen
* @date 2021年1月7日 下午1:36:25
*
*/
@Configuration
public class WxConfig {
@Value("${wechat_appid}")
private String appid;
@Value("${wechat_secret}")
private String secret;
@Value("${wechat_configToken}")
private String token;
private static WxMpDefaultConfigImpl configStorage = new WxMpDefaultConfigImpl();
/**
* 微信客户端配置存储
*
* @return
*/
@Bean
public WxMpConfigStorage wxMpConfigStorage() {
// 公众号appId
configStorage.setAppId(appid);
// 公众号appSecret
configStorage.setSecret(secret);
// 公众号Token
configStorage.setToken(token);
// 公众号EncodingAESKey
// configStorage.setAesKey(wxMpProperties.getAesKey());
return configStorage;
}
/**
* 声明实例
*
* @return
*/
@Bean
public WxMpService wxMpService() {
WxMpService wxMpService = new WxMpServiceImpl();
wxMpService.setWxMpConfigStorage(wxMpConfigStorage());
return wxMpService;
}
}
5.3 消息发送
@Autowired
private WxMpService wxMpService;
@RequestMapping(value = "sendWxMsg", method = RequestMethod.GET, produces = "application/json;charset=UTF-8")
@ApiOperation(value ="发送微信模板消息", notes = "发送微信模板消息")
@ResponseBody
public JSONResponse sendWxMsg(
@ApiParam(required = true, name = "openID", value = "请求地址") @RequestParam(value = "openID", required = true) String openID
) {
boolean sccuess = weChatService.sendWxMsg(openID);
if (sccuess) {
return super.toJSONString(0, "操作成功");
} else {
return super.toJSONString(1, "操作失败");
}
}
/**
* 发送微信模板信息
*
* @param openId 接受者openId
* @return 是否推送成功
*/
@Override
public boolean sendWxMsg(String openID) {
//测试微信中创建的消息模板,正式的服务号模板新建需要审核,可以找已有的模板,并添加进来
String templateId = "x96l6avHbqb2e_yjJfqSe8DvAHQC6BOM_icQqPsUISg";
//消息跳转地址,测试微信就是前端配置的授权回调页面,正式也可以配置
String url = "跳转地址";
//json消息体
JSONObject data = genValue("您的xxx即将到期", "xxx", "2021年4月14日", "请您及时办理");
return wxMsgUtil(openID, templateId, url, data);
}
private static JSONObject genValue(String headContent, String fileType, String expireDate, String tail){
//{{first.DATA}} 证件名称:{{keyword1.DATA}} 到期日期:{{keyword2.DATA}} {{remark.DATA}}
JSONObject json = new JSONObject();
JSONObject first = new JSONObject();
first.put("value", "您好,"+headContent+"\r\n");
first.put("color", "#173177");
json.put("first", first);
JSONObject c = new JSONObject();
c.put("value", fileType);
c.put("color", "#173177");
json.put("keyword1", c);
JSONObject time = new JSONObject();
time.put("value", expireDate);
time.put("color", "#173177");
json.put("keyword2", time);
JSONObject remark = new JSONObject();
remark.put("value", "\r\n"+tail+"\r\n");
remark.put("color", "#173177");
json.put("remark", remark);
return json;
}
/**
* 发送微信模板信息
*
* @param openId 接受者openId
* @return 是否推送成功
*/
private boolean wxMsgUtil(String openID, String templateId, String url, JSONObject data) {
// 发送模板消息接口
WxMpTemplateMessage templateMessage = new WxMpTemplateMessage();
templateMessage.setToUser(openID);
templateMessage.setTemplateId(templateId);
// 模板跳转链接 url和miniprogram都是非必填字段,若都不传则模板无跳转;若都传,会优先跳转至小程序。
if (StringUtils.hasText(url)) {
templateMessage.setUrl(url);
}
// 添加模板数据
for (Object k : data.keySet()) {
Object v = data.get(k);
if (v instanceof JSONObject) {
// 如果内层是json对象的话
WxMpTemplateData temp = new WxMpTemplateData();
temp.setName(k.toString());
temp.setValue(((JSONObject) v).getString("value"));
temp.setColor(((JSONObject) v).getString("color"));
templateMessage.addData(temp);
}
}
String msgId = null;
try {
// 发送模板消息
msgId = wxMpService.getTemplateMsgService().sendTemplateMsg(templateMessage);
} catch (WxErrorException e) {
e.printStackTrace();
}
logger.info("推送微信模板信息:"+ msgId != null ? "成功" : "失败");
return msgId != null;
}
6. 其他
6.1 网页授权有哪几种机制?分别是怎样实现?应用于什么场景?
主要有两种机制,对应两种scope:
以snsapi_base
为scope发起的网页授权,是用来获取进入页面的用户的openid的,并且是静默授权并自动跳转到回调页的。用户感知的就是直接进入了回调页(往往是业务页面)。
以snsapi_userinfo
为scope发起的网页授权,是用来获取用户基本信息的。但这种授权需要用户手动同意,并且由于用户同意过,所以无须关注,就可在授权后获取该用户的基本信息。
那么这两种scope授权的优劣势在哪呢?
snsapi_base 的优势在于用户无感知,体验好,方便快捷;劣势在于获取openid后只能通过用户管理 - 获取用户基本信息(UnionID机制) 接口获取用户基本信息,而这种方式需要确保用户已经关注,不然是没有相关信息的!
snsapi_userinfo 的优势在于无需用户关注公众号,只要用户点击了授权确认,即可通过access_token和openid调用专门的拉去用户信息接口获取信息,比较暴力。。;劣势在于需要用户手动授权,可能影响用户体验。
6.2 想要进行网页授权,我们需要在公众平台配置什么吗?
如果是测试号,需要在 测试号管理 - 体验接口权限表 - 网页服务 - 网页帐号 点击 修改。