若依模板改造(3.8.9)1、基础改造下载代码从[RuoYi-Vue: 🎉 基于SpringBoot,Spring Security,JWT,Vue & Element 的前后端分离权限管理系统,同时提供了 Vue3 的版本](https://gitee.com/y_project/RuoYi-Vue)下载压缩文件代码。并解压到任意地方 使用idea打开项目 ![]() 修改数据库密码
![]() 下载vue依赖右键ruoyi-ui,选择打开于->终端 ![]() 输入命令npm i 等待下载完成。 创建/导入数据库数据库名称我修改为ry-cy了,上面配置文件中也需要统一 ![]() 找到项目代码,其中有一个sql文件夹,其下有两个 sql 文件。ry_20240629是主要sql。quartz.sql是定时任务的sql。如果不需要定时任务模块就不导入这个文件。 注:如果无法导入,也可选择打开ry_20240629文件,全选内容粘贴到数据库软件中全部执行。 ![]() ![]() 删除定时任务模块ruoyi-quartz 右键先 移除模块 再右键删除 ![]() 删除根目录下的pom文件中的定时任务的依赖 ![]() ![]() 删除admin模块的pml文件中的定时任务依赖 ![]() 启动项目springboot项目 ![]() 修改前端显示若依的地方若依后台管理系统![]() 修改这两个文件的文字即可,可把界面上的文字更改, ![]() 修改网页标题删除首页内容![]() ![]() 删除若依官网![]() 第一步,解除角色与若依官网菜单关系 ![]() 第二步,删除若依官网菜单 ![]() 修改部门中的名称![]() 删除通知内容![]() 删除前端github等标识![]() ![]() 增加redis 的key 值前缀我们随便使用一个查看redis的 软件会发现,当登陆后redis 中会存在以下东西、此时会出现一个问题,如果一个服务器上只运行一个若依项目,那么这么做没问题,但是如果服务器上运行多个若依项目时,因为key值 的原因会导致项目紊乱,因此必须在每个项目前面增加前缀来区分。下面是没有前缀的图: ![]() 增加方法: 1、自定义序列化 redis 的类 在 ruoyi-common->common->constant 此路径下中创建 RedisKeySerializer 类,并将如下内容覆盖,且将最上面的包路径改为自己的(注:此类存放地方无强制要求) [code]package com.ruoyi.common.constant; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.utils.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import java.nio.charset.Charset; @Component public class RedisKeySerializer implements RedisSerializer<String> { @Autowired private RuoYiConfig config; private final Charset charset; public RedisKeySerializer() { this(Charset.forName("UTF8")); } public RedisKeySerializer(Charset charset) { Assert.notNull(charset, "字符集不允许为NULL"); this.charset = charset; } @Override public byte[] serialize(String string) throws SerializationException { // 通过项目名称ruoyi.name来定义Redis前缀,用于区分项目缓存 if (StringUtils.isNotEmpty(config.getName())) { return new StringBuilder(config.getName()).append(":").append(string).toString().getBytes(charset); } return string.getBytes(charset); } @Override public String deserialize(byte[] bytes) throws SerializationException { return (bytes == null ? null : new String(bytes, charset)); } } [/code]2、修改 ruoyi-framework -> config 包下的RedisConfig类中代码(三处) [code]@Bean @SuppressWarnings(value = { "unchecked", "rawtypes" }) // 修改1:增加RedisKeySerializer redisKeySerializer参数 public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisKeySerializer redisKeySerializer) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class); // 修改二:将参数值变成redisKeySerializer // 使用redisKeySerializer来序列化和反序列化redis的key值 template.setKeySerializer(redisKeySerializer); template.setValueSerializer(serializer); // 修改三:将参数值变成redisKeySerializer // Hash的key也采用redisKeySerializer的序列化方式 template.setHashKeySerializer(redisKeySerializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } [/code]3、将ruoyi-admin -> controller -> monitor 下的CacheController 类中增加如下代码 [code]@Value("${ruoyi.name}") public static String REDIS_NAME; // 修改一:用来将配置文件中的项目名传递给此变量 private final static List<SysCache> caches = new ArrayList<SysCache>(); { // 修改二:在前面拼接 REDIS_NAME 的值 caches.add(new SysCache(REDIS_NAME + CacheConstants.LOGIN_TOKEN_KEY, "用户信息")); caches.add(new SysCache(REDIS_NAME + CacheConstants.SYS_CONFIG_KEY, "配置信息")); caches.add(new SysCache(REDIS_NAME + CacheConstants.SYS_DICT_KEY, "数据字典")); caches.add(new SysCache(REDIS_NAME + CacheConstants.CAPTCHA_CODE_KEY, "验证码")); caches.add(new SysCache(REDIS_NAME + CacheConstants.REPEAT_SUBMIT_KEY, "防重提交")); caches.add(new SysCache(REDIS_NAME + CacheConstants.RATE_LIMIT_KEY, "限流处理")); caches.add(new SysCache(REDIS_NAME + CacheConstants.PWD_ERR_CNT_KEY, "密码错误次数")); } [/code]此时去admin的配置文件中修改name的值,后面name的值将会是key的前缀 观察此时的Redis,自此自定义Redis前缀完成 ![]() 修改超级用户的用户名和密码用户名直接在数据库修改,密码可以登陆后在后台修改,如果忘记密码可以重新生成密钥并替换,代码如下: [code]public static void main(String[] args) { System.out.println(SecurityUtils.encryptPassword("admin123")); } [/code]将 admin 项目修改为多配置文件多配置文件,就是将配置文件分为在开发时使用的,在测试时,在生产时使用的配置文件,因为不同环境下对应的IP,账号信息都不一样,如果手动更改会繁琐与容易出错,因此将不同环境下的配置使用不同文件储存,然后再由不同环境指定不同文件。 假如现在有俩环境:开发和生产。则配置文件有如下三个: [code]application.yml // 主环境,一定会使用到的,也是设置默认环境的地方 application-dev.yml // 开发环境 application-prod.yml// 生产环境 我们通过如下来设置默认使用的配置文件为 application-dev.yml ,也就是说当程序在开发环境运行起来,其真正的配置文件将由: application.yml + application-dev.yml 里面的配置组成。 # 如下代码写在 application.yml 文件中。 spring: profiles: active: dev #默认为开发环境 但是这里有个问题,设置好默认环境为开发环境后,那么在生产环境怎么切换配置呢,很简单,在运行jar包时设置参数: 当执行 java -jar xxx.jar --spring.profiles.actvie=test 此时,系统将启用 application.yml 和 application-test.yml 配置文件。 当执行 java -jar xxx.jar --spring.profiles.actvie=prod 此时,系统将启用 application.yml 和 application-prod.yml 配置文件。 [/code]1、在配置文件中设置默认配置文件名称 ![]() 2、创建dev和prod配置文件
![]() 代码: application.yml [code]# 项目相关配置 ruoyi: # 名称 name: RuoYi # 版本 version: 3.8.9 # 版权年份 copyrightYear: 2025 # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) profile: D:/ruoyi/uploadPath # 获取ip地址开关 addressEnabled: false # 验证码类型 math 数字计算 char 字符验证 captchaType: math # 开发环境配置 server: # 服务器的HTTP端口,默认为8080 port: 8080 servlet: # 应用的访问路径 context-path: / tomcat: # tomcat的URI编码 uri-encoding: UTF-8 # 连接数满后的排队数,默认为100 accept-count: 1000 threads: # tomcat最大线程数,默认为200 max: 800 # Tomcat启动初始化的线程数,默认值10 min-spare: 100 # Spring配置 spring: # 资源信息 messages: # 国际化资源文件路径 basename: i18n/messages profiles: active: dev # 文件上传 servlet: multipart: # 单个文件大小 max-file-size: 10MB # 设置总上传的文件大小 max-request-size: 20MB # 服务模块 devtools: restart: # 热部署开关 enabled: true # MyBatis配置 mybatis: # 搜索指定包别名 typeAliasesPackage: com.ruoyi.**.domain # 配置mapper的扫描,找到所有的mapper.xml映射文件 mapperLocations: classpath*:mapper/**/*Mapper.xml # 加载全局的配置文件 configLocation: classpath:mybatis/mybatis-config.xml # PageHelper分页插件 pagehelper: helperDialect: mysql supportMethodsArguments: true params: count=countSql # Swagger配置 swagger: # 是否开启swagger enabled: true # 请求前缀 pathMapping: /dev-api # 防止XSS攻击 xss: # 过滤开关 enabled: true # 排除链接(多个用逗号分隔) excludes: /system/notice # 匹配链接 urlPatterns: /system/*,/monitor/*,/tool/* [/code]application-dev.yml (注:application-dev.yml文件与如下一致。但其中参数可自行修改,如数据库账号密码,redis密码,IP等 ) [code]# 用户配置 user: password: # 密码最大错误次数 maxRetryCount: 5 # 密码锁定时间(默认10分钟) lockTime: 10 # 日志配置,这里的配置会高于logback.xml的,只有设置debug才能显示sql logging: level: com.ruoyi: debug org.springframework: warn # token配置 token: # 令牌自定义标识 header: Authorization # 令牌密钥 secret: abcdefghijklmnopqrstuvwxyz # 令牌有效期(默认30分钟) expireTime: 300 # 数据源配置 spring: # redis 配置 redis: # 地址 host: localhost # 端口,默认为6379 port: 6379 # 数据库索引 database: 0 # 密码 password: # 连接超时时间 timeout: 10s lettuce: pool: # 连接池中的最小空闲连接 min-idle: 0 # 连接池中的最大空闲连接 max-idle: 8 # 连接池的最大数据库连接数 max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms datasource: type: com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.cj.jdbc.Driver druid: # 主库数据源 master: url: jdbc:mysql://localhost:3306/ry-cy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 username: root password: 123456 # 从库数据源 slave: # 从数据源开关/默认关闭 enabled: false url: username: password: # 初始连接数 initialSize: 5 # 最小连接池数量 minIdle: 10 # 最大连接池数量 maxActive: 20 # 配置获取连接等待超时的时间 maxWait: 60000 # 配置连接超时时间 connectTimeout: 30000 # 配置网络超时时间 socketTimeout: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 timeBetweenEvictionRunsMillis: 60000 # 配置一个连接在池中最小生存的时间,单位是毫秒 minEvictableIdleTimeMillis: 300000 # 配置一个连接在池中最大生存的时间,单位是毫秒 maxEvictableIdleTimeMillis: 900000 # 配置检测连接是否有效 validationQuery: SELECT 1 FROM DUAL testWhileIdle: true testOnBorrow: false testOnReturn: false webStatFilter: enabled: true statViewServlet: enabled: true # 设置白名单,不填则允许所有访问 allow: url-pattern: /druid/* # 控制台管理用户名和密码 login-username: ruoyi login-password: 123456 filter: stat: enabled: true # 慢SQL记录 log-slow-sql: true slow-sql-millis: 1000 merge-sql: true wall: config: multi-statement-allow: true [/code]修改日志文件2、插件集成集成mybatisplus实现mybatis增强Mybatis-Plus是在Mybatis的基础上进行扩展,只做增强不做改变,可以兼容Mybatis原生的特性。同时支持通用CRUD操作、多种主键策略、分页、性能分析、全局拦截等。极大帮助我们简化开发工作。 PS:不同版本有差别,如果需要使用最新的那么插件那一块可能需要安装最新文档进行修改 根目录下的pom.xml中增加两处 [code]1.在properties中增加 <mybatis-plus.version>3.5.1</mybatis-plus.version> 2.在dependencies中增加 <!-- mybatis-plus 增强CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> [/code]2、ruoyi-common\pom.xml模块添加整合依赖 [code]<!-- mybatis-plus 增强CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> [/code]3、ruoyi-admin文件application.yml,修改mybatis配置为mybatis-plus [code]# MyBatis Plus配置 mybatis-plus: # 搜索指定包别名 typeAliasesPackage: com.ruoyi.**.domain # 配置mapper的扫描,找到所有的mapper.xml映射文件 mapperLocations: classpath*:mapper/**/*Mapper.xml # 加载全局的配置文件 configLocation: classpath:mybatis/mybatis-config.xml [/code]3、添加Mybatis Plus配置MybatisPlusConfig.java。 PS:原来的MyBatisConfig.java需要删除掉 [code]package com.ruoyi.framework.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.annotation.EnableTransactionManagement; /** * Mybatis Plus 配置 * * @author ruoyi */ @EnableTransactionManagement(proxyTargetClass = true) @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件 interceptor.addInnerInterceptor(paginationInnerInterceptor()); // 乐观锁插件 interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor()); // 阻断插件 interceptor.addInnerInterceptor(blockAttackInnerInterceptor()); return interceptor; } /** * 分页插件,自动识别数据库类型 https://baomidou.com/guide/interceptor-pagination.html */ public PaginationInnerInterceptor paginationInnerInterceptor() { PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); // 设置数据库类型为mysql paginationInnerInterceptor.setDbType(DbType.MYSQL); // 设置最大单页限制数量,默认 500 条,-1 不受限制 paginationInnerInterceptor.setMaxLimit(-1L); return paginationInnerInterceptor; } /** * 乐观锁插件 https://baomidou.com/guide/interceptor-optimistic-locker.html */ public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() { return new OptimisticLockerInnerInterceptor(); } /** * 如果是对全表的删除或更新操作,就会终止该操作 https://baomidou.com/guide/interceptor-block-attack.html */ public BlockAttackInnerInterceptor blockAttackInnerInterceptor() { return new BlockAttackInnerInterceptor(); } } [/code]4、修改原代码生成的代码,加入mybatis-plus代码 在mapper上与service,serviceImpl上继承这些类都是为了可以调用mybatis-plus帮我们写好的方法。如果只在mapper上继承,那么只能在service中使用,这样我们还需要在service层去创建方法使用,如果是在service上也定义,那么controller层可以直接调用
安装 MybatisX 插件
用处: 1.XML 映射跳转:MybatisX 提供了便捷的 XML 映射文件与 Java 接口之间的跳转功能,让开发者能够快速地在两者之间切换,提高开发效率。 2.代码生成,通过 MybatisX,您可以轻松地根据数据库表结构生成对应的 Java 实体类、Mapper 接口及 XML 映射文件。 3.MybatisX 支持 JPA 风格的代码提示,包括新增、查询、修改和删除操作的自动代码生成。 集成actuator实现优雅关闭应用优雅停机主要应用在版本更新的时候,为了等待正在工作的线程全部执行完毕,然后再停止。我们可以使用SpringBoot提供的Actuator 1、pom.xml中引入actuator依赖(在哪个模块里面使用就在哪个pom中引用,这里是在admin中) [code]<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> [/code]2、配置文件中endpoint开启shutdown [code]management: endpoint: shutdown: enabled: true endpoints: web: exposure: include: "shutdown" base-path: /monitor [/code]3、在SecurityConfig中设置httpSecurity配置匿名访问(这个是测试用,无需登陆,如果要登陆就不配置匿名访问) [code].antMatchers("/monitor/shutdown").anonymous() [/code]4、Post请求测试验证优雅停机 curl -X POST http://localhost:8080/monitor/shutdown 但是如果登陆后在页面随便创建一个按钮并创建API。执行请求时会发现,url显示为:http://localhost/dev-api/monitor/shutdown 正常情况下应该是:http://localhost:8080/monitor/shutdown。究其原因是: 前端向后端请求的url问题(请求代理)问题: [code]在若依前后端分离框架中,前端需要向后端接口请求,就必须知道ip和端口号。但是为啥我在前端页面调试时网络一栏下很短请求的url是 http://localhost/dev-api/monitor/shutdown。没有显示端口号但又加了个dev-api。在哪里设置的逻辑是啥 [/code]解答: 在若依前后端分离框架中,前端请求 URL 中的 和隐藏端口号的逻辑是通过 前端代理配置 实现的,主要目的是简化开发环境下的跨域请求和路径管理。以下是关键点解释:/dev-api
关键配置文件位于前端项目的 vue.config.js 中,配置示例如下: [code]Javascriptmodule.exports = { devServer: { proxy: { // 代理所有以 /dev-api 开头的请求 '/dev-api': { target: 'http://localhost:8080', // 后端实际地址(含端口) changeOrigin: true, // 允许跨域 pathRewrite: { '^/dev-api': '' // 移除路径中的 /dev-api 前缀 } } } } } [/code]
若依通常结合 .env 环境文件 动态设置代理前缀,例如:
使用netty集成websocket1.在根目录下的pom.xml中增加 [code]1、properties模块中增加 <netty.version>4.1.68.Final</netty.version> 2、在dependencies中增加 <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>${netty.version}</version> </dependency> [/code]2.在admin中的pom.xml增加 [code]1、在dependencies中增加 <!-- netty--> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> </dependency> [/code]![]() 3、在ruoyi-admin/src/main/java/com/ruoyi路径下增加文件夹webSocket 同时在webSocket文件夹下新增类WebSocketServer 注:
WebSocketServer [code]package com.ruoyi.webSocket; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.http.*; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; @Component public class WebSocketServer implements CommandLineRunner, DisposableBean { @Autowired private HttpRequestHandler httpRequestHandler; // 注入 Spring 管理的处理器 private final int PORT = 8081; private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; @Override public void run(String... args) { new Thread(() -> { bossGroup = new NioEventLoopGroup(1); workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO)) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new HttpServerCodec()); pipeline.addLast(new HttpObjectAggregator(65536)); pipeline.addLast(httpRequestHandler); // 处理参数 pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); pipeline.addLast(new WebSocketFrameHandler()); } }); ChannelFuture f = b.bind(PORT).sync(); f.channel().closeFuture().sync(); // 在子线程中阻塞 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { shutdown(); } }).start(); } @Override public void destroy() { shutdown(); } private void shutdown() { if (bossGroup != null) { bossGroup.shutdownGracefully(); } if (workerGroup != null) { workerGroup.shutdownGracefully(); } } } [/code]HttpRequestHandler 注:获取微信小程序用户ID的代码必须配合微信小程序登录的代码,不然无用。可自行选择替代方案,本质上就是寻找唯一Key作为沟通桥梁 [code]package com.ruoyi.webSocket; import com.ruoyi.framework.web.service.WxTokenService; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.HttpHeaderNames; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> { private final WxTokenService wxTokenService; @Autowired public HttpRequestHandler(WxTokenService wxTokenService) { this.wxTokenService = wxTokenService; } @Override protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception { // 检查是否为WebSocket握手请求 if (isWebSocketUpgrade(req)) { System.out.println(req.uri()); // 获取token来获取用户ID String token = req.uri().split("token=")[1]; // 获取用户id代码,注这里的代码必须配合微信小程序登录的代码,不然无用。可自行选择替代方案,本质上就是寻找唯一Key作为沟通桥梁 Long wxUserId = wxTokenService.getWxUserId(token); // 储存 WebSocketUsers.put(String.valueOf(wxUserId),ctx.channel()); req.setUri("/ws"); } ctx.fireChannelRead(req.retain()); } private boolean isWebSocketUpgrade(FullHttpRequest request) { String connection = request.headers().get(HttpHeaderNames.CONNECTION); String upgrade = request.headers().get(HttpHeaderNames.UPGRADE); return "Upgrade".equalsIgnoreCase(connection) && "websocket".equalsIgnoreCase(upgrade); } } [/code]WebSocketFrameHandler [code]package com.ruoyi.webSocket; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import org.springframework.stereotype.Component; @Component public class WebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception { System.out.println("收到信息"); System.out.println(msg.text()); // 收到消息时回显给客户端 ctx.writeAndFlush(new TextWebSocketFrame("Server received: " + msg.text())); } @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { System.out.println("连接...."); // 客户端连接时发送欢迎消息 ctx.writeAndFlush(new TextWebSocketFrame("Welcome to the WebSocket server!")); } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { // 客户端断开连接时处理 System.out.println("Client disconnected: " + ctx.channel().id().asLongText()); // 从map集合中移除 WebSocketUsers.remove(ctx.channel()); } // 发生异常时 @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); // 从map集合中移除 WebSocketUsers.remove(ctx.channel()); ctx.close(); } } [/code]WebSocketUsers [code]package com.ruoyi.webSocket; import io.netty.channel.Channel; import io.netty.channel.ChannelId; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.util.AttributeKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * websocket 客户端用户集 * * @author ruoyi */ public class WebSocketUsers { /** * WebSocketUsers 日志控制器 */ private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketUsers.class); /** * 用户集 */ private static Map<String, Channel> USERS = new ConcurrentHashMap<String, Channel>(); // 在连接中储存id private static final AttributeKey<String> USER_ID_KEY = AttributeKey.valueOf("userId"); /** * 存储用户 * * @param key 唯一键 * @param channel 用户信息 */ public static void put(String key, Channel channel) { // 存储前先判断是否已经存在,若存在则断开旧连接 if (USERS.containsKey(key)){ Channel channelTemp = USERS.get(key); if (channelTemp != null && channelTemp.isActive()){ // 断开即可 LOGGER.info("\n 断开旧连接 - {} - {} - 当前人数 - {}", channelTemp.id(), key, WebSocketUsers.getUsers().size()); channelTemp.close(); } } // 将用户id存储到channel中 channel.attr(USER_ID_KEY).set(key); USERS.put(key, channel); LOGGER.info("\n 建立连接 - {} - {} - 当前人数 - {}", channel.id(), key, WebSocketUsers.getUsers().size()); } /** * 获取连接 * * @param key 唯一键 * @return channel 用户信息 */ public static Channel get(String key){ return USERS.get(key); } /** * 移出用户 * * @param channel 值 */ public static boolean remove(Channel channel) { String key = channel.attr(USER_ID_KEY).get(); if (USERS.containsKey(key)){ ChannelId id = USERS.get(key).id(); if (!id.equals(channel.id())){ // ID一致就删除,不一致就不操作 return true; } } Channel remove = USERS.remove(key); if (remove != null) { if (remove.isActive()){ // 如果还是活跃就关闭 remove.close(); } boolean containsValue = USERS.containsValue(remove); LOGGER.info("\n 正在移出用户 - {} - {} - 结果 - {} - 剩余人数 - {}",channel.id(), key, containsValue ? "失败" : "成功",WebSocketUsers.getUsers().size()); LOGGER.info(getUsers().toString()); return containsValue; } else { return true; } } /** * 获取在线用户列表 * * @return 返回用户集合 */ public static Map<String, Channel> getUsers() { return USERS; } /** * 群发消息文本消息 * * @param message 消息内容 */ public static void sendMessageToUsersByText(String message) { int count = 0; int allCount = 0; Collection<Channel> values = USERS.values(); for (Channel value : values) { allCount++; if (value != null && value.isActive()) { // 通道处于活跃状态,可以发送消息 value.writeAndFlush(new TextWebSocketFrame(message)); count++; } } LOGGER.info("\n[群发 - 应发({})人 - 实发({})人]", allCount, count); } /** * 发送文本消息 * * @param userId 自己的用户名 * @param message 消息内容 */ public static void sendMessageWebSocket(String userId, String message) { Channel channel = USERS.get(userId); if (channel != null) { if (channel.isActive()){ // 通道处于活跃状态,可以发送消息 // new TextWebSocketFrame(message)webSocket需要的专属处理类,不能直接写字符串(WebSocket 协议要求数据以帧(frame)的形式进行传输,而 帧 是 WebSocket 协议中的基本数据单位。) channel.writeAndFlush(new TextWebSocketFrame(message)); }else { remove(USERS.get(userId)); LOGGER.info("\n[用户 {} 已离线-消息发送失败({})]",userId,message); } } else { LOGGER.info("\n[用户 {} 不存在-消息发送失败({})]",userId,message); } } } [/code]测试部分 在后端接口类中随便找一个类写下,这个是用来测试后端收到连接后主动发消息功能 [code]@GetMapping("/testWebSocket") public AjaxResult testWebSocket(){ // 主动发送消息,当链接webSocket后 // 获取id,这里固定为 1 WebSocketUsers.sendMessageWebSocket("1","测试消息"); return AjaxResult.success(); } [/code]前端写了个测试的vue代码,我写在了首页里面。主要是用来测试登陆后才可连接,以及若同一用户多次连接是否会断开旧连接。
**在 src/api/login.js**文件中增加 注:注意url要一致 [code]// 停机 // 退出方法 export function shutdown() { return request({ url: '/monitor/shutdown', method: 'post' }) } // 测试webSocket主动消息 export function testWebSocket() { return request({ url: '/system/config/testWebSocket', method: 'get' }) } [/code]在表示首页的vue中编写如下内容 [code]<template> <div class="app-container home"> <div @click="shutdown">停机</div> <!-- URL 输入框 --> <div class="url-input"> <label for="url">设置URL:</label> <input style="margin-left: 10px; width: 40%;height: 50px;margin-right: 10px;" type="text" id="url" v-model="url" /> </div> <!-- 消息输入框 --> <div class="message-input"> <label for="message">发送消息:</label> <input type="text" style="margin-left: 10px; margin-top: 20px; width: 40%;height: 50px;margin-right: 10px;" id="message" v-model="message" /> <button @click="sendMessage" id="btn_send">发送</button> </div> <!-- 消息记录和连接按钮 --> <label for="message">接收消息:</label> <textarea style="margin-left: 10px; margin-top: 20px;width: 40%;height: 200px;margin-right: 10px;" id="text_content" readonly>{{ text_content }}</textarea> <button @click="join" id="btn_join">连接</button> <button @click="exit" id="btn_exit">断开</button> <button @click="testWebSocket" style="width: 200px;height: 100px;margin-top: 20px;">服务器主动发消息</button> </div> </template> <script> import {shutdown, testWebSocket} from "@/api/login"; import {getToken} from "@/utils/auth"; export default { name: "Index", data() { return { // 版本号 version: "3.8.9", ws: null, // WebSocket 实例 url: 'ws://127.0.0.1:8081/ws', // WebSocket URL message: '', // 发送的消息 text_content: '', // 消息记录 }; }, methods: { testWebSocket, shutdown, goTarget(href) { window.open(href, "_blank"); }, // 连接到 WebSocket join() { if (this.ws) { this.text_content += '已经连接过!' + '\n'; return; } const url = this.url; this.ws = new WebSocket(url+"?token="+getToken()); this.ws.onopen = () => { this.text_content += '已经打开连接!' + '\n'; }; this.ws.onmessage = (event) => { this.text_content += event.data + '\n'; }; this.ws.onclose = () => { this.text_content += '已经关闭连接!' + '\n'; this.ws = null; }; }, // 发送消息 sendMessage() { if (!this.ws) { alert("未连接到服务器"); return; } this.ws.send(this.message); this.text_content += '发送:' + this.message + '\n'; this.message = ''; // 清空输入框 }, // 断开连接 exit() { if (this.ws) { this.ws.close(); this.ws = null; } }, } }; </script> <style scoped lang="scss"> .home { blockquote { padding: 10px 20px; margin: 0 0 20px; font-size: 17.5px; border-left: 5px solid #eee; } hr { margin-top: 20px; margin-bottom: 20px; border: 0; border-top: 1px solid #eee; } .col-item { margin-bottom: 20px; } ul { padding: 0; margin: 0; } font-family: "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 13px; color: #676a6c; overflow-x: hidden; ul { list-style-type: none; } h4 { margin-top: 0px; } h2 { margin-top: 10px; font-size: 26px; font-weight: 100; } p { margin-top: 10px; b { font-weight: 700; } } .update-log { ol { display: block; list-style-type: decimal; margin-block-start: 1em; margin-block-end: 1em; margin-inline-start: 0; margin-inline-end: 0; padding-inline-start: 40px; } } } </style> [/code]![]() 使用EMQX集成mqtt要使用mqtt来作为与硬件设备通讯的桥梁,需要完成一下内容: 选择一个mqtt服务器,自己编写或者使用别人开源的。这个相当于中转站,硬件与springboot连接mqtt服务器,然后双方都向mqtt服务器发送消息。发送消息时会设置主题。mqtt则会将收到的消息转发给对应主题。然后硬件与springboot订阅对应的主题来完成消息接收。 主题:就相当于群号,给这个主题发消息相当于在群里发消息。 订阅:相当于加入这个群,别人发的消息只有加入了才能收到。 基本流程: 假设邮件订阅主题a,springboot订阅主题b。此时硬件可以向主题b发消息,这样springboot就能接收到消息,反正同理。但是还有个问题,这个类型相当于对硬件进行群发。因为硬件都订阅一个主题。因此实际使用时,springboot可能会向 a/设备唯一编号 来发送消息,这样保证只有对应硬件收到。 注意:没有创建主题的说法,想发直接发,想订阅直接订阅。相当于对暗号,对上了就行。 注意:硬件最好使用域名来连接服务器,IP地址换了一个服务器就不能用了。但是域名可以重新指向另一个IP。 创建一个mqtt服务器 1.我使用的是EMQX的mqtt开源版服务器,访问连接 安装 EMQX 开源版 | EMQX文档 下载 EMQX 开源版 快速开始 | EMQX文档 选择一个方式进行部署,然后下载它上面的客户端 MQTTX:全功能 MQTT 客户端工具 部署好后就可以使用springboot来连接了。 springboot上编写代码来连接与订阅和发消息 老规矩,先导入依赖, 1.在根目录下的pom.xml中增加 [code]1、properties模块中增加 <mqtt.version>1.2.5</mqtt.version> 2、在dependencies中增加 <!-- mqtt--> <dependency> <groupId>org.eclipse.paho</groupId> <artifactId>org.eclipse.paho.client.mqttv3</artifactId> <version>${mqtt.version}</version> </dependency> [/code]2.在admin中的pom.xml增加 [code]1、在dependencies中增加 <!-- mqtt--> <dependency> <groupId>org.eclipse.paho</groupId> <artifactId>org.eclipse.paho.client.mqttv3</artifactId> </dependency> [/code]**在ruoyi-admin模块下的src/main/java/com/ruoyi 下创建mqtt文件夹,并创建三个类 MqttConfig ,MqttPublishService,MqttSubscribeService **,类中代码如下: MqttConfig [code]package com.ruoyi.mqtt; import org.eclipse.paho.client.mqttv3.MqttClient; import org.eclipse.paho.client.mqttv3.MqttConnectOptions; import org.eclipse.paho.client.mqttv3.MqttException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MqttConfig { // 连接mqtt的地址 如:tcp://127.0.0.1:1883 private final String brokerUrl = "tcp://175.178.3.218:1883"; // 此程序的自定义名称,方便mqtt服务器辨识 private final String clientId = "lvGuiService"; // 连接mqtt服务器的账号 admin private final String username = "admin"; // 连接mqtt服务器的密码 lvguidianzi2023 private final String password = "lvguidianzi2023"; @Bean public MqttClient mqttClient() throws MqttException { MqttClient client = new MqttClient(brokerUrl, clientId); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); // false: 会话保留,重新连接时无需重新订阅。且会保留消息 options.setUserName(username); options.setPassword(password.toCharArray()); options.setConnectionTimeout(10); // 连接超时时间 options.setAutomaticReconnect(true); // 自动重连; client.connect(options); return client; } } [/code]MqttPublishService [code]package com.ruoyi.mqtt; import org.eclipse.paho.client.mqttv3.MqttClient; import org.eclipse.paho.client.mqttv3.MqttException; import org.eclipse.paho.client.mqttv3.MqttMessage; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; // 发送消息 @Service public class MqttPublishService { @Autowired private MqttConfig mqttConfig; public void publish(String topic, String payload) throws MqttException { MqttClient mqttClient = mqttConfig.mqttClient(); MqttMessage message = new MqttMessage(payload.getBytes()); message.setQos(0); // 设置消息的QoS mqttClient.publish(topic, message); } } [/code]MqttSubscribeService [code]package com.ruoyi.mqtt; import org.eclipse.paho.client.mqttv3.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Service; // 订阅主题 @Service public class MqttSubscribeService implements ApplicationRunner { @Autowired private MqttConfig mqttConfig; @Autowired private MqttPublishService mqttMessageService; // 这个是服务器需要订阅的主题 private String service = "CY/service/#"; // 这个是设备需要订阅的主题,同时也是服务器需要发送消息的主题一部分,后面会拼接设备唯一标识符 private String devices = "CY/devices"; private static final Logger log = LoggerFactory.getLogger(MqttSubscribeService.class); @Override public void run(ApplicationArguments args) throws Exception { MqttClient mqttClient = mqttConfig.mqttClient(); // 订阅的主题 mqttClient.setCallback(new MqttCallbackExtended() { @Override public void connectComplete(boolean b, String s) { log.warn("是否重连成功:{},连接地址:{}",b,s); // 开启监听 try { mqttClient.subscribe(service, 2); } catch (MqttException e) { throw new RuntimeException(e); } log.info("订阅主题:{}", service); } @Override public void messageArrived(String topic, MqttMessage message) throws Exception { String msgTemp = new String(message.getPayload()); if (msgTemp.isEmpty()){ return; } // 分割消息 String[] msgList = msgTemp.split(","); if (msgTemp.length() == 1){ log.error("消息长度不正确 - {}",msgTemp); return ; } } @Override public void connectionLost(Throwable cause) { log.error("连接丢失: {}", cause.getMessage()); } @Override public void deliveryComplete(IMqttDeliveryToken token) { // System.out.println("deliveryComplete: " + token.isComplete()); } }); mqttClient.subscribe(service, 2); log.info("订阅主题:{}", service); } } [/code]第三方授权登录(微信小程序登陆)关于如何让若依集成微信小程序登录是比较头疼的地方,也是花费最多心力的地方。最终找到了接下来相对满意的解决方案。 从若依的代码中可以分析出两种大致方向。
要完成此目的我们需要先搞明白原有的登录与认证流程: 安全框架登录流程**前端点击登录后会找到ruoyi-admin下的/web/controller/system/SysLoginController.java。**并执行如下代码: [code] /** * 登录方法 * * @param loginBody 登录信息 * @return 结果 */ @PostMapping("/login") public AjaxResult login(@RequestBody LoginBody loginBody) { AjaxResult ajax = AjaxResult.success(); // 生成令牌 String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), loginBody.getUuid()); ajax.put(Constants.TOKEN, token); return ajax; } [/code]这个没啥好讲的,调用方法获取token然后返回,所以我们主要看loginService.login(loginBody.getUsername(),loginBody.getPassword(), loginBody.getCode(),loginBody.getUuid()) 方法。 在ruoyi-framework模块下的com.ruoyi.framework.web.service.SysLoginService [code] /** * 登录验证 * * @param username 用户名 * @param password 密码 * @param code 验证码 * @param uuid 唯一标识 * @return 结果 */ public String login(String username, String password, String code, String uuid) { // 验证码校验 validateCaptcha(username, code, uuid); // 登录前置校验,(用户名或密码为空,密码如果不在指定范围内,...) loginPreCheck(username, password); // 用户验证 Authentication authentication = null; try { // 这里之所以使用authenticationToken是为了方便UserDetailsServiceImpl.loadUserByUsername执行时获取用户信息(用户名)。 // 然后就没有其他用处了,UserDetailsServiceImpl.loadUserByUsername是用来验证用户是否可用 // UsernamePasswordAuthenticationToken实现了Authentication接口 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); // 这个参数必须是Authentication类型 AuthenticationContextHolder.setContext(authenticationToken); // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager.authenticate(authenticationToken); } catch (Exception e) { if (e instanceof BadCredentialsException) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match"))); throw new UserPasswordNotMatchException(); } else { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage())); throw new ServiceException(e.getMessage()); } } finally { AuthenticationContextHolder.clearContext(); } // 这种是调用若依自创的线程池来进行异步日志记录,上面的也是。 AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success"))); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 记录登录信息 recordLoginInfo(loginUser.getUserId()); // 生成token (这个在讲完安全框架再讲) return tokenService.createToken(loginUser); } [/code]验证码与前置校验都没什么好讲的,我主要讲解安全框架内容。 之所以要定义 UsernamePasswordAuthenticationToken [code]UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); [/code]是因为的下面的参数必须是Authentication 类型,同时UsernamePasswordAuthenticationToken类型变量是用来封装用户名密码,然后作为参数传入。而下面代码的作用可以理解为:作用是临时储存用户信息为后面流程提供用户信息。临时的范围可以理解此次请求。 [code] AuthenticationContextHolder.setContext(authenticationToken); [/code]这个是自定义的,完整代码在com.ruoyi.framework.security.context: [code]/** * 身份验证信息 * * @author ruoyi */ public class AuthenticationContextHolder { private static final ThreadLocal<Authentication> contextHolder = new ThreadLocal<>(); public static Authentication getContext() { return contextHolder.get(); } public static void setContext(Authentication context) { contextHolder.set(context); } public static void clearContext() { contextHolder.remove(); } } [/code]这个会在密码校验时用到。 但是有有一个问题,为什么在 finally 模块中已经将 AuthenticationContextHolder.clearContext(); 清除了,但是下面的LoginUser loginUser = (LoginUser) authentication.getPrincipal(); 还是能获取到呢? 你可以理解为一个是使用 AuthenticationContextHolder 存储,然后被清除了。 一个是 接收了authenticationManager.authenticate(authenticationToken); 的返回值。而这个返回值虽然也是 Authentication对象,但是是存储在 SecurityContextHolder 中,也就是后面请求进来时的认证我们需要使用到的。因此,他们两个是独立的。 而且 authenticationManager.authenticate(authenticationToken); 是调用 UserDetailsServiceImpl.loadUserByUsername。方法的,从这个方法的返回值可以看到,是 UserDetails 表明,用户信息是有被存储到 SecurityContextHolder 中。 注:调用 UserDetailsServiceImpl.loadUserByUsername。方法只是 authenticationManager.authenticate(authenticationToken); 方法的其中一个环节。因此前者的返回值不是 UserDetails 。 那在来讲 UserDetailsServiceImpl.loadUserByUsername 方法。 这个方法不是原来安全框架的方法,是实现了 UserDetailsService 接口然后重写了 loadUserByUsername 方法。代码如下: 文件位置:ruoyi-framework模块下的 com.ruoyi.framework.web.service.UserDetailsServiceImpl [code]public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userService.selectUserByUserName(username); if (StringUtils.isNull(user)) { log.info("登录用户:{} 不存在.", username); throw new ServiceException(MessageUtils.message("user.not.exists")); } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) { log.info("登录用户:{} 已被删除.", username); throw new ServiceException(MessageUtils.message("user.password.delete")); } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) { log.info("登录用户:{} 已被停用.", username); throw new ServiceException(MessageUtils.message("user.blocked")); } passwordService.validate(user); return createLoginUser(user); } public UserDetails createLoginUser(SysUser user) { return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user)); } [/code]可以看到,大致流程就是判断用户情况。这个没啥好讲,最后是调用 passwordService.validate(user); 我们直接看代码,在ruoyi-framework模块下的 com.ruoyi.framework.web.service.SysPasswordService [code]public void validate(SysUser user) { Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext(); String username = usernamePasswordAuthenticationToken.getName(); String password = usernamePasswordAuthenticationToken.getCredentials().toString(); Integer retryCount = redisCache.getCacheObject(getCacheKey(username)); if (retryCount == null) { retryCount = 0; } if (retryCount >= Integer.valueOf(maxRetryCount).intValue()) { throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime); } if (!matches(user, password)) { retryCount = retryCount + 1; redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES); throw new UserPasswordNotMatchException(); } else { clearLoginRecordCache(username); } } [/code]可以看到,第一行便是在我们最开始设置的临时用户信息中取出用户信息 AuthenticationContextHolder.getContext(); 提取完成后就是密码输入次数校验和密码是否一致校验,这个就不细讲,主要讲安全框架。 最后我们回到 loadUserByUsername方法,可以知道它的返回值是 UserDetails 类型。 如此登录流程完结。 注意:因为 最开始的登录方法中需要获取LoginUser loginUser = (LoginUser) authentication.getPrincipal();用户信息,所以这里返回值是这个,如果不需要那么这里返回值其实可以为null。这个小知识在微信小程序登录时会用到。如果可以为null,那么表示在整个依托于安全框架的登录流程中是可以不需要储存任何信息到安全框架中的,这个很重要。 安全框架认证流程因为代码太多,就不全部贴出来,只贴主要流程,可定位自己对照看,文件地址: ruoyi-framework模块下的com.ruoyi.framework.config.SecurityConfig 1、authenticationManager():这个方法就是设置 loadUserByUsername方法的地方。因为若依是自定义的loadUserByUsername方法。 我们可以看到,有三个过滤器:退出处理类,认证失败处理类,token认证过滤器。 前两个过滤器好理解,一个是退出登录用到的,另一个如果认证失败会用到的。都是实现了接口然后自己写逻辑。接下来就遇到了我最疑惑的问题。是怎么知道某个请求认证失败了? 我们看token认证过滤器代码,在 com.ruoyi.framework.security.filter 下: [code]@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { LoginUser loginUser = tokenService.getLoginUser(request); if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) { tokenService.verifyToken(loginUser); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } chain.doFilter(request, response); } [/code]流程很简单,先通过 request 获取用户信息,大致通过请求中的token然后到redis中获取。(后面细讲) 如果发现返回值为null或者 SecurityUtils.getAuthentication() 不为空,就不执行下面代码。直接执行后续过滤器。 1、我们追踪 SecurityUtils.getAuthentication() 会发现最终是 SecurityContextHolder.getContext().getAuthentication()。 我们还要明确一个事情 SecurityContextHolder 的作用域是当前线程,也就是单个请求。 因此,每一个请求,无论登录与否。进入到 StringUtils.isNull(SecurityUtils.getAuthentication()) 它的值应该是空的,除非有在它之前的过滤器存储过了。 那么什么时候请求显示未认证就很明显了,当从请求中无法获取token,从而无法在redis中回去用户信息时。又加上此时 SecurityContextHolder 里面没有认证信息。所以会触发认证失败。 如果当请求中有token且在redis中获取到了用户信息。此时进入if。 [code]tokenService.verifyToken(loginUser); // 如果存储在redis的用户信息过期时间不足20分钟就刷新时间 [/code]注:因为信息存储在redis里面,设置了过期时间,如果过期了会自动删除数据。所以只要能获取到用户信息表示没过期。 [code]UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); [/code]解决用户信息过期问题后,接下来就定义 UsernamePasswordAuthenticationToken变量,然后设置信息并存储到 SecurityContextHolder 中。那么只要里面有了信息,就不会出现认证失败的问题,同时整个请求都可以使用这个用户信息。 以上便是整个认证流程。 Token与Redis在若依中,我们将用户信息存储到redis时,k值并不是前端传递的token。v值是用户信息 我们把眼光拉回 ruoyi-framework 模块下的com.ruoyi.framework.web.service.SysLoginService 类,也就是登录方法。在最后有: [code]// 生成token return tokenService.createToken(loginUser); [/code]进入这个方法会看到: [code]/** * 创建令牌 * * @param loginUser 用户信息 * @return 令牌 */ public String createToken(LoginUser loginUser) { // 生成一个uuid String token = IdUtils.fastUUID(); // 将uuid存储到用户信息中 loginUser.setToken(token); // 设置设置用户代理信息 setUserAgent(loginUser); // 将用户信息存储到redis refreshToken(loginUser); // 生成最终token Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); return createToken(claims); } [/code]注释已经写好了,我们重点看refreshToken(loginUser); [code]/** * 刷新令牌有效期 * * @param loginUser 登录信息 */ public void refreshToken(LoginUser loginUser) { // 设置登录时间 loginUser.setLoginTime(System.currentTimeMillis()); // 设置过期时间,这里是用来后面验证令牌有效期,相差不足20分钟,自动刷新缓存用的 loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE); // 根据uuid与前缀将loginUser缓存 String userKey = getTokenKey(loginUser.getToken()); // 存储到redis redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES); } private String getTokenKey(String uuid) { return CacheConstants.LOGIN_TOKEN_KEY + uuid; } [/code]从上面可以知道,真正的k,是前缀+uuid 那么,是怎么从token中获得用户信息呢?也就是token怎么从Redis中获取用户信息 我们回到创建token的代码: [code]// 生成最终token Map<String, Object> claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token); return createToken(claims); private String createToken(Map<String, Object> claims) { String token = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret).compact(); return token; } [/code]token生成算法我们不关心,但是我们知道 Map的key值为Constants.LOGIN_USER_KEY , v值就是我们需要的uuid,然后通过前缀+uuid作为key从Redis中取出用户信息。前缀是定义好的,不会变。所以uuid获取很重要。 我们可以看到 createToken方法将 claims 作为参数生成了token。那么一定会有一个方法能通过token变成 claims。这个方法就在同类中的 parseToken [code]private Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } [/code]那么我们来看同类下另一个获取用户信息的方法,也是请求验证时获取用户信息的方法: [code]public LoginUser getLoginUser(HttpServletRequest request) { // 获取请求携带的令牌,就是把 "Bearer " 去除 String token = getToken(request); if (StringUtils.isNotEmpty(token)) { try { // 这个就是调用刚才token变`claims` 的方法 Claims claims = parseToken(token); // 解析对应的权限以及用户信息 // 通过固定Key值Constants.LOGIN_USER_KEY来获取uuid。这个值在创建时也是作为key值存储在map中 String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); // 我们在存储用户信息到redis时是加了一个前缀的,这里就是拼接这个前缀。 String userKey = getTokenKey(uuid); // 最后从redis中取出用户信息 LoginUser user = redisCache.getCacheObject(userKey); return user; } catch (Exception e) { log.error("获取用户信息异常'{}'", e.getMessage()); } } return null; } [/code]最后我们再将权限校验,讲完这个就大致解决安全框架的问题了 若依自定义的权限校验正常情况下,安全框架是有自己校验的注解的,放在方法上来判断是否有访问这个接口的权限。但是这个需要我们在设置SecurityContextHolder时加入权限信息。也就是在登录与请求认证时加入 [code]// 第一个参数是用户信息,第二个是密码,第三个是权限信息 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); [/code]但是从上面代码中可以看出,若依是将权限信息参数设置为null (注:loginUser.getAuthorities() 返回值是 null) 所有可以肯定若依没有使用安全框架的权限校验注解,而是自己写的。 我们把视角转到任意请求接口方法上面,比如说: [code]/** * 获取用户列表 */ @PreAuthorize("@ss.hasPermi('system:user:list')") @GetMapping("/list") public TableDataInfo list(SysUser user) { startPage(); List<SysUser> list = userService.selectUserList(user); return getDataTable(list); } [/code]主要看:@PreAuthorize("@ss.hasPermi('system:user:list')")。这个注解的返回值需要是字符串,就是true或者false的字符串形式来确认是通过还是不通过。 @ss.hasPermi('system:user:list')就是自定义的权限处理逻辑。我们点进去看看 ruoyi-framework模块下的com.ruoyi.framework.web.service.PermissionService [code]/** * 验证用户是否具备某权限 * * @param permission 权限字符串 * @return 用户是否具备某权限 */ public boolean hasPermi(String permission) { if (StringUtils.isEmpty(permission)) { return false; } // SecurityUtils.getLoginUser():是从安全框架中获取的用户信息 LoginUser loginUser = SecurityUtils.getLoginUser(); // loginUser.getPermissions()就是这个用户的权限列表 // 判断两者不为空,为空就返回false if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())) { return false; } // 这个就是将这个权限字符暂时存储,作用域就是此次请求。方便后面的service和数据层等其他地方假如需要使用权限判断的方法用 PermissionContextHolder.setContext(permission); // 用户的权限列表是一个set集合,所以就是判断传入的权限字符在不在这个列表中 return hasPermissions(loginUser.getPermissions(), permission); } [/code]加入微信小程序登录接下来我不会对代码进行详细解释,只事先讲解大致流程。 登录流程 先编写微信小程序的请求封装文件,然后调用微信小程序登录方法获取code。然后调用后端微信小程序登录接口。然后后端通过code来获取openId。最后判断是否已经存在此openId在数据库。存在就修改登录时间与IP,不存在就新增并设置用户名。最后是生成token。 认证 先设置微信登录接口可匿名访问,然后新增微信登录JWT校验过滤器类。在过滤器中逻辑和若依的JWT过滤器大致相同,保证如果有token就将用户信息写入到认证中。保证不会出现认证失败。这里重点需要修改原JWT过滤器中代码,就是增加一个if判断接口路径如果是微信的就直接略过。在微信过滤器中就是只处理微信接口。 代码 数据库新增: [code]CREATE TABLE `wx_auth_user` ( `auth_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '授权ID', `uuid` varchar(500) NOT NULL COMMENT '第三方平台用户唯一ID', `user_id` bigint(20) DEFAULT NULL COMMENT '系统用户ID', `user_name` varchar(30) DEFAULT NULL COMMENT '登录账号', `nick_name` varchar(30) DEFAULT '' COMMENT '用户昵称', `avatar` varchar(500) DEFAULT '' COMMENT '头像地址', `email` varchar(255) DEFAULT '' COMMENT '用户邮箱', `login_ip` varchar(255) DEFAULT NULL COMMENT '最后登录IP', `login_date` datetime DEFAULT NULL COMMENT '最后登录时间', `phone_number` varchar(255) DEFAULT NULL COMMENT '手机号码', `source` varchar(255) DEFAULT '' COMMENT '用户来源', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`auth_id`) ) ENGINE=InnoDB AUTO_INCREMENT=104 DEFAULT CHARSET=utf8mb4 COMMENT='第三方登录授权表' [/code]然后通过若依代码生成器增加这个表的实体类,mapper,service等文件。这个就不贴出来了。 然后在ruoyi-admin模块中的com.ruoyi.web.controller下增加 wx文件夹。在文件夹下新增类 WxLoginController ![]() 在application-dev和application-prod配置文件中增加,最终全部配置为,以dev为例 [code]# 用户配置 user: password: # 密码最大错误次数 maxRetryCount: 5 # 密码锁定时间(默认10分钟) lockTime: 10 # 日志配置,这里的配置会高于logback.xml的,只有设置debug才能显示sql logging: level: com.ruoyi: debug org.springframework: warn # token配置 token: # 令牌自定义标识 header: Authorization # 令牌密钥 secret: abcdefghijklmnopqrstuvwxyz # 令牌有效期(默认30分钟) expireTime: 300 # 微信登陆配置 weChat: appid: 填写自己的 appsecret: 填写自己的 openIdUrl: "https://api.weixin.qq.com/sns/jscode2session" # 令牌自定义标识 header: Authorization # 令牌密钥 secret: abcdefghijklmnopqrstuvwxyz # 令牌有效期(默认30分钟) expireTime: 1200 # 数据源配置 spring: # redis 配置 redis: # 地址 host: localhost # 端口,默认为6379 port: 6379 # 数据库索引 database: 0 # 密码 password: # 连接超时时间 timeout: 10s lettuce: pool: # 连接池中的最小空闲连接 min-idle: 0 # 连接池中的最大空闲连接 max-idle: 8 # 连接池的最大数据库连接数 max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms datasource: type: com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.cj.jdbc.Driver druid: # 主库数据源 master: url: jdbc:mysql://localhost:3306/ry-cy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 username: root password: 123456 # 从库数据源 slave: # 从数据源开关/默认关闭 enabled: false url: username: password: # 初始连接数 initialSize: 5 # 最小连接池数量 minIdle: 10 # 最大连接池数量 maxActive: 20 # 配置获取连接等待超时的时间 maxWait: 60000 # 配置连接超时时间 connectTimeout: 30000 # 配置网络超时时间 socketTimeout: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 timeBetweenEvictionRunsMillis: 60000 # 配置一个连接在池中最小生存的时间,单位是毫秒 minEvictableIdleTimeMillis: 300000 # 配置一个连接在池中最大生存的时间,单位是毫秒 maxEvictableIdleTimeMillis: 900000 # 配置检测连接是否有效 validationQuery: SELECT 1 FROM DUAL testWhileIdle: true testOnBorrow: false testOnReturn: false webStatFilter: enabled: true statViewServlet: enabled: true # 设置白名单,不填则允许所有访问 allow: url-pattern: /druid/* # 控制台管理用户名和密码 login-username: ruoyi login-password: 123456 filter: stat: enabled: true # 慢SQL记录 log-slow-sql: true slow-sql-millis: 1000 merge-sql: true wall: config: multi-statement-allow: true [/code]在ruoyi-common模块com.ruoyi.common.constant中增加类WxConstants ![]() 在ruoyi-framework模块中的com.ruoyi.framework.config下的SecurityConfig类修改为如下: [code]package com.ruoyi.framework.config; import com.ruoyi.framework.security.filter.WxJwtAuthenticationTokenFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.web.filter.CorsFilter; import com.ruoyi.framework.config.properties.PermitAllUrlProperties; import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter; import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl; import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl; /** * spring security配置 * * @author ruoyi */ @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true) @Configuration public class SecurityConfig { /** * 自定义用户认证逻辑 */ @Autowired private UserDetailsService userDetailsService; /** * 认证失败处理类 */ @Autowired private AuthenticationEntryPointImpl unauthorizedHandler; /** * 退出处理类 */ @Autowired private LogoutSuccessHandlerImpl logoutSuccessHandler; /** * token认证过滤器 */ @Autowired private JwtAuthenticationTokenFilter authenticationTokenFilter; /** * 微信token认证过滤器 */ @Autowired private WxJwtAuthenticationTokenFilter wxJwtAuthenticationTokenFilter; /** * 跨域过滤器 */ @Autowired private CorsFilter corsFilter; /** * 允许匿名访问的地址 */ @Autowired private PermitAllUrlProperties permitAllUrl; /** * 身份验证实现 */ @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); return new ProviderManager(daoAuthenticationProvider); } /** * anyRequest | 匹配所有请求路径 * access | SpringEl表达式结果为true时可以访问 * anonymous | 匿名可以访问 * denyAll | 用户不能访问 * fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录) * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 * hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问 * hasAuthority | 如果有参数,参数表示权限,则其权限可以访问 * hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 * hasRole | 如果有参数,参数表示角色,则其角色可以访问 * permitAll | 用户可以任意访问 * rememberMe | 允许通过remember-me登录的用户访问 * authenticated | 用户登录后可访问 */ @Bean protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity // CSRF禁用,因为不使用session .csrf(csrf -> csrf.disable()) // 禁用HTTP响应标头 .headers((headersCustomizer) -> { headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin()); }) // 认证失败处理类 .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) // 基于token,所以不需要session .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 注解标记允许匿名访问的url .authorizeHttpRequests((requests) -> { permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll()); // 对于登录login 注册register 验证码captchaImage 允许匿名访问 requests.antMatchers("/login", "/wx/login", "/register", "/captchaImage").permitAll() // 静态资源,可匿名访问 .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll() .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); }) // 添加Logout filter .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler)) // 添加JWT filter .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class) // 添加JWT filter .addFilterBefore(wxJwtAuthenticationTokenFilter, JwtAuthenticationTokenFilter.class) // 添加CORS filter .addFilterBefore(corsFilter, WxJwtAuthenticationTokenFilter.class) .addFilterBefore(corsFilter, LogoutFilter.class) .build(); } /** * 强散列哈希加密实现 */ @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } } [/code]![]() 修改JwtAuthenticationTokenFilter [code]package com.ruoyi.framework.security.filter; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.framework.web.service.TokenService; /** * token过滤器 验证token有效性 * * @author ruoyi */ @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private TokenService tokenService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 此过滤器为系统过滤器,当遇到微信小程序的请求时不予理睬 if (!request.getRequestURI().startsWith("/wx")){ LoginUser loginUser = tokenService.getLoginUser(request); if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) { tokenService.verifyToken(loginUser); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } chain.doFilter(request, response); } } [/code]新增WxJwtAuthenticationTokenFilter [code]package com.ruoyi.framework.security.filter; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.framework.web.service.WxTokenService; import com.ruoyi.wx.domain.LoginWxUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * 微信token过滤器 验证token有效性 * * @author ruoyi */ @Component public class WxJwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private WxTokenService wxTokenService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 此过滤器为系统过滤器,当遇到微信小程序的请求时不予理睬 if (request.getRequestURI().startsWith("/wx")){ LoginWxUser wxUserRequest = wxTokenService.getWxUserRequest(request); if (StringUtils.isNotNull(wxUserRequest) && StringUtils.isNull(SecurityUtils.getAuthentication())) { wxTokenService.verifyToken(wxUserRequest); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(wxUserRequest, null, null); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } chain.doFilter(request, response); } } [/code]新增WxLoginService [code]package com.ruoyi.framework.web.service; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.ruoyi.common.utils.http.HttpUtils; import com.ruoyi.common.utils.ip.IpUtils; import com.ruoyi.wx.domain.LoginWxUser; import com.ruoyi.wx.domain.WxAuthUser; import com.ruoyi.wx.mapper.WxAuthUserMapper; import com.ruoyi.wx.service.IWxAuthUserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; /** * 微信登录校验方法 * * @author ruoyi */ @Component public class WxLoginService { // 配置日志操作 private static final Logger logger = LoggerFactory.getLogger(WxLoginService.class); @Autowired IWxAuthUserService wxAuthUserService; @Autowired WxAuthUserMapper wxAuthUserMapper; @Autowired WxTokenService wxTokenService; @Value("${weChat.appid}") private String appId; @Value("${weChat.appsecret}") private String appSecret; @Value("${weChat.openIdUrl}") private String openIdUrl; /** * 登录方法 * @param code 微信小程序获取openId的code * @return token */ public String login(String code){ // 通过code获取openid String openid = getOpenid(code); // 判空 if (openid == null){ return null; } // 通过openId来重新数据库,有此用户就更新IP和登录时间,没就新增然后更新IP与登录时间 WxAuthUser wxAuthUser = recordLoginInfo(openid); LoginWxUser loginWxUser = new LoginWxUser(); loginWxUser.setWxAuthUser(wxAuthUser); // 生成token return wxTokenService.createToken(loginWxUser); } /** * 记录登录信息 * * @param openId 用户openId */ public WxAuthUser recordLoginInfo(String openId){ Date date = new Date(); WxAuthUser wxAuthUser = new WxAuthUser(); wxAuthUser.setUuid(openId); wxAuthUser.setLoginIp(IpUtils.getIpAddr()); wxAuthUser.setLoginDate(date); LambdaQueryWrapper<WxAuthUser> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(WxAuthUser::getUuid, openId); // 查询条件:uuid 等于 openId WxAuthUser wxAuthUser1 = wxAuthUserMapper.selectOne(queryWrapper); if (wxAuthUser1 == null){ // 新增用户 wxAuthUser.setCreateTime(date); // 设置用户名称 wxAuthUser.setUserName("微信用户_"+wxAuthUserMapper.selectCount(null)); wxAuthUserMapper.insertWxAuthUser(wxAuthUser); // 再查询一次 wxAuthUser = wxAuthUserMapper.selectOne(queryWrapper); }else { LambdaUpdateWrapper<WxAuthUser> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(WxAuthUser::getUuid, openId); wxAuthUserMapper.update(wxAuthUser, updateWrapper); // 赋值ID wxAuthUser.setAuthId(wxAuthUser1.getAuthId()); } return wxAuthUser; } // 获取openid public String getOpenid(String code){ // 获取openid :GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code // 请求参数应该是 name1=value1&name2=value2 的形式。 String param = "appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; // {"session_key":"ReNqAU5cdvyhDccRY3SkFg==","openid":"oBWIX6ZfRE63r32e9rhW69DoN5wA"} String json = HttpUtils.sendGet(openIdUrl, param); // 将字符串形式的json转换为json对象 JSONObject jsonObject = JSONObject.parseObject(json); // 取出openid Object openid = jsonObject.get("openid"); if (openid == null){ logger.error("WeChat Get openId error,code:{} json: {}",code, json); return null; } return (String) openid; } } [/code]新增WxTokenService [code]package com.ruoyi.framework.web.service; import com.ruoyi.common.constant.WxConstants; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.ServletUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.ip.AddressUtils; import com.ruoyi.common.utils.ip.IpUtils; import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.wx.domain.LoginWxUser; import eu.bitwalker.useragentutils.UserAgent; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; /** * 微信token验证处理,包含获取token,创建token,通过token获取微信用户信息,刷新token等等 * * @author ruoyi */ @Component public class WxTokenService { private static final Logger log = LoggerFactory.getLogger(WxTokenService.class); // 令牌自定义标识 @Value("${weChat.header}") private String header; // 令牌秘钥 @Value("${weChat.secret}") private String secret; // 令牌有效期(默认30分钟) @Value("${weChat.expireTime}") private int expireTime; protected static final long MILLIS_SECOND = 1000; protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND; private static final Long MILLIS_MINUTE_TEN = 2 * 60 * 60 * 1000L; @Autowired private RedisCache redisCache; /** * 通过token获取微信用户ID */ public Long getWxUserId(String token) { if (StringUtils.isNotEmpty(token) && token.startsWith(WxConstants.TOKEN_PREFIX)) { token = token.replace(WxConstants.TOKEN_PREFIX, ""); } if (StringUtils.isNotEmpty(token)) { try { Claims claims = parseToken(token); // 解析对应的权限以及用户信息 String uuid = (String) claims.get(WxConstants.WX_LOGIN_USER_KEY); String userKey = getTokenKey(uuid); LoginWxUser loginWxUser = (LoginWxUser)redisCache.getCacheObject(userKey); if (loginWxUser != null && loginWxUser.getWxAuthUser() != null && loginWxUser.getWxAuthUser().getAuthId() != null){ return loginWxUser.getWxAuthUser().getAuthId(); } } catch (Exception e) { log.error("获取微信用户信息异常'{}'", e.getMessage()); } } return null; } /** * 通过request获取用户身份信息 * * @return 用户信息 */ public LoginWxUser getWxUserRequest(HttpServletRequest request){ // 获取请求携带的令牌 String token = getToken(request); if (StringUtils.isNotEmpty(token)) { try { Claims claims = parseToken(token); // 解析对应的权限以及用户信息 String uuid = (String) claims.get(WxConstants.WX_LOGIN_USER_KEY); String userKey = getTokenKey(uuid); return redisCache.getCacheObject(userKey); } catch (Exception e) { log.error("获取微信用户信息异常'{}'", e.getMessage()); } } return null; } /** * 设置用户身份信息,并刷新token时间然后存储到redis */ public void setLoginWxUser(LoginWxUser loginWxUser) { if (StringUtils.isNotNull(loginWxUser) && StringUtils.isNotEmpty(loginWxUser.getToken())) { refreshToken(loginWxUser); } } /** * 删除用户身份信息 */ public void delLoginWxUser(String token) { if (StringUtils.isNotEmpty(token)) { Claims claims = parseToken(token); // 解析对应的权限以及用户信息 String uuid = (String) claims.get(WxConstants.WX_LOGIN_USER_KEY); String userKey = getTokenKey(uuid); redisCache.deleteObject(userKey); } } /** * 创建令牌 * * @param loginWxUser 微信用户信息 * @return 令牌 */ public String createToken(LoginWxUser loginWxUser) { String token = IdUtils.fastUUID(); loginWxUser.setToken(token); setUserAgent(loginWxUser); refreshToken(loginWxUser); Map<String, Object> claims = new HashMap<>(); claims.put(WxConstants.WX_LOGIN_USER_KEY, token); return createToken(claims); } /** * 从数据声明生成令牌 * * @param claims 数据声明 * @return 令牌 */ private String createToken(Map<String, Object> claims) { String token = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret).compact(); return token; } /** * 验证令牌有效期,相差不足20分钟,自动刷新缓存 * * @param loginWxUser * @return 令牌 */ public void verifyToken(LoginWxUser loginWxUser) { long expireTime = loginWxUser.getExpireTime(); long currentTime = System.currentTimeMillis(); if (expireTime - currentTime <= MILLIS_MINUTE_TEN) { refreshToken(loginWxUser); } } /** * 刷新令牌有效期 * * @param loginWxUser 登录信息 */ public void refreshToken(LoginWxUser loginWxUser) { loginWxUser.setLoginTime(System.currentTimeMillis()); loginWxUser.setExpireTime(loginWxUser.getLoginTime() + expireTime * MILLIS_MINUTE); // 根据uuid将loginUser缓存 String userKey = getTokenKey(loginWxUser.getToken()); redisCache.setCacheObject(userKey, loginWxUser, expireTime, TimeUnit.MINUTES); } /** * 从令牌中获取数据声明 * * @param token 令牌 * @return 数据声明 */ private Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public String getUsernameFromToken(String token) { Claims claims = parseToken(token); return claims.getSubject(); } /** * 获取请求token * * @param request * @return token */ private String getToken(HttpServletRequest request) { String token = request.getHeader(header); if (StringUtils.isNotEmpty(token) && token.startsWith(WxConstants.TOKEN_PREFIX)) { token = token.replace(WxConstants.TOKEN_PREFIX, ""); } return token; } /** * 设置用户代理信息 * * @param loginWxUser 登录信息 */ public void setUserAgent(LoginWxUser loginWxUser) { UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent")); String ip = IpUtils.getIpAddr(); loginWxUser.setIpaddr(ip); loginWxUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip)); loginWxUser.setBrowser(userAgent.getBrowser().getName()); loginWxUser.setOs(userAgent.getOperatingSystem().getName()); } private String getTokenKey(String uuid) { return WxConstants.WX_LOGIN_TOKEN_KEY + uuid; } } [/code]在ruoyi-system模块下的com.ruoyi.wx.domain新增LoginWxUser类 ![]() 前端部分注,登录的vue使用了全屏图片,需要自行解决。 ![]() request.js [code]// utils/request.js let loadingCount = 0; // loading计数器 const pendingRequests = new Map(); // 防止重复请求 const baseUrl = "http://127.0.0.1:8080" // 获取本地存储的Token function getToken() { return uni.getStorageSync('token') || ''; } const defaultConfig = { loading: true, // 默认显示loading showSuccess: false, // 默认不显示成功提示 showError: true, // 默认显示错误提示 successMsg: '操作成功', // 默认成功提示 errorMsg: '请求错误', // 默认错误提示 timeout: 10000, // 默认超时时间 auth: true // 默认需要认证 }; // 显示loading function showLoading() { if (loadingCount === 0) { uni.showLoading({ title: '加载中...', mask: true }); } loadingCount++; } // 隐藏loading function hideLoading() { loadingCount--; if (loadingCount <= 0) { uni.hideLoading(); loadingCount = 0; } } // 生成请求key function generateReqKey(config) { return `${config.method}-${config.url}-${JSON.stringify(config.data)}`; } // 处理响应错误(更新401处理) function handleResponseError(response) { const [error, res] = response; console.log("响应:",response) if (error) { return Promise.reject({ code: -1, msg: error.errMsg || '网络错误,请检查网络连接' }); } const { code, msg } = res.data; if (code !== 200) { if (code === 401) { // 清除本地token并跳转登录 uni.removeStorageSync('token'); // 提示是否需要登录 // uni.showModal({ // title: '提示', // content: "登录状态已过期,您可以继续留在该页面,或者重新登录?", // cancelText: '取消', // confirmText: '确定', // success: function(res) { // uni.navigateTo({ url: '/pages/login/login' }); // } // }) } return Promise.reject({ code, msg }); } return res.data; } // 请求核心方法(新增header处理) export function request(userConfig) { const config = { ...defaultConfig, ...userConfig }; const requestKey = generateReqKey(config); if (pendingRequests.has(requestKey)) { uni.showToast({ title: "请勿重复提交", icon: 'none', mask: true }) return Promise.reject({ code: -2, msg: '请勿重复提交' }); } pendingRequests.set(requestKey, true); // 自动携带Token逻辑 const baseHeader = { 'Content-Type': 'application/json' }; if (config.auth) { const token = getToken(); if (token) { baseHeader.Authorization = `Bearer ${token}`; } } // 合并headers(用户自定义header优先级最高) const mergedHeader = { ...baseHeader, ...(config.header || {}) }; if (config.loading) showLoading(); return new Promise((resolve, reject) => { uni.request({ url: baseUrl + config.url, method: config.method || 'GET', data: config.data || {}, header: mergedHeader, // 使用合并后的header timeout: config.timeout, success: (response) => { // 处理成功响应后自动存储Token(如登录接口) // if (response.data && response.data.token) { // uni.setStorageSync('token', response.data.token); // } const res = handleResponseError([null, response]); console.log("response:",response) if (config.showSuccess) { uni.showToast({ title: response.data.code==200 && config.successMsg ? config.successMsg:response.data.msg, icon: response.data.code==200?'success':'none', duration: 2000 }); } resolve(res); }, fail: (error) => { const res = handleResponseError([error, null]); if (config.showError) { uni.showToast({ title: res.msg || config.errorMsg, icon: 'none', duration: 2000 }); } reject(res); }, complete: () => { pendingRequests.delete(requestKey); if (config.loading) hideLoading(); } }); }); } export default request [/code]pages.json 因为登录页是全屏无标题,需要设置:"navigationStyle": "custom" [code]{ "path" : "pages/login/login", "style" : { "navigationBarTitleText" : "登录", "navigationStyle": "custom" } }, [/code]login.js [code]import request from '@/utils/request' // loading: false // 关闭loading // 登录方法 export function login(data) { return request({ 'url': '/wx/login', 'method': 'post', 'data': data, auth: false, // 关闭token认证 showSuccess: true, // 成功消息显示 successMsg: '登录成功' ,// 成功消息文本 }) } // 获取微信用户ID export function getWxId(data) { return request({ 'url': '/wx/getWxId', 'method': 'get' }) } [/code]index.vue [code]<template> <view class="container"> <!-- URL输入区域 --> <view class="input-group"> <input class="input" v-model="socketUrl" placeholder="请输入WebSocket地址(ws://)" /> <button class="btn" :disabled="isConnected" @tap="connect">连接</button> <button class="btn" :disabled="!isConnected" @tap="disconnect">断开</button> </view> <!-- 消息发送区域 --> <view class="input-group"> <input class="input" v-model="message" placeholder="请输入要发送的消息" @confirm="sendMessage" /> <button class="btn" :disabled="!isConnected" @tap="sendMessage">发送</button> </view> <!-- 消息接收区域 --> <view class="receive-box"> <scroll-view class="scroll-view" scroll-y> <view class="message-item" v-for="(item, index) in receiveMessages" :key="index"> {{ item }} </view> </scroll-view> </view> <button @tap="getWxId">获取微信用户ID</button> </view> </template> <script> import {getWxId} from "@/api/login.js" export default { data() { return { socketUrl: "ws://127.0.0.1:8081/ws?token=" + uni.getStorageSync('token'), // 默认测试地址 message: "", isConnected: false, socketTask: null, receiveMessages: [] }; }, methods: { getWxId(){ getWxId().then(res=>{ console.log("获取用户ID:",res) }) }, // 连接WebSocket connect() { if (!this.socketUrl) { uni.showToast({ title: "请输入WebSocket地址", icon: "none" }); return; } if (this.isConnected) { uni.showToast({ title: "已连接", icon: "none" }); return; } this.socketTask = uni.connectSocket({ url: this.socketUrl, success: () => { console.log("正在连接..."); }, fail: (err) => { console.error("连接失败:", err); uni.showToast({ title: "连接失败", icon: "none" }); } }); // 监听事件 this.socketTask.onOpen(() => { console.log("连接成功"); this.isConnected = true; uni.showToast({ title: "连接成功", icon: "none" }); }); this.socketTask.onError((err) => { console.error("发生错误:", err); this.isConnected = false; uni.showToast({ title: "连接错误", icon: "none" }); }); this.socketTask.onMessage((res) => { this.receiveMessages.push(`[接收] ${res.data}`); }); this.socketTask.onClose(() => { console.log("连接已关闭"); this.isConnected = false; }); }, // 断开连接 disconnect() { if (this.socketTask) { this.socketTask.close(); this.socketTask = null; uni.showToast({ title: "已断开", icon: "none" }); } }, // 发送消息 sendMessage() { if (!this.isConnected) { uni.showToast({ title: "未连接服务器", icon: "none" }); return; } if (!this.message.trim()) { uni.showToast({ title: "消息不能为空", icon: "none" }); return; } this.socketTask.send({ data: this.message, success: () => { this.receiveMessages.push(`[发送] ${this.message}`); this.message = ""; }, fail: (err) => { console.error("发送失败:", err); uni.showToast({ title: "发送失败", icon: "none" }); } }); } }, beforeDestroy() { if (this.socketTask) { this.socketTask.close(); } } }; </script> <style scoped> .container { padding: 20rpx; } .input-group { display: flex; margin-bottom: 20rpx; } .input { flex: 1; border: 1rpx solid #ccc; padding: 20rpx; margin-right: 20rpx; border-radius: 8rpx; } .btn { width: 150rpx; display: flex; justify-content: center; align-items: center; } .receive-box { border: 1rpx solid #ccc; border-radius: 8rpx; padding: 20rpx; min-height: 400rpx; } .scroll-view { height: 600rpx; } .message-item { padding: 10rpx 0; border-bottom: 1rpx solid #eee; color: #666; font-size: 28rpx; } </style> [/code]login.vue [code]<template> <view style="width: 100%; min-height: 100vh; display: flex;align-items: center; position: relative;"> <image style="filter: blur(3px); -webkit-filter: blur(3px); z-index: -1; position: absolute; width: 100%;min-height: 100vh;" src="@/static/登录风景.jpeg" mode="scaleToFill"> </image> <view style="padding-top: 30px; margin-left: 10%; width: 80%; background: rgba(255, 255, 255, 0.5); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 10px;"> <!-- 标题 --> <view style="padding: 20px; display: flex; align-items: center;justify-content: center;"> <!-- logo图案 --> <view> <image style="width: 80px;height: 80px;" src="@/static/商标_黑.png" mode="widthFix"> </image> </view> </view> <!-- title --> <view style="font-weight: bold; text-align: center; padding: 20px;padding-top: 0px; font-size: 24px;"> 臭氧监测管理系统 </view> <view style="width: 90%; margin-left: 5%; font-size: 12px; text-align: center;"> 欢迎回来!小程序可享受一键登录服务 </view> <!-- 按钮 --> <view @click="login()" style="margin: 20px; margin-bottom: 50px; width: 90%; margin-left: 5%; height: 40px; background-color: black; color: white; border-radius: 10px; font-size: 18px; display: flex; line-height: 40px; justify-content: center;"> 一键登录 </view> </view> </view> </template> <script> import {login} from "@/api/login.js" export default { data() { return { } }, onLoad() { }, methods: { login(){ // 获取code uni.login({ success: (res) => { console.log(res) login(res.code).then(resTemp => { console.log(resTemp) if(resTemp.code == 200){ uni.setStorageSync('token', resTemp.token); uni.reLaunch({ url:"/pages/index/index" }) } }) } }) }, } } </script> <style> </style> [/code]最后全部搞完了,测试可以通过微信小程序的获取用户ID按钮,然后debug后端代码。微信小程序还有简易的webSocket可以测试。 3、docker一键部署免责声明:本内容来源于网络,如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |