WeChat+Sa-token

首先,还是建议先去阅读官方文档,本文章只是为了记录在项目中的使用

Sa-token文档

想要使用Sa-token,第一步就是先通过maven引入该框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot-starter</artifactId>
<version>1.38.0</version>
</dependency>

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.38.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

在这里,我们从头说起,这个项目实现的是微信扫码登录,所以实际上就是微信登陆+Sa-token的实现,先从微信登录开始。

1. 微信公众号接口配置

如果想要使用微信登录,那么首先就需要注册一个微信公众号,我们在开发过程中可以先使用免费的测试公众号。开始开发 / 接口测试号申请 这里附上网址,可以自行扫码申请。

image-20240830165421151

这边我们主要关注一个url,以及一个token,后续使用微信扫码需要调用的接口以及使用的token。url就是在你的服务器或者本机上启动项目后的地址,同时你需要实现一个callback接口,去给微信进行调用。这边微信调用的时候,会有四个参数的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("callBack")
public String callBack(@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr){

log.info("get验签请求参数 signature:{},timestamp:{},nonce:{},echostr:{}",signature,timestamp,nonce,echostr);
String shaStr = SHA1.getSHA1(TOKEN, timestamp, nonce, "");
if(signature.equals(shaStr)){
return echostr;
}

return "?unknown?";
}

当你在微信公众平台上设置服务器配置时,微信服务器会发送一个GET请求(callback方法)到你设置的URL上,这个请求包含了四个参数:signature,timestamp,nonce和echostr

  • signature:微信加密签名,是一个由token、timestamp、nonce三个参数进行一定的加密生成的字符串。
  • timestamp:时间戳。
  • nonce:随机数。
  • echostr:随机字符串。

这个方法首先会记录这四个参数的值,然后使用SHA1算法对tokentimestampnonce进行加密生成一个新的字符串shaStr。 然后,这个方法会比较signature和shaStr是否相等。如果相等,说明这个GET请求是来自微信服务器的,返回echostr参数值,完成服务器验证。如果不相等,返回字符串”?unknown?”,表示验证失败。

而这里的token,就是刚才需要在微信平台上配置的token,与代码中保持一致。

这个方法写好之后,我们再回到微信平台进行配置调用,就可以调用成功。

image-20240830170251151

在我们正确配置,点击修改之后配置url,点击提交,会发现微信平台调用了我们的callback方法,本机收到了验签的参数请求并且请求通过了,说明配置成功。

这里使用Getmappings是为了能够通过微信平台的校验,同时进行我们自身的调用配置。

2. 微信公众号接收消息

而实际上如果有用户给我们的公众号发送消息,微信会产生什么样的消息体呢?

image-20240830170801144

先来看结果,再去看代码。

刚刚实现的是callback方法的GetMapping,现在需要实现的是PostMapping

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
@PostMapping(value ="callBack",produces="application/xml;charset=UTF-8")
public String callBack(
@RequestBody String requestBody,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam(value = "msg_signature",required = false) String msgSignature){

log.info("接收到微信的请求:requestBody:{}, signature:{},timestamp:{},nonce:{}",
requestBody,signature,timestamp,nonce);

Map<String, String> messageMap = MessageUtil.parseXml(requestBody);
String toUserName = messageMap.get("ToUserName");
String fromUserName = messageMap.get("FromUserName");
String msgType = messageMap.get("MsgType");
String event = messageMap.get("Event") == null ? "" : messageMap.get("Event");
log.info("msgType:{},event:{}",msgType,event);
String msgTypeKey = event.isEmpty() ? msgType : msgType+"."+event;
WeChatMsgHandler weChatMsgHandler = weChatMsgFactory.getHandlerByMsgType(msgTypeKey);
if(weChatMsgHandler == null){
return "unknown";
}

String replyContent = weChatMsgHandler.dealMsg(messageMap);
log.info("回复消息 replyContent:{}",replyContent);
return replyContent;

}

我们所期望的其实就是第一个参数,requestBody,这就是用户发送给我们的消息,当然,收到的是被微信所处理过的消息。

image-20240830171326186

可以发现,收到的消息是有一定的格式的消息,通过对应的标签名称,可以看出每个字段的含义。

  • FromUserName: 消息来源的微信用户

  • CreateTime: 消息创建时间戳

  • MsgType: 消息类型

  • Content: 消息内容

这个入参我们可以发现,他是有一定格式的,所以我们可以自己实现一个工具类,去对微信的发送过来的xml格式的消息进行一次解析。这里面读取xml主要用到dom4j库中的一些api,后文会展示代码。

我们通过读取消息的类型,也就是MsgType,对不同类型的消息以及器消息内容进行不同的处理。

image-20240901160108567主要就是这些代码,用于存放消息的信息,以及通过工厂+策略模式进行消息的处理。

3. 工厂+策略实现消息处理

3.1 handler接口

1
2
3
4
5
public interface WeChatMsgHandler {
WeChatMsgTypeEnum getMsgType();

String dealMsg(Map<String,String> msgMap);
}

