Shiro+Vue通用后台管理系统(附源码)

目录

前言

一、项目介绍

1.运行

项目下载

2.技术栈

3.功能

4.角色权限介绍

二、流程讲解

三、数据库

E-R图设计

数据库脚本

四、系统搭建

项目结构

项目依赖 

核心代码

1.JWT 工具类

2.JWTFilter

3.JwtToken

4.自定义 Realm

5.配置Shiro

五、项目核心逻辑介绍

1.jwt无状态登录

2.token可控

3.token续期

4.redis缓存数据

小结

前言

最近一直在学习权限框架,光学不敲,那肯定不行,所以有了这个项目。项目实现了jwt无状态登录、redis缓存、token续期和可控。算是个比较通用且有亮点的权限管理项目吧。🤭

项目下载

gitee:https://gitee.com/wusupweilgy/springboot-vue.git(点个star呀😎)

一、项目介绍

1.运行

2.技术栈

前端:vue2,element-ui、axios、echars组件

后端:springboot、mybatis-plus、shiro、jwt、redis

3.功能

  • 用户、角色和菜单的权限管理

  • 统计在线人数、注册人数等

  • 个人密码、用户信息的修改

  • 根据角色不同,前端动态渲染菜单导航

4.角色权限介绍

  • admin角色拥有删除功能,其他角色没有

  • admin和vip角色能查看用户信息,普通用户不行

  • admin和vip能进行添加操作,普通用户不行

  • 个人信息和修改密码,登录过就可以访问

二、流程讲解

1.用户点击注册,系统将密码加密后存入数据库中。

2.用户登录,主要是校验账号密码并生成 token(jwt),然后存储到Redis,这里存的是签发时间,比token(jwt)中设置的过期时间长,为了实现token的自动续期。文章末尾我有细说。 

3.用户访问需要认证的资源时,需要进行token校验和续期判断

三、数据库

E-R图设计

没有加外键,因为增加会造成数据库压力。实体表都加入了逻辑删除字段。

数据库脚本

