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
| <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-reactor-spring-boot-starter</artifactId> <version>1.38.0</version> </dependency>
<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. 微信公众号接口配置
如果想要使用微信登录,那么首先就需要注册一个微信公众号,我们在开发过程中可以先使用免费的测试公众号。开始开发 / 接口测试号申请 这里附上网址,可以自行扫码申请。
这边我们主要关注一个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算法对token
、timestamp
、nonce
进行加密生成一个新的字符串shaStr
。 然后,这个方法会比较signature和shaStr是否相等。如果相等,说明这个GET请求是来自微信服务器的,返回echostr参数值,完成服务器验证。如果不相等,返回字符串”?unknown?”,表示验证失败。
而这里的token,就是刚才需要在微信平台上配置的token,与代码中保持一致。
这个方法写好之后,我们再回到微信平台进行配置调用,就可以调用成功。
在我们正确配置,点击修改之后配置url,点击提交,会发现微信平台调用了我们的callback
方法,本机收到了验签的参数请求并且请求通过了,说明配置成功。
这里使用Getmappings
是为了能够通过微信平台的校验,同时进行我们自身的调用配置。
2. 微信公众号接收消息
而实际上如果有用户给我们的公众号发送消息,微信会产生什么样的消息体呢?
先来看结果,再去看代码。
刚刚实现的是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
,这就是用户发送给我们的消息,当然,收到的是被微信所处理过的消息。
可以发现,收到的消息是有一定的格式的消息,通过对应的标签名称,可以看出每个字段的含义。
FromUserName: 消息来源的微信用户
CreateTime: 消息创建时间戳
MsgType: 消息类型
Content: 消息内容
这个入参我们可以发现,他是有一定格式的,所以我们可以自己实现一个工具类,去对微信的发送过来的xml
格式的消息进行一次解析。这里面读取xml
主要用到dom4j
库中的一些api,后文会展示代码。
我们通过读取消息的类型,也就是MsgType
,对不同类型的消息以及器消息内容进行不同的处理。
主要就是这些代码,用于存放消息的信息,以及通过工厂+策略模式进行消息的处理。
3. 工厂+策略实现消息处理
3.1 handler接口
1 2 3 4 5
| public interface WeChatMsgHandler { WeChatMsgTypeEnum getMsgType();
String dealMsg(Map<String,String> msgMap); }
|
WeChatMsgHandler
是一个接口,定义了处理微信消息的两个主要方法:
getMsgType()
:此方法应返回一个 WeChatMsgTypeEnum
枚举值,表示该处理器可以处理的微信消息类型。
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)); 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 中的进行比对,如果匹配,则说明验证码正确,可以登录。
这里的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); } @Override public void afterPropertiesSet() throws Exception { for(WeChatMsgHandler handler : weChatMsgHandlerList){ handlerMap.put(handler.getMsgType(),handler); } } }
|
通过@Resource
注解,将所有实现了WeChatMsgHandler
接口的类都注入进weChatMsgHandlerList
中,同时继承InitializingBean
接口,实现其方法afterPropertiesSet
,在初始化bean的时候,将所有的handler放入map中,后续调用就只需要通过工厂去进行调用了。
现在微信的处理我们介绍完毕了,接下来再看一下登录的接口(完整代码比较多,这里只是展示逻辑)。
接口只接受一个验证码的参数,再用该验证码作为参数传给authUserDomainService.doLogin
进行真正的登录逻辑。也就是下面的方法。
之前已经将key,value
,构造出来,这里再用key去查询,如果验证码正确,就可以查询出对应的openId
,项目中userName
即为openId
,如果用户不存在,会先去注册一个用户,如果用户存在,则直接返回,进入登录逻辑,登录逻辑里面,使用的就是Sa-token
框架的接口了。
4. Sa-token配置及使用
先放一些yml
的配置信息,包括SpringCloud
的路由转发以及Sa-token
的
这是一些基础的配置,可以根据自己的需要去查看文首的文档中查看并进行配置。
想要实现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
|
@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) { return getAuth(loginId.toString(), authPermissionPrefix); } @Override public List<String> getRoleList(Object loginId, String loginType) { 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
中的结构
key
为对应的prefix
+openId
(因为我们登录的loginId
使用的微信返回的openId
)。value
就是对应的权限信息,权限信息在注册用户的时候会刷入redis
。
采用Gson
转Json
刷入redis
,后续取出的时候,也需要用Gson
去转译一下。
这样我们就通过继承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
|
@Configuration public class SaTokenConfigure { @Bean public SaReactorFilter getSaReactorFilter() { return new SaReactorFilter() .addInclude("/**") .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
的调用。