当前位置: 首页 > article >正文

若依前后端分离框架修改3.8.9版本(重点在安全框架讲解与微信小程序登录集成)

若依模板改造(3.8.9)

1、基础改造

下载代码

[RuoYi-Vue: 🎉 基于SpringBoot,Spring Security,JWT,Vue & Element 的前后端分离权限管理系统,同时提供了 Vue3 的版本](https://gitee.com/y_project/RuoYi-Vue)下载压缩文件代码。并解压到任意地方

使用idea打开项目

在这里插入图片描述

修改数据库密码

  • 修改数据库账号密码为本机MySQL数据库的账号密码,同时,若想修改数据库名称ry-vue 可提前在此处修改好,还没创建数据库时使用修改后的名称

在这里插入图片描述

下载vue依赖

右键ruoyi-ui,选择打开于->终端

在这里插入图片描述

输入命令npm i 等待下载完成。

创建/导入数据库

数据库名称我修改为ry-cy了,上面配置文件中也需要统一

找到项目代码,其中有一个sql文件夹,其下有两个 sql 文件。ry_20240629是主要sql。quartz.sql是定时任务的sql。如果不需要定时任务模块就不导入这个文件。

注:如果无法导入,也可选择打开ry_20240629文件,全选内容粘贴到数据库软件中全部执行。

在这里插入图片描述

在这里插入图片描述

删除定时任务模块

ruoyi-quartz 右键先 移除模块 再右键删除

在这里插入图片描述

删除根目录下的pom文件中的定时任务的依赖

在这里插入图片描述

在这里插入图片描述

删除admin模块的pml文件中的定时任务依赖

在这里插入图片描述

启动项目

springboot项目

在这里插入图片描述

修改前端显示若依的地方

若依后台管理系统

在这里插入图片描述

修改这两个文件的文字即可,可把界面上的文字更改,

在这里插入图片描述

修改网页标题
  • 在这里插入图片描述

  • 在这里插入图片描述

删除首页内容

在这里插入图片描述

在这里插入图片描述

删除若依官网

在这里插入图片描述

第一步,解除角色与若依官网菜单关系

在这里插入图片描述

第二步,删除若依官网菜单

在这里插入图片描述

修改部门中的名称

在这里插入图片描述

删除通知内容

在这里插入图片描述

删除前端github等标识

在这里插入图片描述

在这里插入图片描述

增加rediskey 值前缀

我们随便使用一个查看redis的 软件会发现,当登陆后redis 中会存在以下东西、此时会出现一个问题,如果一个服务器上只运行一个若依项目,那么这么做没问题,但是如果服务器上运行多个若依项目时,因为key值 的原因会导致项目紊乱,因此必须在每个项目前面增加前缀来区分。下面是没有前缀的图:

在这里插入图片描述

增加方法:

1、自定义序列化 redis 的类

ruoyi-common->common->constant 此路径下中创建 RedisKeySerializer 类,并将如下内容覆盖,且将最上面的包路径改为自己的(注:此类存放地方无强制要求)

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>
{@Autowiredprivate RuoYiConfig config;private final Charset charset;public RedisKeySerializer(){this(Charset.forName("UTF8"));}public RedisKeySerializer(Charset charset){Assert.notNull(charset, "字符集不允许为NULL");this.charset = charset;}@Overridepublic 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);}@Overridepublic String deserialize(byte[] bytes) throws SerializationException{return (bytes == null ? null : new String(bytes, charset));}
}

2、修改 ruoyi-framework -> config 包下的RedisConfig类中代码(三处)

@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;}

3、将ruoyi-admin -> controller -> monitor 下的CacheController 类中增加如下代码

@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, "密码错误次数"));
}

此时去admin的配置文件中修改name的值,后面name的值将会是key的前缀

观察此时的Redis,自此自定义Redis前缀完成

在这里插入图片描述

修改超级用户的用户名和密码

用户名直接在数据库修改,密码可以登陆后在后台修改,如果忘记密码可以重新生成密钥并替换,代码如下:

public static void main(String[] args)
{System.out.println(SecurityUtils.encryptPassword("admin123"));
}

admin 项目修改为多配置文件

多配置文件,就是将配置文件分为在开发时使用的,在测试时,在生产时使用的配置文件,因为不同环境下对应的IP,账号信息都不一样,如果手动更改会繁琐与容易出错,因此将不同环境下的配置使用不同文件储存,然后再由不同环境指定不同文件。

假如现在有俩环境:开发和生产。则配置文件有如下三个:

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 配置文件。

1、在配置文件中设置默认配置文件名称

在这里插入图片描述

2、创建dev和prod配置文件

  • 将原本的druid.yml的代码复制到dev和prod中,同时若application.yml中有需要分开发环境与生产环境的也可以一并剪切过来
  • 自行修改数据库,redis等等地方的代码

在这里插入图片描述

代码:

application.yml

# 项目相关配置
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端口,默认为8080port: 8080servlet:# 应用的访问路径context-path: /tomcat:# tomcat的URI编码uri-encoding: UTF-8# 连接数满后的排队数,默认为100accept-count: 1000threads:# tomcat最大线程数,默认为200max: 800# Tomcat启动初始化的线程数,默认值10min-spare: 100
# Spring配置
spring:# 资源信息messages:# 国际化资源文件路径basename: i18n/messagesprofiles: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: mysqlsupportMethodsArguments: trueparams: count=countSql# Swagger配置
swagger:# 是否开启swaggerenabled: true# 请求前缀pathMapping: /dev-api# 防止XSS攻击
xss:# 过滤开关enabled: true# 排除链接(多个用逗号分隔)excludes: /system/notice# 匹配链接urlPatterns: /system/*,/monitor/*,/tool/*

application-dev.yml (注:application-dev.yml文件与如下一致。但其中参数可自行修改,如数据库账号密码,redis密码,IP等 )

