SpringBoot3大事件

效果展示:实战篇-01_实战概述_哔哩哔哩_bilibili

开发模式

前后端分离开发,前后端分别按照同样的接口文档进行开发。接口文档有接口的路径和请求方式,请求参数和响应数据的说明。

环境搭建

  • 执行资料中的big_event.sql脚本,准备数据库表

    执行SQL指令,创建对应的数据库和表

  • 创建springboot工程,引入对应的依赖(web、mybatis、mysql驱动)

    <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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
      <modelVersion>4.0.0</modelVersion>
    
      <!--    配置起步依赖--> 
      <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.3</version>
      </parent>
      <groupId>com.ario</groupId>
      <artifactId>big-event</artifactId>
      <version>1.0-SNAPSHOT</version>
      <packaging>jar</packaging>
    
      <name>big-event</name>
      <url>http://maven.apache.org</url>
    
      <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
      </properties>
    
      <dependencies>
    <!--    web依赖-->
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    <!--    mybatis依赖-->
        <dependency>
          <groupId>org.mybatis.spring.boot</groupId>
          <artifactId>mybatis-spring-boot-starter</artifactId>
          <version>3.0.0</version>
        </dependency>
    <!--    mysql驱动依赖-->
        <dependency>
          <groupId>com.mysql</groupId>
          <artifactId>mysql-connector-j</artifactId>
          <version>9.1.0</version>
        </dependency>
      </dependencies>
    </project>
    
  • 配置文件application.yml中引入mybatis的配置信息

  • 创建包结构,并准备实体类

用户相关接口

注册接口

可以用lombok方式在实体类中添加get,set方法

常见开发流程

image

响应类

根据接口文档可以看出,不同请求的响应结果区别不大,可以建立一个同一的响应类,有个响应类,可以很方便的在前后端直接进行json转换。

package com.ario.pojo;


import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

//统一响应结果
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
    private Integer code;//业务状态码  0-成功  1-失败
    private String message;//提示信息
    private T data;//响应数据

    //快速返回操作成功响应结果(带响应数据)
    public static <E> Result<E> success(E data) {
        return new Result<>(0, "操作成功", data);
    }

    //快速返回操作成功响应结果
    public static Result success() {
        return new Result(0, "操作成功", null);
    }

    public static Result error(String message) {
        return new Result(1, message, null);
    }
}

思路分析

写代码依然用三层架构的方式书写

image

开发

先建立对应的文件

image

Md5加密算法的使用

工具类

package com.ario.utils;


import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Md5Util {
    /**
     * 默认的密码字符串组合,用来将字节转换成 16 进制表示的字符,apache校验下载的文件的正确性用的就是默认的这个组合
     */
    protected static char hexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};

    protected static MessageDigest messagedigest = null;

    static {
        try {
            messagedigest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException nsaex) {
            System.err.println(Md5Util.class.getName() + "初始化失败,MessageDigest不支持MD5Util。");
            nsaex.printStackTrace();
        }
    }

    /**
     * 生成字符串的md5校验值
     *
     * @param s
     * @return
     */
    public static String getMD5String(String s) {
        return getMD5String(s.getBytes());
    }

    /**
     * 判断字符串的md5校验码是否与一个已知的md5码相匹配
     *
     * @param password  要校验的字符串
     * @param md5PwdStr 已知的md5校验码
     * @return
     */
    public static boolean checkPassword(String password, String md5PwdStr) {
        String s = getMD5String(password);
        return s.equals(md5PwdStr);
    }


    public static String getMD5String(byte[] bytes) {
        messagedigest.update(bytes);
        return bufferToHex(messagedigest.digest());
    }

    private static String bufferToHex(byte bytes[]) {
        return bufferToHex(bytes, 0, bytes.length);
    }

    private static String bufferToHex(byte bytes[], int m, int n) {
        StringBuffer stringbuffer = new StringBuffer(2 * n);
        int k = m + n;
        for (int l = m; l < k; l++) {
            appendHexPair(bytes[l], stringbuffer);
        }
        return stringbuffer.toString();
    }

    private static void appendHexPair(byte bt, StringBuffer stringbuffer) {
        char c0 = hexDigits[(bt & 0xf0) >> 4];// 取字节中高 4 位的数字转换, >>>
        // 为逻辑右移,将符号位一起右移,此处未发现两种符号有何不同
        char c1 = hexDigits[bt & 0xf];// 取字节中低 4 位的数字转换
        stringbuffer.append(c0);
        stringbuffer.append(c1);
    }

}

使用

@Override
public void register(String username, String password) {
	//加密,密码不要明文存在数据库中,要用工具类加密
	String md5String = Md5Util.getMD5String(password);
	//添加
	userMapper.add(username, md5String);
}

参数校验

对上面的输入信息有要求的话要校验,最直接的方式就是用Java代码进行逻辑判断,如下

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/register")
    public Result register(String username, String password) {
        //参数校验,这种方式不推荐
        if (username != null && username.length() >= 5 && username.length() <= 16 &&
            password != null && password.length() >= 5 && password.length() <= 16) {
            //查询用户
            User u = userService.findByUserName(username);
            if (u == null) {
                //没有占用
                //注册
                userService.register(username, password);
                //不用返回带有参数的响应
                return Result.success();
            } else {
                //占用
                return Result.error("用户名已被占用");
            }
        } else {
            return Result.error("参数不合法");
        }

    }
}

当参数多了以后,这种方式就显得非常繁琐了。Spring 提供的一个参数校验框架Spring Validation,使用预定义的注解完成参数校验

