微信公众号

微信公众号开发
1、公众号机器人:包括设置菜单、自动回复、推送消息
2、公众号网页:即在网页中调用微信的JS-SDK;网页必须从属于公众号(服务器端给前端提供授权信息),才能得到调用JS-SDK的授权

公众号机器人 和 公众号网页 都要用到的 access_token 管理

用Bean(全局可用的单例)来管理access_token
注意:在不同机子上获取 access_token,会使得其他机子上的access_token失效
1、service

@Service
public class WeixinService {
    @Value("${wx.appId}")
    private String appId;

    @Value("${wx.appSecret}")
    private String appSecret;

    @Autowired
    private RestTemplate restTemplate;

    private String accessToken;

    private String jsApiTicket;      // 只在 公众号网页用到

    private Date tokenExpire;

    private Date ticketExpire;

    public String fetchToken() {
        String urlStr = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appId, appSecret);
        WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
        accessToken = wxSession.getAccess_token();
        long millis = System.currentTimeMillis() + (wxSession.getExpires_in() - 300) * 1000;
        tokenExpire = new Date(millis);
        return accessToken;
    }

    public String fetchTicket() {
        String accessToken = getAccessToken();
        String urlStr = String.format("https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi", accessToken);
        WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
        jsApiTicket = wxSession.getTicket();
        long millis = System.currentTimeMillis() + (wxSession.getExpires_in() - 300) * 1000;
        ticketExpire = new Date(millis);
        return jsApiTicket;
    }

    public String getAccessToken() {
        if (accessToken == null || tokenExpire == null || tokenExpire.before(new Date())) {
            fetchToken();
        }
        return accessToken;
    }

    public String getJsApiTicket() {
        if (ticketExpire == null || ticketExpire.before(new Date())) {
            fetchTicket();
        }
        return jsApiTicket;
    }
}

2、用于微信相关请求 和 响应 的POJO

@Autowired
private WeixinService weixinService;

public class WxSession implements Serializable {
    private String access_token;    // 从微信获取 access_token 的响应
    private String ticket;   // 从微信获取 jsapi_ticket 的响应
    private String url;      // 前端请求 JS-SDK 签名 的参数
    private long timestamp;    // 前端获取 JS-SDK 签名的响应
    private String nonceStr;    // 前端获取 JS-SDK 签名的响应
    private String signature;   // 前端获取 JS-SDK 签名的响应
    private String appId;       // 前端获取 JS-SDK 签名的响应
    private int expires_in;
    private int errcode = 0;
    private String errmsg;
}

3、由于 只有最后一次获取的access_token 有效,因此将access_token放到一个redis里;同时增加token无效时重试一次的机制

@Service
public class WeixinService {
    @Value("${wx.appId}")
    private String appId;

    @Value("${wx.appSecret}")
    private String appSecret;

    @Value("${wx.tokenUrl}")
    private String tokenUrl;

    @Value("${wx.subscribeUrl}")
    private String subscribeUrl;

    @Autowired
    private RestTemplate restTemplate;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private String accessToken;

    private Date tokenExpire;

    public String fetchToken() {
        String urlStr = String.format(tokenUrl, appId, appSecret);
        WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
        accessToken = wxSession.getAccess_token();
        long duration = wxSession.getExpires_in() - 300;
        tokenExpire = new Date(System.currentTimeMillis() + duration * 1000);
        stringRedisTemplate.opsForValue().set("access_token", accessToken);
        stringRedisTemplate.expire("access_token", duration, TimeUnit.SECONDS);
        return accessToken;
    }

    public String getAccessToken() {
        if (accessToken == null) {
            accessToken = stringRedisTemplate.opsForValue().get("access_token");
            if (accessToken != null) {
                Long millis = stringRedisTemplate.getExpire("access_token", TimeUnit.MILLISECONDS);
                if(millis != null){
                    tokenExpire = new Date(System.currentTimeMillis() + millis);
                }
            }
        }
        if (accessToken == null || tokenExpire == null || tokenExpire.before(new Date())) {
            fetchToken();
        }
        return accessToken;
    }

    // 推送订阅时,如果access_token无效,再次获取token,并重新推送一次
    public void subscribePost(Object request){
        String accessToken = this.getAccessToken();
        String urlStr = String.format(subscribeUrl, accessToken);
        WxSession wxSession = restTemplate.postForObject(urlStr, request, WxSession.class);
        if (wxSession.getErrcode() != 0) {
            if(wxSession.getErrcode() == 42001 || wxSession.getErrcode() == 40001){
                accessToken = this.fetchToken();
                urlStr = String.format(subscribeUrl, accessToken);
                restTemplate.postForObject(urlStr, request, WxSession.class);
            }
        }
    }
}

公众号网页

限制:
1、【设置->公众号设置->功能设置】JS接口安全域名 只能是已备案的域名,可以是http,端口必须是80或443
2、jsapi_ticket有效期两个小时,每次获取都不一样,只有最新的一次有效