# 用户配置
user:password:# 密码最大错误次数maxRetryCount: 5# 密码锁定时间(默认10分钟)lockTime: 10
# 日志配置,这里的配置会高于logback.xml的,只有设置debug才能显示sql
logging:level:com.ruoyi: debugorg.springframework: warn
# token配置
token:# 令牌自定义标识header: Authorization# 令牌密钥secret: abcdefghijklmnopqrstuvwxyz# 令牌有效期(默认30分钟)expireTime: 300
# 数据源配置
spring:# redis 配置redis:# 地址host: localhost# 端口,默认为6379port: 6379# 数据库索引database: 0# 密码password:# 连接超时时间timeout: 10slettuce:pool:# 连接池中的最小空闲连接min-idle: 0# 连接池中的最大空闲连接max-idle: 8# 连接池的最大数据库连接数max-active: 8# #连接池最大阻塞等待时间(使用负值表示没有限制)max-wait: -1msdatasource:type: com.alibaba.druid.pool.DruidDataSourcedriverClassName: com.mysql.cj.jdbc.Driverdruid:# 主库数据源master:url: jdbc:mysql://localhost:3306/ry-cy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8username: rootpassword: 123456# 从库数据源slave:# 从数据源开关/默认关闭enabled: falseurl: username: password: # 初始连接数initialSize: 5# 最小连接池数量minIdle: 10# 最大连接池数量maxActive: 20# 配置获取连接等待超时的时间maxWait: 60000# 配置连接超时时间connectTimeout: 30000# 配置网络超时时间socketTimeout: 60000# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒timeBetweenEvictionRunsMillis: 60000# 配置一个连接在池中最小生存的时间,单位是毫秒minEvictableIdleTimeMillis: 300000# 配置一个连接在池中最大生存的时间,单位是毫秒maxEvictableIdleTimeMillis: 900000# 配置检测连接是否有效validationQuery: SELECT 1 FROM DUALtestWhileIdle: truetestOnBorrow: falsetestOnReturn: falsewebStatFilter: enabled: truestatViewServlet:enabled: true# 设置白名单,不填则允许所有访问allow:url-pattern: /druid/*# 控制台管理用户名和密码login-username: ruoyilogin-password: 123456filter:stat:enabled: true# 慢SQL记录log-slow-sql: trueslow-sql-millis: 1000merge-sql: truewall:config:multi-statement-allow: true

修改日志文件

2、插件集成

集成mybatisplus实现mybatis增强

Mybatis-Plus是在Mybatis的基础上进行扩展,只做增强不做改变,可以兼容Mybatis原生的特性。同时支持通用CRUD操作、多种主键策略、分页、性能分析、全局拦截等。极大帮助我们简化开发工作。

PS:不同版本有差别,如果需要使用最新的那么插件那一块可能需要安装最新文档进行修改

根目录下的pom.xml中增加两处

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>

2、ruoyi-common\pom.xml模块添加整合依赖

<!-- mybatis-plus 增强CRUD -->
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

3、ruoyi-admin文件application.yml,修改mybatis配置为mybatis-plus

# MyBatis Plus配置
mybatis-plus:# 搜索指定包别名typeAliasesPackage: com.ruoyi.**.domain# 配置mapper的扫描,找到所有的mapper.xml映射文件mapperLocations: classpath*:mapper/**/*Mapper.xml# 加载全局的配置文件configLocation: classpath:mybatis/mybatis-config.xml

3、添加Mybatis Plus配置MybatisPlusConfig.javaPS:原来的MyBatisConfig.java需要删除掉

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
{@Beanpublic 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();// 设置数据库类型为mysqlpaginationInnerInterceptor.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();}
}

4、修改原代码生成的代码,加入mybatis-plus代码

在mapper上与service,serviceImpl上继承这些类都是为了可以调用mybatis-plus帮我们写好的方法。如果只在mapper上继承,那么只能在service中使用,这样我们还需要在service层去创建方法使用,如果是在service上也定义,那么controller层可以直接调用

  • ruoyi-generator/resources/generator.yml中可修改配置

  • ruoyi-generator/resources/vm/mapper.java.vm中修改

    1、在import中增加
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;2、将 public interface ${ClassName}Mapper
    修改为:
    public interface ${ClassName}Mapper extends BaseMapper<${ClassName}>
    

    在这里插入图片描述

  • ruoyi-generator/resources/vm/service.java.vm中修改

    1、在import中增加
    import com.baomidou.mybatisplus.extension.service.IService;2、将public interface I${ClassName}Service
    修改为:public interface I${ClassName}Service extends IService<${ClassName}>
    

    在这里插入图片描述

  • ruoyi-generator/resources/vm/serviceImpl.java.vm中修改

    1、在import中增加
    import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;2、修改
    public class ${ClassName}ServiceImpl implements I${ClassName}Service
    为:
    public class ${ClassName}ServiceImpl extends ServiceImpl<${ClassName}Mapper, ${ClassName}> implements I${ClassName}Service
    

    在这里插入图片描述

  • domain.java.vm文件中修改,将时间格式从yyyy-MM-dd修改为yyyy-MM-dd HH:mm:ss

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "${comment}", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    

安装 MybatisX 插件

  1. 打开 IntelliJ IDEA。
  2. 进入 File -> Settings -> Plugins -> Browse Repositories
  3. 在搜索框中输入 mybatisx
  4. 找到 MybatisX 插件并点击安装。

用处:

1.XML 映射跳转:MybatisX 提供了便捷的 XML 映射文件与 Java 接口之间的跳转功能,让开发者能够快速地在两者之间切换,提高开发效率。

2.代码生成,通过 MybatisX,您可以轻松地根据数据库表结构生成对应的 Java 实体类、Mapper 接口及 XML 映射文件。

3.MybatisX 支持 JPA 风格的代码提示,包括新增、查询、修改和删除操作的自动代码生成。

集成actuator实现优雅关闭应用

优雅停机主要应用在版本更新的时候,为了等待正在工作的线程全部执行完毕,然后再停止。我们可以使用SpringBoot提供的Actuator

1、pom.xml中引入actuator依赖(在哪个模块里面使用就在哪个pom中引用,这里是在admin中)

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

2、配置文件中endpoint开启shutdown

management:endpoint:shutdown:enabled: trueendpoints:web:exposure:include: "shutdown"base-path: /monitor