使用步骤如下:

  1. 引入Spring Validation 起步依赖

    <!--      validation依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
  2. 在参数前面添加@Pattern注解【当然这里不止有这一个注解】

  3. 在Controller类上添加@Validated注解

    @RestController
    @RequestMapping("/user")
    @Validated
    public class UserController {
    
        @Autowired
        private UserService userService;
    
        @PostMapping("/register")
        //【\\S】表示非空
        public Result register(@Pattern(regexp= "^\\S{5,16}$") String username, @Pattern(regexp= "^\\S{5,16}$") String password) {
            //查询用户
            User u = userService.findByUserName(username);
            if (u == null) {
                //没有占用
                //注册
                userService.register(username, password);
                //不用返回带有参数的响应
                return Result.success();
            } else {
                //占用
                return Result.error("用户名已被占用");
            }
        }
    }
    

    但是这里报出来的一场,前端收到的信息是异常,缺少描述信息

    {
        "timestamp": "2025-04-03T13:19:40.856+00:00",
        "status": 500,
        "error": "Internal Server Error",
        "path": "/user/register"
    }
    
  4. 在全局异常处理器中处理参数校验失败的异常

    返回信息给前端

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        //注解的作用是指定要处理的异常
        @ExceptionHandler(Exception.class)
        public Result handleException(Exception e) {
            e.printStackTrace();
            //如果有包装信息,如果没有包装就是返回【操作失败】
            return Result.error(StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "操作失败");
        }
    }
    

    返回的信息:

    {
        "code": 1,
        "message": "register.username: 需要匹配正则表达式\"^\\S{5,16}$\"",
        "data": null
    }
    

登录接口

@PostMapping("/login")
public Result<String> login(@Pattern(regexp= "^\\S{5,16}$") String username, @Pattern(regexp= "^\\S{5,16}$") String password) {
    //根据用户名查询用户
    User loginUser = userService.findByUserName(username);
    //判断该用户是否存在
    if (loginUser == null) {
        return Result.error("用户名错误");
    }
    //判断密码是否正确 loginUser 对象中的password是密文
    if (Md5Util.getMD5String(password).equals(loginUser.getPassword())) {
        //登录成功
        return Result.success("jwt token令牌……");
    }
    return Result.error("密码错误");
}

其中用到service层和mapper层上面已经实现了,所有这里省事儿了。

登录认证

很常见的问题,保护好页面不被未登录的用户所访问。可以用常见的拦截器,这里用jwt令牌

令牌就是一段字符串

  • 承载业务数据, 减少后续请求查询数据库的次数

    可以存一些公开的用户信息,减少对数据库的查询次数

  • 防篡改, 保证信息的合法性和有效性

    防止恶意篡改伪造令牌

JWT-概述

  • 全称:JSON Web Token (https://jwt.io/)
  • 定义了一种简洁的、自包含的格式,用于通信双方以json数据格式安全的传输信息。
  • 组成
    • 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:
    • 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"},常用Base64编码表示,注意:这只是一种编码,是公开的,所有不应该保存用户的敏感信息,比如密码
    • 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。通过第一部分和第二部分借助密钥,通过加密算法【第一部分指出的】得出的

image

Base64:是一种基于64个可打印字符(A-Z a-z 0-9 + /)来表示二进制数据的编码方式。

image

JWT-生成

其实代码是可以自己写的,因为这只是一种规范。这里用别人写好的

  1. 引入依赖

    <!--      java-jwt坐标-->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>4.4.0</version>
    </dependency>
    
    <!--      spring为跟好的测试spring boot。提供了单元测试的坐标-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    
  2. 进行测试

    package com.ario;
    
    import com.auth0.jwt.JWT;
    import com.auth0.jwt.algorithms.Algorithm;
    import org.junit.jupiter.api.Test;
    
    import java.util.Date;
    import java.util.HashMap;
    import java.util.Map;
    
    public class JwtTest {
        @Test
        public void testGen() {
            Map<String, Object> claims = new HashMap<>();
            claims.put("id", 1);
            claims.put("username", "张三");
            //生成jwt的代码
            String token = JWT.create()
                    .withClaim("user", claims)//添加载荷
                    .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))//设置过期时间
                    .sign(Algorithm.HMAC256("ario"));//指定算法,配置密钥
            System.out.println(token);
        }
    }
    //eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJ1c2VybmFtZSI6IuW8oOS4iSJ9LCJleHAiOjE3NDM4MTM2MDl9.6ddADZl4eU6EqCRKK2o_1j8PrM_NvdX0wOebfkL2o6w
    

JWT-验证

@Test
public void testParse() {
    //定义字符串,模拟用户传递过来的token
    String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJ1c2VybmFtZSI6IuW8oOS4iSJ9LCJleHAiOjE3NDM4MTM2MDl9.6ddADZl4eU6EqCRKK2o_1j8PrM_NvdX0wOebfkL2o6w";

    JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("ario")).build();//建立应该验证器

    DecodedJWT decodedJWT = jwtVerifier.verify(token);//验证token,生成一个解析后的JWT对象
    Map<String, Claim> claims = decodedJWT.getClaims();
    System.out.println(claims.get("user"));
    //如果篡改了头部和载荷部分的数据,那么验证失败
    //如果密钥改了,验证失败
    //token过期,验证失败
}
  • JWT验证时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
  • 如果JWT令牌解析时报错,则说明 JWT令牌被篡改 或 失效了,令牌非法。

完善登录认证

  • 一般为了减少代码冗余,可以使用工具类

    public class JwtUtil {
    
      private static final String KEY = "ario";
     
     //接收业务数据,生成token并返回
      public static String genToken(Map<String, Object> claims) {
          return JWT.create()
                  .withClaim("claims", claims)
                  .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))
                  .sign(Algorithm.HMAC256(KEY));
      }
    
     //接收token,验证token,并返回业务数据
      public static Map<String, Object> parseToken(String token) {
          return JWT.require(Algorithm.HMAC256(KEY))
                  .build()
                  .verify(token)
                  .getClaim("claims")
                  .asMap();
      }
    }
    
  • 登录时生成令牌

    @PostMapping("/login")
    public Result<String> login(@Pattern(regexp= "^\\S{5,16}$") String username, @Pattern(regexp= "^\\S{5,16}$") String password) {
        //根据用户名查询用户
        User loginUser = userService.findByUserName(username);
        //判断该用户是否存在
        if (loginUser == null) {
            return Result.error("用户名错误");
        }
        //判断密码是否正确 loginUser 对象中的password是密文
        if (Md5Util.getMD5String(password).equals(loginUser.getPassword())) {
            //登录成功
            Map<String, Object> claims = new HashMap<>();
            claims.put("id", loginUser.getId());
            claims.put("username", loginUser.getUsername());
            String token = JwtUtil.genToken(claims);
            return Result.success(token);
        }
        return Result.error("密码错误");
    }
    
  • 其他接口拦截验证示例

    @GetMapping("/list")
    public Result<String> list(@RequestHeader(name = "Authorization") String token, HttpServletResponse response) {
        //验证token
        try {
            Map<String, Object> claims = JwtUtil.parseToken(token);
            return Result.success("所有的文章数据……");
        } catch (Exception e) {
            //http响应状态码401
            response.setStatus(401);
            return Result.error("未登录");
        }
    }
    