获取JS-SDK签名

    @Value("${wx.appId}")
    private String appId;

    @Autowired
    private WeixinService weixinService;

    @RequestMapping(value = "/signature", method = RequestMethod.POST)
    public WxSession signature(@RequestBody WxSession wxSession) {
        String jsApiTicket = weixinService.getJsApiTicket();
        String nonceStr = RandomStringUtils.randomAlphanumeric(16);  // 随机串
        long timestamp = System.currentTimeMillis() / 1000;  // 秒级时间戳
        String url = wxSession.getUrl();  // 前端传来url
        /* 字段名 字典排序,拼接 */
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("jsapi_ticket=" + jsApiTicket);
        stringBuffer.append("&noncestr=" + nonceStr);
        stringBuffer.append("&timestamp=" + timestamp);
        stringBuffer.append("&url=" + url);

        String signature = DigestUtils.sha1Hex(stringBuffer.toString());         // sha1 摘要
        return new WxSession(timestamp, nonceStr, signature, appId);
    }

公众号机器人

限制:
1、被动回复用户消息:要求在5秒内必须回复
2、客服消息: 可以在用户操作后48小时内回复
3、回复图片或视频:回复多媒体消息只能是已经上传到【微信公众平台】的素材
4、access_token有效期两个小时,每次获取都不一样,只有最新的一次有效
5、媒体文件(临时素材)在微信后台保存时间为3天,即3天后media_id失效
6、永久素材库保存总数量有上限:图文消息素材、图片素材上限为100000,其他类型为1000
7、用户能发送的媒体格式 mp4、jpg、jpeg、png
8、不管用户发的图片是jpg还是png,微信发给我方服务器的 PicUrl 的 Content-Type 都是 image/jpeg
9、【开发->基本设置->服务器配置】服务器地址(URL)可以是IP,可以是http,端口必须是80或443

服务器配置

1、验证服务器(微信会发来多次请求,每次都响应正确即可验证通过)

    @RequestMapping(value = "", method = RequestMethod.GET)
    public String verify(@RequestParam("signature") String signature, @RequestParam("timestamp") String timestamp, @RequestParam("nonce") String nonce, @RequestParam("echostr") String echostr) {
        List<String> list = new ArrayList<String>();
        list.add(nonce);
        list.add(timestamp);
        list.add("token");
        Collections.sort(list);  // 将nonce、timestamp、token进行字典排序
        // 连接后进行 sha1 摘要
        String computSignature = DigestUtils.sha1Hex(list.get(0) + list.get(1) + list.get(2));
        // 对比自己计算的signature 和 微信传来的signature 
        if (computSignature.equals(signature)) {
            // 对比一致则返回 echostr 源串
            return echostr;
        }
        return "验证错误!";
    }

开启 服务器配置

1、【微信公众平台】开启 服务器配置 ,会导致【微信公众平台】上设置的 自定义菜单和自动回复失效
注:服务器配置开启后,自定义菜单需要调用微信的接口进行设置,调用微信接口都需要传access_token;
用户发送的消息会以POST的方式发送到配置的服务器URL
2、【微信公众平台】获取AppSecret
3、【微信公众平台】设置获取access_token 的 IP白名单
4、【微信公众平台接口调试工具】发送"获取Access Token"请求,得到access_token
注:access_token有效期两个小时

设置自定义菜单

自定义菜单的功能有:访问页面模板、访问已群发过的文章、访问小程序、发送素材
1、获取现有自定义菜单:【微信公众平台接口调试工具】发送"自定义菜单查询"请求
2、获取"页面模板"的URL:【微信公众平台】上复制
3、获取历史文章的URL:【微信公众平台】首页,找到历史群发文章,得到文章链接,去掉URL中尾部几个参数
4、获取素材的media_id:【Postman】发送 "获取素材列表" 请求
5、更新自定义菜单:【微信公众平台接口调试工具】发送"自定义菜单创建"请求

案例:用户发送视频,立马回复文字,再异步回复客服消息

用户发送的视频,视作一个临时素材;临时视频素材最大10MB,支持MP4格式
微信发来的数据是xml格式,返回给微信的数据也要是xml格式
1、pom.xml

        <dependency>
            <groupId>dom4j</groupId>
            <artifactId>dom4j</artifactId>
            <version>1.6.1</version>
        </dependency>
        <dependency>
            <groupId>jaxen</groupId>
            <artifactId>jaxen</artifactId>
            <version>1.1.6</version>
        </dependency>

2、XML解析、生成 工具类