3、在SecurityConfig中设置httpSecurity配置匿名访问(这个是测试用,无需登陆,如果要登陆就不配置匿名访问

.antMatchers("/monitor/shutdown").anonymous()

4、Post请求测试验证优雅停机 curl -X POST http://localhost:8080/monitor/shutdown

但是如果登陆后在页面随便创建一个按钮并创建API。执行请求时会发现,url显示为:http://localhost/dev-api/monitor/shutdown

正常情况下应该是:http://localhost:8080/monitor/shutdown。究其原因是:

前端向后端请求的url问题(请求代理)

问题:

在若依前后端分离框架中,前端需要向后端接口请求,就必须知道ip和端口号。但是为啥我在前端页面调试时网络一栏下很短请求的url是 http://localhost/dev-api/monitor/shutdown。没有显示端口号但又加了个dev-api。在哪里设置的逻辑是啥

解答:

在若依前后端分离框架中,前端请求 URL 中的 和隐藏端口号的逻辑是通过 前端代理配置 实现的,主要目的是简化开发环境下的跨域请求和路径管理。以下是关键点解释:/dev-api

  1. 为何隐藏端口号并添加 /dev-api?
  • 开发环境代理:前端开发时(如用Vue CLI),直接请求后端接口会触发浏览器的跨域限制。通过配置代理,前端将特定路径(如 )的请求转发到后端服务器,避免跨域问题。/dev-api
  • 路径统一管理:通过代理前缀(如 ),前端代码可以统一管理接口路径,无需硬编码后端IP和端口,适应不同环境(开发、测试、生产)。/dev-api
  1. 配置位置与逻辑

关键配置文件位于前端项目的 vue.config.js 中,配置示例如下:

Javascriptmodule.exports = {devServer: {proxy: {// 代理所有以 /dev-api 开头的请求'/dev-api': {target: 'http://localhost:8080', // 后端实际地址(含端口)changeOrigin: true, // 允许跨域pathRewrite: {'^/dev-api': '' // 移除路径中的 /dev-api 前缀}}}}
}
  • 工作流程
    1. 前端请求 (假设前端运行在80端口)。http://localhost/dev-api/monitor/shutdown
    2. Vue开发服务器拦截 开头的请求。/dev-api
    3. 代理将请求转发到 (如 ),隐藏真实端口。target``http://localhost:8080/monitor/shutdown
    4. 后端收到请求并响应,代理将结果返回给前端。
  1. 环境变量支持

若依通常结合 .env 环境文件 动态设置代理前缀,例如:

  • .env.development(开发环境):

    VUE_APP_BASE_API = /dev-api
    
  • .env.production(生产环境):

    VUE_APP_BASE_API = /prod-api
    
  • 代码中通过 获取当前环境的基础路径,实现多环境适配。process.env.VUE_APP_BASE_API

  1. 生产环境差异
  • 开发环境:依赖 代理,解决跨域和端口问题。vue.config.js

  • 生产环境:前端打包后, 会被替换为实际后端地址(如Nginx配置反向代理):

    /dev-api
    
    Nginxlocation /prod-api/ {proxy_pass http://backend-server:8080/;
    }
    

使用netty集成websocket

1.在根目录下的pom.xml中增加

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>

2.在admin中的pom.xml增加

1、在dependencies中增加
<!--        netty-->
<dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId>
</dependency>

在这里插入图片描述

3、在ruoyi-admin/src/main/java/com/ruoyi路径下增加文件夹webSocket

同时在webSocket文件夹下新增类WebSocketServer

注:

  • pipeline.addLast(new HttpObjectAggregator(65536));这里的数字表示一次性消息最大字节数
  • pipeline.addLast(new HttpRequestHandler());这个用来处理前端携带参数的路径,比如说携带token。比如说需要使用用户id来关联通道,然后在其他地方调用并发送消息。
  • 这里是开启了新线程来运行netty,没有使用主线程,因为会阻塞主线程。具体原因可百度

WebSocketServer

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 {@Autowiredprivate HttpRequestHandler httpRequestHandler; // 注入 Spring 管理的处理器private final int PORT = 8081;private EventLoopGroup bossGroup;private EventLoopGroup workerGroup;@Overridepublic 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>() {@Overrideprotected 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();}@Overridepublic void destroy() {shutdown();}private void shutdown() {if (bossGroup != null) {bossGroup.shutdownGracefully();}if (workerGroup != null) {workerGroup.shutdownGracefully();}}}

HttpRequestHandler

注:获取微信小程序用户ID的代码必须配合微信小程序登录的代码,不然无用。可自行选择替代方案,本质上就是寻找唯一Key作为沟通桥梁

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;@Autowiredpublic HttpRequestHandler(WxTokenService wxTokenService) {this.wxTokenService = wxTokenService;}@Overrideprotected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {// 检查是否为WebSocket握手请求if (isWebSocketUpgrade(req)) {System.out.println(req.uri());// 获取token来获取用户IDString 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);}
}

WebSocketFrameHandler

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> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {System.out.println("收到信息");System.out.println(msg.text());// 收到消息时回显给客户端ctx.writeAndFlush(new TextWebSocketFrame("Server received: " + msg.text()));}@Overridepublic void handlerAdded(ChannelHandlerContext ctx) throws Exception {System.out.println("连接....");// 客户端连接时发送欢迎消息ctx.writeAndFlush(new TextWebSocketFrame("Welcome to the WebSocket server!"));}@Overridepublic void handlerRemoved(ChannelHandlerContext ctx) throws Exception {// 客户端断开连接时处理System.out.println("Client disconnected: " + ctx.channel().id().asLongText());// 从map集合中移除WebSocketUsers.remove(ctx.channel());}// 发生异常时@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {cause.printStackTrace();// 从map集合中移除WebSocketUsers.remove(ctx.channel());ctx.close();}
}

WebSocketUsers

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>();// 在连接中储存idprivate 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);}}
}

测试部分

在后端接口类中随便找一个类写下,这个是用来测试后端收到连接后主动发消息功能

@GetMapping("/testWebSocket")
public AjaxResult testWebSocket(){// 主动发送消息,当链接webSocket后// 获取id,这里固定为 1WebSocketUsers.sendMessageWebSocket("1","测试消息");return AjaxResult.success();}

前端写了个测试的vue代码,我写在了首页里面。主要是用来测试登陆后才可连接,以及若同一用户多次连接是否会断开旧连接。

  • <div @click="shutdown">停机</div>这个是上上面优雅停机时用来测试停机的代码
  • 下面代码只是用来测试,实际使用需要按需要自己修改。

**在 src/api/login.js**文件中增加

注:注意url要一致

// 停机
// 退出方法
export function shutdown() {return request({url: '/monitor/shutdown',method: 'post'})
}// 测试webSocket主动消息
export function testWebSocket() {return request({url: '/system/config/testWebSocket',method: 'get'})
}

在表示首页的vue中编写如下内容

<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 URLmessage: '', // 发送的消息text_content: '', // 消息记录};},methods: {testWebSocket,shutdown,goTarget(href) {window.open(href, "_blank");},// 连接到 WebSocketjoin() {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>

在这里插入图片描述

使用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中增加

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>

2.在admin中的pom.xml增加

1、在dependencies中增加
<!--            mqtt-->
<dependency><groupId>org.eclipse.paho</groupId><artifactId>org.eclipse.paho.client.mqttv3</artifactId>
</dependency>

**在ruoyi-admin模块下的src/main/java/com/ruoyi 下创建mqtt文件夹,并创建三个类 MqttConfigMqttPublishServiceMqttSubscribeService **,类中代码如下:

MqttConfig

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:1883private final String brokerUrl = "tcp://175.178.3.218:1883";// 此程序的自定义名称,方便mqtt服务器辨识private final String clientId = "lvGuiService";// 连接mqtt服务器的账号 adminprivate final String username = "admin";// 连接mqtt服务器的密码 lvguidianzi2023private final String password = "lvguidianzi2023";@Beanpublic 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;}
}

MqttPublishService

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 {@Autowiredprivate MqttConfig mqttConfig;public void publish(String topic, String payload) throws MqttException {MqttClient mqttClient = mqttConfig.mqttClient();MqttMessage message = new MqttMessage(payload.getBytes());message.setQos(0); // 设置消息的QoSmqttClient.publish(topic, message);}
}

MqttSubscribeService

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 {@Autowiredprivate MqttConfig mqttConfig;@Autowiredprivate MqttPublishService mqttMessageService;// 这个是服务器需要订阅的主题private String service = "CY/service/#";// 这个是设备需要订阅的主题,同时也是服务器需要发送消息的主题一部分,后面会拼接设备唯一标识符private String devices = "CY/devices";private static final Logger log = LoggerFactory.getLogger(MqttSubscribeService.class);@Overridepublic void run(ApplicationArguments args) throws Exception {MqttClient mqttClient = mqttConfig.mqttClient();// 订阅的主题mqttClient.setCallback(new MqttCallbackExtended() {@Overridepublic 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);}@Overridepublic 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 ;}}@Overridepublic void connectionLost(Throwable cause) {log.error("连接丢失: {}", cause.getMessage());}@Overridepublic void deliveryComplete(IMqttDeliveryToken token) {//                System.out.println("deliveryComplete: " + token.isComplete());}});mqttClient.subscribe(service, 2);log.info("订阅主题:{}", service);}}

第三方授权登录(微信小程序登陆)

关于如何让若依集成微信小程序登录是比较头疼的地方,也是花费最多心力的地方。最终找到了接下来相对满意的解决方案。

从若依的代码中可以分析出两种大致方向。

  • 让微信小程序用户与现有的用户共用一张表,这个看起来不错,但实际上C端用户与管理员用户我认为不应该放在一张表里面,其次,如果真放在一张表,那么代码的耦合,修改以及为了适应微信小程序登录而进行的用户密码逻辑设定都不够优雅。可能会导致微信小程序用户直接登录到后台,包括权限方面也不好处理。
  • 让微信小程序用户单独使用一张表。那么这个方案就需要考虑如何让用户与微信小程序用户共用一个安全框架。最简单无脑的方法就是过滤所有微信小程序的请求,然后返回给微信小程序用户token来作为后续通过token与redis获得微信小程序用户信息的方法。但是这样就导致即使没token也可以访问所有请求。而且权限方面也相当于没有。所以只能是既分表也使用安全框架的请求认证。因此,我们最终目的是:微信小程序使用openId登录,然后返回token。在后续微信请求中拦截未认证的请求,即无token的请求。

要完成此目的我们需要先搞明白原有的登录与认证流程:

安全框架登录流程

**前端点击登录后会找到ruoyi-admin下的/web/controller/system/SysLoginController.java。**并执行如下代码:

 /*** 登录方法* * @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;}

这个没啥好讲的,调用方法获取token然后返回,所以我们主要看loginService.login(loginBody.getUsername(),loginBody.getPassword(), loginBody.getCode(),loginBody.getUuid())

方法。

ruoyi-framework模块下的com.ruoyi.framework.web.service.SysLoginService

    /*** 登录验证* * @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.loadUserByUsernameauthentication = 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);}

验证码与前置校验都没什么好讲的,我主要讲解安全框架内容。

之所以要定义 UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

是因为的下面的参数必须是Authentication 类型,同时UsernamePasswordAuthenticationToken类型变量是用来封装用户名密码,然后作为参数传入。而下面代码的作用可以理解为:作用是临时储存用户信息为后面流程提供用户信息。临时的范围可以理解此次请求。

 AuthenticationContextHolder.setContext(authenticationToken);

这个是自定义的,完整代码在com.ruoyi.framework.security.context

/*** 身份验证信息* * @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();}
}

这个会在密码校验时用到。

但是有有一个问题,为什么在 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

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));}

可以看到,大致流程就是判断用户情况。这个没啥好讲,最后是调用 passwordService.validate(user);

我们直接看代码,在ruoyi-framework模块下的 com.ruoyi.framework.web.service.SysPasswordService

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);}}

可以看到,第一行便是在我们最开始设置的临时用户信息中取出用户信息 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 下:

@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);
}

流程很简单,先通过 request 获取用户信息,大致通过请求中的token然后到redis中获取。(后面细讲)

如果发现返回值为null或者 SecurityUtils.getAuthentication() 不为空,就不执行下面代码。直接执行后续过滤器。

1、我们追踪 SecurityUtils.getAuthentication() 会发现最终是 SecurityContextHolder.getContext().getAuthentication()

我们还要明确一个事情 SecurityContextHolder 的作用域是当前线程,也就是单个请求。

因此,每一个请求,无论登录与否。进入到 StringUtils.isNull(SecurityUtils.getAuthentication()) 它的值应该是空的,除非有在它之前的过滤器存储过了。

那么什么时候请求显示未认证就很明显了,当从请求中无法获取token,从而无法在redis中回去用户信息时。又加上此时 SecurityContextHolder 里面没有认证信息。所以会触发认证失败。

如果当请求中有token且在redis中获取到了用户信息。此时进入if。

tokenService.verifyToken(loginUser); // 如果存储在redis的用户信息过期时间不足20分钟就刷新时间

注:因为信息存储在redis里面,设置了过期时间,如果过期了会自动删除数据。所以只要能获取到用户信息表示没过期。

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);

解决用户信息过期问题后,接下来就定义 UsernamePasswordAuthenticationToken变量,然后设置信息并存储到 SecurityContextHolder 中。那么只要里面有了信息,就不会出现认证失败的问题,同时整个请求都可以使用这个用户信息。

以上便是整个认证流程。

Token与Redis

在若依中,我们将用户信息存储到redis时,k值并不是前端传递的token。v值是用户信息

我们把眼光拉回 ruoyi-framework 模块下的com.ruoyi.framework.web.service.SysLoginService 类,也就是登录方法。在最后有:

// 生成token
return tokenService.createToken(loginUser);

进入这个方法会看到:

/*** 创建令牌** @param loginUser 用户信息* @return 令牌*/
public String createToken(LoginUser loginUser)
{// 生成一个uuidString token = IdUtils.fastUUID();// 将uuid存储到用户信息中loginUser.setToken(token);// 设置设置用户代理信息setUserAgent(loginUser);// 将用户信息存储到redisrefreshToken(loginUser);// 生成最终tokenMap<String, Object> claims = new HashMap<>();claims.put(Constants.LOGIN_USER_KEY, token);return createToken(claims);
}

注释已经写好了,我们重点看refreshToken(loginUser);

/*** 刷新令牌有效期** @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());// 存储到redisredisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}private String getTokenKey(String uuid)
{return CacheConstants.LOGIN_TOKEN_KEY + uuid;
}

从上面可以知道,真正的k,是前缀+uuid

那么,是怎么从token中获得用户信息呢?也就是token怎么从Redis中获取用户信息

我们回到创建token的代码:

// 生成最终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;
}

token生成算法我们不关心,但是我们知道 Map的key值为Constants.LOGIN_USER_KEY , v值就是我们需要的uuid,然后通过前缀+uuid作为key从Redis中取出用户信息。前缀是定义好的,不会变。所以uuid获取很重要。

我们可以看到 createToken方法将 claims 作为参数生成了token。那么一定会有一个方法能通过token变成 claims。这个方法就在同类中的 parseToken

private Claims parseToken(String token){return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}

那么我们来看同类下另一个获取用户信息的方法,也是请求验证时获取用户信息的方法:

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;
}

最后我们再将权限校验,讲完这个就大致解决安全框架的问题了

若依自定义的权限校验

正常情况下,安全框架是有自己校验的注解的,放在方法上来判断是否有访问这个接口的权限。但是这个需要我们在设置SecurityContextHolder时加入权限信息。也就是在登录与请求认证时加入

// 第一个参数是用户信息,第二个是密码,第三个是权限信息
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

但是从上面代码中可以看出,若依是将权限信息参数设置为null (注:loginUser.getAuthorities() 返回值是 null

所有可以肯定若依没有使用安全框架的权限校验注解,而是自己写的。

我们把视角转到任意请求接口方法上面,比如说:

/*** 获取用户列表*/
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user)
{startPage();List<SysUser> list = userService.selectUserList(user);return getDataTable(list);
}

主要看:@PreAuthorize("@ss.hasPermi('system:user:list')")。这个注解的返回值需要是字符串,就是true或者false的字符串形式来确认是通过还是不通过。

@ss.hasPermi('system:user:list')就是自定义的权限处理逻辑。我们点进去看看

ruoyi-framework模块下的com.ruoyi.framework.web.service.PermissionService

/*** 验证用户是否具备某权限* * @param permission 权限字符串* @return 用户是否具备某权限*/
public boolean hasPermi(String permission)
{if (StringUtils.isEmpty(permission)){return false;}// SecurityUtils.getLoginUser():是从安全框架中获取的用户信息LoginUser loginUser = SecurityUtils.getLoginUser();// loginUser.getPermissions()就是这个用户的权限列表// 判断两者不为空,为空就返回falseif (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){return false;}// 这个就是将这个权限字符暂时存储,作用域就是此次请求。方便后面的service和数据层等其他地方假如需要使用权限判断的方法用PermissionContextHolder.setContext(permission);// 用户的权限列表是一个set集合,所以就是判断传入的权限字符在不在这个列表中return hasPermissions(loginUser.getPermissions(), permission);
}
加入微信小程序登录

接下来我不会对代码进行详细解释,只事先讲解大致流程。

登录流程

先编写微信小程序的请求封装文件,然后调用微信小程序登录方法获取code。然后调用后端微信小程序登录接口。然后后端通过code来获取openId。最后判断是否已经存在此openId在数据库。存在就修改登录时间与IP,不存在就新增并设置用户名。最后是生成token。

认证

先设置微信登录接口可匿名访问,然后新增微信登录JWT校验过滤器类。在过滤器中逻辑和若依的JWT过滤器大致相同,保证如果有token就将用户信息写入到认证中。保证不会出现认证失败。这里重点需要修改原JWT过滤器中代码,就是增加一个if判断接口路径如果是微信的就直接略过。在微信过滤器中就是只处理微信接口。

代码

数据库新增:

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='第三方登录授权表'

然后通过若依代码生成器增加这个表的实体类,mapper,service等文件。这个就不贴出来了。

然后在ruoyi-admin模块中的com.ruoyi.web.controller下增加 wx文件夹。在文件夹下新增类 WxLoginController

在这里插入图片描述

package com.ruoyi.web.controller.wx;import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginBody;
import com.ruoyi.framework.web.service.WxLoginService;
import com.ruoyi.framework.web.service.WxTokenService;
import com.ruoyi.wx.domain.LoginWxUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;/*** 微信登录验证** @author zou*/
@RestController
public class WxLoginController {@AutowiredWxLoginService wxLoginService;@AutowiredWxTokenService wxTokenService;// 配置日志操作private static final Logger logger = LoggerFactory.getLogger(WxLoginController.class);/*** 登录方法** @param code 登录信息* @return 结果*/@PostMapping("/wx/login")public AjaxResult login(@RequestBody String code){// 判断已经存在token情况if (code.isEmpty()){logger.error("Web Get code error,code:{}",code);return AjaxResult.error("登录失败!");}// 生成令牌String token = wxLoginService.login(code);if (token == null){return AjaxResult.error("登录失败!");}AjaxResult ajax = AjaxResult.success();ajax.put(Constants.TOKEN, token);return ajax;}/*** 获取微信用户ID*** @return 微信用户ID*/@GetMapping("/wx/getWxId")public AjaxResult login(HttpServletRequest request){LoginWxUser wxUserRequest = wxTokenService.getWxUserRequest(request);if (wxUserRequest == null ||wxUserRequest.getWxAuthUser() == null ||wxUserRequest.getWxAuthUser().getAuthId() == null){return AjaxResult.error();}return AjaxResult.success(wxUserRequest.getWxAuthUser().getAuthId());}
}

application-dev和application-prod配置文件中增加,最终全部配置为,以dev为例

# 用户配置
user:password:# 密码最大错误次数maxRetryCount: 5# 密码锁定时间(默认10分钟)lockTime: 10
# 日志配置,这里的配置会高于logback.xml的,只有设置debug才能显示sql
logging:level:com.ruoyi: debugorg.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# 端口,默认为6379port: 6379# 数据库索引database: 0# 密码password:# 连接超时时间timeout: 10slettuce:pool:# 连接池中的最小空闲连接min-idle: 0# 连接池中的最大空闲连接max-idle: 8# 连接池的最大数据库连接数max-active: 8# #连接池最大阻塞等待时间(使用负值表示没有限制)max-wait: -1msdatasource:type: com.alibaba.druid.pool.DruidDataSourcedriverClassName: com.mysql.cj.jdbc.Driverdruid:# 主库数据源master:url: jdbc:mysql://localhost:3306/ry-cy?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8username: rootpassword: 123456# 从库数据源slave:# 从数据源开关/默认关闭enabled: falseurl: username: password: # 初始连接数initialSize: 5# 最小连接池数量minIdle: 10# 最大连接池数量maxActive: 20# 配置获取连接等待超时的时间maxWait: 60000# 配置连接超时时间connectTimeout: 30000# 配置网络超时时间socketTimeout: 60000# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒timeBetweenEvictionRunsMillis: 60000# 配置一个连接在池中最小生存的时间,单位是毫秒minEvictableIdleTimeMillis: 300000# 配置一个连接在池中最大生存的时间,单位是毫秒maxEvictableIdleTimeMillis: 900000# 配置检测连接是否有效validationQuery: SELECT 1 FROM DUALtestWhileIdle: truetestOnBorrow: falsetestOnReturn: falsewebStatFilter: enabled: truestatViewServlet:enabled: true# 设置白名单,不填则允许所有访问allow:url-pattern: /druid/*# 控制台管理用户名和密码login-username: ruoyilogin-password: 123456filter:stat:enabled: true# 慢SQL记录log-slow-sql: trueslow-sql-millis: 1000merge-sql: truewall:config:multi-statement-allow: true

ruoyi-common模块com.ruoyi.common.constant中增加类WxConstants

在这里插入图片描述

package com.ruoyi.common.constant;/*
微信常量
* */
public class WxConstants {/*** 微信注册*/public static final String WX_REGISTER = "[wx_Register]";/*** 微信登录成功*/public static final String WX_LOGIN_SUCCESS = "[wx_Login_Success]";/*** 微信登录失败*/public static final String WX_LOGIN_ERROR = "[wx_Login_Error]";/*** 令牌前缀*/public static final String TOKEN_PREFIX = "Bearer ";/*** 令牌前缀*/public static final String WX_LOGIN_USER_KEY = "wx_login_user_key";/*** 登录微信用户 redis key*/public static final String WX_LOGIN_TOKEN_KEY = "wx_login_tokens:";}

ruoyi-framework模块中的com.ruoyi.framework.config下的SecurityConfig类修改为如下:

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
{/*** 自定义用户认证逻辑*/@Autowiredprivate UserDetailsService userDetailsService;/*** 认证失败处理类*/@Autowiredprivate AuthenticationEntryPointImpl unauthorizedHandler;/*** 退出处理类*/@Autowiredprivate LogoutSuccessHandlerImpl logoutSuccessHandler;/*** token认证过滤器*/@Autowiredprivate JwtAuthenticationTokenFilter authenticationTokenFilter;/*** 微信token认证过滤器*/@Autowiredprivate WxJwtAuthenticationTokenFilter wxJwtAuthenticationTokenFilter;/*** 跨域过滤器*/@Autowiredprivate CorsFilter corsFilter;/*** 允许匿名访问的地址*/@Autowiredprivate PermitAllUrlProperties permitAllUrl;/*** 身份验证实现*/@Beanpublic 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       |   用户登录后可访问*/@Beanprotected 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();}/*** 强散列哈希加密实现*/@Beanpublic BCryptPasswordEncoder bCryptPasswordEncoder(){return new BCryptPasswordEncoder();}
}

在这里插入图片描述

修改JwtAuthenticationTokenFilter

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
{@Autowiredprivate TokenService tokenService;@Overrideprotected 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);}
}

新增WxJwtAuthenticationTokenFilter

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 {@Autowiredprivate WxTokenService wxTokenService;@Overrideprotected 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);}
}