使用拦截器

如果每个接口都验证,会很冗余,可以使用连接器,过滤请求

  • 编写拦截器

    package com.ario.interceptors;
    
    import com.ario.utils.JwtUtil;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import java.util.Map;
    
    @Component
    public class LoginInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            //令牌验证
            String token = request.getHeader("Authorization");
            //验证token
            try {
                Map<String, Object> claims = JwtUtil.parseToken(token);
                //放行
                return true;
            } catch (Exception e) {
                //http响应状态码401
                response.setStatus(401);
                //不放行
                return false;
            }
        }
    }
    
  • 注册拦截器

    com.ario.config中的WebConfig

    @Component
    public class WebConfig implements WebMvcConfigurer {
    
        @Autowired
        private LoginInterceptor loginInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            //登录接口和注册接口不拦截
            registry.addInterceptor(loginInterceptor).excludePathPatterns("/user/login", "/user/register");
        }
    }
    

获取用户详细信息

  • controller层

    @GetMapping("/userInfo")
    public Result<User> userInfo(@RequestHeader(name = "Authorization") String token) {
        //根据用户名查询用户
        Map<String, Object> map = JwtUtil.parseToken(token);
        String username = (String) map.get("username");
        
        User user = userService.findByUserName(username);
        return Result.success(user);
    }
    
  • service和mapper层已经实现

注意:

  • 获得的数据包含了用户的密码等敏感数据,这里要再转到json数据时忽略

    在实体类属性上添加注释

    注意:导入的是import com.fasterxml.jackson.annotation.JsonIgnore;

    @JsonIgnore//让springmvc把当前对象转换成json字符串的时候,忽路password,最终的json字符申中就没有password这个属性了
    private String password;//密码
    
  • 得到的数据,时间为空,但是数据库中不为空,这是因为实体类属性名时驼峰命名,而数据库时下划线命名,mybatis转换时对应不起来了。此时配置mybatis,开启驼峰命名转换即可

    mybatis:
      configuration:
        map-underscore-to-camel-case: true
    

优化

上面可以看出,我们多层重令牌中解析了用户信息,同时还要传参。这可以用ThreadLocal优化

实现再拦截器中只解析一次。后面直接从ThreadLocal对象中取。

ThreadLocal

提供线程局部变量

  • 用来存取数据: set()/get()
  • 使用ThreadLocal存储的数据, 线程安全

功能测试

public class ThreadLocalTest {

    @Test
    public void testThreadLocalSetAndGet(){
        //提供一个ThreadLocal对象
        ThreadLocal tl = new ThreadLocal();

        //开启两个线程
        new Thread(()->{
            tl.set("萧炎");
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
        },"蓝色").start();

        new Thread(()->{
            tl.set("药尘");
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
            System.out.println(Thread.currentThread().getName()+": "+tl.get());
        },"绿色").start();
    }
}
/*蓝色: 萧炎
绿色: 药尘
绿色: 药尘
蓝色: 萧炎
绿色: 药尘
蓝色: 萧炎*/

功能实现

这里分装成工具类

public class ThreadLocalUtil {
    //提供ThreadLocal对象,
    private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();

    //根据键获取值
    public static <T> T get(){
        return (T) THREAD_LOCAL.get();
    }
   
    //存储键值对
    public static void set(Object value){
        THREAD_LOCAL.set(value);
    }


    //清除ThreadLocal 防止内存泄漏。只存不清楚,会泄露的
    public static void remove(){
        THREAD_LOCAL.remove();
    }
}

修改拦截器,存数数学,并请求结束后释放空间

@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //令牌验证
        String token = request.getHeader("Authorization");
        //验证token
        try {
            Map<String, Object> claims = JwtUtil.parseToken(token);

            //把业务数据存储到ThreadLocal中
            ThreadLocalUtil.set(claims);

            //放行
            return true;
        } catch (Exception e) {
            //http响应状态码401
            response.setStatus(401);
            //不放行
            return false;
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //清空ThreadLocal中的数据
        ThreadLocalUtil.remove();
    }
}

Controller直接取就行

@GetMapping("/userInfo")
public Result<User> userInfo(/*@RequestHeader(name = "Authorization") String token*/) {
    //根据用户名查询用户
    /*Map<String, Object> map = JwtUtil.parseToken(token);
        String username = (String) map.get("username");*/

    Map<String, Object> map = ThreadLocalUtil.get();
    String username = (String) map.get("username");

    User user = userService.findByUserName(username);
    return Result.success(user);
}

线程之间是不干扰的

image

更新用户基本信息

image

代码实现依旧是根据接口文档,通过三层架构的思想

image

  • controller层

    @PutMapping("/update")
    public Result update(@RequestBody User user) {
        userService.update(user);
        return Result.success();
    }
    
  • service层

    接口

    //更新
    void update(User user);
    

    实现类

    @Override
    public void update(User user) {
        //更新时间
        user.setUpdateTime(LocalDateTime.now());
        userMapper.update(user);
    }
    
  • UserMapper

    @Update("update user set nickname=#{nickname},email=#{email},update_time=#{updateTime} where id=#{id}")
    void update(User user);
    

实体参数校验

上面注册接口提到的参数校验是传过来的参数。这里是传过来的参数是实体类如何校验呢,

  1. 可以再实体类上添加注解。
  2. 再参数列表添加Validated注解使其生效。

下面是常用的几个注解

注解 作用
NotNull 值不能为null
NotEmpty 值不能为null,并且内容不为空
Email 满足邮箱格式
Pattern 自定义限制
  • 实体类

    @Data
    public class User {
      @NotNull
      private Integer id;//主键ID
      private String username;//用户名
      @JsonIgnore//让springmvc把当前对象转换成json字符串的时候,忽路password,最终的json字符申中就没有password这个属性了
      private String password;//密码
      
      @NotEmpty
      @Pattern(regexp = "^\\S{1,10}$")
      private String nickname;//昵称
      
      @NotEmpty
      @Email
      private String email;//邮箱
      //图像是上传到三方服务器中的,所有这里提供地址就行了
      private String userPic;//用户头像地址
      private LocalDateTime createTime;//创建时间
      private LocalDateTime updateTime;//更新时间
    }
    
  • controller

    @PutMapping("/update")
    public Result update(@RequestBody @Validated User user) {
      userService.update(user);
      return Result.success();
    }
    