WeChatMsgHandler 是一个接口,定义了处理微信消息的两个主要方法:

  1. getMsgType():此方法应返回一个 WeChatMsgTypeEnum 枚举值,表示该处理器可以处理的微信消息类型。

  2. dealMsg(Map<String,String> msgMap):此方法接收一个 Map,其中包含了微信消息的各种属性。方法应根据这些属性处理消息,并返回一个字符串作为响应。

有多个类实现了 WeChatMsgHandler 接口,每个类处理一种特定类型的微信消息。例如,SubscribeMsgHandler 类处理订阅消息。当接收到微信消息时,可以根据消息类型获取对应的处理器,并调用其 dealMsg 方法来处理消息。

3.2 枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.maple.wx.handler;

public enum WeChatMsgTypeEnum {
SUB_MESSAGE("event.subscribe", "关注"),
TEXT_MSG("text", "文本消息");
private String msgType;
private String desc;

WeChatMsgTypeEnum(String msgType, String desc) {
this.msgType = msgType;
this.desc = desc;
}
public static WeChatMsgTypeEnum getMsgType(String msgType) {
for (WeChatMsgTypeEnum weChatMsgTypeEnum : WeChatMsgTypeEnum.values()) {
if (weChatMsgTypeEnum.msgType.equals(msgType)) {
return weChatMsgTypeEnum;
}
}
return null;
}
}

WeChatMsgTypeEnum 枚举定义了一个静态方法 getMsgType(String msgType),这个方法接收一个字符串参数,然后遍历所有的 WeChatMsgTypeEnum 枚举值,如果找到了与参数相同的消息类型,就返回对应的枚举值。如果没有找到,就返回 null。 可以根据微信消息的类型字符串,快速找到对应的枚举值,然后根据枚举值进行相应的处理。

3.3 消息处理实现类

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
@Component
@Slf4j
public class ReceiveTextMsgHandler implements WeChatMsgHandler{
@Override
public WeChatMsgTypeEnum getMsgType() {
return WeChatMsgTypeEnum.TEXT_MSG;
}
private static final String VERIFY_CODE = "验证码";
private static final String LOGIN_PREFIX = "loginCode";

@Resource
private RedisUtil redisUtil;
@Override
public String dealMsg(Map<String, String> msgMap) {
log.info("接收到文本消息:{}", msgMap);
String content = msgMap.get("Content");
if(VERIFY_CODE.equals(content)){
String fromUserName = msgMap.get("FromUserName");
String toUserName = msgMap.get("ToUserName");
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String nowString = now.format(formatter);

Random random = new Random();
int verifyCode = 1000 + random.nextInt(9000);

String verifyKey = redisUtil.buildKey(LOGIN_PREFIX,String.valueOf(verifyCode));
// 5分钟有效
redisUtil.setNx(verifyKey, fromUserName, 5L, TimeUnit.MINUTES);

String verifyContent = "["+nowString+"] "+"您当前的验证码是:"+verifyCode+" ,5分钟内有效~";
String resultContent = "<xml>\n"+
"<ToUserName><![CDATA["+fromUserName+"]]></ToUserName>\n"+
"<FromUserName><![CDATA["+toUserName+"]]></FromUserName>\n"+
"<CreateTime>20030819</CreateTime>\n"+
"<MsgType><![CDATA[text]]></MsgType>\n"+
"<Content><![CDATA["+verifyContent+"]]></Content>\n"+
"</xml>";

return resultContent;
}
return "";
}
}

这个就是我们真正需要去处理消息的实现类及其方法了,主要的目的就是当用户发送**”验证码”**给公众号时,我们能够返回一个验证码给用户,同时将该验证码存入 redis 中,用户收到验证码后,再用该验证码去输入,程序用输入的验证码与 redis 中的进行比对,如果匹配,则说明验证码正确,可以登录。

image-20240901164144080

image-20240901164134827这里的key就是loginCode+随机四位数value就是发送验证码请求的用户的openId

我们需要返回给微信的消息与之前我们收到的格式需要相匹配,就比如代码中的变量resultContent,就要用这种形式去返回给微信,才可以正常的发送,同时需要注意ToUserName,FromUserName,MsgType字段的值。

3.4 工厂类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class WeChatMsgFactory implements InitializingBean {

@Resource
private List<WeChatMsgHandler> weChatMsgHandlerList;
private final Map<WeChatMsgTypeEnum,WeChatMsgHandler> handlerMap = new HashMap<>();

public WeChatMsgHandler getHandlerByMsgType(String msgType){
WeChatMsgTypeEnum msgTypeEnum = WeChatMsgTypeEnum.getMsgType(msgType);
return handlerMap.get(msgTypeEnum);
}
// 在初始化bean的时候,将所有的handler放入map中
@Override
public void afterPropertiesSet() throws Exception {
for(WeChatMsgHandler handler : weChatMsgHandlerList){
handlerMap.put(handler.getMsgType(),handler);
}
}
}

