【微信公众号-订阅号发送群发消息】

第一章 微信公众号-订阅号发送群发消息


前言

在公众平台网站上,为订阅号提供了每天一条的群发权限,为服务号提供每月(自然月)4条的群发权限。而对于某些具备开发能力的公众号运营者,可以通过高级群发接口,实现更灵活的群发能力。

很多开发者开发时云里雾里,这里总结一下 公众号-订阅号群发功能的开发实现过程。。。


一、微信官方文档介绍

示例:接口文档地址:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Batch_Sends_and_Originality_Checks.html#0

在公众平台网站上,为订阅号提供了每天一条的群发权限,为服务号提供每月(自然月)4条的群发权限。而对于某些具备开发能力的公众号运营者,可以通过高级群发接口,实现更灵活的群发能力。

请注意:

对于认证订阅号,群发接口每天可成功调用1次,此次群发可选择发送给全部用户或某个标签;
对于认证服务号虽然开发者使用高级群发接口的每日调用限制为100次,但是用户每月只能接收4条,无论在公众平台网站上,还是使用接口群发,用户每月只能接收4条群发消息,多于4条的群发将对该用户发送失败;
开发者可以使用预览接口校对消息样式和排版,通过预览接口可发送编辑好的消息给指定用户校验效果;
群发过程中,微信后台会自动进行图文消息原创校验,请提前设置好相关参数(send_ignore等);
开发者可以主动设置 clientmsgid 来避免重复推送。
群发接口每分钟限制请求60次,超过限制的请求会被拒绝。
图文消息正文中插入自己帐号和其他公众号已群发文章链接的能力。
对于已开启 API 群发保护的账号,群发全部用户时需要等待管理员进行确认,如管理员拒绝或30分钟内没有确认,该次群发失败。用户可通过“设置 - 安全中心 - 风险操作保护”中关闭 API 群发保护功能。
群发图文消息的过程如下:

首先,预先将图文消息中需要用到的图片,使用上传图文消息内图片接口,上传成功并获得图片 URL;
上传图文消息素材,需要用到图片时,请使用上一步获取的图片 URL;
使用对用户标签的群发,或对 OpenID 列表的群发,将图文消息群发出去,群发时微信会进行原创校验,并返回群发操作结果;
在上述过程中,如果需要,还可以预览图文消息、查询群发状态,或删除已群发的消息等。
群发图片、文本等其他消息类型的过程如下:

如果是群发文本消息,则直接根据下面的接口说明进行群发即可;
如果是群发图片、视频等消息,则需要预先通过素材管理接口准备好 mediaID。
关于群发时使用is_to_all为 true 使其进入公众号在微信客户端的历史消息列表:

使用is_to_all为 true 且成功群发,会使得此次群发进入历史消息列表,群发成功后 media_id 会失效,后台草稿也会被自动删除。
为防止异常,认证订阅号在一天内,只能使用is_to_all为 true 进行群发一次,或者在公众平台官网群发(不管本次群发是对全体还是对某个分组)一次。以避免一天内有2条群发进入历史消息列表。
类似地,服务号在一个月内,使用is_to_all为 true 群发的次数,加上公众平台官网群发(不管本次群发是对全体还是对某个分组)的次数,最多只能是4次。
设置is_to_all为 false 时是可以多次群发的,但每个用户只会收到最多4条,且这些群发不会进入历史消息列表。
另外,请开发者注意,本接口中所有使用到media_id的地方,现在都可以使用素材管理中的永久素材media_id了。请但注意,使用同一个素材群发出去的链接是一样的,这意味着,删除某一次群发,会导致整个链接失效。

此外,对于群发和预览接口中的图文消息 (mpnews) ,请使用通过 “草稿箱 / 新建草稿” 接口获得的 media_id

二、使用步骤

1.获取 微信公共参数 access_token

微信公众平台登录地址:https://mp.weixin.qq.com/cgi-bin/home?t=home/index&lang=zh_CN
前置条件
1、要拿到公众号的 appId 和 AppSecret
微信公众平台管理后台

2、配置公众号IP白名单

在这里插入图片描述

获取access_token 代码(示例):

import cn.com.refratechnik.common.constant.CommonConstant;
import cn.com.refratechnik.common.constant.NumberConstants;
import cn.com.refratechnik.common.dto.PushMessageDto;
import cn.com.refratechnik.common.entity.*;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.ArrayList;
import java.util.List;


@Slf4j
public class WeChatUtils {
    /**
     * 获取Access token
     * access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token
     * @return
     * @throws Exception
     */
    public static String getWxAccessToken() throws Exception {

        String ACCESS_TOKEN_API = "https://api.weixin.qq.com/cgi-bin/token";
        String wx_appid = "你的appId";
        String wx_secret = "你的wx_secret";
        String getAccessTokenParam = "appid=" + wx_appid + "&secret=" + wx_secret + "&grant_type=client_credential";
        // 获取凭证
        String wechatResult = HttpUtils.get(ACCESS_TOKEN_API, getAccessTokenParam);
        JSONObject jsonObj = JSONObject.parseObject(wechatResult);
        // 解析微信返回消息
        String token = null;
        if (jsonObj != null) {
            token = (String) jsonObj.get("access_token");
        }
        if (StringUtils.isEmpty(token)) {
            System.err.println("******************** getWxAccessToken ********************");
            System.err.println("**   获取微信凭证失败");
            System.err.println("**   wx_appid:" + wx_appid);
            System.err.println("**   wx_secret:" + wx_secret);
            System.err.println("**   ACCESS_TOKEN_API:" + ACCESS_TOKEN_API);
            System.err.println("**   请求参数:" + getAccessTokenParam);
            System.err.println("**   返回结果:" + wechatResult);
            throw new Exception("调用微信接口,取 AccessToken 失败,返回结果:" + wechatResult);
        }

        return token;
    }
}