更新用户头像

同样三层架构思想。

patch是http里面的局部更新方式

  • controller注意:这里URL的注解是参数校验,保证参数是合法的URL

    @PatchMapping("updateAvatar")
    public Result updateAvatar(@RequestParam @URL String avatarUrl) {
        userService.updateAvatar(avatarUrl);
        return Result.success();
    }
    
  • service

    接口类

    //更新头像
    void updateAvatar(String avatarUrl);
    

    实现类

    @Override
    public void updateAvatar(String avatarUrl) {
        //获取用户id
        Map<String, Object> map = ThreadLocalUtil.get();
        Integer id = (Integer) map.get("id");
        userMapper.updateAvatar(avatarUrl, id);
    }
    
  • mapper

    @Update("update user set user_pic=#{avatarUrl},update_time=now() where id=#{id}")
    void updateAvatar(String avatarUrl, Integer id);
    

更新用户密码

image

这里用Map传参,而更新用户信息是用User,是因为更新用户时参数和属性这一样springmvc会自动匹配,而这里不一样,springmvc会自动把json数据转换成Map集合

文章分类接口

新增文章分类

  • 功能介绍

    image

  • 实体类,同时实现参数校验

    @Data
    public class Category {
        private Integer id;//主键ID
        @NotEmpty
        private String categoryName;//分类名称
        @NotEmpty
        private String categoryAlias;//分类别名
        private Integer createUser;//创建人ID
        private LocalDateTime createTime;//创建时间
        private LocalDateTime updateTime;//更新时间
    }
    
  • CategoryController,注意这里PostMapping后面没有添加映射路径。这里可以根据请求方式区分

    @RestController
    @RequestMapping("/category")
    public class CategoryController {
    
        @Autowired
        private CategoryService categoryService;
    
        @PostMapping
        public Result add(@RequestBody Category category) {
            categoryService.add(category);
            return Result.success();
        }
    
    }
    
  • CategoryService

    public interface CategoryService {
        //新增
        void add(Category category);
    }
    

    CategoryServiceImpl

    @Service
    public class CategoryServiceImpl implements CategoryService {
    
        @Autowired
        private CategoryMapper categoryMapper;
    
        @Override
        public void add(Category category) {
            //补充属性值
            category.setCreateTime(LocalDateTime.now());
            category.setUpdateTime(LocalDateTime.now());
    
            Map<String, Object> map = ThreadLocalUtil.get();
            Integer userId = (Integer) map.get("id");
            category.setCreateUser(userId);
            categoryMapper.add(category);
        }
    }
    
  • GategoryMapper

    @Mapper
    public interface CategoryMapper {
        //新增
        @Insert("insert into category(category_name,category_alias,create_user,create_time,update_time) " +
                "values(#{categoryName},#{categoryAlias},#{createUser},#{createTime},#{updateTime})")
        void add(Category category);
    }
    

文章分类列表

  • 功能介绍,这里展示出来的分类只是本用户创建的分类

    image

  • Controller

    @GetMapping
    public Result<List<Category>> list() {
        List<Category> cs = categoryService.list();
        return Result.success(cs);
    }
    
  • Service

    接口

    //分类查询
    List<Category> list();
    

    实现类

    @Override
    public List<Category> list() {
        Map<String, Object> map = ThreadLocalUtil.get();
        Integer userId = (Integer) map.get("id");
        return categoryMapper.list(userId);
    }
    
  • Mapper

    //分类查询
    @Select("select * from category where create_user = #{userId}")
    List<Category> list(Integer userId);
    
  • 同时为了规范时间格式,可以再实体类中属性上添加注解

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;//创建时间
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;//更新时间
    

获取文章分类详情

  • 功能介绍

    image

  • controller

    @GetMapping("/detail")
    public Result<Category> detail(Integer id) {
        Category c = categoryService.findById(id);
        return Result.success(c);
    }
    
  • service

    接口

    //根据id查询分类
    Category findById(Integer id);
    

    实现类

    @Override
    public Category findById(Integer id) {
        Category c = categoryMapper.findById(id);
        return c;
    }
    
  • mapper

    //根据分类id查询分类
    @Select("select * from category where id = #{id}")
    Category findById(Integer id);
    

更新文章分类

  • 功能介绍

    image

  • controller

    @PutMapping
    public Result update(@RequestBody @Validated Category category) {
        categoryService.update(category);
        return Result.success();
    }
    
  • service

    接口

    //跟新分类
    void update(Category category);
    

    实现类

    @Override
    public void update(Category category) {
        category.setUpdateTime(LocalDateTime.now());
        categoryMapper.update(category);
    }
    
  • mapper

    //更新分类
    @Update("update category set category_name=#{categoryName},category_alias=#{categoryAlias},update_time=#{updateTime} where id = #{id}")
    void update(Category category);
    
  • 再实体类上添加注解,实现参数校验

    //NotNull是不能不传,NotEmpty只能传非空的字符串
    @NotNull
    private Integer id;//主键ID
    

分组校验【修复BUG】

上面添加NotNull注解后,是无法添加分类了,因为添加分类时不传递id。

这样让把不同场合的注解作用分开呢——分组校验

分组校验:

把校验项进行归类分组,在完成不同的功能的时候,校验指定组中的校验项

  1. 定义分组
  2. 定义校验项时指定归属的分组
  3. 校验时指定要校验的分组
  4. 定义校验项时如果没有指定分组,则属于Default分组,分组可以继承

代码详情

  • 实体类

    @Data
    public class Category {
        //NotNull是不能不传,NotEmpty只能传非空的字符串
        @NotNull(groups = Update.class)
        private Integer id;//主键ID
        @NotEmpty/*(groups = {Add.class, Update.class})*/
        private String categoryName;//分类名称
        @NotEmpty/*(groups = {Add.class, Update.class})*/
        private String categoryAlias;//分类别名
        private Integer createUser;//创建人ID
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime createTime;//创建时间
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime updateTime;//更新时间
    
        //如果说某个校验项没有指定分组,默认属于Default
        //分组之间可以继承,A extends B 那么A中拥有B中所有的校验项
    
        //这里categoryName和categoryAlias没有指定分组,那么属于Default的分组。下面这两个接口都继承了这个分组,那么这两个分组都有,所有上面就可以注释了
        //而id指定了,那么Add分组中就没有这个限制了
    
        public interface Add extends Default {
    
        }
    
        public interface Update extends Default {
    
        }
    }
    
  • controller

    @PostMapping
    public Result add(@RequestBody @Validated(Category.Add.class) Category category) {
        categoryService.add(category);
        return Result.success();
    }
    @PutMapping
    public Result update(@RequestBody @Validated(Category.Update.class) Category category) {
        categoryService.update(category);
        return Result.success();
    }
    

