使用WxJava开发微信公众号

公众号(服务号)配置

利用 weixin-java-mp 配置 appId 和 secret 。

  1. 添加pom.xml依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-mp</artifactId>
    <version>3.3.8.B</version>
    </dependency>
  2. 添加application.yml配置

    1
    2
    3
    4
    5
    6
    7
    wx:  
    mp:
    configs:
    - appId: xxx #(一个公众号的appid)(测试平台账号)
    secret: xxx
    token: test_token #(接口配置里的Token值)
    aesKey: iKVJDUh1yWR7PWB83D7z93HLn48wILNMU4Ls8A681pK #(接口配置里的EncodingAESKey值)
  3. Java配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Data
@ConfigurationProperties(prefix = "wx.mp")
public class WxMpProperties {
private List<MpConfig> configs;

@Data
public static class MpConfig {
/**
* 设置微信公众号的appid
*/
private String appId;
/**
* 设置微信公众号的app secret
*/
private String secret;
/**
* 设置微信公众号的token
*/
private String token;
/**
* 设置微信公众号的EncodingAESKey
*/
private String aesKey;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@EnableConfigurationProperties(WxMpProperties.class)
public class WxMpServiceConfiguration {
@Autowired
private WxMpProperties properties;

@Bean
public WxMpService wxMpService() {
final List<WxMpProperties.MpConfig> configs = this.properties.getConfigs();
if (configs == null) {
throw new RuntimeException("大哥,拜托先看下项目首页的说明(readme文件),添加下相关配置,注意别配错了!");
}

WxMpService service = new WxMpServiceImpl();
Map<String, WxMpConfigStorage> collect = configs
.stream().map(a -> {
WxMpInMemoryConfigStorage configStorage = new WxMpInMemoryConfigStorage();
configStorage.setAppId(a.getAppId());
configStorage.setSecret(a.getSecret());
configStorage.setToken(a.getToken());
configStorage.setAesKey(a.getAesKey());

return configStorage;
}).collect(Collectors.toMap(WxMpInMemoryConfigStorage::getAppId, a -> a, (o, n) -> o));
service.setMultiConfigStorages(collect);
return service;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RestController
@RequestMapping("xxx/{appid}")
public class WxMpPortalController {

@Autowired
private WxMpServiceAdapter wxAdapter;

@GetMapping(produces = "text/plain;charset=utf-8")
public String authGet(@PathVariable String appid,
@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {

if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求参数非法,请核实!");
}

if (!wxAdapter.switchover(appid)) {
throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
}

if (wxAdapter.checkSignature(timestamp, nonce, signature)) {
return echostr;
}

return "非法请求";
}
}

access_token

​ 同小程序一样。公众号大部分接口都是需要 access_token 。

me.chanjar.weixin.mp.api.impl.BaseWxMpServiceImpl#executeInternal,每次发送请求的时候也会获取 access_token。

1
2
3
4
5
6
7
8
9
10
public <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
......
String accessToken = getAccessToken(false);
String uriWithAccessToken = uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
try {
T result = executor.execute(uriWithAccessToken, data);
return result;
} catch (WxErrorException e) {
......
}

me.chanjar.weixin.mp.api.WxMpService#getAccessToken(boolean) 针对不同的api,有3种实现类

  • me.chanjar.weixin.mp.api.impl.WxMpServiceHttpClientImpl
  • me.chanjar.weixin.mp.api.impl.WxMpServiceJoddHttpImpl
  • me.chanjar.weixin.mp.api.impl.WxMpServiceOkHttpImpl

WxMpServiceHttpClientImpl 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
public String getAccessToken(boolean forceRefresh) throws WxErrorException {
if (!this.getWxMpConfigStorage().isAccessTokenExpired() && !forceRefresh) {
return this.getWxMpConfigStorage().getAccessToken();
}

Lock lock = this.getWxMpConfigStorage().getAccessTokenLock();
lock.lock();
try {
String url = String.format(WxMpService.GET_ACCESS_TOKEN_URL,
this.getWxMpConfigStorage().getAppId(), this.getWxMpConfigStorage().getSecret());
try {
HttpGet httpGet = new HttpGet(url);
if (this.getRequestHttpProxy() != null) {
RequestConfig config = RequestConfig.custom().setProxy(this.getRequestHttpProxy()).build();
httpGet.setConfig(config);
}
try (CloseableHttpResponse response = getRequestHttpClient().execute(httpGet)) {
String resultContent = new BasicResponseHandler().handleResponse(response);
WxError error = WxError.fromJson(resultContent, WxType.MP);
if (error.getErrorCode() != 0) {
throw new WxErrorException(error);
}
WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
this.getWxMpConfigStorage().updateAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
return this.getWxMpConfigStorage().getAccessToken();
} finally {
httpGet.releaseConnection();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
} finally {
lock.unlock();
}
}

​ 公众号中,存储 token 的接口是 me.chanjar.weixin.mp.api.WxMpConfigStorage。它的实现类有

  • me.chanjar.weixin.mp.api.WxMpInMemoryConfigStorage 基于本地内存实现

  • me.chanjar.weixin.mp.api.WxMpInRedisConfigStorage 基于 JedisPool 实现

    因为项目中使用 RedisTemplate,因此参考 WxMpInRedisConfigStorage 重写了一个基于 RedisTemplate 实现的 WxMpConfigStorage 实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
public class CqWxMpInRedisConfigStorage extends WxMpInMemoryConfigStorage {

private static final String ACCESS_TOKEN_KEY = "cq:wx:access_token:";
private static final String UPDATE_ACCESS_TOKEN_LOOK_KEY = "cq:wx:access_token:lock:updating";
private String accessTokenKey;

private RedisTemplate redisTemplate;

public CqWxMpInRedisConfigStorage(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* 每个公众号生成独有的存储key.
*/
@Override
public void setAppId(String appId) {
super.setAppId(appId);
this.accessTokenKey = ACCESS_TOKEN_KEY.concat(appId);
}


private String getTicketRedisKey(TicketType type) {
return String.format("cq:wx:ticket:key:%s:%s", this.appId, type.getCode());
}

@Override
public String getAccessToken() {
return (String) redisTemplate.opsForValue().get(this.accessTokenKey);
}

/**
* token 是否已经过期
*
* @return
*/
@Override
public boolean isAccessTokenExpired() {
return !redisTemplate.hasKey(accessTokenKey);
}

@Override
public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) {
String lockStr = UPDATE_ACCESS_TOKEN_LOOK_KEY;
RedisLock lock = new RedisLock(redisTemplate);
if (lock.lock(lockStr)) {
try {
redisTemplate.opsForValue().set(this.accessTokenKey, accessToken);
redisTemplate.expire(this.accessTokenKey, expiresInSeconds - 200, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("获取锁" + lockStr + "异常", e);
throw new RuntimeException(e);
} finally {
lock.unlock(lockStr);
}
} else {
log.error("updateAccessToken: get lock " + lockStr + " failed");
}
}

@Override
public void expireAccessToken() {
redisTemplate.expire(this.accessTokenKey, 0, TimeUnit.SECONDS);
}

@Override
public String getTicket(TicketType type) {
return (String) redisTemplate.opsForValue().get(this.getTicketRedisKey(type));
}

@Override
public boolean isTicketExpired(TicketType type) {
return !redisTemplate.hasKey(this.getTicketRedisKey(type));
}

@Override
public synchronized void updateTicket(TicketType type, String jsapiTicket, int expiresInSeconds) {
String lockStr = UPDATE_ACCESS_TOKEN_LOOK_KEY;
RedisLock lock = new RedisLock(redisTemplate);
if (lock.lock(lockStr)) {
try {
redisTemplate.opsForValue().set(this.getTicketRedisKey(type), jsapiTicket);
redisTemplate.expire(this.getTicketRedisKey(type), expiresInSeconds - 200, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("updateTicket", e);
throw new RuntimeException(e);
} finally {
lock.unlock(lockStr);
}
} else {
log.error("updateTicket: get lock " + lockStr + " failed");
}
}

@Override
public void expireTicket(TicketType type) {
redisTemplate.expire(this.getTicketRedisKey(type), 0, TimeUnit.SECONDS);
}

@Data
public static class AccessToken {
String accessToken;
}
}

​ 并且在配置 WxMpService 的时候,使用自定义 CqWxMpInRedisConfigStorage 替换 WxMpInMemoryConfigStorage。

接入测试平台

​ 由于微信公众号(服务号)认证成本较高,因此提供了 微信公众平台接口调试工具

测试号信息 提供了测试号的 appID 和 appsecret,需要配置在后端应用中。

接口配置信息 配置后端的接口。

消息管理

服务号通知配置

​ 在使用公众号 消息管理 前,需要在 开发 》基础配置 》服务器配置 配置开发者配置。

接收事件推送

关注/取消关注事件

​ 用户在关注与取消关注公众号时,微信会把这个事件推送到开发者填写的URL。方便开发者给用户下发欢迎消息或者做帐号的解绑。为保护用户数据隐私,开发者收到用户取消关注事件时需要删除该用户的所有信息。

推送XML数据包示例:

1
2
3
4
5
6
7
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
</xml>
参数 描述
ToUserName 开发者微信号
FromUserName 发送方帐号(一个OpenID)
CreateTime 消息创建时间 (整型)
MsgType 消息类型,event
Event 事件类型,subscribe(订阅)、unsubscribe(取消订阅)

注意点

  1. 由于微信公众号的UnionID机制,一个用户同时关注公众号和小程序,会有两个OpenID和一个UnionID。但是前提是需要在 微信开放平台 上同时绑定 公众号和小程序。

Java示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration
@EnableConfigurationProperties(WxMpProperties.class)
public class WxMpServiceConfiguration {
......
@Autowired
private LogHandler logHandler;
@Autowired
private UnsubscribeHandler unsubscribeHandler;
@Autowired
private SubscribeHandler subscribeHandler;

@Bean
public WxMpMessageRouter messageRouter(WxMpService wxMpService) {
final WxMpMessageRouter newRouter = new WxMpMessageRouter(wxMpService);

// 记录所有事件的日志 (异步执行)
newRouter.rule().handler(this.logHandler).next();

// 关注事件
newRouter.rule().async(false).msgType(WxConsts.XmlMsgType.EVENT)
.event(WxConsts.EventType.SUBSCRIBE).handler(this.subscribeHandler)
.end();

// 取消关注事件
newRouter.rule().async(false).msgType(WxConsts.XmlMsgType.EVENT)
.event(WxConsts.EventType.UNSUBSCRIBE)
.handler(this.unsubscribeHandler).end();

return newRouter;
}
}
1
2
3
4
5
6
public abstract class AbstractHandler implements WxMpMessageHandler {}

public abstract class AbstractBuilder {
public abstract WxMpXmlOutMessage build(String content,
WxMpXmlMessage wxMessage, WxMpService service);
}
1
2
3
4
5
6
7
8
9
10
@Component
public class LogHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context, WxMpService wxMpService,
WxSessionManager sessionManager) {
log.info("\n接收到请求消息,内容:{}", JSONObject.toJSONString(wxMessage));
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Component
public class SubscribeHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context,
WxMpService weixinService,
WxSessionManager sessionManager) throws WxErrorException {
log.info("新关注用户 OPENID: " + wxMessage.getFromUser());
// 获取微信用户基本信息
try {
WxMpUser userWxInfo = weixinService.getUserService().userInfo(wxMessage.getFromUser(), null);
if (userWxInfo != null) {
// 可以添加关注用户到本地数据库
}
} catch (WxErrorException e) {
if (e.getError().getErrorCode() == 48001) {
log.info("该公众号没有获取用户信息权限!");
}
}

WxMpXmlOutMessage responseResult = null;
try {
responseResult = this.handleSpecial(wxMessage);
} catch (Exception e) {
log.error(e.getMessage(), e);
}

if (responseResult != null) {
return responseResult;
}

try {
return new TextBuilder().build("感谢关注", wxMessage, weixinService);
} catch (Exception e) {
log.error(e.getMessage(), e);
}

return null;
}

/**
* 处理特殊请求,比如如果是扫码进来的,可以做相应处理
*/
private WxMpXmlOutMessage handleSpecial(WxMpXmlMessage wxMessage)
throws Exception {
//TODO
return null;
}
}
1
2
3
4
5
6
7
8
9
10
public class TextBuilder extends AbstractBuilder {
@Override
public WxMpXmlOutMessage build(String content, WxMpXmlMessage wxMessage,
WxMpService service) {
WxMpXmlOutTextMessage m = WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser())
.build();
return m;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class UnsubscribeHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context, WxMpService wxMpService,
WxSessionManager sessionManager) {
String openId = wxMessage.getFromUser();
// 取消订阅只能返回 openId
log.info("取消关注用户 OPENID: " + openId);
return null;
}
}

客服消息

​ 当用户和公众号产生特定动作的交互时(具体动作列表请见下方说明),微信将会把消息数据推送给开发者,开发者可以在一段时间内(目前修改为48小时)调用客服接口,通过POST一个JSON数据包来发送消息给普通用户。此接口主要用于客服等有人工消息处理环节的功能,方便开发者为用户提供更加优质的服务。

​ 目前允许的动作列表如下(公众平台会根据运营情况更新该列表,不同动作触发后,允许的客服接口下发消息条数不同,下发条数达到上限后,会遇到错误返回码,具体请见返回码说明页):

1
2
3
4
5
6
1、用户发送信息
2、点击自定义菜单(仅有点击推事件、扫码推事件、扫码推事件且弹出“消息接收中”提示框这3种菜单类型是会触发客服接口的)
3、关注公众号
4、扫描二维码
5、支付成功
6、用户维权

另外,请开发者注意,本接口中所有使用到media_id的地方,现在都可以使用素材管理中的永久素材media_id了。

Java示例

me.chanjar.weixin.mp.api.WxMpKefuService#sendKefuMessage

1
2
3
// 发送客服消息,发送图片
adapter.getKefuService().sendKefuMessage(WxMpKefuMessage.IMAGE()
.mediaId(mediaId).toUser(wxMessage.getFromUser()).build());

模板消息

​ 模板消息仅用于公众号向用户发送重要的服务通知,只能用于符合其要求的服务场景中,如信用卡刷卡通知,商品购买成功通知等。不支持广告等营销类消息以及其它所有可能对用户造成骚扰的消息。

​ 在模版消息发送任务完成后,微信服务器会将是否送达成功作为通知,发送到开发者中心中填写的服务器配置地址中。

关于使用规则,请注意:

1
2
3
4
5
1、所有服务号都可以在功能->添加功能插件处看到申请模板消息功能的入口,但只有认证后的服务号才可以申请模板消息的使用权限并获得该权限;
2、需要选择公众账号服务所处的2个行业,每月可更改1次所选行业;
3、在所选择行业的模板库中选用已有的模板进行调用;
4、每个账号可以同时使用25个模板。
5、当前每个账号的模板消息的日调用上限为10万次,单个模板没有特殊限制。【2014年11月18日将接口调用频率从默认的日1万次提升为日10万次,可在MP登录后的开发者中心查看】。当账号粉丝数超过10W/100W/1000W时,模板消息的日调用上限会相应提升,以公众号MP后台开发者中心页面中标明的数字为准。

注意点

  1. 如果模板消息要支持跳转小程序的话,需要在公众号上添加小程序关联。小程序 > 小程序管理

Java示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public Boolean sendTemplateMsg(WxMpTemplateMessageBO templateMessageBO) throws WxErrorException {

WxMpTemplateMessage.MiniProgram miniProgram = null;
if (templateMessageBO.getMiniProgram() != null) {
miniProgram = new WxMpTemplateMessage.MiniProgram();
miniProgram.setAppid(templateMessageBO.getMiniProgram().getAppid());
miniProgram.setPagePath(templateMessageBO.getMiniProgram().getPagePath());
}

WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder()
.toUser(templateMessageBO.getToUser())
.templateId(templateMessageBO.getTemplateId())
.url(templateMessageBO.getUrl())
.miniProgram(miniProgram)
.data(templateMessageBO.getData().stream().map(e -> {
WxMpTemplateData data = new WxMpTemplateData();
data.setColor(e.getColor());
data.setName(e.getName());
data.setValue(e.getValue());
return data;
}).collect(Collectors.toList()))
.build();

String result = wxService.getTemplateMsgService().sendTemplateMsg(templateMessage);
log.info("WxMpServiceAdapter.sendTemplateMsg result:{}", result);
return StringUtils.isNotEmpty(result);
}
参数 是否必填 说明
touser 接收者openid
template_id 模板ID
url 模板跳转链接(海外帐号没有跳转能力)
miniprogram 跳小程序所需数据,不需跳小程序可不用传该数据
appid 所需跳转到的小程序appid(该小程序appid必须与发模板消息的公众号是绑定关联关系,暂不支持小游戏)
pagepath 所需跳转到小程序的具体页面路径,支持带参数,(示例index?foo=bar),要求该小程序已发布,暂不支持小游戏
data 模板数据
color 模板内容字体颜色,不填默认为黑色

注:url和miniprogram都是非必填字段,若都不传则模板无跳转;若都传,会优先跳转至小程序。开发者可根据实际需要选择其中一种跳转方式即可。当用户的微信客户端版本不支持跳小程序时,将会跳转至url。

在调用模板消息接口后,会返回JSON数据包。正常时的返回JSON数据包示例:

1
2
3
4
5
{
"errcode":0,
"errmsg":"ok",
"msgid":200228332
}

素材管理

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

注意点:

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

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

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

    1. 图片(image): 2M,支持PNG\JPEG\JPG\GIF格式`
    2. 语音(voice):2M,播放长度不超过60s,支持AMR\MP3格式
    3. 视频(video):10MB,支持MP4格式
    4. 缩略图(thumb):64KB,支持JPG格式
  4. 需使用https调用本接口。

参数说明

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

返回说明

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

1
{"type":"TYPE","media_id":"MEDIA_ID","created_at":123456789}
参数 描述
type 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb,主要用于视频与音乐格式的缩略图)
media_id 媒体文件上传后,获取标识
created_at 媒体文件上传时间戳

错误情况下的返回JSON数据包示例如下(示例为无效媒体类型错误):

1
{"errcode":40004,"errmsg":"invalid media type"}

Java示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private String getImageMediaId() {
String mediaId = (String) redisTemplate.opsForValue().get(CQ_OREVER_MATERIAL);
// 获取声音或者图片永久素材
try (InputStream inputStream = adapter.getMaterialService().materialImageOrVoiceDownload(mediaId)) {
if (inputStream != null) {
return mediaId;
}
} catch (Exception e) {
log.error(" getMaterialService.materialImageOrVoiceDownload error", e);
}

// 新增非图文永久素材
try {
WxMpMaterial material = createWxMpMaterial();
WxMpMaterialUploadResult result = adapter.getMaterialService()
.materialFileUpload("image", material);
return result.getMediaId();
} catch (Exception e) {
log.error(" getMaterialService.materialFileUpload error", e);
}
return null;
}

private WxMpMaterial createWxMpMaterial() throws IOException {
WxMpMaterial material = new WxMpMaterial();
material.setName("微信小程序二维码");
material.setFile(new ClassPathResource("qrcode_for_service.jpg").getFile());
return material;
}

me.chanjar.weixin.mp.api.WxMpMaterialService#materialFileBatchGet

1
2
// 分页获取其他媒体素材列表
WxMpMaterialFileBatchGetResult materialFileBatchGet(String type, int offset, int count) throws WxErrorException;