使用WxJava开发微信小程序

小程序服务配置

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

  1. 添加pom.xml依赖

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    debug: false
    logging:
    level:
    org.springframework.web: info
    cn.binarywang.wx.miniapp: info
    me.chanjar.weixin: info
    wx:
    miniapp:
    configs:
    - appid: appid
    secret: appidsecret
    token: # cqtest (测试环境)
    aesKey: # RGAioK00rOP3TKnNVMVtBvCarimzvq4AFqltvAT0TiF
    msgDataFormat: JSON
  3. Java配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Data
    @ConfigurationProperties(prefix = "wx.miniapp")
    public class WxMaProperties {
    private List<Config> configs;
    @Data
    public static class Config {
    //设置微信小程序的appid
    private String appid;

    //设置微信小程序的Secret
    private String secret;

    //设置微信小程序消息服务器配置的token
    private String token;

    //设置微信小程序消息服务器配置的EncodingAESKey
    private String aesKey;

    //消息格式,XML或者JSON
    private String msgDataFormat;
    }
    }
    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
     @Configuration
    @EnableConfigurationProperties(WxMaProperties.class)
    public class WxMaServiceConfiguration {
    @Autowired
    private WxMaProperties properties;
    //按照appid分wxMaService
    private Map<String, WxMaService> maServices = new HashMap<>();

    @PostConstruct
    public void init() {
    maServices = this.properties.getConfigs().stream()
    .map(a -> {
    WxMaInMemoryConfig config = new WxMaInMemoryConfig();
    config.setAppid(a.getAppid());
    config.setSecret(a.getSecret());
    config.setToken(a.getToken());
    config.setAesKey(a.getAesKey());
    config.setMsgDataFormat(a.getMsgDataFormat());
    WxMaService service = new WxMaServiceImpl();
    service.setWxMaConfig(config);
    return service;
    }).collect(Collectors.toMap(s -> s.getWxMaConfig().getAppid(), a -> a));
    }

    public WxMaService getMaService(String appid) {
    WxMaService wxService = maServices.get(appid);
    if (wxService == null) {
    throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
    }
    return wxService;
    }
    }

注意

  1. 使用之前,需要保证使用 JRE/JDK 8u151 之后的版本,或者替换两个jar,否则加密时会出现 java.security.InvalidKeyException: Illegal key size 的问题。详见

access_token

接口调用凭证,调用绝大多数后台接口时都需使用 access_token,开发者需要进行妥善保存。

weixin-java-miniapp 对 access_token 做了相应的封装,每次调用接口的时候都会主动获取 access_token 。

cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl#executeInternal

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
private <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
......
if (uri.contains("access_token=")) {
throw new IllegalArgumentException("uri参数中不允许有access_token: " + uri);
}
// 获取 access_token
String accessToken = getAccessToken(false);
String uriWithAccessToken = uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;

try {
T result = executor.execute(uriWithAccessToken, data);
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
return result;
} catch (WxErrorException e) {
......
}
}