小结

  1. 如何定义分组?
    • 在实体类内部定义接口
  2. 如何对校验项分组?
    • 通过groups属性指定
  3. 校验时如何指定分组?
    • 给@Validated注解的value属性赋值
  4. 校验项默认属于什么组?【指明后,就不属于了】
    • Default

删除文章分类

全自主实现,easy

  • controller

    @DeleteMapping
    public Result delete(Integer id) {
        categoryService.delete(id);
        return Result.success();
    }
    
  • service

    接口

    //删除
    void delete(Integer id);
    

    实现类

    @Override
    public void delete(Integer id) {
        categoryMapper.delete(id);
    }
    
  • mapper

    //删除分类
    @Delete("delete from category where id = #{id}")
    void delete(Integer id);
    

文章管理接口

新增文章

  • 功能介绍

    image

和上面两类接口相同,先创建三层文件,和实体类。

先写主逻辑,再参数校验

  • controller

    @PostMapping
    public Result add(@RequestBody Article article) {
        articleService.add(article);
        return Result.success();
    }
    
  • service

    接口

    //新增
    void add(Article article);
    

    实现类

    @Autowired
    private ArticleMapper articleMapper;
    
    @Override
    public void add(Article article) {
        article.setCreateTime(LocalDateTime.now());
        article.setUpdateTime(LocalDateTime.now());
        Map<String, Object> map = ThreadLocalUtil.get();
        Integer UserId = (Integer) map.get("id");
        article.setCreateUser(UserId);
        articleMapper.add(article);
    }
    
  • mapper

    //新增
    @Insert("insert into article(title,content,cover_img,state,category_id,create_user,create_time,update_time) " +
            "values(#{title},#{content},#{coverImg},#{state},#{categoryId},#{createUser},#{createTime},#{updateTime})")
    void add(Article article);
    

参数校验【自定义注解】

先按照接口文档,把常规的参数校验设置上。

private Integer id;//主键ID
@NotEmpty
@Pattern(regexp = "^\\S{1,10}$")
private String title;//文章标题
@NotEmpty
private String content;//文章内容
@NotEmpty
@URL
private String coverImg;//封面图像
@NotEmpty
private String state;//发布状态 已发布|草稿
@NotNull
private Integer categoryId;//文章分类id
private Integer createUser;//创建人ID
private LocalDateTime createTime;//创建时间
private LocalDateTime updateTime;//更新时间

state属性要用到自定义注解

已有的注解不能满足所有的校验需求,特殊的情况需要自定义校验(自定义校验注解)

  1. 自定义注解State
  2. 自定义校验数据的类StateValidation实现ConstraintValidator接口
  3. 在需要校验的地方使用自定义注解

image

实现步骤

  1. 创建自定义注解

    package com.ario.anno;
    
    import com.ario.validation.StateValidation;
    import jakarta.validation.Constraint;
    import jakarta.validation.Payload;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;
    
    import static java.lang.annotation.ElementType.*;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;
    
    @Documented//元注解
    @Target({ FIELD})//元注解
    @Retention(RUNTIME)//元注解
    @Constraint(validatedBy = { StateValidation.class})//指定提供校验规则的类
    public @interface State {
        //提供校验失败后的提示信息
        String message() default "state参数的值只能是已发布或者草稿";
        //指定分组
        Class<?>[] groups() default { };
        //负载  获取到State注解的附加信息
        Class<? extends Payload>[] payload() default { };
    }
    
  2. 自定义校验数据的类StateValidation实现ConstraintValidator接口

    package com.ario.validation;
    
    import com.ario.anno.State;
    import jakarta.validation.ConstraintValidator;
    import jakarta.validation.ConstraintValidatorContext;
    //两个参数指定分别是,【给哪个注解提供校验规则】,【校验参数的数据类型】
    public class StateValidation implements ConstraintValidator<State,String> {
        /**
         *
         * @param value 将来要校验的数据
         * @param context context in which the constraint is evaluated
         *
         * @return 如果返回false,则校验不通过,如果返回true,则校验通过
         */
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            //提供校验规则
            if (value == null){
                return false;
            }
            if (value.equals("已发布") || value.equals("草稿")){
                return true;
            }
            return false;
        }
    }
    
  3. 在需要校验的地方使用自定义注解

    @State
    private String state;//发布状态 已发布|草稿
    

文章列表

  • 功能介绍。涉及到分页,动态SQL,动态参数

    image

  • 三层架构

    image

代码实现

  • 创建PageBean实现类

    package com.ario.pojo;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    import java.util.List;
    
    //分页返回结果对象
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class PageBean <T>{
        private Long total;//总条数
        private List<T> items;//当前页数据集合
    }
    
  • controller

    @GetMapping
    public Result<PageBean<Article>> list(
            Integer pageNum,
            Integer pageSize,
            @RequestParam(required = false) Integer categoryId,
            @RequestParam(required = false) String state
    ) {
       PageBean<Article> pb = articleService.list(pageNum, pageSize, categoryId, state);
       return Result.success(pb);
    }
    
  • service

    接口

    //查询
    PageBean<Article> list(Integer pageNum, Integer pageSize, Integer categoryId, String state);
    

    用到了PageHelper插件,要导入起步依赖

    <!--      spring为跟好的测试spring boot。提供了单元测试的坐标-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    

    实现类

    @Override
    public PageBean<Article> list(Integer pageNum, Integer pageSize, Integer categoryId, String state) {
        //1.创建PageBean对象
        PageBean<Article> pb = new PageBean<>();
    
        //2.开启分页查询PageHelper
        PageHelper.startPage(pageNum, pageSize);
    
        //3.调用mapper
        Map<String, Object> map = ThreadLocalUtil.get();
        Integer userId = (Integer) map.get("id");
        List<Article> as = articleMapper.list(userId, categoryId, state);
        //Page中提供了方法,可以获取PageHelper分页查询后,得到的总记录条数和当前也数据
        Page<Article> p = (Page<Article>) as;
    
        //把数据填充PageBean对象中
        pb.setTotal(p.getTotal());
        pb.setItems(p.getResult());
        return pb;
    }
    
  • mapper

    接口

    //查询
    List<Article> list(Integer userId, Integer categoryId, String state);
    

    mapper配置文件

    在资源文件夹中创建com/airo/mapper。创建ArticleMapper.xml

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.ario.mapper.ArticleMapper">
        <!--动态sql-->
        <select id="list" resultType="com.ario.pojo.Article">
            select * from article
            <where>
                <if test="categoryId!=null">
                    category_id=#{categoryId}
                </if>
    
                <if test="state!=null">
                    and state=#{state}
                </if>
    
                and create_user=#{userId}
            </where>
        </select>
    </mapper>
    

