微信公众号开发

一、注册公众号

公众号分类:公众号分为订阅号,服务号和企业号

个人用户大多数选择订阅号,而企业用户一般选择服务号和企业号

  • 我们平常大多数关注的都是订阅号,他们统一都放置在微信应用的订阅号消息列表中,没有微信支付等高级功能,只是用于发布文章等基础功能。

  • 服务号企业号都在会话列表,和我们的微信好友是同级别的位置,具备微信支付等高级功能,一般是某个企业品牌的对外操作窗口,如海底捞火锅、顺丰速运等。

我们前期开发测试只需要注册个人订阅号即可,真正开发使用的是开发者工具里的测试号

二、公众号管理页面

功能模块

img

作为开发人员,首先应该关注的是设置、开发模块;而作为产品运营人员,关注的是功能、管理、推广模块;作为数据分析人员,关注的是统计模块。

这里有一点需要注意,如果我们决定技术人员开发公众号,必须启用服务器配置,而这将导致UI界面设置的自动回复和自定义菜单失效!

我们在 开发 - 基本配置 - 服务器配置 中点击启用

在这里插入图片描述

在这里插入图片描述

三、开发工具

1. 工具

在开发-> 开发工具中

在这里插入图片描述

2. 工具登录地址

3. 配置

image-20210817141230790

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 + "&timestamp=" + 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 消息发送

image-20210817152816991

	@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 想要进行网页授权,我们需要在公众平台配置什么吗?

如果是测试号,需要在 测试号管理 - 体验接口权限表 - 网页服务 - 网页帐号 点击 修改

image-20210817154317965

image-20210817154328883