@Override
public String getAccessToken(boolean forceRefresh) throws WxErrorException {
Lock lock = this.getWxMaConfig().getAccessTokenLock();
try {
lock.lock();
// 判断 access_token 是否超时
if (this.getWxMaConfig().isAccessTokenExpired() || forceRefresh) {
String url = String.format(WxMaService.GET_ACCESS_TOKEN_URL, this.getWxMaConfig().getAppid(),
this.getWxMaConfig().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);
if (error.getErrorCode() != 0) {
throw new WxErrorException(error);
}
WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
// 更新新的 access_token
this.getWxMaConfig().updateAccessToken(accessToken.getAccessToken(),
accessToken.getExpiresIn());
} finally {
httpGet.releaseConnection();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
} finally {
lock.unlock();
}

return this.getWxMaConfig().getAccessToken();
}

注意

weixin-java-miniapp 中使用 cn.binarywang.wx.miniapp.config.WxMaConfig 记录着 access_token ,在 access_token 超时之前都可以直接从 WxMaConfig 中获取。需要注意的是,每次请求的access_token都是不一样的,即使在超时时间内,获取到的也是新的 access_token 。

weixin-java-miniappcn.binarywang.wx.miniapp.config.WxMaConfig 的实现类只有 cn.binarywang.wx.miniapp.config.WxMaInMemoryConfig类,是基于本地内存存储的,**在生产环境多机器环境中应该将这些配置持久化到一个独立的地方(可以使用Redis)**。

​ 比如,继承 WxMaInMemoryConfig ,利用 Redis 重写 getAccessTokenupdateAccessToken 等方法。然后在初始化 WxMaService 的时候使用自定义的 WxMaConfig

用户信息

小程序登录

微信小程序提供的登录流程时序

img

说明:

  1. 微信小程序 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 开发者服务器 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID会话密钥 session_key

之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

注意

  1. 会话密钥 session_key 是对用户数据进行 加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥
  2. 临时登录凭证 code 只能使用一次

Java后台登陆

​ 从上面的登陆流程可以看见,开发者服务器在小程序和微信服务器之间充当了中专、代理的功能。

​ 在这个登陆流程中,起到了置换登陆状态的功能。

​ 这里利用了 weixin-java-miniapp 实现微信小程序接口的调用。

1
2
3
4
5
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>3.3.0</version>
</dependency>

​ 获取用户登陆后的session信息

1
2
final WxMaService wxService = wxMaServiceConfiguration.getMaService(appId);  
WxMaJscode2SessionResult session = wxService.getUserService().getSessionInfo(code);

​ 然后就可以使用自定义的登陆状态,封装微信返回的 session 信息。

消息

模板消息

微信官方文档·小程序·开放能力·消息·模板消息

  • 模板推送位置:服务通知
  • 模板下发条件:用户本人在微信体系内与页面有交互行为后触发
    • 支付:当用户 在小程序内完成过支付行为,可允许开发者向用户在7天内推送有限条数的模板消息(1次支付可下发3条,多次支付下发条数独立,互相不影响)
    • 提交表单:当用户在小程序内发生过提交表单行为且该表单声明为要发模板消息的,开发者需要向用户提供服务时,可允许开发者向用户在7天内推送有限条数的模板消息(1次提交表单可下发1条,多次提交下发条数独立,相互不影响)
  • 模板跳转能力:点击查看详情仅能跳转下发模板的该帐号的各个页面

注意

  1. 发送模板消息之前需要有相关的模板,获取该模板ID
  2. 发送模板消息的时候,还需要前端收集的 form_id (用户提交表单下产生的。也就是先收集用户产生的 form_id ,然后再利用 form_id 发送模板消息。form_id 的有限期是7天。

客服消息

微信官方文档·小程序·开放能力·消息·客服消息

​ 在页面使用客服消息

​ 需要将 button 组件 open-type 的值设置为 contact,当用户点击后就会进入客服会话,如果用户在会话中点击了小程序消息,则会返回到小程序,开发者可以通过 bindcontact 事件回调获取到用户所点消息的页面路径 path 和对应的参数 query

​ 后台接入消息服务

​ 用户向小程序客服发送消息、或者进入会话等情况时,开发者填写的服务器配置 URL (如果使用的是云开发,则是配置的云函数)将得到微信服务器推送过来的消息和事件,开发者可以依据自身业务逻辑进行响应。接入和使用方式请参考消息推送

​ 发送消息

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

目前允许的动作列表如下,不同动作触发后,允许的客服接口下发消息条数和下发时限不同。

用户动作 允许下发条数限制 下发时限
用户发送消息 5 条 48 小时

注意

  1. 利用客服接口,可以针对一个请求,恢复多条内容。比如,用户发送了一个1,客服可以回复图片和文字两条消息。

Java后台消息

发送模板消息

cn.binarywang.wx.miniapp.api.WxMaMsgService#sendTemplateMsg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final WxMaService wxService = wxMaServiceConfiguration.getMaService(getAppId());
WxMaMsgService maMsgService = wxService.getMsgService();
WxMaTemplateMessage message = WxMaTemplateMessage.builder()
.toUser(messageBO.getToUser())
.templateId(messageBO.getTemplateId())
.formId(messageBO.getFormId())
.page(messageBO.getPage())
.color(messageBO.getColor())
.emphasisKeyword(messageBO.getEmphasisKeyword())
.data(messageBO.getData().stream()
.map(TemplateDataAssembler::buildWxMaTemplateMessage)
.collect(Collectors.toList()))
.build();

maMsgService.sendTemplateMsg(message);

发送客服消息

​ 用户发送客服消息,开发者需要填写服务器配置。

步骤一:填写服务器配置

登录小程序后台后,在「开发」-「开发设置」-「消息推送」中,管理员扫码启用消息服务,填写服务器地址(URL)、令牌(Token) 和 消息加密密钥(EncodingAESKey)等信息。

  • URL: 开发者用来接收微信消息和事件的接口 URL。开发者所填写的URL 必须以 http:// 或 https:// 开头,分别支持 80 端口和 443 端口。
  • Token: 可由开发者可以任意填写,用作生成签名(该 Token 会和接口 URL 中包含的 Token 进行比对,从而验证安全性)。
  • EncodingAESKey: 由开发者手动填写或随机生成,将用作消息体加解密密钥。

同时,开发者可选择消息加解密方式:明文模式(默认)、兼容模式和安全模式。可以选择消息数据格式:XML 格式(默认)或 JSON 格式。

填写服务器配置

模式的选择与服务器配置在提交后都会立即生效,请开发者谨慎填写及选择。切换加密方式和数据格式需要提前配置好相关代码,详情请参考 消息加解密说明

步骤二:验证消息的确来自微信服务器

开发者提交信息后,微信服务器将发送GET请求到填写的服务器地址URL上,GET请求携带参数如下表所示:

参数 描述
signature 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。
timestamp 时间戳
nonce 随机数
echostr 随机字符串

开发者通过检验 signature 对请求进行校验(下面有校验方式)。若确认此次 GET 请求来自微信服务器,请原样返回 echostr 参数内容,则接入生效,成为开发者成功,否则接入失败。加密/校验流程如下:

  1. 将token、timestamp、nonce三个参数进行字典序排序
  2. 将三个参数字符串拼接成一个字符串进行sha1加密
  3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信

验证URL有效性成功后即接入生效,成为开发者。

Java 实例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("xxx/{appid}")
public class WxMaPortalController {
@Autowired
WxMaServiceConfiguration wxMaServiceConfiguration;
@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("请求参数非法,请核实!");
}
final WxMaService wxService = wxMaServiceConfiguration.getMaService(appid);
if (wxService.checkSignature(timestamp, nonce, signature)) {
return echostr;
}
return "非法请求";
}
}