文章详情、更新,删除

这几个比较简单,放在一起吧

  • controller

    @GetMapping("/detail")
    public Result<Article> detail(Integer id) {
        Article article = articleService.detail(id);
        return Result.success(article);
    }
    
    @PutMapping
    public Result update(@RequestBody @Validated Article article) {
        articleService.update(article);
        return Result.success();
    }
    
    @DeleteMapping
    public Result delete(Integer id) {
        articleService.delete(id);
        return Result.success();
    }
    
  • service

    接口

    //文章详情
    Article detail(Integer id);
    
    //文章更新
    void update(Article article);
    
    //删除文章
    void delete(Integer id);
    

    实现类

    @Override
    public Article detail(Integer id) {
        Article article = articleMapper.detail(id);
        return article;
    }
    
    @Override
    public void update(Article article) {
        article.setUpdateTime(LocalDateTime.now());
        articleMapper.update(article);
    }
    
    @Override
    public void delete(Integer id) {
        articleMapper.delete(id);
    }
    
  • mapper

    //文章详情
    @Select("select * from article where id = #{id}")
    Article detail(Integer id);
    
    //更新文章
    @Update("update article " +
            "set title=#{title},content=#{content},cover_img=#{coverImg},state=#{state},category_id=#{categoryId},update_time=#{updateTime} " +
            "where id=#{id}")
    void update(Article article);
    
    //删除文章
    @Delete("delete from article where id=#{id}")
    void delete(Integer id);
    

其他接口

文件上传

用户头像和文章封面都是要上传图片的

前端三要素

<form action="/upload" method="post" enctype="multipart/form-data">    头像: <input type="file" name="image"><br>    <input type="submit" value="提交"></form>

文件操作MultipartFile

  • String getOriginalFilename(); //获取原始文件名
  • void transferTo(File dest); //将接收的文件转存到磁盘文件中
  • long getSize(); //获取文件的大小,单位:字节
  • byte[] getBytes(); //获取文件内容的字节数组
  • InputStream getInputStream(); //获取接收到的文件内容的输入流

controller

@RestController
public class FileUploadController {

    @PostMapping("/upload")
    public Result<String> upload(MultipartFile file) throws IOException {
        //把文件的内容存储到本地磁盘上
        String originalFilename = file.getOriginalFilename();
        //保证文件的名字是唯一的,从而防止文件覆盖
        String filename = UUID.randomUUID().toString()+originalFilename.substring(originalFilename.lastIndexOf("."));
        file.transferTo(new File("C:\\Users\\Anthony\\Desktop\\test\\"+filename));
        return Result.success("url访问地址……");
    }
}

上面把图片上传到本地服务器了。存在一些问题

  • 无法直接访问
  • 磁盘满了

一般是要上传到”云“的

云:互联网上的一堆计算机

image

阿里云

阿里云是阿里巴巴集团旗下全球领先的云计算公司,也是国内最大的云服务提供商 。

image

阿里云OSS

阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。

第三方服务-通用思路

image

SDK:Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。

阿里云OSS-使用步骤

image

Bucket:存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。

SDK:Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。