public class XMLUtil {
    public static WxMsg parseXml(String xml) throws UnsupportedEncodingException,DocumentException {
        logger.info(xml);
        SAXReader reader = new SAXReader();
        Document document = reader.read(new ByteArrayInputStream(xml.getBytes("UTF-8")));
        Element root = document.getRootElement();
        String fromUserName = root.selectSingleNode("/xml/FromUserName").getText();
        String toUserName = root.selectSingleNode("/xml/ToUserName").getText();
        String msgType = root.selectSingleNode("/xml/MsgType").getText();
        WxMsg wxMsg = new WxMsg(fromUserName, toUserName, msgType);
        if (msgType.equals("video")) {
            String mediaId = root.selectSingleNode("/xml/MediaId").getText();
            wxMsg.setMediaId(mediaId);
        }
        if (msgType.equals("image")) {
            String picUrl = root.selectSingleNode("/xml/PicUrl").getText();
            wxMsg.setPicUrl(picUrl);
        }
        // 订阅、点击菜单 等事件
        if (msgType.equals("event")) {
            String event = root.selectSingleNode("/xml/Event").getText();
            wxMsg.setEvent(event);
            // 点击菜单事件
            if (event.equals("CLICK")) {
                String eventKey = root.selectSingleNode("/xml/EventKey").getText();
                wxMsg.setEventKey(eventKey);
            }
        }
        return wxMsg;
    }

    public static String generateXml(WxMsg wxMsg) {
        Document document = DocumentHelper.createDocument();
        Element root = document.getRootElement();
        root = document.addElement("xml");
        root.addElement("FromUserName").addText(wxMsg.getFromUserName());
        root.addElement("ToUserName").addText(wxMsg.getToUserName());
        root.addElement("CreateTime").addText("" + System.currentTimeMillis());
        root.addElement("MsgType").addText(wxMsg.getMsgType());
        root.addElement("Content").addText(wxMsg.getContent());
        return document.asXML();
    }
}

3、Controller

    @RequestMapping(value = "", method = RequestMethod.POST)
    public String autoReply(HttpServletRequest request) throws Exception {
        // 微信发来的数据是xml格式
        String xml = new String(ByteStreams.toByteArray(request.getInputStream()));
        // 解析XML
        WxMsg receiveMsg = XMLUtil.parseXml(xml);
        String msgType = receiveMsg.getMsgType();
        String openId = receiveMsg.getFromUserName();
        WxMsg responseMsg = new WxMsg(receiveMsg.getToUserName(), openId, "text");
        // 区分动作类型
        if (msgType.equals("event") && receiveMsg.getEvent().equals("subscribe")) {  // 订阅事件
            responseMsg.setContent("欢迎订阅");
            return XMLUtil.generateXml(responseMsg);
        }
        // 点击菜单
        if (msgType.equals("event") && receiveMsg.getEvent().equals("CLICK") && receiveMsg.getEventKey().equals("XX")) {
            responseMsg.setContent("您点击了XX菜单");
            return XMLUtil.generateXml(responseMsg);
        }
        if (!msgType.equals("video")) {
            return "";
        }
        String mediaId = receiveMsg.getMediaId();

        /* 相关业务逻辑,例如记录数据到数据库 */

        // 根据mediaId,异步 从微信下载视频
        asyncTasks.downloadFromWx(openId, mediaId);
        // 给微信返回的也是 xml 格式
        responseMsg.setContent("立即回复给用户的文字");
        return XMLUtil.generateXml(responseMsg);
    }

4、用于接收微信消息 和 回复微信消息的 POJO

public class WxMsg implements Serializable {
    private String FromUserName;
    private String ToUserName;
    private String MsgType;
    private String mediaId;
    private String PicUrl;
    private String Event;
    private String EventKey;
    private String Content;
    private String CreateTime;
}

5、异步任务 或 定时任务

@Component
@EnableAsync
public class AsyncTasks {
    @Autowired
    private RestTemplate restTemplate;

    @Async
    public void downloadFromWx(String openId, String mediaId) throws Exception {
        String appId = "***";
        String appSecret = "***";
        // 下载视频
        String urlStr = String.format("http://api.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s", wexinService.getAccessToken(), mediaId);
        URL url = new URL(urlStr);
        HttpURLConnection conn = (HttpURLConnection)url.openConnection();
        conn.setConnectTimeout(100*1000);
        InputStream inputStream = conn.getInputStream();

        /* 相关业务逻辑,例如记录数据到数据库 */
    }
}

6、发送客服消息

        // 构造url
        String url = String.format("https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=%s", wexinService.getAccessToken());
        // 构造请求
        String content = "这是客服消息";
        restTemplate.postForObject( url, new ServiceMsg(openId,"text",content), String.class );

7、客服消息请求体 POJO

public class ServiceMsg implements Serializable {
    private String touser;
    private String msgtype;
    private Text text;

    public class Text implements Serializable {
        private String content;
    }
}

案例:用户发送文字,自动回复一条视频

1、接收用户消息,记下openId,并立即(5秒内)返回一个 提示消息
2、异步任务调用 "新增临时素材",得到media_id
3、(48小时内)调用"客服接口-发消息",带上openId 和 media_id

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350