第三步:接收消息和事件

当某些特定的用户操作引发事件推送时(如用户向小程序客服发送消息、或者进入会话等情况),微信服务器会将消息(或事件)的数据包以 POST 请求发送到开发者配置的 URL,开发者可以依据自身业务逻辑进行响应。

微信服务器在将用户的消息发给开发者服务器地址后,微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次。如果在调试中,发现用户无法收到响应的消息,可以检查是否消息处理超时。关于重试的消息排重,有 msgid 的消息推荐使用 msgid 排重。事件类型消息推荐使用 FromUserName + CreateTime 排重。

服务器收到请求必须做出下述回复,这样微信服务器才不会对此作任何处理,并且不会发起重试,否则,将出现严重的错误提示。详见下面说明:

  1. 直接回复success(推荐方式)
  2. 直接回复空串(指字节长度为0的空字符串,而不是结构体中content字段的内容为空)
  3. 若接口文档有指定返回内容,应按文档说明返回

对于客服消息,一旦遇到以下情况,微信会在小程序会话中向用户下发系统提示“该小程序客服暂时无法提供服务,请稍后再试”:

  1. 开发者在5秒内未回复任何内容
  2. 开发者回复了异常数据

如果开发者希望增强安全性,可以在开发者中心处开启消息加密,这样,用户发给小程序的消息以及小程序被动回复用户消息都会继续加密,详见消息加解密说明

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
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
private  Map<String, WxMaService> maServices = new HashMap<>();
private Map<String, WxMaMessageRouter> routers = new HashMap<>() ;
private byte[] imageBuffer;