代码集成

  1. 根据官方SDK导入依赖

    <!-- 阿里云oss依赖坐标 -->
    <dependency>
     <groupId>com.aliyun.oss</groupId>
     <artifactId>aliyun-sdk-oss</artifactId>
     <version>3.15.1</version>
    </dependency>
    <dependency>
     <groupId>javax.xml.bind</groupId>
     <artifactId>jaxb-api</artifactId>
     <version>2.3.1</version>
    </dependency>
    <dependency>
     <groupId>javax.activation</groupId>
     <artifactId>activation</artifactId>
     <version>1.1.1</version>
    </dependency>
    <!--  no more than 2.3.3 -->
    <dependency>
     <groupId>org.glassfish.jaxb</groupId>
     <artifactId>jaxb-runtime</artifactId>
     <version>2.3.3</version>
    </dependency>
    
  2. 测试类验证

    package com.ario;
    
    import com.aliyun.oss.ClientException;
    import com.aliyun.oss.OSS;
    import com.aliyun.oss.OSSClientBuilder;
    import com.aliyun.oss.OSSException;
    import com.aliyun.oss.model.PutObjectRequest;
    import com.aliyun.oss.model.PutObjectResult;
    import java.io.FileInputStream;
    
    public class Demo {
    
        public static void main(String[] args) throws Exception {
            // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
            String endpoint = "https://oss-cn-beijing.aliyuncs.com";
            // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
            //EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
            String ACCESS_KEY_ID="你的ID";
            String ACCESS_KEY_SECRET="你的密钥";
            // 填写Bucket名称,例如examplebucket。
            String bucketName = "big-event343";
            // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。
            String objectName = "001.png";
    
            // 创建OSSClient实例。
            OSS ossClient = new OSSClientBuilder().build(endpoint,ACCESS_KEY_ID, ACCESS_KEY_SECRET);
    
            try {
                // 填写字符串。
                String content = "Hello OSS,你好世界";
    
                // 创建PutObjectRequest对象。
                PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, new FileInputStream("D:\\Pictures\\头像等\\头像.jpg"));
    
                // 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
                // ObjectMetadata metadata = new ObjectMetadata();
                // metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
                // metadata.setObjectAcl(CannedAccessControlList.Private);
                // putObjectRequest.setMetadata(metadata);
    
                // 上传字符串。
                PutObjectResult result = ossClient.putObject(putObjectRequest);
            } catch (OSSException oe) {
                System.out.println("Caught an OSSException, which means your request made it to OSS, "
                        + "but was rejected with an error response for some reason.");
                System.out.println("Error Message:" + oe.getErrorMessage());
                System.out.println("Error Code:" + oe.getErrorCode());
                System.out.println("Request ID:" + oe.getRequestId());
                System.out.println("Host ID:" + oe.getHostId());
            } catch (ClientException ce) {
                System.out.println("Caught an ClientException, which means the client encountered "
                        + "a serious internal problem while trying to communicate with OSS, "
                        + "such as not being able to access the network.");
                System.out.println("Error Message:" + ce.getMessage());
            } finally {
                if (ossClient != null) {
                    ossClient.shutdown();
                }
            }
        }
    }
    

    上传成功,在阿里云可以看到,生成了Url:https://big-event343.oss-cn-beijing.aliyuncs.com/001.png

    可以观察到地址是有规律的。后面可以用这个规律,在后端代码中编写url地址

  3. 上面大多数代码都是固定的,可以集成到工具类中

    package com.ario.utils;
    
    import com.aliyun.oss.ClientException;
    import com.aliyun.oss.OSS;
    import com.aliyun.oss.OSSClientBuilder;
    import com.aliyun.oss.OSSException;
    import com.aliyun.oss.model.PutObjectRequest;
    import com.aliyun.oss.model.PutObjectResult;
    
    import java.io.FileInputStream;
    import java.io.InputStream;
    
    public class AliOssUtil {
    
        // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
        private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com";
        // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        //EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
        private static final String ACCESS_KEY_ID="你的ID";
        private static final String ACCESS_KEY_SECRET="你的密钥";
        // 填写Bucket名称,例如examplebucket。
        private static final String BUCKET_NAME = "big-event343";
    
        public static String uploadFile(String objectName, InputStream in) throws Exception {
    
    
            // 创建OSSClient实例。
            OSS ossClient = new OSSClientBuilder().build(ENDPOINT,ACCESS_KEY_ID, ACCESS_KEY_SECRET);
            String url = "";
            try {
                // 填写字符串。
                String content = "Hello OSS,你好世界";
    
                // 创建PutObjectRequest对象。
                PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, objectName, in);
    
                // 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
                // ObjectMetadata metadata = new ObjectMetadata();
                // metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
                // metadata.setObjectAcl(CannedAccessControlList.Private);
                // putObjectRequest.setMetadata(metadata);
    
                // 上传字符串。
                PutObjectResult result = ossClient.putObject(putObjectRequest);
                //url组成: https://bucket名称.区域节点/objectName
                url = "https://"+BUCKET_NAME+"."+ENDPOINT.substring(ENDPOINT.lastIndexOf("/")+1)+"/"+objectName;
            } catch (OSSException oe) {
                System.out.println("Caught an OSSException, which means your request made it to OSS, "
                        + "but was rejected with an error response for some reason.");
                System.out.println("Error Message:" + oe.getErrorMessage());
                System.out.println("Error Code:" + oe.getErrorCode());
                System.out.println("Request ID:" + oe.getRequestId());
                System.out.println("Host ID:" + oe.getHostId());
            } catch (ClientException ce) {
                System.out.println("Caught an ClientException, which means the client encountered "
                        + "a serious internal problem while trying to communicate with OSS, "
                        + "such as not being able to access the network.");
                System.out.println("Error Message:" + ce.getMessage());
            } finally {
                if (ossClient != null) {
                    ossClient.shutdown();
                }
            }
    
            return url;
        }
    }
    
  4. controller修改代码逻辑,使用阿里云

    @RestController
    public class FileUploadController {
    
        @PostMapping("/upload")
        public Result<String> upload(MultipartFile file) throws Exception {
            //把文件的内容存储到本地磁盘上
            String originalFilename = file.getOriginalFilename();
            //保证文件的名字是唯一的,从而防止文件覆盖
            String filename = UUID.randomUUID().toString()+originalFilename.substring(originalFilename.lastIndexOf("."));
    //        file.transferTo(new File("C:\\Users\\Anthony\\Desktop\\test\\"+filename));
            String url = AliOssUtil.uploadFile(filename,file.getInputStream());
            return Result.success(url);
        }
    }
    

登录优化-redis

上面关于用户接口时密码修改后但是令牌没有失效,旧的令牌依然可以使用。

这里可以用redis优化

令牌主动失效机制

  • 登录成功后,给浏览器响应令牌的同时,把该令牌存储到redis中
  • LoginInterceptor拦截器中,需要验证浏览器携带的令牌,并同时需要获取到redis中存储的与之相同的令牌
  • 当用户修改密码成功后,删除redis中存储的旧令牌

如果对redis不熟悉,可以通过下面视频学习redis黑马

安装redis【windows版】

这里资料已经下载了,直接点击就能用

image

image

image

SpringBoot集成redis

  • 导入spring-boot-starter-data-redis起步依赖

    <!--      redis坐标-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  • 在yml配置文件中, 配置redis连接信息

    data:
      redis:
        host: localhost
        port: 6379
    
  • 调用API(StringRedisTemplate)完成字符串的存取操作

    @SpringBootTest //如果在测试类上添加了这个注解,那么将来单元测试方法执行之前,会先初始化Spring容器
    public class RedisTest {
    
        //依赖中已经自动注入对象了,这里直接用就行
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Test
        public void testSet() {
            //往redis中存储一个键值对  StringRedisTemplate
            ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
    
            operations.set("username", "zhangsan");
            //可以设置过期时间
            operations.set("id", "1", 15, TimeUnit.SECONDS);
        }
    
        @Test
        public void testGet() {
            //从redis中获取一个键值对  StringRedisTemplate
            ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
    
            System.out.println(operations.get("username"));
        }
    }
    