/*
 Navicat Premium Data Transfer

 Source Server         : local
 Source Server Type    : MySQL
 Source Server Version : 80028
 Source Host           : localhost:3306
 Source Schema         : shiro_jwt_vue2

 Target Server Type    : MySQL
 Target Server Version : 80028
 File Encoding         : 65001

 Date: 22/04/2023 14:18:39
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for files
-- ----------------------------
DROP TABLE IF EXISTS `files`;
CREATE TABLE `files`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件名称',
  `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件类型',
  `size` bigint(0) NULL DEFAULT NULL COMMENT '文件大小(kb)',
  `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '下载链接',
  `md5` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件md5',
  `is_delete` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除',
  `enable` tinyint(1) NULL DEFAULT 1 COMMENT '是否禁用链接',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 73 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of files
-- ----------------------------
INSERT INTO `files` VALUES (73, 'lgy.jpg', 'jpg', 35, 'http://localhost:9090/files/2023/04/22/20230422123301000000166.jpg', 'eb81db8974a4924ba39ccc049c078516', 0, 1, '2023-04-22 00:33:01', NULL);
INSERT INTO `files` VALUES (74, 'lgy.png', 'png', 197, 'http://localhost:9090/files/2023/04/22/20230422123303000000698.png', '466ebb0a2ea027b04ab2f60f2dcbf1f6', 0, 1, '2023-04-22 00:33:04', NULL);

-- ----------------------------
-- Table structure for sys_dict
-- ----------------------------
DROP TABLE IF EXISTS `sys_dict`;
CREATE TABLE `sys_dict`  (
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '名称',
  `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '内容',
  `type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '类型'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_dict
-- ----------------------------
INSERT INTO `sys_dict` VALUES ('user', 'el-icon-user', 'icon');
INSERT INTO `sys_dict` VALUES ('house', 'el-icon-house', 'icon');
INSERT INTO `sys_dict` VALUES ('menu', 'el-icon-menu', 'icon');
INSERT INTO `sys_dict` VALUES ('s-custom', 'el-icon-s-custom', 'icon');
INSERT INTO `sys_dict` VALUES ('s-grid', 'el-icon-s-grid', 'icon');
INSERT INTO `sys_dict` VALUES ('document', 'el-icon-document', 'icon');
INSERT INTO `sys_dict` VALUES ('coffee', 'el-icon-coffee\r\n', 'icon');
INSERT INTO `sys_dict` VALUES ('s-marketing', 'el-icon-s-marketing', 'icon');
INSERT INTO `sys_dict` VALUES ('files', 'el-icon-files', 'icon');

-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '名称',
  `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '路径',
  `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '图标',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述',
  `permission` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '权限标识',
  `pid` int(0) NULL DEFAULT NULL COMMENT '父级id',
  `page_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '页面路径',
  `sort_num` int(0) NULL DEFAULT NULL COMMENT '排序',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 48 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES (4, '系统管理', NULL, 'el-icon-s-grid', NULL, NULL, NULL, NULL, 300, NULL, NULL, 0);
INSERT INTO `sys_menu` VALUES (5, '用户管理', '/user', 'el-icon-user', NULL, 'user', 4, 'User', 301, NULL, '2023-04-20 10:17:23', 0);
INSERT INTO `sys_menu` VALUES (6, '角色管理', '/role', 'el-icon-s-custom', NULL, 'role', 4, 'Role', 302, NULL, '2023-04-20 10:53:11', 0);
INSERT INTO `sys_menu` VALUES (7, '菜单管理', '/menu', 'el-icon-menu', NULL, NULL, 4, 'Menu', 303, NULL, NULL, 0);
INSERT INTO `sys_menu` VALUES (10, '主页', '/home', 'el-icon-house', '主页', NULL, NULL, 'Home', 0, NULL, '2023-04-20 09:45:07', 0);

-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `role_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '唯一标识',
  `name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '名称',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '描述',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'admin', '超级管理员', '烦烦烦', NULL, '2023-04-20 09:32:12', 0);
INSERT INTO `sys_role` VALUES (2, 'user', '普通用户', NULL, NULL, NULL, 0);
INSERT INTO `sys_role` VALUES (3, 'vip', 'Vip用户', NULL, NULL, '2023-04-22 00:33:12', 0);

-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu`  (
  `role_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色id',
  `menu_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '菜单id',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`role_id`, `menu_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色-菜单-关联表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES ('1', '10', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '4', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '47', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '5', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '6', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '7', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '10', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '4', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '5', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '6', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '10', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '4', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '5', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '6', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '7', '2023-04-22 14:13:53', NULL);

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户名',
  `password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '密码',
  `nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '昵称',
  `email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '电话',
  `address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '地址',
  `avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'http://localhost:9090/files/20230409082108000000936.jpg' COMMENT '头像',
  `is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
  `create_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 38 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'admin', '202cb962ac59075b964b07152d234b70', '无所谓^_^', '2673152463@qq.com', '2673152463', '浙江省', 'http://localhost:9090/files/2023/04/22/20230422123301000000166.jpg', 0, '2022-01-22 21:10:27', '2023-04-22 00:33:05');
INSERT INTO `sys_user` VALUES (16, 'vip', '202cb962ac59075b964b07152d234b70', '小黑子', '2', '2', '2', 'http://localhost:9090/files/2023/04/22/20230422123303000000698.png', 0, '2022-02-26 22:10:14', '2023-04-22 12:20:29');
INSERT INTO `sys_user` VALUES (17, 'user', '202cb962ac59075b964b07152d234b70', '我是三三哦豁', '3', '2673152463', '3', 'https://profile.csdnimg.cn/B/7/0/1_weixin_51603038', 0, '2022-02-26 22:10:18', '2023-04-22 12:16:05');
INSERT INTO `sys_user` VALUES (18, 'nzz', '202cb962ac59075b964b07152d234b70', '哪吒', '2', '2', '2', '', 0, '2022-03-29 16:59:44', '2023-04-21 23:16:50');
INSERT INTO `sys_user` VALUES (25, 'sir', '202cb962ac59075b964b07152d234b70', '安琪拉', NULL, NULL, NULL, NULL, 0, '2022-06-08 17:00:47', '2023-04-21 23:16:50');
INSERT INTO `sys_user` VALUES (26, 'err', '202cb962ac59075b964b07152d234b70', '妲己', '11', '1', '1', NULL, 0, '2022-07-08 17:20:01', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (28, 'ddd', '202cb962ac59075b964b07152d234b70', 'ddd', '', '', '', 'http://localhost:9090/file/7de0e50f915547539db12023cf997276.jpg', 0, '2022-11-09 10:41:07', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (29, 'ffff', '202cb962ac59075b964b07152d234b70', 'ffff', NULL, NULL, NULL, NULL, 0, '2022-12-10 11:53:31', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (36, 'aaa', '47bce5c74f589f4867dbd57e9ca9f808', NULL, NULL, NULL, NULL, 'http://localhost:9090/files/20230409082108000000936.jpg', 0, '2023-04-21 22:45:25', '2023-04-21 23:10:16');
INSERT INTO `sys_user` VALUES (37, 'fff', '343d9040a671c45832ee5381860e2996', NULL, NULL, NULL, NULL, 'http://localhost:9090/files/20230409082108000000936.jpg', 0, '2023-04-21 23:02:56', '2023-04-21 23:17:24');

-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role`  (
  `user_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户id',
  `role_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色id',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`user_id`, `role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户-角色关联表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES ('1', '1', '2023-04-20 11:03:24', NULL);
INSERT INTO `sys_user_role` VALUES ('16', '3', '2023-04-22 12:21:54', NULL);
INSERT INTO `sys_user_role` VALUES ('17', '2', '2023-04-22 12:16:05', NULL);
INSERT INTO `sys_user_role` VALUES ('18', '2', '2023-04-22 12:30:26', NULL);
INSERT INTO `sys_user_role` VALUES ('25', '2', '2023-04-22 12:30:29', NULL);
INSERT INTO `sys_user_role` VALUES ('26', '2', '2023-04-22 12:30:34', NULL);
INSERT INTO `sys_user_role` VALUES ('28', '2', '2023-04-22 12:30:36', NULL);
INSERT INTO `sys_user_role` VALUES ('29', '2', '2023-04-22 12:30:39', NULL);
INSERT INTO `sys_user_role` VALUES ('35', '1', '2023-04-20 09:07:19', NULL);
INSERT INTO `sys_user_role` VALUES ('36', '2', '2023-04-22 12:30:41', NULL);
INSERT INTO `sys_user_role` VALUES ('37', '2', '2023-04-22 12:30:45', NULL);

SET FOREIGN_KEY_CHECKS = 1;

四、系统搭建

项目结构

建议小伙伴们去我的gitee上下载源码,然后运行。因为代码有点(优点)多,不好全部写在博客里😂

项目依赖 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.wusuowei</groupId>
    <artifactId>Shiro_Jwt</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>Shiro_Jwt</name>
    <description>Shiro_Jwt</description>
    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.6.13</spring-boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.20</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>2.13.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.3.2</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.2.0</version>
        </dependency>
        <!-- md5加密 -->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.6</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.50</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.11.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.wusuowei.shiro_jwt_vue.ShiroJwtApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

核心代码

1.JWT 工具类

主要用来生成 token、校验 token

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.wusuowei.shiro_jwt.model.po.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JWTUtil {

    //token有效时长30分钟
    private static Long EXPIRE;
    //token的密钥
    private static String SECRET;
    //refresh-expire续期过期时间
    private static Long REFRESHEXPIRE;

    @Value("${jwt.expire}")
    public void setExpire(Long expire){
        JWTUtil.EXPIRE = expire*1000;
    }

    @Value("${jwt.secret}")
    public void setSecret(String secret){
        JWTUtil.SECRET = secret;
    }

    @Value("${jwt.refresh-expire}")
    public void setRefreshExpire(Long refreshExpire){
        JWTUtil.REFRESHEXPIRE = refreshExpire;
    }
    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedisUtil redisUtil2;

    private static RedisUtil redisUtil;

    @PostConstruct
    public void init(){
        JWTUtil.redisUtil = redisUtil2;
    }

    public static String createToken(User user) throws UnsupportedEncodingException {
        //token过期时间
        Date date=new Date(System.currentTimeMillis()+EXPIRE);

        //Date now = new Date();
        long now = System.currentTimeMillis();

        //jwt的header部分
        Map<String ,Object> map=new HashMap<>();
        map.put("alg","HS256");
        map.put("typ","JWT");

        //使用jwt的api生成token
        String token= JWT.create()
                .withHeader(map)
                .withClaim("uid", user.getId().toString())//私有声明
                .withExpiresAt(date)//过期时间
                .withIssuedAt(new Date(now))//签发时间
                .sign(Algorithm.HMAC256(SECRET));//签名
        redisUtil.hset("refresh",String.valueOf(user.getId()),Long.valueOf((long) Math.floor(now/1000)), REFRESHEXPIRE);
        return token;
    }

    //校验token的有效性,1、token的header和payload是否没改过;2、没有过期
    public static boolean verify(String token){
        try {
            //解密
            JWTVerifier verifier=JWT.require(Algorithm.HMAC256(SECRET)).build();
            verifier.verify(token);
            return true;
        }catch (TokenExpiredException e){
            return true;
        }catch (Exception e) {
            return false;
        }
    }

    public static boolean isJwtExpired(String token){
        /**
         * @desc 判断token是否过期
         * @author lj
         */
        try {
            DecodedJWT decodeToken = JWT.decode(token);
            return decodeToken.getExpiresAt().before(new Date());
        } catch(Exception e){
            return true;
        }
    }
    //无需解密也可以获取token的信息
    public static String getUserId(String token){
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("uid").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    //无需解密也可以获取token的信息
    public static String getAccessToken(String token){
        try {
            DecodedJWT jwt = JWT.decode(token);
            return String.valueOf(jwt.getIssuedAt().getTime()/1000);
        } catch (JWTDecodeException e) {
            return "";
        }
    }

}