@PostConstruct
public void init() {
maServices = this.properties.getConfigs().stream()
.map(a -> {
WxMaInMemoryConfig config = new WxMaInMemoryConfig();
config.setAppid(a.getAppid());
config.setSecret(a.getSecret());
config.setToken(a.getToken());
config.setAesKey(a.getAesKey());
config.setMsgDataFormat(a.getMsgDataFormat());
WxMaService service = new WxMaServiceImpl();
service.setWxMaConfig(config);

// 配置事件处理器
routers.put(a.getAppid(), this.newRouter(service));
return service;
}).collect(Collectors.toMap(s -> s.getWxMaConfig().getAppid(), a -> a));

try(InputStream is = new ClassPathResource("qrcode_for_gh_ac1de8498046_258.jpg").getInputStream()){
byte[] byteArray = IOUtils.toByteArray(is);
imageBuffer=byteArray;
}catch (Exception e) {
throw new RuntimeException(e);
}
}

private WxMaMessageRouter newRouter(WxMaService service) {
final WxMaMessageRouter router = new WxMaMessageRouter(service);
router.rule().handler(logHandler).next()
.rule().async(false).content("1").handler(picHandler).end();
return router;
}

private final WxMaMessageHandler logHandler = (wxMessage, context, service, sessionManager) -> {
log.info("收到消息:" + wxMessage.toString());
};

private final WxMaMessageHandler picHandler = (wxMessage, context, service, sessionManager) -> {
try (ByteArrayInputStream bais=new ByteArrayInputStream(imageBuffer)){
InputStream is = bais;
WxMediaUploadResult uploadResult = service.getMediaService()
.uploadMedia("image", "jpg", is);
service.getMsgService().sendKefuMsg(
WxMaKefuMessage
.newImageBuilder()
.mediaId(uploadResult.getMediaId())
.toUser(wxMessage.getFromUser())
.build());

service.getMsgService().sendKefuMsg(WxMaKefuMessage.newTextBuilder().content(KEFU_TEXT)
.toUser(wxMessage.getFromUser()).build());
} catch (Exception e) {
log.error("WxMaMessageHandler error:", e);
}
};
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

@PostMapping(produces = "application/xml; charset=UTF-8")
public String post(@PathVariable String appid,
@RequestBody String requestBody,
@RequestParam("msg_signature") String msgSignature,
@RequestParam("encrypt_type") String encryptType,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce) {
log.info("\n接收微信请求:[msg_signature=[{}], encrypt_type=[{}], signature=[{}]," +
" timestamp=[{}], nonce=[{}], requestBody=[\n{}\n] ",
msgSignature, encryptType, signature, timestamp, nonce, requestBody);

final WxMaService wxService = wxMaServiceConfiguration.getMaService(appid);

final boolean isJson = Objects.equals(wxService.getWxMaConfig().getMsgDataFormat(),
WxMaConstants.MsgDataFormat.JSON);
if (StringUtils.isBlank(encryptType)) {
// 明文传输的消息
WxMaMessage inMessage;
if (isJson) {
inMessage = WxMaMessage.fromJson(requestBody);
} else {//xml
inMessage = WxMaMessage.fromXml(requestBody);
}

this.route(inMessage, appid);
return "success";
}

if ("aes".equals(encryptType)) {
// 是aes加密的消息
WxMaMessage inMessage;
if (isJson) {
inMessage = WxMaMessage.fromEncryptedJson(requestBody, wxService.getWxMaConfig());
} else {//xml
inMessage = WxMaMessage.fromEncryptedXml(requestBody, wxService.getWxMaConfig(),
timestamp, nonce, msgSignature);
}

this.route(inMessage, appid);
return "success";
}

throw new RuntimeException("不可识别的加密类型:" + encryptType);
}

private void route(WxMaMessage message, String appid) {
try {
wxMaServiceConfiguration.getRouters().get(appid).route(message);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}