新增WxLoginService

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);@AutowiredIWxAuthUserService wxAuthUserService;@AutowiredWxAuthUserMapper wxAuthUserMapper;@AutowiredWxTokenService 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获取openidString openid = getOpenid(code);// 判空if (openid == null){return null;}// 通过openId来重新数据库,有此用户就更新IP和登录时间,没就新增然后更新IP与登录时间WxAuthUser wxAuthUser = recordLoginInfo(openid);LoginWxUser loginWxUser = new LoginWxUser();loginWxUser.setWxAuthUser(wxAuthUser);// 生成tokenreturn 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 等于 openIdWxAuthUser 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);// 赋值IDwxAuthUser.setAuthId(wxAuthUser1.getAuthId());}return wxAuthUser;}// 获取openidpublic 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);// 取出openidObject openid = jsonObject.get("openid");if (openid == null){logger.error("WeChat Get openId error,code:{} json: {}",code, json);return null;}return (String) openid;}
}

新增WxTokenService

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;@Autowiredprivate 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;}
}

在ruoyi-system模块下的com.ruoyi.wx.domain新增LoginWxUser类

在这里插入图片描述

package com.ruoyi.wx.domain;import com.ruoyi.common.utils.file.ImageUtils;import java.io.Serializable;public class LoginWxUser implements Serializable {private static final long serialVersionUID = 1L;/*** 用户信息*/private WxAuthUser wxAuthUser;/*** 头像*/private ImageUtils avatar;/*** 用户唯一标识*/private String token;/*** 登录时间*/private Long loginTime;/*** 过期时间*/private Long expireTime;/*** 登录IP地址*/private String ipaddr;/*** 登录地点*/private String loginLocation;/*** 浏览器类型*/private String browser;/*** 操作系统*/private String os;public LoginWxUser() {}public LoginWxUser(WxAuthUser wxAuthUser, ImageUtils avatar, String token, Long loginTime, Long expireTime, String ipaddr, String loginLocation, String browser, String os) {this.wxAuthUser = wxAuthUser;this.avatar = avatar;this.token = token;this.loginTime = loginTime;this.expireTime = expireTime;this.ipaddr = ipaddr;this.loginLocation = loginLocation;this.browser = browser;this.os = os;}public WxAuthUser getWxAuthUser() {return wxAuthUser;}public void setWxAuthUser(WxAuthUser wxAuthUser) {this.wxAuthUser = wxAuthUser;}public ImageUtils getAvatar() {return avatar;}public void setAvatar(ImageUtils avatar) {this.avatar = avatar;}public String getToken() {return token;}public void setToken(String token) {this.token = token;}public Long getLoginTime() {return loginTime;}public void setLoginTime(Long loginTime) {this.loginTime = loginTime;}public Long getExpireTime() {return expireTime;}public void setExpireTime(Long expireTime) {this.expireTime = expireTime;}public String getIpaddr() {return ipaddr;}public void setIpaddr(String ipaddr) {this.ipaddr = ipaddr;}public String getLoginLocation() {return loginLocation;}public void setLoginLocation(String loginLocation) {this.loginLocation = loginLocation;}public String getBrowser() {return browser;}public void setBrowser(String browser) {this.browser = browser;}public String getOs() {return os;}public void setOs(String os) {this.os = os;}@Overridepublic String toString() {return "LoginWxUser{" +"wxUser=" + wxAuthUser +", avatar=" + avatar +", token='" + token + '\'' +", loginTime=" + loginTime +", expireTime=" + expireTime +", ipaddr='" + ipaddr + '\'' +", loginLocation='" + loginLocation + '\'' +", browser='" + browser + '\'' +", os='" + os + '\'' +'}';}
}
前端部分