2.JWTFilter

主要作用就是拦截请求,判断请求头中书否携带 token。如果携带,就交给 Realm 处理。

import com.wusuowei.shiro_jwt.model.po.User;
import com.wusuowei.shiro_jwt.shiro.JWTToken;
import com.wusuowei.shiro_jwt.utils.JWTUtil;
import com.wusuowei.shiro_jwt.utils.RedisUtil;
import com.wusuowei.shiro_jwt.utils.SpringContextUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;

@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {


    //是否允许访问,如果带有 token,则对 token 进行检查,否则直接通过
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        //判断请求的请求头是否带上 "Token"
        if (isLoginAttempt(request, response)) {
            //如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
                log.info("认证出错");
                responseError(response, e.getMessage()); //这里就不进行跳转了,直接全局异常捕获
            }
        }
        //如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
        return true;
    }

    /**
     * 判断用户的请求是否为认证。
     * 检测 header 里面是否包含 Token 字段
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        System.out.println("是认证请求isLoginAttempt");
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader("token");
        return token != null;
    }

    /*
     * executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token
     * 然后调用getSubject方法来获取当前用户再调用login方法来实现登录
     * 这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
     * */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        System.out.println("executeLogin");
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        String token = req.getHeader("token");
        JWTToken jwt = null;

        String newJwtToken = getNewJwtToken(req, res, token);
        if (newJwtToken != null) {
            token = newJwtToken;
        }

        jwt = new JWTToken(token);
        //交给自定义的realm对象去登录,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwt);
        return true;
    }

    /**
     * @description token续期
     * @param request 要求
     * @param response 回答
     * @param token 令牌
     * @return {@link String }
     * @author LGY
     * @date 2023/04/18 19:44
     */
    private String getNewJwtToken(HttpServletRequest request, HttpServletResponse response, String token) throws Exception {
        RedisUtil redisUtil = SpringContextUtils.getBean(RedisUtil.class);
        String uid = null;
        try {
            uid = JWTUtil.getUserId(token);
        } catch (Exception e) {
            throw new AuthenticationException("token非法,不是规范的token,可能被篡改了");
        }
        if (!JWTUtil.verify(token) || uid == null) {
            throw new AuthenticationException("token认证失效,token错误或者过期,请重新登陆");
        }

        String refreshToken = String.valueOf(redisUtil.hget("refresh",uid));
        String accessToken = JWTUtil.getAccessToken(token);

        if (StringUtils.isBlank(refreshToken) || !accessToken.equals(refreshToken)) {
            throw new AuthenticationException("token过期,请重新登陆");
        }

        //token续期
        if (JWTUtil.isJwtExpired(token) && accessToken.equals(refreshToken)) {
            //生成新token
            User user = new User();
            user.setId(Integer.valueOf(uid));
            token = JWTUtil.createToken(user);
            log.info("token续期成功:" + token);
            response.addHeader("refreshtoken", token);
            response.setHeader("Access-Control-Expose-Headers", "refreshtoken");
            return token;
        }
        return null;
    }

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        System.out.println("preHandle");
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        res.setHeader("Access-control-Allow-Origin", req.getHeader("Origin"));
        res.setHeader("Access-control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        res.setHeader("Access-control-Allow-Headers", req.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
            res.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * 将非法请求跳转到 /unauthorized/**
     */
    private void responseError(ServletResponse response, String message) {
        System.out.println("responseError");

        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            //设置编码,否则中文字符在重定向时会变为空字符串
            message = URLEncoder.encode(message, "UTF-8");
            httpServletResponse.sendRedirect("/unauthorized/" + message);
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

3.JwtToken

shiro 在没有和 jwt 整合之前,用户的账号密码被封装成了 UsernamePasswordToken 对象,UsernamePasswordToken 其实是 AuthenticationToken 的实现类。这里既然要和 jwt 整合,JWTFilter 传递给 Realm 的 token 必须是 AuthenticationToken 的实现类。

import org.apache.shiro.authc.AuthenticationToken;

public class JWTToken implements AuthenticationToken {

    private String token;

    public JWTToken(String token){
        this.token=token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

4.自定义 Realm

继承 AuthorizingRealm从数据库中读取用户数据,实现认证和授权两个方法

import com.wusuowei.shiro_jwt.model.po.Menu;
import com.wusuowei.shiro_jwt.model.po.Role;
import com.wusuowei.shiro_jwt.model.po.User;
import com.wusuowei.shiro_jwt.service.MenuService;
import com.wusuowei.shiro_jwt.service.RoleService;
import com.wusuowei.shiro_jwt.service.UserService;
import com.wusuowei.shiro_jwt.utils.JWTUtil;
import com.wusuowei.shiro_jwt.utils.RedisUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Component
public class MyRealm extends AuthorizingRealm {

    @Value("${jwt.refresh-expire}")
    //refresh-expire续期过期时间
    private Long REFRESHEXPIRE;
    @Autowired
    private UserService userService;

    @Autowired
    private RoleService roleService;

    @Autowired
    private MenuService menuService;


    @Autowired
    private RedisUtil redisUtil;

    //根据token判断此Authenticator是否使用该realm
    //必须重写不然shiro会报错
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如@RequiresRoles,@RequiresPermissions之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        System.out.println("授权~~~~~");
        User user = (User) principals.getPrimaryPrincipal();
        String uid = String.valueOf(user.getId());
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        List<Role> redisRoles = (List<Role>) redisUtil.hget("userPower", "roles:"+uid);
        List<Menu> redisPermissions = (List<Menu>) redisUtil.hget("userPower", "permission:"+uid);
        if (redisRoles != null && redisPermissions != null) {
            info.addRoles(redisRoles.stream().map(Role::getRoleKey).collect(Collectors.toSet()));
            info.addStringPermissions(redisPermissions.stream().filter(item -> StringUtils.isNotBlank(item.getPermission())).map(Menu::getPermission).collect(Collectors.toSet()));
            return info;
        }
        //查询数据库来获取用户的角色
        List<Role> roles = roleService.getRoles(uid);
        info.addRoles(roles.stream().map(Role::getRoleKey).collect(Collectors.toSet()));

        //查询数据库来获取用户的权限
        List<Menu> permissions = menuService.getPermissionByUid(uid);
        Set<String> collect = permissions.stream().filter(item -> StringUtils.isNotBlank(item.getPermission())).map(Menu::getPermission).collect(Collectors.toSet());
        info.addStringPermissions(collect);
        redisUtil.hset("userPower", "roles:" + uid, roles, REFRESHEXPIRE);
        redisUtil.hset("userPower", "permissions:" + uid, collect, REFRESHEXPIRE);
        return info;
    }


    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可,在需要用户认证和鉴权的时候才会调用
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        System.out.println("认证~~~~~~~");
        String jwt = (String) token.getCredentials();
        String uid = JWTUtil.getUserId(jwt);
        User redisUser = (User) redisUtil.get("userInfo:" + uid);
        if (redisUser != null) {
            return new SimpleAuthenticationInfo(redisUser, jwt, "MyRealm");
        }
        User user = userService.getById(uid);
        if (user == null) {
            throw new AuthenticationException("该用户不存在");
        }
        redisUtil.hset("userInfo",uid, user, REFRESHEXPIRE);
        return new SimpleAuthenticationInfo(user, jwt, "MyRealm");
    }
}

5.配置Shiro

ShiroConfig主要配置了:过滤器、安全管理器和不进行拦截的路径,比如登录

import com.wusuowei.shiro_jwt.filter.JWTFilter;
import com.wusuowei.shiro_jwt.shiro.MyRealm;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;

@Configuration
public class ShiroConfig {

    @Bean(name = "securityManager")
    public DefaultWebSecurityManager securityManager(MyRealm myRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置自定义 realm.
        securityManager.setRealm(myRealm);

        //关闭session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }

    /**
     * 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
     */
    @Bean
    public ShiroFilterFactoryBean factory(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        factoryBean.setSecurityManager(securityManager);

        // 添加自己的过滤器并且取名为jwt
        LinkedHashMap<String, Filter> filterMap = new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);

        // 设置无权限时跳转的 url;
        factoryBean.setUnauthorizedUrl("/unauthorized/无权限");

        LinkedHashMap<String, String> filterRuleMap = new LinkedHashMap<>();
        // 访问 /unauthorized/** 不通过JWTFilter
        filterRuleMap.put("/unauthorized/**", "anon");
        filterRuleMap.put("/login", "anon");
        filterRuleMap.put("/register", "anon");
        filterRuleMap.put("/check", "anon");
        filterRuleMap.put("/files/**", "anon");
        filterRuleMap.put("/test2", "anon");
      //  filterRuleMap.put("/logout", "anon");
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**", "jwt");

        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 添加注解支持,如果不加的话很有可能注解失效
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {

        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {

        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
    

}

五、项目核心逻辑介绍

1.jwt无状态登录

在微服务中我们一般采用的是无状态登录,而传统的session方式,在前后端分离的微服务架构下,如继续使用则必须要解决跨域sessionId问题、集群session共享问题等等。这显然是费力不讨好的事,而整合shiro,它的默认实现就是通过session的方式。
原因:   
(1)shiro默认的拦截跳转都是跳转url页面,而前后端分离后,后端并无权干涉页面跳转。   
(2)shiro默认使用的登录拦截校验机制恰恰就是使用的session。 

解决:在这个系统中,我通过在ShiroConfig中配置filterRuleMap.put("/login", "anon");放行登录请求,然后登录成功后生成token并返回给前端,之后前端的每次请求都携带这个token,后端的JWTFilter进行过滤判断,然后通过调用getSubject(request, response).login(jwt);交给MyRealm进行认证、授权。

2.token可控

为什么要token可控。因为如果用户登录好几次,拿到很多token,用户就可以通过这些token(没有过期且正确)中的任意一个进行访问。但是如果我们想控制用户的登录,实现一些功能,比如让能统计在线人数,就需要实现token的可控。

解决:登录认证通过后返回AccessToken信息(在AccessToken中保存当前的时间和用户id),同时在Redis中设置一条Key为用户id,Value为当前时间戳(登录时间和token中的一样)的RefreshToken

核心代码在JWTUtil的createToken方法中

//使用jwt的api生成token
        String token= JWT.create()
                .withHeader(map)
                .withClaim("uid", user.getId().toString())//私有声明
                .withExpiresAt(date)//过期时间
                .withIssuedAt(new Date(now))//签发时间
                .sign(Algorithm.HMAC256(SECRET));//签名
        redisUtil.hset("refreshToken",String.valueOf(user.getId()),Long.valueOf((long) Math.floor(now/1000)), REFRESHTOKENEXPIRE);

现在认证时必须AccessToken没被篡改过以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中时间戳一致才算认证通过,这样可以做到JWT的可控性,如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了,因为Redis中所存放的的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。

核心代码在JWTFilter中的getNewJwtToken方法中

if (!JWTUtil.verify(token) || uid == null) {
            throw new AuthenticationException("token认证失效,请重新登陆");
}

String refreshToken = String.valueOf(redisUtil.hget("refreshToken",uid));
String accessToken = JWTUtil.getAccessToken(token);

if (StringUtils.isBlank(refreshToken) || !accessToken.equals(refreshToken)) {
    throw new AuthenticationException("token过期,请重新登陆");
}

3.token续期

如果用户正在访问我们的网站,突然token过期了,这时用户只能重新登录获取新的token进行访问,这样的用户体验肯定不好。

解决:1. 本身AccessToken的过期时间为5分钟,RefreshToken过期时间为30分钟,当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问判断是否过期,如果过期,开始判断是否要进行AccessToken刷新,首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新。

2. 刷新后新的AccessToken过期时间依旧为5分钟,时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期,最终将刷新的AccessToken设置到在Response的Header中的refreshToken字段返回。

3. 同时前端进行获取替换,下次用新的AccessToken进行访问即可。

核心代码还是在JWTFilter中的getNewJwtToken方法中

 //token续期
if (JWTUtil.isJwtExpired(token) && accessToken.equals(refreshToken)) {
   //生成新token
   User user = new User();
   user.setId(Integer.valueOf(uid));
   token = JWTUtil.createToken(user);
   log.info("token续期成功:" + token);
   response.addHeader("refreshToken", token);
   response.setHeader("Access-Control-Expose-Headers", "refreshToken");
   return token;
}

4.redis缓存数据

在MyRealm中用Redis对认证、授权数据进行缓存,不然每次请求都会去查询数据库。


小结

本文的所有源码包含前端我都放在我的gitee上了,大家可以下载下来作为自己项目的后台管理系统。下一篇我会用这个系统实现各种文件的上传下载预览,包括分片上传和断点续传,具体会整合minio和kkViewFile。

创作不易,可以的话,给作者施舍一个三连吧😶‍🌫️😶‍🌫️😶‍🌫️

  • 28
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

无所谓^_^

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值