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

发布于:2025-02-25 ⋅ 阅读:(13) ⋅ 点赞:(0)

若依模板改造(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>
{
    @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));
    }
}

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端口,默认为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/*

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

# 用户配置
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

修改日志文件

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

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: true
  endpoints:
    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 {

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



}

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;

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

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> {

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

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

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

测试部分

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

@GetMapping("/testWebSocket")
public AjaxResult testWebSocket(){

    // 主动发送消息,当链接webSocket后
    // 获取id,这里固定为 1
    WebSocketUsers.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 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>


在这里插入图片描述

使用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: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;
    }
}

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 {

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

    }
}

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 {

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

}

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

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

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

  • 让微信小程序用户与现有的用户共用一张表,这个看起来不错,但实际上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.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);
    }

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

之所以要定义 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)
{
    // 生成一个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);
}

注释已经写好了,我们重点看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());
    // 存储到redis
    redisCache.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()就是这个用户的权限列表
    // 判断两者不为空,为空就返回false
    if (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 {


    @Autowired
    WxLoginService wxLoginService;

    @Autowired
    WxTokenService 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: 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

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
{
    /**
     * 自定义用户认证逻辑
     */
    @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();
    }
}

在这里插入图片描述

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

新增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 {


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

新增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);

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

新增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;

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


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

    @Override
    public 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,          // 默认显示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

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

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

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

3、docker一键部署


网站公告

今日签到

点亮在社区的每一天
去签到