注,登录的vue使用了全屏图片,需要自行解决。

在这里插入图片描述

request.js

// 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,          // 默认显示loadingshowSuccess: 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, // 使用合并后的headertimeout: 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

pages.json

因为登录页是全屏无标题,需要设置:"navigationStyle": "custom"

{"path" : "pages/login/login","style" : {"navigationBarTitleText" : "登录","navigationStyle": "custom"}
},

login.js

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'})
}

index.vue

<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)})},// 连接WebSocketconnect() {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>

login.vue

<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(){// 获取codeuni.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>

最后全部搞完了,测试可以通过微信小程序的获取用户ID按钮,然后debug后端代码。微信小程序还有简易的webSocket可以测试。

3、docker一键部署

相关文章:

若依前后端分离框架修改3.8.9版本(重点在安全框架讲解与微信小程序登录集成)

若依模板改造&#xff08;3.8.9&#xff09; 1、基础改造 下载代码 从[RuoYi-Vue: &#x1f389; 基于SpringBoot&#xff0c;Spring Security&#xff0c;JWT&#xff0c;Vue & Element 的前后端分离权限管理系统&#xff0c;同时提供了 Vue3 的版本](https://gitee.co…...

selenium爬取苏宁易购平台某产品的评论

目录 selenium的介绍 1、 selenium是什么&#xff1f; 2、selenium的工作原理 3、如何使用selenium&#xff1f; webdriver浏览器驱动设置 关键步骤 代码 运行结果 注意事项 selenium的介绍 1、 selenium是什么&#xff1f; 用于Web应用程序测试的工具。可以驱动浏览…...

kubernetes-完美下载

话不多说&#xff0c;直接开始从0搭建k8s集群 环境&#xff1a;centous7.9 2核 20G k8s-master 192.168.37.20 k8s-node1 192.168.37.21 k8s-node2 192.168.37.22 一&#xff1a;设置主机名 #设置主机名 hostnamectl set-hostname k8s-master hostnamectl set-h…...

PostgreSQL 常用函数

PostgreSQL 常用函数 在数据库管理系统中&#xff0c;函数是执行特定任务的基本构建块。PostgreSQL 是一个功能强大的开源关系数据库管理系统&#xff0c;提供了丰富的内置函数&#xff0c;这些函数极大地增强了数据库操作的能力。以下是一些在 PostgreSQL 中常用的函数&#…...

【初阶数据结构】树和二叉树

目录 前言树的概念与结构树的概念树的相关概念树的表示 二叉树的概念及结构二叉树的概念几种特殊的二叉树1.满二叉树2.完全二叉树 二叉树的性质二叉树的存储结构1、顺序存储2、链式存储 前言 前面我们学习了顺序表&#xff0c;单链表&#xff0c;栈和队列&#xff0c;它们在逻…...

【中等】59.螺旋矩阵Ⅱ

题目描述 给你一个正整数 n &#xff0c;生成一个包含 1 到 n2 所有元素&#xff0c;且元素按顺时针顺序螺旋排列的 n x n 正方形矩阵 matrix 。 示例 1&#xff1a; 输入&#xff1a;n 3 输出&#xff1a;[[1,2,3],[8,9,4],[7,6,5]]示例 2&#xff1a; 输入&#xff1a;n…...

Spring Boot + Vue 接入腾讯云人脸识别API(SDK版本3.1.830)

一、需求分析 这次是基于一个Spring Boot Vue的在线考试系统进行二次开发&#xff0c;添加人脸识别功能以防止学生替考。其他有对应场景的也可按需接入API&#xff0c;方法大同小异。 主要有以下两个步骤&#xff1a; 人脸录入&#xff1a;将某个角色&#xff08;如学生&…...

测试工程师玩转DeepSeek之Prompt

以下是测试工程师使用DeepSeek的必知必会提示词指南&#xff0c;分为核心场景和高效技巧两大维度&#xff1a; 一、基础操作提示模板 1. 测试用例生成 "作为[金融系统/物联网设备/云服务]测试专家&#xff0c;请为[具体功能模块]设计测试用例&#xff0c;要求&#xff1…...

虚中断理解

虚中断&#xff08;Virtual Interrupt&#xff09;是指在计算机系统中&#xff0c;特别是在虚拟化环境下&#xff0c;虚拟机或虚拟操作系统中使用的一种中断机制。它允许虚拟机监控程序&#xff08;Hypervisor&#xff09;或虚拟化管理程序在虚拟机之间进行中断处理和资源管理。…...

PC端-发票真伪查验系统-Node.js全国发票查询接口

在现代企业的财务管理中&#xff0c;发票真伪的验证至关重要。随着电子发票的普及&#xff0c;假发票问题日益严峻&#xff0c;如何高效、准确的对发票进行真伪查验&#xff0c;已经成为各类企业在日常运营中必须解决的关键问题。翔云发票查验接口做企业财务管理、税务合规的好…...

给Python加入自己的函数

在日常研究中&#xff0c;我们有时候会写一些Python没有的&#xff0c;但是很多个脚本都需要用的函数&#xff0c;反复的复制函数太过麻烦&#xff0c;我们可以进行一些简单的操作来变成一个可以直接import的函数 1. 首先我们新建一个.py文件&#xff0c;把我们的函数放进去&a…...

JAVA中包装类和泛型 通配符

目录 1. 包装类 1.1 基本数据类型和对应的包装类 1.2 装箱和封箱 1.3 自动自动装箱和封箱 2. 什么是泛型 3. 引出泛型 3.1 语法 4. 泛型类的使⽤ 4.1 语法 4.2 ⽰例 4.3 类型推导(Type Inference) 5 泛型的上界 5.1 语法 6. 通配符 6.1 通配符解决什么问题 6.2…...

Qt TCP服务端和客户端程序

1、服务端程序 利用QtCreator新建QMainWindow或QWidget工程&#xff0c;绘制UI如下所示。 mainwindow.h代码如下&#xff1a; #ifndef MAINWINDOW_H #define MAINWINDOW_H#include <QMainWindow> #include <QTcpServer> #include <QTcpSocket> #include &l…...

level2Day5

Makefile make是工程管理器 先写了1个f1.c里面写了一个函数 然后f2.c里面也写了一个函数 还有一个头节点 又写了一个makefile的函数 输入make编译&#xff0c;但是我没装make需要装一下。 sudo apt install make 然后make&#xff0c; Makefile变量的使用 通过赋值&#xff…...

青少年学习编程如何平衡使用DeepSeek与独立思考

前言 对于正在学习编程的青少年来说&#xff0c;DeepSeek生成代码的功能是一把双刃剑。如果合理使用&#xff0c;它可以成为青少年学习编程的有力助手&#xff1b;但如果过度依赖&#xff0c;可能会阻碍他们的思维发展和能力提升。关键在于引导青少年正确看待工具的作用&#…...

MySQL 8.0 Enterprise Backup (MEB) 备份与恢复实践指南

一、MEB 核心价值与特性 1.1 产品定位 MySQL Enterprise Backup (MEB) 是Oracle官方推出的企业级物理热备份工具&#xff0c;专为MySQL 8.0设计&#xff0c;支持InnoDB/XtraDB引擎的在线备份&#xff0c;同时兼容MyISAM表的锁定备份。 1.2 核心优势 零停机热备份&#xff1…...

UE5从入门到精通之多人游戏编程常用函数

文章目录 前言一、权限与身份判断函数1. 服务器/客户端判断2. 网络角色判断二、网络同步与复制函数1. 变量同步2. RPC调用三、连接与会话管理函数1. 玩家连接控制2. 网络模式判断四、实用工具函数前言 UE5给我们提供了非常强大的多人网路系统,让我们可以很方便的开发多人游戏…...

[Web 安全] 反序列化漏洞 - 学习笔记

关注这个专栏的其他相关笔记&#xff1a;[Web 安全] Web 安全攻防 - 学习手册-CSDN博客 0x01&#xff1a;反序列化漏洞 — 漏洞介绍 反序列化漏洞是一种常见的安全漏洞&#xff0c;主要出现在应用程序将 序列化数据 重新转换为对象&#xff08;即反序列化&#xff09;的过程中…...

minio作为K8S后端存储

docker部署minio mkdir -p /minio/datadocker run -d \-p 9000:9000 \-p 9001:9001 \--name minio \-v /minio/data:/data \-e "MINIO_ROOT_USERjbk" \-e "MINIO_ROOT_PASSWORDjbjbjb123" \quay.io/minio/minio server /data --console-address ":90…...

Leetcode2717:半有序排列

题目描述&#xff1a; 给你一个下标从 0 开始、长度为 n 的整数排列 nums 。 如果排列的第一个数字等于 1 且最后一个数字等于 n &#xff0c;则称其为 半有序排列 。你可以执行多次下述操作&#xff0c;直到将 nums 变成一个 半有序排列 &#xff1a; 选择 nums 中相邻的两…...

redis小记

redis小记 下载redis sudo apt-get install redis-server redis基本命令 ubuntu16下的redis没有protected-mode属性&#xff0c;就算sudo启动&#xff0c;也不能往/var/spool/cron/crontabs写计划任务&#xff0c;感觉很安全 #连接到redis redis-cli -h 127.0.0.1 -p 6379 …...

C/C++基础知识复习(47)

1) 接口继承与实现继承的区别 接口继承 接口继承意味着定义一个类&#xff0c;它只声明一组方法&#xff08;通常是纯虚函数&#xff09;&#xff0c;但是不提供任何实现。继承这个接口的子类必须实现这些方法。接口继承的主要目的是规范化行为。 C 例子&#xff1a; 在 C 中…...