通过@Resource注解,将所有实现了WeChatMsgHandler接口的类都注入进weChatMsgHandlerList中,同时继承InitializingBean接口,实现其方法afterPropertiesSet,在初始化bean的时候,将所有的handler放入map中,后续调用就只需要通过工厂去进行调用了。

现在微信的处理我们介绍完毕了,接下来再看一下登录的接口(完整代码比较多,这里只是展示逻辑)。

image-20240901165024285

接口只接受一个验证码的参数,再用该验证码作为参数传给authUserDomainService.doLogin进行真正的登录逻辑。也就是下面的方法。

image-20240901165438310

之前已经将key,value,构造出来,这里再用key去查询,如果验证码正确,就可以查询出对应的openId,项目中userName即为openId,如果用户不存在,会先去注册一个用户,如果用户存在,则直接返回,进入登录逻辑,登录逻辑里面,使用的就是Sa-token框架的接口了。

4. Sa-token配置及使用

先放一些yml的配置信息,包括SpringCloud的路由转发以及Sa-token

image-20240901170119298

image-20240901170130424

这是一些基础的配置,可以根据自己的需要去查看文首的文档中查看并进行配置。

想要实现Sa-token基础的权限以及角色校验,有一个重要的接口,StpInterface

我们先看一下这个接口的内容

1
2
3
4
5
6
7
8
9
package cn.dev33.satoken.stp;

import java.util.List;

public interface StpInterface {
List<String> getPermissionList(Object var1, String var2);

List<String> getRoleList(Object var1, String var2);
}

根据名称,可以发现它需要我们实现两个接口,一个是获取权限列表,一个是获取角色列表

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
/**
* 自定义权限验证接口扩展
* @author maple
*/
@Component
public class StpInterfaceImpl implements StpInterface {

@Resource
private RedisUtil redisUtil;

private final String authPermissionPrefix = "auth.permission";
private final String authRolePrefix = "auth.role";

@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 返回此 loginId 拥有的权限列表
//1. 直接跟数据库交互,查询权限列表
//2. 放在redis中,根据loginId查询权限列表
//3. redis缓存,没有的话,调用微服务获取
return getAuth(loginId.toString(), authPermissionPrefix);
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 返回此 loginId 拥有的角色列表
return getAuth(loginId.toString(), authRolePrefix);
}

private List<String> getAuth(String loginId, String prefix){
String authKey = redisUtil.buildKey(prefix, loginId);
String authValue = redisUtil.get(authKey);
if(StringUtils.isEmpty(authValue)){
return Collections.emptyList();
}
List<String> authList = new ArrayList<>();
if(authRolePrefix.equals(prefix)){
List<AuthRole> roleList = new Gson().fromJson(authValue, new TypeToken<List<AuthRole>>() {
}.getType());
authList = roleList.stream().map(AuthRole::getRoleKey).collect(Collectors.toList());
}else if(authPermissionPrefix.equals(prefix)){
List<AuthPermission> permissionList = new Gson().fromJson(authValue, new TypeToken<List<AuthPermission>>() {
}.getType());
authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
}
return authList;
}
}

这是我们的实现方式,通过继承接口,实现抽象方法,返回一个权限的列表,而如何返回,如何查询权限以及角色,这是可以让我们自行决定的。

这里采用的还是直接从redis中获取,看下我们在redis中的结构

image-20240901170923026

key为对应的prefix+openId(因为我们登录的loginId使用的微信返回的openId)。value就是对应的权限信息,权限信息在注册用户的时候会刷入redis

image-20240901171352336

采用GsonJson刷入redis,后续取出的时候,也需要用Gson去转译一下。

image-20240901171553765

这样我们就通过继承Sa-token的接口,实现了它获取权限以及角色的方法,也许你会好奇,这样实现了,那么它在哪里会被调用呢?

我们接下来需要配置的,就是Sa-token的全局过滤器,而在过滤器中,我们需要使用到StpUtil中的方法,其中的方法,就会调用我们刚刚实现的接口中的权限和角色校验的方法。

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
/**
* [Sa-Token 权限认证] 配置类
* @author maple
*/
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
.addExclude("/auth/user/doLogin") /* 放行登录接口 */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
System.out.println("前端访问路径:" + SaHolder.getRequest().getRequestPath());
System.out.println("前端信息:" + SaHolder.getRequest() + "," + StpUtil.getLoginId());
// 登录校验
SaRouter.match("/auth/**", r -> StpUtil.checkRole("admin"));
SaRouter.match("/oss/**", r -> StpUtil.checkLogin());
SaRouter.match("/subject/subject/add", r -> StpUtil.checkPermission("subject:add"));
SaRouter.match("/subject/**", r -> StpUtil.checkLogin());

})
;
}
}

checkRole以及checkPermission,其内部会调用StpInterface中的方法。

我们可以看其内部的方法,实际上会用loginId以及我们的传参(比如subject:add)去进行对应,如果获取到了,说明有对应的权限,如果没有获取到,则说明无权限,在过滤的时候就会被筛出,不会进行方法/subject/subject/add的调用。