HttpUtils 工具类 代码(示例 ):

package cn.com.aaa.common.util;

import com.alibaba.druid.util.StringUtils;
import com.google.common.base.Throwables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class HttpUtils {


    private static Logger logger = LoggerFactory.getLogger(HttpUtils.class);

    /**
     * 向指定URL发送GET方法的请求
     *
     * @param url   发送请求的URL
     * @param param 请求参数,请求参数应该是 name1=value1&name2=value2 的形式。
     * @return URL 所代表远程资源的响应结果
     */
    public static String get(String url, String param) {
        String result = "";
        BufferedReader in = null;
        try {
            String api = "";
            if (StringUtils.isEmpty(param)) {
                api = url;
            } else {
                api = url + "?" + param;
            }
            URL realUrl = new URL(api);
            // 打开和URL之间的连接
            if (api.startsWith("https://")) {
                HttpsURLConnection httpConn = (HttpsURLConnection) realUrl.openConnection();
                SSLContext sslContext = SSLContext.getInstance("SSL", "SunJSSE");
                //第一个参数为 返回实现指定安全套接字协议的SSLContext对象。第二个为提供者
                TrustManager[] tm = {new MyX509TrustManager()};
                sslContext.init(null, tm, new SecureRandom());
                SSLSocketFactory ssf = sslContext.getSocketFactory();
                httpConn.setSSLSocketFactory(ssf);
                // 设置通用的请求属性
                httpConn.setRequestProperty("accept", "*/*");
                httpConn.setRequestProperty("connection", "Keep-Alive");
                httpConn.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)");
                // 建立实际的连接
                httpConn.connect();
                // 获取所有响应头字段
                Map<String, List<String>> map = httpConn.getHeaderFields();
                // 定义 BufferedReader输入流来读取URL的响应
                in = new BufferedReader(new InputStreamReader(httpConn.getInputStream()));
                String line;
                while ((line = in.readLine()) != null) {
                    result += line;
                }
            } else {
                URLConnection httpConn = realUrl.openConnection();
                // 设置通用的请求属性
                httpConn.setRequestProperty("accept", "*/*");
                httpConn.setRequestProperty("connection", "Keep-Alive");
                httpConn.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)");
                // 建立实际的连接
                httpConn.connect();
                // 获取所有响应头字段
                Map<String, List<String>> map = httpConn.getHeaderFields();
                // 遍历所有的响应头字段
                for (String key : map.keySet()) {
                    System.out.println(key + "--->" + map.get(key));
                }
                // 定义 BufferedReader输入流来读取URL的响应
                in = new BufferedReader(new InputStreamReader(httpConn.getInputStream()));
                String line;
                while ((line = in.readLine()) != null) {
                    result += line;
                }
            }
        } catch (Exception e) {
            System.out.println("发送GET请求出现异常!" + e);
            e.printStackTrace();
        }
        // 使用finally块来关闭输入流
        finally {
            try {
                if (in != null) {
                    in.close();
                }
            } catch (Exception e2) {
                e2.printStackTrace();
            }
        }
        return result;
    }


    /**
     * POST 请求
     *
     * @param url    http请求地址
     * @param params http请求参数
     * @return String
     */
    public static ApiResponseFile sendPost(String url, String params) {
        PrintWriter pw = null;
        BufferedReader br = null;
        ApiResponseFile response = new ApiResponseFile();
        try {
            HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();

            // 设置请求头
            connection.setRequestProperty("accept", "*/*");
            connection.setRequestProperty("connection", "Keep-Alive");
            connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)");

            // 设置 POST
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setDoInput(true);
            // Post 请求不能使用缓存
            connection.setUseCaches(false);

            connection.setConnectTimeout(5000);

            // 获取URL的输出流, 发送请求参数
            pw = new PrintWriter(connection.getOutputStream());
            pw.print(params);
            pw.flush();

            // 获取请求头字段
            Map<String, List<String>> header = connection.getHeaderFields();

            // 获取URL的输入流,读取请求响应
            String body = readString(connection.getInputStream());

            response.setHeader(header);
            response.setBody(body);

        } catch (Exception e) {
            logger.error("发送POST请求出现异常!, cause;{}", Throwables.getStackTraceAsString(e));
        } finally {
            try {
                if (pw != null) {
                    pw.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return response;
    }


    private static String readString(InputStream is) {
        BufferedReader br = null;
        String content = "";
        try {
            br = new BufferedReader(new InputStreamReader(is, "utf-8"));
            String line;
            while ((line = br.readLine()) != null) {
                content += line;
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (br != null) {
                    br.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return content;
    }


}

class MyX509TrustManager implements X509TrustManager {
    @Override
    public void checkClientTrusted(X509Certificate[] chain, String authType)
            throws CertificateException {
    }

    @Override
    public void checkServerTrusted(X509Certificate[] chain, String authType)
            throws CertificateException {
    }

    @Override
    public X509Certificate[] getAcceptedIssuers() {
        return null;
    }
}
/**
 * Http 请求响应
 */
class ApiResponseFile {
    private Map header;
    private Object body;

    public ApiResponseFile() {
        this.header = new TreeMap();
        this.body = "";
    }

    public ApiResponseFile(Map header, String body) {
        this.header = header;
        this.body = body;
    }

    public Map getHeader() {
        return this.header;
    }

    public void setHeader(Map header) {
        this.header = header;
    }

    public Object getBody() {
        return this.body;
    }

    public void setBody(Object body) {
        this.body = body;
    }
}

2. 上传素材,获取缩略媒资id thumb_media_id

图文消息缩略图的media_id,可以在素材管理 - 新增临时素材中获得

*** 新增临时素材 官方文档 ***

公众号经常有需要用到一些临时性的多媒体素材的场景,例如在使用接口特别是发送消息时,对多媒体文件、多媒体消息的获取和调用等操作,是通过media_id来进行的。素材管理接口对所有认证的订阅号和服务号开放。通过本接口,公众号可以新增临时素材(即上传临时多媒体文件)。使用接口过程中有任何问题,可以前往微信开放社区 #公众号 专区发帖交流

注意点:

1、临时素材media_id是可复用的。

2、媒体文件在微信后台保存时间为3天,即3天后media_id失效。

3、上传临时素材的格式、大小限制与公众平台官网一致。

图片(image): 10M,支持PNG\JPEG\JPG\GIF格式

语音(voice):2M,播放长度不超过60s,支持AMR\MP3格式

视频(video):10MB,支持MP4格式

缩略图(thumb):64KB,支持 JPG 格式

4、需使用 https 调用本接口。

接口调用请求说明

http请求方式:POST/FORM,使用https https://api.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=TYPE 调用示例(使用 curl 命令,用 FORM 表单方式上传一个多媒体文件): curl -F media=@test.jpg "https://api.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=TYPE"

参数说明

参数	是否必须	说明
access_token	是	调用接口凭证
type	是	媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb)
media	是	form-data中媒体文件标识,有filename、filelength、content-type等信息
返回说明

正确情况下的返回 JSON 数据包结果如下:

{"type":"TYPE","media_id":"MEDIA_ID","created_at":123456789}
参数	描述
type	媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb,主要用于视频与音乐格式的缩略图)
media_id	媒体文件上传后,获取标识
created_at	媒体文件上传时间戳
错误情况下的返回 JSON 数据包示例如下(示例为无效媒体类型错误):

{"errcode":40004,"errmsg":"invalid media type"}
使用网页调试工具调试该接口

上传素材获取压缩媒资id 工具类 代码(示例 ):


import cn.com.refratechnik.common.constant.CommonConstant;
import cn.com.refratechnik.common.constant.NumberConstants;
import cn.com.refratechnik.common.dto.PushMessageDto;
import cn.com.refratechnik.common.entity.*;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * @author wangxiaodong
 * @description: 微信订阅号发送一次性订阅消息工具类
 * @date 2022/03/25 15:13
 */
@Slf4j
public class WeChatUtils {


    // 上传订阅号素材 获取 thumb_media_id
    public static String getThumbMediaId(MultipartFile media,String accessToken) throws Exception {
        if (StringUtils.isEmpty(accessToken)){
            accessToken = getWxAccessToken();
        }
        // 压缩素材文件 thumb_media_id
        String thumb_media_id = "";
        // 调取 上传素材接口
        Result<MdlUpload> result=FileUpload .Upload(accessToken,"thumb", media);
        if (ObjectUtil.isNotNull(result.getObj())){
            thumb_media_id = result.getObj().getThumb_media_id();
        }
        return thumb_media_id;

    }
 }

发送请求工具类 (代码示例)

package cn.com.refratechnik.common.util;
import java.io.*;

import cn.com.refratechnik.common.constant.CommonConstant;
import cn.com.refratechnik.common.entity.MdlUpload;
import cn.com.refratechnik.common.entity.PushMpNews;
import cn.com.refratechnik.common.entity.Result;
import cn.com.refratechnik.common.entity.SendMpNews;
import cn.com.refratechnik.framework.exception.BusinessException;
import cn.hutool.http.HttpRequest;
import net.sf.json.JSONObject;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;

/**
 *
 * @author Sunlight
 *
 */
public class FileUpload {

    private static final String upload_url = "https://api.weixin.qq.com/cgi-bin/media/upload?";

    /**
     * 上传文件
     *
     * @param accessToken
     * @param type
     * @param file
     * @return
     */
    public static Result<MdlUpload> Upload(String accessToken, String type, MultipartFile file) {
        Result<MdlUpload> result = new Result<MdlUpload>();
        String url = handUrlParam(upload_url , accessToken ,type) ;
        JSONObject jsonObject;
        try {
            File media = MultipartFileToFile(file);
            HttpPostUtil post = new HttpPostUtil(url);
            post.addParameter("media", media);
            String s = post.send();
            jsonObject = JSONObject.fromObject(s);
            if (jsonObject.containsKey("thumb_media_id")) {
                MdlUpload upload=new MdlUpload();
                upload.setThumb_media_id(jsonObject.getString("thumb_media_id"));
                upload.setType(jsonObject.getString("type"));
                upload.setCreated_at(jsonObject.getString("created_at"));
                result.setObj(upload);
                result.setErrmsg("success");
                result.setErrcode("0");
            } else {
                result.setErrmsg(jsonObject.getString("errmsg"));
                result.setErrcode(jsonObject.getString("errcode"));
            }
        } catch (Exception e) {
            e.printStackTrace();
            result.setErrmsg("Upload Exception:"+e.toString());
        }
        return result;
    }

    /**
     * 上传图文素材接口
     * @param accessToken token
     * @param jsonNews 入参
     * @return 返回参数
     */
    public static Result<PushMpNews> pushMpNews(String accessToken, String jsonNews) {
        Result<PushMpNews> result = new Result<PushMpNews>();
        String url = CommonConstant.TAG_SEND_MESSAGE_UPLOADNEWS + accessToken;
        JSONObject jsonObject;
        try {
            String s = HttpRequest.post(url)
                    .timeout(60000)
                    .body(jsonNews, MediaType.APPLICATION_JSON_UTF8_VALUE)
                    .execute()
                    .body();
            jsonObject = JSONObject.fromObject(s);
            if (jsonObject.containsKey("media_id")) {
                PushMpNews pushMpNews = new PushMpNews();
                pushMpNews.setMedia_id(jsonObject.getString("media_id"));
                pushMpNews.setType(jsonObject.getString("type"));
                pushMpNews.setCreated_at(jsonObject.getString("created_at"));
                result.setObj(pushMpNews);
                result.setErrmsg("success");
                result.setErrcode("0");
            } else {
                result.setErrmsg(jsonObject.getString("errmsg"));
                result.setErrcode(jsonObject.getString("errcode"));
            }
        } catch (Exception e) {
            e.printStackTrace();
            result.setErrmsg("Upload Exception:"+e.toString());
        }
        return result;
    }



    /**
     * 推送订阅群发消息
     * @param accessToken token
     * @param jsonNews 入参
     * @return 返回参数
     */
    public static Result<SendMpNews> sendMpNews(String accessToken, String jsonNews) {
        Result<SendMpNews> result = new Result<SendMpNews>();
        String url = CommonConstant.TAG_SEND_MESSAGE + accessToken;
        JSONObject jsonObject;
        try {
            String s = HttpRequest.post(url)
                    .timeout(60000)
                    .body(jsonNews, MediaType.APPLICATION_JSON_UTF8_VALUE)
                    .execute()
                    .body();
            jsonObject = JSONObject.fromObject(s);
            if (jsonObject.containsKey("msg_id")) {
                SendMpNews sendMpNews = new SendMpNews();
                sendMpNews.setMsg_data_id(jsonObject.getString("msg_data_id"));
                sendMpNews.setMsg_id(jsonObject.getString("msg_id"));
                sendMpNews.setErrmsg(jsonObject.getString("errmsg"));
                result.setObj(sendMpNews);
                result.setErrmsg("success");
                result.setErrcode("0");
            } else {
                result.setErrmsg(jsonObject.getString("errmsg"));
                result.setErrcode(jsonObject.getString("errcode"));
            }
        } catch (Exception e) {
            e.printStackTrace();
            result.setErrmsg("Upload Exception:"+e.toString());
        }
        return result;
    }

    /**
     * 拼接url请求参数
     *
     * @param httpUrl      请求url
     * @param accessToken  调用接口凭证
     * @param type 获取的部门id
     * @return
     */
    public static String handUrlParam(String httpUrl, String accessToken, String type ) {
        UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(httpUrl)
                .queryParam("access_token", accessToken)
                .queryParam("type", type);
        return builder.build().encode().toUriString();
    }


    /**
     * MultipartFile 转 File
     *
     * @param multipartFile
     * @throws Exception
     */
    public static File MultipartFileToFile(MultipartFile multipartFile) {

        File file = null;
        //判断是否为null
        if (multipartFile.equals("") || multipartFile.getSize() <= 0) {
            return file;
        }
        //MultipartFile转换为File
        InputStream ins = null;
        OutputStream os = null;
        try {
            ins = multipartFile.getInputStream();
            file = new File(multipartFile.getOriginalFilename());
            os = new FileOutputStream(file);
            int bytesRead = 0;
            byte[] buffer = new byte[8192];
            while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(os != null){
                try {
                    os.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(ins != null){
                try {
                    ins.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return file;
    }

}

thumb_media_id 获取 返回参数封装 类 (代码示例)

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;

public class MdlUpload {
    private String type;
    private String thumb_media_id;
    private String created_at;
    public String getType() {
        return type;
    }
    public void setType(String type) {
        this.type = type;
    }
    public String getThumb_media_id() {
        return thumb_media_id;
    }
    public void setThumb_media_id(String mediaId) {
        thumb_media_id = mediaId;
    }
    public String getCreated_at() {
        return created_at;
    }
    public void setCreated_at(String createdAt) {
        created_at = createdAt;
    }
    public MdlUpload() {
        super();
    }
    @Override
    public String toString() {
        return "MdlUpload [created_at=" + created_at + ", thumb_media_id=" + thumb_media_id + ", type=" + type + "]";
    }


}

3、上传图文信息,获取素材media_id

*** 官方文档 ***
地址:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Batch_Sends_and_Originality_Checks.html#0

接口文档节选内容

上传图文消息素材【订阅号与服务号认证后均可用】
接口调用请求说明

http请求方式: POST https://api.weixin.qq.com/cgi-bin/media/uploadnews?access_token=ACCESS_TOKEN

POST数据说明

POST数据示例如下:

{
   "articles": [	 
        {
            "thumb_media_id":"qI6_Ze_6PtV7svjolgs-rN6stStuHIjs9_DidOHaj0Q-mwvBelOXCFZiq2OsIU-p",
            "author":"xxx",		
            "title":"Happy Day",		 
            "content_source_url":"www.qq.com",		
            "content":"content",		 
            "digest":"digest",
            "show_cover_pic":1,
            "need_open_comment":1,
            "only_fans_can_comment":1
        },	 
        {
            "thumb_media_id":"qI6_Ze_6PtV7svjolgs-rN6stStuHIjs9_DidOHaj0Q-mwvBelOXCFZiq2OsIU-p",
            "author":"xxx",		
            "title":"Happy Day",		 
            "content_source_url":"www.qq.com",		
            "content":"content",		 
            "digest":"digest",
            "show_cover_pic":0,
            "need_open_comment":1,
            "only_fans_can_comment":1
        }
   ]
}
参数	是否必须	说明
Articles	是	图文消息,一个图文消息支持1到8条图文
thumb_media_id	是	图文消息缩略图的media_id,可以在素材管理 - 新增临时素材中获得
author	否	图文消息的作者
title	是	图文消息的标题
content_source_url	否	在图文消息页面点击“阅读原文”后的页面,受安全限制,如需跳转Appstore,可以使用 itun.es 或appsto.re的短链服务,并在短链后增加 #wechat_redirect 后缀。
content	是	图文消息页面的内容,支持 HTML 标签。具备微信支付权限的公众号,可以使用 a 标签,其他公众号不能使用,如需插入小程序卡片,可参考下文。
digest	否	图文消息的描述,如本字段为空,则默认抓取正文前64个字
show_cover_pic	否	是否显示封面,1为显示,0为不显示
need_open_comment	否	Uint32 是否打开评论,0不打开,1打开
only_fans_can_comment	否	Uint32 是否粉丝才可评论,0所有人可评论,1粉丝才可评论
如果需要在群发图文中插入小程序,则在调用上传图文消息素材接口时,需在 content 字段中添加小程序跳转链接,有以下三种样式的可供选择。

小程序卡片跳转小程序,代码示例:

<mp-miniprogram data-miniprogram-appid="wx123123123" data-miniprogram-path="pages/index/index" data-miniprogram-title="小程序示例" data-miniprogram-imageurl="http://example.com/demo.jpg"></mp-miniprogram>
文字跳转小程序,代码示例:

<p><a data-miniprogram-appid="wx123123123" data-miniprogram-path="pages/index" href="">点击文字跳转小程序</a></p>
图片跳转小程序,代码示例:

<p><a data-miniprogram-appid="wx123123123" data-miniprogram-path="pages/index" href=""><img src="https://mmbiz.qpic.cn/mmbiz_jpg/demo/0?wx_fmt=jpg" alt="" data-width="null" data-ratio="NaN"></a></p>
参数说明

参数	是否必须	说明
data-miniprogram-appid	是	小程序的AppID
data-miniprogram-path	是	小程序要打开的路径
data-miniprogram-title	是	小程序卡片的标题,不超过35个字
data-miniprogram-imageurl	是	小程序卡片的封面图链接,图片必须为1080*864像素
返回说明

返回数据示例(正确时的 JSON 返回结果):

{
   "type":"news",
   "media_id":"CsEf3ldqkAYJAU6EJeIkStVDSvffUJ54vqbThMgplD-VJXXof6ctX5fI6-aYyUiQ",
   "created_at":1391857799
}
参数	说明
type	媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb),图文消息(news)
media_id	媒体文件/图文消息上传后获取的唯一标识
created_at	媒体文件上传时间

上传素材 获取media_id 工具类 代码示例


import cn.com.refratechnik.common.constant.CommonConstant;
import cn.com.refratechnik.common.constant.NumberConstants;
import cn.com.refratechnik.common.dto.PushMessageDto;
import cn.com.refratechnik.common.entity.*;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * @author wangxiaodong
 * @description: 微信订阅号发送一次性订阅消息工具类
 * @date 2022/03/25 15:13
 */
@Slf4j
public class WeChatUtils {

    /**
     * 上传图文消息素材【订阅号与服务号认证后均可用】 请求地址
     */
    public static final String TAG_SEND_MESSAGE_UPLOADNEWS = "https://api.weixin.qq.com/cgi-bin/media/uploadnews?access_token=";

    /**
     *上传图文消息素材
     */
    public static String uploadNews(String accessToken, PushMessageDto articles) throws Exception {
        if (accessToken != null) {
            String url = CommonConstant.TAG_SEND_MESSAGE_UPLOADNEWS + accessToken;
            log.info("UPLOAD_NEWS_URL:{}", url);
            //将菜单对象转换成JSON字符串
            String jsonNews = JSONObject.toJSONString(articles);
            log.info("JSONNEWS:{}",jsonNews);
            // 发送消息使用的 ID
            String media_id = "";
            // 调取 上传素材接口 ( 调取前面提供的 FileUpload 工具类 )
            Result<PushMpNews> result=FileUpload .pushMpNews(accessToken, jsonNews);
            if (ObjectUtil.isNotNull(result.getObj())){
                media_id = result.getObj().getMedia_id();
            }
            return media_id;
        }
        return null;
    }

上传素材 获取media_id 工具类 入参构造 代码示例

/**
* Copyright (c) 2017-2020 Htwins
* https://www.htwins.com.cn
* All rights reserved.
*/
package cn.com.refratechnik.common.dto;

import cn.com.refratechnik.common.entity.Article;
import io.swagger.annotations.ApiModel;
import lombok.Data;

import java.io.Serializable;
import java.util.List;

/**
* @description: 后端用户表DTO
* @author Chengyu.yang@htwins.com.cn
* @date 2022/04/12
*/
@Data
@ApiModel
public class PushMessageDto implements Serializable {

    private List<Article> articles;
}

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;

import io.swagger.annotations.ApiModel;
import lombok.Data;

import javax.validation.constraints.NotNull;
import java.io.Serializable;

/**
 * 上传图文消息素材
 * 类名: Mpnews.java</br>
 * 描述: 凭证</br>
 */
@Data
@ApiModel
public class Article implements Serializable {

    /**
     * 图文消息缩略图的media_id,可以在素材管理 - 新增临时素材中获得
     */
    @NotNull(message = "图文消息缩略图的media_id 不能为空")
    private String thumb_media_id;
    /**
     * 图文消息的作者
     */
    private String author;
    /**
     * 图文消息的标题
     */
    @NotNull(message = "图文消息的标题不能为空")
    private String title;
    /**
     * 在图文消息页面点击“阅读原文”后的页面,受安全限制,如需跳转Appstore,可以使用 itun.es 或appsto.re的短链服务,并在短链后增加 #wechat_redirect 后缀。
     */
    private String content_source_url;
    /**
     * 图文消息页面的内容,支持 HTML 标签。具备微信支付权限的公众号,可以使用 a 标签,其他公众号不能使用,如需插入小程序卡片,可参考下文。
     */
    @NotNull( message = "图文消息页面的内容不能为空")
    private String content;
    /**
     * 图文消息的描述,如本字段为空,则默认抓取正文前64个字
     */
    private String digest;
    /**
     * 是否显示封面,1为显示,0为不显示
     */
    private Integer show_cover_pic;
    /**
     * Uint32 是否打开评论,0不打开,1打开
     */
    private Integer need_open_comment;
    /**
     * Uint32 是否粉丝才可评论,0所有人可评论,1粉丝才可评论
     */
    private Integer only_fans_can_comment;

}

4、发布消息

1、文档内容截取

错误时微信会返回错误码等信息,请根据错误码查询错误信息

图文消息群发前将进行原创校验

一、群发接口新增原创校验流程

开发者调用群发接口进行图文消息的群发时,微信会将开发者准备群发的文章,与公众平台原创库中的文章进行比较,校验结果分为以下几种:

当前准备群发的文章,未命中原创库中的文章,则可以群发。

当前准备群发的文章,已命中原创库中的文章,则:

2.1 若原创作者允许转载该文章,则可以进行群发。群发时,会自动替换成原文的样式,且会自动将文章注明为转载并显示来源。

若希望修改原文内容或样式,或群发时不显示转载来源,可自行与原创公众号作者联系并获得授权之后再进行群发。

2.2 若原创作者禁止转载该文章,则不能进行群发。

若希望转载该篇文章,可自行与原创公众号作者联系并获得授权之后再进行群发。

二、群发接口新增 send_ignore_reprint 参数

群发接口新增 send_ignore_reprint 参数,开发者可以对群发接口的 send_ignore_reprint 参数进行设置,指定待群发的文章被判定为转载时,是否继续群发。

当 send_ignore_reprint 参数设置为1时,文章被判定为转载时,且原创文允许转载时,将继续进行群发操作。

当 send_ignore_reprint 参数设置为0时,文章被判定为转载时,将停止群发操作。

send_ignore_reprint 默认为0。

群发操作的相关返回码,可以参考全局返回码说明文档。


根据标签进行群发【订阅号与服务号认证后均可用】
接口调用请求说明

http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/mass/sendall?access_token=ACCESS_TOKEN

POST数据说明

POST数据示例如下:

图文消息(注意图文消息的media_id需要通过上述方法,或通过 “草稿箱 / 新建草稿” 接口来得到,海外微信公众号仅支持发送图文(mpnews)消息):

{
   "filter":{
      "is_to_all":false,
      "tag_id":2
   },
   "mpnews":{
      "media_id":"123dsdajkasd231jhksad"
   },
    "msgtype":"mpnews",
    "send_ignore_reprint":0
}
文本:

{
   "filter":{
      "is_to_all":false,
      "tag_id":2
   },
   "text":{
      "content":"CONTENT"
   },
    "msgtype":"text"
}
语音/音频(注意此处media_id需通过素材管理->新增素材来得到):

{
   "filter":{
      "is_to_all":false,
      "tag_id":2
   },
   "voice":{
      "media_id":"123dsdajkasd231jhksad"
   },
    "msgtype":"voice"
}
图片(注意此处media_id需通过素材管理->新增素材来得到):

{
   "filter":{
      "is_to_all":false,
      "tag_id":2
   },
   "images": {
      "media_ids": [
         "aaa",
         "bbb",
         "ccc"
      ],
      "recommend": "xxx",
      "need_open_comment": 1,
      "only_fans_can_comment": 0
   },
   "msgtype":"image"
}
视频

请注意,此处视频的media_id需通过 POST 请求到下述接口特别地得到:https://api.weixin.qq.com/cgi-bin/media/uploadvideo?access_token=ACCESS_TOKEN POST数据如下(此处media_id需通过素材管理->新增素材来得到):

{
  "media_id": "rF4UdIMfYK3efUfyoddYRMU50zMiRmmt_l0kszupYh_SzrcW5Gaheq05p_lHuOTQ",
  "title": "TITLE",
  "description": "Description"
}
返回将为

{
  "type":"video",
  "media_id":"IhdaAQXuvJtGzwwc0abfXnzeezfO0NgPK6AQYShD8RQYMTtfzbLdBIQkQziv2XJc",
  "created_at":1398848981
}
然后,POST下述数据(将media_id改为上一步中得到的media_id),即可进行发送

{
   "filter":{
      "is_to_all":false,
      "tag_id":2
   },
   "mpvideo":{
      "media_id":"IhdaAQXuvJtGzwwc0abfXnzeezfO0NgPK6AQYShD8RQYMTtfzbLdBIQkQziv2XJc"
   },
    "msgtype":"mpvideo"
}
卡券消息(注意图文消息的media_id需要通过上述方法来得到):

{
   "filter":{
    "is_to_all":false,
      "tag_id":"2"
   },
  "wxcard":{              
    "card_id":"123dsdajkasd231jhksad"         
    },
   "msgtype":"wxcard"
}
参数	是否必须	说明
filter	是	用于设定图文消息的接收者
is_to_all	否	用于设定是否向全部用户发送,值为 true 或false,选择 true 该消息群发给所有用户,选择 false 可根据tag_id发送给指定群组的用户
tag_id	否	群发到的标签的tag_id,参见用户管理中用户分组接口,若is_to_all值为true,可不填写tag_id
mpnews	是	用于设定即将发送的图文消息
media_id	是	用于群发的消息的media_id
recommend	否	推荐语,不填则默认为“分享图片”
msgtype	是	群发的消息类型,图文消息为mpnews,文本消息为text,语音为voice,音乐为music,图片为image,视频为video,卡券为wxcard
title	否	消息的标题
description	否	消息的描述
thumb_media_id	是	视频缩略图的媒体ID
send_ignore_reprint	是	图文消息被判定为转载时,是否继续群发。 1为继续群发(转载),0为停止群发。 该参数默认为0。
返回说明

返回数据示例(正确时的 JSON 返回结果):

{
   "errcode":0,
   "errmsg":"send job submission success",
   "msg_id":34182, 
   "msg_data_id": 206227730
}
参数	说明
type	媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb),图文消息为news
errcode	错误码
errmsg	错误信息
msg_id	消息发送任务的ID
msg_data_id	消息的数据ID,该字段只有在群发图文消息时,才会出现。可以用于在图文分析数据接口中,获取到对应的图文消息的数据,是图文分析数据接口中的 msgid 字段中的前半部分,详见图文分析数据接口中的 msgid 字段的介绍。
请注意:在返回成功时,意味着群发任务提交成功,并不意味着此时群发已经结束,所以,仍有可能在后续的发送过程中出现异常情况导致用户未收到消息,如消息有时会进行审核、服务器不稳定等。此外,群发任务一般需要较长的时间才能全部发送完毕,请耐心等待。

错误时微信会返回错误码等信息,请根据错误码查询错误信息

2、代码示例

发布群发消息 工具类 代码示例



import cn.com.refratechnik.common.constant.CommonConstant;
import cn.com.refratechnik.common.constant.NumberConstants;
import cn.com.refratechnik.common.dto.PushMessageDto;
import cn.com.refratechnik.common.entity.*;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * @author wangxiaodong
 * @description: 微信订阅号发送一次性订阅消息工具类
 * @date 2022/03/25 15:13
 */
@Slf4j
public class WeChatUtils {

    /**
     * 发送公众号消息
     *
     * @throws Exception
     */
    public static Result<SendMpNews> pushWxMessage(Article article) throws Exception {
        // 校验参数
        Result<SendMpNews> result = new Result<>();
        if (ObjectUtil.isNotNull(article) && ObjectUtil.isNotNull(article.getThumb_media_id())){
            // 获取 accessToken
            String accessToken =  getWxAccessToken();
            // 压缩素材文件 thumb_media_id
            article.setThumb_media_id(article.getThumb_media_id());
            PushMessageDto pushMessageDto = createArticles(article);
            // 上传图文素材信息 获取图文信息媒资id
            String media_id = uploadNews(accessToken,pushMessageDto);
            // 发布消息
            result = sendWxMessage(accessToken,new WeChatMessage(),media_id);
        }
        return result ;
    }

    /**
     * 发布订阅群发消息
     * @param accessToken token
     * @param weChatMessage 订阅消息入参
     * @param media_id 图文素材Id
     * @return 返回结果
     * @throws Exception
     */
    public static Result<SendMpNews> sendWxMessage( String accessToken, WeChatMessage weChatMessage,String media_id ) throws Exception {
        // 校验参数
        //群发的消息类型,图文消息为mpnews
        weChatMessage.setMsgtype("mpnews");
        // 图文消息被判定为转载时,是否继续群发。 1为继续群发(转载),0为停止群发。 该参数默认为0。
        weChatMessage.setSend_ignore_reprint(NumberConstants.ONE_INTEGER);
        // 过滤发布用户类
        Filter filter = new Filter();
        // 用于设定是否向全部用户发送,值为 true 或false,选择 true 该消息群发给所有用户,选择 false 可根据tag_id发送给指定群组的用户
        filter.setIs_to_all(true);
        // 插入过滤用户 全员发送
        weChatMessage.setFilter(filter);
        // 图文消息类
        Mpnews mpnews = new Mpnews();
        // 图文素材资源Id
        mpnews.setMedia_id(media_id);
        weChatMessage.setMpnews(mpnews);
        // 发布消息 请求地址
        String url = CommonConstant.TAG_SEND_MESSAGE + accessToken;
        //将菜单对象转换成JSON字符串
        String jsonNews = JSONObject.toJSONString(weChatMessage);
        // 调取 推送消息接口
        Result<SendMpNews> result=FileUpload.sendMpNews(accessToken, jsonNews);
        return result;
    }

    /**
     * 构造参数 
     * @param article 
     * @return 返回结果
     * @throws PushMessageDto 
     */
    private static PushMessageDto createArticles(Article article) {

        PushMessageDto articles = new PushMessageDto();

        List<Article> dataList = new ArrayList<>();
        Article  news1 = new Article();
        news1.setTitle(article.getTitle());
        // 图文消息缩略图的media_id,可以在素材管理 - 新增临时素材中获得
        news1.setThumb_media_id(article.getThumb_media_id());
        // 作者
        news1.setAuthor(article.getAuthor());
        // 图文消息的描述,如本字段为空,则默认抓取正文前64个字
        news1.setDigest(article.getDigest());
        //显示封面
        news1.setShow_cover_pic(NumberConstants.ONE_INTEGER);
        // 文章内容 不能为空 图文消息的具体内容,支持HTML标签,必须少于2万字符,小于1M,且此处会去除JS,涉及图片url必须来源接口获取。外部图片url将被过滤。"
        news1.setContent(article.getContent());
        //图文消息的原文地址,即点击“阅读原文”后的URL
        news1.setContent_source_url(article.getContent_source_url());
        //Uint32  是否打开评论,0不打开,1打开
        news1.setNeed_open_comment(NumberConstants.ONE_INTEGER);
        //Uint32 是否粉丝才可评论,0所有人可评论,1粉丝才可评论
        news1.setOnly_fans_can_comment(NumberConstants.ONE_INTEGER);
        dataList.add(news1);
        articles.setArticles(dataList);
        return articles;

    }
}

群发消息接口 返回参数 构造 代码示例

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;
import lombok.Data;

@Data
public class SendMpNews {
    private String errcode;
    private String errmsg;
    private String msg_id;
    private String msg_data_id;
    public SendMpNews() {
        super();
    }
    @Override
    public String toString() {
        return "MdlUpload [errcode=" + errcode + ", errmsg=" + errmsg + ", msg_id=" + msg_id + ", msg_data_id=" + msg_data_id + "]";
    }
}

群发消息接口 入参构造 代码示例

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;

import io.swagger.annotations.ApiModel;
import lombok.Data;

import java.io.Serializable;

/**
 * 消息发送类
 * 类名: WeChatMessage.java</br>
 * 描述: 凭证</br>
 */
@Data
@ApiModel
public class WeChatMessage implements Serializable {

    /**
     * 群发的消息类型,图文消息为mpnews,文本消息为text,语音为voice,音乐为music,图片为image,视频为video,卡券为wxcard
     */
    private String msgtype;

    /**
     * 图文消息被判定为转载时,是否继续群发。 1为继续群发(转载),0为停止群发。 该参数默认为0。
     */
    private int send_ignore_reprint;

    /**
     * 用于设定图文消息的接收者
     */
    private Filter filter;

    /**
     * 用于设定即将发送的图文消息
     */
    private Mpnews mpnews;

}

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;

import io.swagger.annotations.ApiModel;
import lombok.Data;

import java.io.Serializable;

/**
 * 用于设定图文消息的接收者
 * 类名: Filter.java</br>
 * 描述: 凭证</br>
 */
@Data
@ApiModel
public class Filter implements Serializable {

    /**
     * 用于设定是否向全部用户发送,值为 true 或false,选择 true 该消息群发给所有用户,选择 false 可根据tag_id发送给指定群组的用户
     */
    private Boolean is_to_all;

    /**
     * 群发到的标签的tag_id,参见用户管理中用户分组接口,若is_to_all值为true,可不填写tag_id
     */
    private int tag_id;

}

/**
 * Copyright (c) 2017-2020 Htwins
 * https://www.htwins.com.cn
 * All rights reserved.
 */
package cn.com.refratechnik.common.entity;

import io.swagger.annotations.ApiModel;
import lombok.Data;

import java.io.Serializable;

/**
 * 用于设定即将发送的图文消息
 * 类名: Mpnews.java</br>
 * 描述: 凭证</br>
 */
@Data
@ApiModel
public class Mpnews implements Serializable {

    /**
     * 用于群发的消息的media_id
     */
    private String media_id;

}

3、业务调取 封装接口 代码示例

    @DuplicateSubmitDefense
    @ApiOperation("发送群发消息接口")
    @PostMapping("/send-mpnews")
    @Override
    public Result<SendMpNews> sendMpNews ( @RequestBody Article article ) {
        Result<SendMpNews> result = new Result<>();
        article.setAuthor(UserUtils.currentUsername());
        try {
            result = WeChatUtils.pushWxMessage(article);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }




    @DuplicateSubmitDefense
    @ApiOperation("获取图文消息缩略图的media_id")
    @PostMapping("/get-media-id")
    @Override
    public R<String> getThumbMediaId(@RequestBody MultipartFile file) {
        R<String> stringR = new R<>();
        try {
            stringR = R.<String>ok().data(WeChatUtils.getThumbMediaId(file,null));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return stringR;
    }


总结

总结集合坑:

1.获取图文信息缩略图的media_id 接口封装 入参 使用 MultipartFile 类型接受,http 访问时 笔者是将其转换为File类型进行传递的。时间关系 没有进行优化。。。
2. http post请求工具类封装了几个不同的工具,时间关系,未进行统一处理。代码内存在未使用的方法,忽略即可。

以上就是今天要讲的内容,本文仅仅简单介绍 公众号 -订阅号群发 的实现,详细文档请访问微信官方文档进行查看。

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值