令牌主动失效机制

  • 登录成功后,给浏览器响应令牌的同时,把该令牌存储到redis中

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @PostMapping("/login")
    public Result<String> login(@Pattern(regexp= "^\\S{5,16}$") String username, @Pattern(regexp= "^\\S{5,16}$") String password) {
        //根据用户名查询用户
        User loginUser = userService.findByUserName(username);
        //判断该用户是否存在
        if (loginUser == null) {
            return Result.error("用户名错误");
        }
        //判断密码是否正确 loginUser 对象中的password是密文
        if (Md5Util.getMD5String(password).equals(loginUser.getPassword())) {
            //登录成功
            Map<String, Object> claims = new HashMap<>();
            claims.put("id", loginUser.getId());
            claims.put("username", loginUser.getUsername());
            String token = JwtUtil.genToken(claims);
            //把token存储到redis中
            ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
            //这里的过期时间和令牌过期时间保持一致就行
            operations.set(token, token, 12, TimeUnit.HOURS);
            return Result.success(token);
        }
        return Result.error("密码错误");
    }
    
  • LoginInterceptor拦截器中,需要验证浏览器携带的令牌,并同时需要获取到redis中存储的与之相同的令牌

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //令牌验证
        String token = request.getHeader("Authorization");
        //验证token
        try {
            //从redis中获取相同的token
            ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
            String redisToken = operations.get(token);
            if (redisToken == null) {
                //token已经失效了
                throw new RuntimeException();
            }
    
            Map<String, Object> claims = JwtUtil.parseToken(token);
    
            //把业务数据存储到ThreadLocal中
            ThreadLocalUtil.set(claims);
    
            //放行
            return true;
        } catch (Exception e) {
            //http响应状态码401
            response.setStatus(401);
            //不放行
            return false;
        }
    }
    
  • 当用户修改密码成功后,删除redis中存储的旧令牌

    @PatchMapping("/updatePwd")
    public Result updatePwd(@RequestBody Map<String, String> params,@RequestHeader(name = "Authorization") String token) {
        //1.校验参数
        String oldPwd = params.get("old_pwd");
        String newPwd = params.get("new_pwd");
        String rePwd = params.get("re_pwd");
    
        //StringUtils是spring提供的方法
        if (!StringUtils.hasLength(oldPwd) || !StringUtils.hasLength(newPwd) || !StringUtils.hasLength(rePwd)) {
            return Result.error("缺少必要的参数");
        }
    
        //原密码是否正确
        //调用userService根据用户名拿到原密码,再和old_pwd比对
        Map<String,Object> map = ThreadLocalUtil.get();
        String username = (String) map.get("username");
        User loginUser = userService.findByUserName(username);
        if (!loginUser.getPassword().equals(Md5Util.getMD5String(oldPwd))){
            return Result.error("原密码填写不正确");
        }
    
        //newPwd和rePwd是否一样
        if (!rePwd.equals(newPwd)){
            return Result.error("两次填写的新密码不一样");
        }
    
        //2.调用service完成密码更新
        userService.updatePwd(newPwd);
        //删除redis中对应的token ,这里的token通过参数传递过来
        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        operations.getOperations().delete(token);
        return Result.success();
    }
    

项目部署

打包运行

注意:jar包部署,要求服务器必须有jre环境

项目开放完毕之后虽然可以通过idea运行。但是不是一致打开的。所有要放到服务器上面完成部署。

image

这里为了方便,以Windows环境为例进行项目部署

  1. 先加入打包插件

    <build>
        <plugins>
            <!-- 打包插件 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>3.1.3</version>
            </plugin>
        </plugins>
    </build>
    
  2. 开始打包

    image

    这里突然报错了

    Please refer to D:\code\javacode\Projects\big-event\target\surefire-reports for the individual test results.
    Please refer to dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
    

    查看对应目录下面文件以后发现redis连接失败,应该是我把redis关了的原因。

    结果发现redis连接成功了,然后报token失效了。发现在测试类中有token死代码。跟新以后没问题了。当然也可以跳过测试类进行打包

    image

    这是可以把jar包传到服务器上面了。

  3. 然后在对应目录的命令行中执行运行指令java -jar big-event-1.0-SNAPSHOT.jar

    image

  4. 运行成功后就可以正常访问了。如果要停止运行的话输入Ctrl + C快速停止

注意:运行时可能出现端口冲突问题而报错

属性配置方式

面对上面的端口报错问题,对于运维人员或客户时没有办法更改项目中的配置文件的,因为已经打包了。

下面时几个常见的属性配置方式

  1. 命令行参数方式

    • 语法:--键=值。例如--server.port=10010

    • 原理:这个参数时通过args参数传递的

      image

  2. 环境变量方式

    注意:想要让环境变量起效,要重新打开命令行。

    image

  3. 外部配置文件方式

    image

    image

那么这几种方式的优先级是什么呢?想想也可以看出。在上面问题的解决中其他方式会覆盖掉项目内部的配置。完整的优先级如下

项目中resources目录下的application.yml < LJar包所在目录下的application.yml < 操作系统环境变量 < 命令行参数

多环境开发-Profiles

项目可能在开放、测试和生成等个环境开发,以数据库为例,不同环境的数据库也不一样,要不断更改配置文件吗?这样造成了修改繁琐,容易出错的缺点

单文件配置

SpringBoot提供的Profiles可以用来隔离应用程序配置的各个部分,并在特定环境下指定某些部分的配置生效

  • 如何分隔不同环境的配置?---

  • 如何指定哪些配置属于哪个环境?如何指定哪个环境的配置生效?

    #通用信息,指定生效的环境
    #多环境下共性的属性
    #如果特定环境中的配置和通用信息冲突了,特定环境中的配置生效【局部优于全局】
    spring:
      profiles:
        active: dev
    
    ---
    #开发环境
    spring:
      config:
        activate:
          on-profile: dev
    server:
      port: 8081
    ---
    #测试环境
    spring:
      config:
        activate:
          on-profile: test
    server:
      port: 8082
    ---
    #生成环境
    spring:
      config:
        activate:
          on-profile: pro
    server:
      port: 8083
    

多文件配置

当配置较多时,维护起来很杂乱。可以分成多个文件单独维护

image

image

小结

  1. 单文件配置

    • --- 分隔不同环境的配置

    • spring.config.activate.on-profile 配置所属的环境

    • spring.profiles.active 激活环境

  2. 多文件配置

    • 通过多个文件分别配置不同环境的属性
    • 文件的名字为 application-环境名称.yml
    • 在application.yml中激活环境

分组

当配置信息很多时,可以根据配置的种类在进行拆分成单独文件,这样便于管理

image

image

image

posted @ 2025-04-03 22:36  韩熙隐ario  阅读(80)  评论(0)    收藏  举报