OkHttp、Retrofit、RxJava:一文讲清楚

一、okHttp的同步和异步请求 Call 是 OkHttp 的核心接口&#xff0c;代表一个已准备好执行的 HTTP 请求。它支持 同步 和 异步 两种模式&#xff1a; enqueue——>okHttp异步 OkHttpClient client new OkHttpClient();Request request new Request.Builder().url("…...

netty详细使用

Netty是一个基于Java的高性能网络应用框架&#xff0c;主要用于快速开发高性能的网络通信应用程序。以下是Netty的详细使用步骤&#xff1a; 添加Netty依赖&#xff1a;在项目的pom.xml中添加Netty的依赖项&#xff0c;例如&#xff1a; <dependency><groupId>io…...

计算机视觉(opencv-python)入门之图像的读取,显示,与保存

在计算机视觉领域&#xff0c;Python的cv2库是一个不可或缺的工具&#xff0c;它提供了丰富的图像处理功能。作为OpenCV的Python接口&#xff0c;cv2使得图像处理的实现变得简单而高效。 示例图片 目录 opencv获取方式 图像基本知识 颜色空间 RGB HSV CV2常用图像处理方…...

ActiveMQ之VirtualTopic

一句话总结&#xff1a; VirtualTopic是为了解决持久化模式下多消费端同时接收同一条消息的问题。 现实中多出现这样一个场景&#xff1a; 生产端产生了一笔订单&#xff0c;作为消息MessageOrder发了出去。 这笔订单既要入订单系统归档&#xff0c;又要入结算系统收款&#x…...

第16届蓝桥杯模拟赛3 python组个人题解

第16届蓝桥杯模拟赛3 python组 思路和答案不保证正确 1.填空 如果一个数 p 是个质数&#xff0c;同时又是整数 a 的约数&#xff0c;则 p 称为 a 的一个质因数。 请问&#xff0c; 2024 的最大的质因数是多少&#xff1f; 因为是填空题&#xff0c;所以直接枚举2023~2 &am…...

UE5 Computer Shader学习笔记

首先这里是绑定.usf文件的路径&#xff0c;并声明是用声明着色器 上面就是对应的usf文件路径&#xff0c;在第一张图进行链接 Shader Frequency 的作用 Shader Frequency 是 Unreal Engine 中用于描述着色器类型和其执行阶段的分类。常见的 Shader Frequency 包括&#xff1a…...

2.1部署logstash:9600

实验环境&#xff1a;关闭防火墙&#xff0c;完成java环境 yum -y install wget wget https://d6.injdk.cn/oraclejdk/8/jdk-8u341-linux-x64.rpm yum localinstall jdk-8u341-linux-x64.rpm -y java -version 1.安装logstash tar xf logstash-6.4.1.tar.gz -C /usr/local…...

SQL笔记#集合运算

目录 一、表的加减法 1、什么是集合运算 2、表的加法——UNION 3、集合运算的注意事项 4、包含重复行的集合运算——ALL运算 5、选取表中公共部分——INTERSECT 6、记录的减法——EXCEPT 二、联结(以列为单位对表进行联结) 1、什么是联结(JOIN) 2、内联结——INSER…...