【黑马点评 - 实战篇01】Redis项目实战(Windows安装Redis6.2.6 + 发送验证码 + 短信验证码登录注册 + 拦截器链 - 登录校验)

目录

一、安装Redis

1、卸载旧版本Redis

2、Windows安装6.2.6版本Redis

二、导入黑马点评项目

1、导入数据库

2、导入后端代码

3、配置maven

4、修改application.yml和config配置

5、启动后端

6、启动前端

三、基于Session实现短信登录

1、业务流程概览

(1)session机制介绍

[2、发送短信验证码 - POST接口](#2、发送短信验证码 - POST接口)

(1)需求分析

(2)代码开发

[3、短信验证码登陆、注册 - POST接口](#3、短信验证码登陆、注册 - POST接口)

(1)需求分析

(2)代码开发

[4、登录校验 - 拦截器 LoginInterceptor](#4、登录校验 - 拦截器 LoginInterceptor)

(1)需求分析

(2)代码开发

四、基于Redis实现短信校验

1、session共享问题

2、Redis实现短信登录业务流程

(1)发送短信验证码流程

(2)短信验证码登录、注册流程

(3)登录校验流程

3、代码优化

[(1)发送短信验证码 - 优化](#(1)发送短信验证码 - 优化)

[(2)短信验证码登录、注册 - 优化](#(2)短信验证码登录、注册 - 优化)

[(3)登录校验 - 优化](#(3)登录校验 - 优化)

[4、解决状态登录刷新问题 - 拦截器链](#4、解决状态登录刷新问题 - 拦截器链)

(1)刷新Token拦截器

(2)登录校验拦截器

(3)MvcConfig配置类

[五、复习回顾 - Redis实现短信登录](#五、复习回顾 - Redis实现短信登录)

[1、发送验证码 - redis流程](#1、发送验证码 - redis流程)

[2、短信验证码登录、注册 - redis流程](#2、短信验证码登录、注册 - redis流程)

[3、登录校验 - 拦截器链流程](#3、登录校验 - 拦截器链流程)


一、安装Redis

1、卸载旧版本Redis

如果你的redis版本太低,先进入Redis安装目录,输入cmd启动控制台,然后输入下面命令进行卸载

然后再把redis文件夹删除就OK了

2、Windows安装6.2.6版本Redis

参考教程https://yonghongtech.csdn.net/681d632ea5baf817cf49a9ed.html?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7Ebaidujs_utm_term%7Eactivity-1-142692413-blog-140069813.235%5Ev43%5Epc_blog_bottom_relevance_base2&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7Ebaidujs_utm_term%7Eactivity-1-142692413-blog-140069813.235%5Ev43%5Epc_blog_bottom_relevance_base2&utm_relevant_index=1

https://link.csdn.net/?target=https%3A%2F%2Fgithub.com%2Fbinghe021%2Fredis-setup%2Freleases%3Flogin%3Dfrom_csdn

上面是下载链接,也可以通过置顶的资源进行下载

解压后双击运行,一直下一步即可

如果要修改密码,参考上面的教程链接,新手不建议设置密码,我这里就先不设置了

在redis安装路径文件夹输入cmd启动控制台

接着输入下面的指令(注意:先粘贴前面一段,按空格,然后再粘贴后面一段,最后按回车

XML 复制代码
redis-server.exe redis.windows.conf

如果出现以上界面说明安装成功,接下来测试一下

重新启动一个控制框

如果出现上面内容说明成功!

二、导入黑马点评项目

1、导入数据库

2、导入后端代码

3、配置maven

下载依赖如果报错 :Failure to transfer org.springframework.boot:spring-boot-starter-data-redis:jar:2.3.12.RELEASE from http://maven.aliyun.com/nexus/content/groups/public/ was cached in the local repository, resolution will not be reattempted until the update interval of alimaven has elapsed or updates are forced.这个错误表明阿里云仓库连接超时

解决方法:在C盘User -> .m2 -> settings.xml中加入下面的代码(更换镜像),然后回到IDE重新下载maven就可以了

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<settings>
    <mirrors>
        <mirror>
            <id>huaweicloud</id>
            <mirrorOf>*</mirrorOf>
            <name>华为云</name>
            <url>https://repo.huaweicloud.com/repository/maven/</url>
        </mirror>
    </mirrors>
</settings>

如果你的User-.m2文件夹下没有setting.xml,那就创建一个,在.m2文件夹下新建文本文件,然后重命名为【settings.xml】,用记事本方式打开,再粘贴上面的代码,最后回到IDE重新下载maven就好了

4、修改application.yml和config配置

然后修改RedissonConfig

5、启动后端

http://localhost:8081/shop-type/list

如果能获取到网页数据,说明后端配置成功

6、启动前端

把资料包中的nginx解压到一个全英文路径的文件夹下

在nginx所在目录下打开cmd,输入下列命令

XML 复制代码
start nginx.exe

最后在刚刚的页面中,按F12调出检查框,然后点击右上角的小手机图标,切换成手机模式

把网址端口改为8080,如果能成功显示,说明前端配置成功

三、基于Session实现短信登录

1、业务流程概览

(1)session机制介绍

  • Session是服务器端的用户状态管理机制。
  • 每个用户首次访问时,服务器会创建唯一的Session ID并通过Cookie返回给浏览器。
  • 后续请求浏览器自动携带此ID,服务器据此找到对应的Session对象。
  • Session数据存储在服务器内存中,包含用户登录状态、验证码等个人信息,各用户的Session完全隔离,有效保障数据安全和会话独立性。

2、发送短信验证码 - POST接口

(1)需求分析

前端页面点击【我的】,则会弹出登陆界面,如果点击【发送验证码】按钮,则前端会向后端发送一个请求,后端负责接收该请求并做相应处理

(2)代码开发

【1】controller层

java 复制代码
    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

【2】service层

  • 验证手机号格式是否正确
  • 若错误,返回错误信息
  • 若正确,生成验证码
  • 将验证码存入session,用于后续和用户输入的验证码比对
  • 发送验证码(模拟发送,不作为重点)
  • 返回成功信息
java 复制代码
    /**
     * 发送验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.验证手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果手机号无效,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        //3.如果有效,生成验证码
        String code = RandomUtil.randomNumbers(6);
        //4.将验证码存入session
        session.setAttribute("code",code);
        //5.发送验证码(模拟)
        log.debug("您的验证码是:{}",code);
        //6.返回成功
        return Result.ok();
    }

3、短信验证码登陆、注册 - POST接口

(1)需求分析

(2)代码开发

【1】controller层

java 复制代码
    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm, session);
    }

【2】service层

  • 校验手机号
  • 经验验证码
  • 若不一致,返回错误信息
  • 若一致,通过手机号查询数据库中是否存在该用户
  • 如果不存在,创建一个新用户保存到数据库中
  • 将用户保存到session中
  • 返回成功信息
java 复制代码
    /**
     * 短信实现登陆、注册
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机格式错误!");
        }

        //2.校验验证码
        String code = loginForm.getCode();
        Object cacheCode = session.getAttribute("code");

        if(cacheCode == null || !cacheCode.equals(code)){
            //3.不一致,返回错误信息
            return Result.fail("验证码错误!");
        }

        //4.一致,查询数据库是否存在用户
        User user = query().eq("phone", phone).one(); //mybatis-plus实现用手机号查询用户信息

        //5.用户不存在,创建新用户
        if(user == null){
            user = createUserWithPhone(phone);
        }
        //6.将用户保存进session
        //每一个用户的Session都是分开的,不是公共的
        //每个用户首次访问时,服务器生成唯一的Session ID
        session.setAttribute("user",user);

        return Result.ok();
    }

    private User createUserWithPhone(String phone) {
        // 1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        // 2.保存用户
        save(user);
        return user;
    }

4、登录校验 - 拦截器 LoginInterceptor

(1)需求分析

  • 如果没有拦截器,每个控制器方法都需重复编写登录验证、权限检查、日志记录等通用逻辑
  • 例如:用户权限校验代码会在订单查询、个人中心等数十个接口中重复出现,导致代码冗余、维护困难。
  • 拦截器通过统一预处理将这些横切关注点集中管理,极大提升代码复用性和系统可维护性。

(2)代码开发

【1】controller层

java 复制代码
    @GetMapping("/me")
    public Result me(){
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

【2】拦截器

  • 获取session

  • 获取session中的用户

  • 判断用户是否存在,若不存在则报错401,不放行

  • 若存在,保存用户到ThreadLocal(用UserHolder方法实现)

  • 放行

preHandle 负责进门前的检查和准备,而afterCompletion 负责离开后的打扫和清理,两者共同构成了一个完整且安全的请求处理闭环

java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取session
        HttpSession session = request.getSession();

        //2.获取session中的用户
        Object userDTO = session.getAttribute("user");

        //3.判断用户是否存在
        if(userDTO == null){
            //4.不放行
            response.setStatus(401);
            return false;
        }
        //5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) userDTO);

        //6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }
}

【3】Mvc配置文件

编写好拦截器后要在配置文件中添加拦截器,并规定【无需拦截的接口】

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns( //排除不需要拦截的接口
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
    }
}

四、基于Redis实现短信校验

1、session共享问题

  • 多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时会导致数据丢失
  • 为了解决共享问题,我们采用独立于多个Tomcat之外的Redis存储器

2、Redis实现短信登录业务流程

(1)发送短信验证码流程

  • 由【保存验证码到session】改变为【保存验证码到Redis】,Redis数据结构采用【String】比较直观【key:phone | value:code】

(2)短信验证码登录、注册流程

  • 校验验证码时,以手机号为key,从Redis中获取验证码
  • 由【保存用户信息到session】改变为【保存用户信息到Redis】,Redis数据结构采用【hash】比较合适【key:token | value:name:lisi】
  • 最后要把token返回给客户端,用于登录校验

(3)登录校验流程

  • 由【请求并携带token】改变为【请求并携带token】,用token为key,在Redis中获取用户信息
  • 登录校验时,为什么用token作为key从redis中获取用户信息,而不用手机号作为key?
  • 答:

3、代码优化

(1)发送短信验证码 - 优化

java 复制代码
    /**
     * 发送验证码 - session优化
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.验证手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果手机号无效,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        //3.如果有效,生成验证码
        String code = RandomUtil.randomNumbers(6);

        //【4.将验证码存入redis】新!!
        //采用String存储,【key:业务前缀+phone  value:code  过期时间:2min】
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);

        //5.发送验证码(模拟)
        log.debug("您的验证码是:{}",code);
        //6.返回成功
        return Result.ok();
    }

(2)短信验证码登录、注册 - 优化

问题1:将用户信息保存进Redis

  • 随机生成token作为登录令牌,也作为用户信息的key
  • 将【User对象】转换为【UserDTO】再转换为【hashmap】,转换为hashmap是方便Redis的hash格式存储(key | value:field:value)
  • 存入Redis的hash结构中,用putAll可以一次性把用户所有属性存入(适用于传入map结构)
  • 设置token有效期(因为用户会源源不断地登录,如果不把不活跃的用户token清理,redis就会承受不住)

问题2:为什么要把UserDTO转换为String类型的Map?下面这段复杂代码是干什么的?

java 复制代码
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));
  • 因为Redis Hash 更适合存储字符串值,而UserDTO中的id为Long类型,为了更加适配Redis Hash,这里需要自定义字段值编辑器,把所有属性类型转换为String
java 复制代码
    /**
     * 短信实现登陆、注册
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机格式错误!");
        }

        //【2.从redis中获取验证码并校验】新!!
        String code = loginForm.getCode();
        Object cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);

        if(cacheCode == null || !cacheCode.equals(code)){
            //3.不一致,返回错误信息
            return Result.fail("验证码错误!");
        }

        //4.一致,查询数据库是否存在用户
        User user = query().eq("phone", phone).one(); //mybatis-plus实现用手机号查询用户信息

        //5.用户不存在,创建新用户
        if(user == null){
            user = createUserWithPhone(phone);
        }

        //【6.将用户保存进redis】新!!
        //6.1 随机生成token作为登录令牌
        String token = UUID.randomUUID().toString(true);

        //6.2 将User对象转换为hashMap
        UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
        //将UserDTO对象安全地转换为String类型的Map,忽略空值字段,确保数据格式统一,便于存储到Redis Hash结构中。
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));

        //6.3 存储 用putAll可以一次性把用户所有属性传进去
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token,userMap);
        //6.4 设置token有效期(30min)
        stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL,TimeUnit.MINUTES);

        //【7.返回token】新!!
        return Result.ok(token);
    }

(3)登录校验 - 优化

问题一:为什么要在拦截器刷新token有效期?

  • 因为拦截器能拦截所有需要登录的请求,确保用户任何有效操作都会触发续期。
  • 用户每次操作都刷新过期时间,保持活跃会话,避免中途退出,同时自动清理不活跃的会话以释放资源。
java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    //不能使用 @Autowired 是因为拦截器实例是在配置类中手动创建的
    // Spring 无法对手动 new 的对象进行依赖注入。
    // 构造函数注入是更明确、更安全的依赖管理方式。
    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //【1.获取请求头中的token】新!!
        String token = request.getHeader("authorization");
        if(StrUtil.isBlank(token)){
            //不放行
            response.setStatus(401);
            return false;
        }

        String key = RedisConstants.LOGIN_USER_KEY + token;

        //【2.基于token获取redis中的用户】新!!
        //entries() 方法是用于获取 Redis Hash 结构中的所有字段和值
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

        //3.判断用户是否存在
        if(userMap.isEmpty()){
            //4.不放行
            response.setStatus(401);
            return false;
        }

        //【5.将查询到的hash数据转换为DTO】新!!
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

        //7.刷新token有效期
        //为什么要在拦截器刷新token有效期?
        //答:因为拦截器能拦截所有需要登录的请求,确保用户任何有效操作都会触发续期。
        // 用户每次操作都刷新过期时间,保持活跃会话,避免中途退出,同时自动清理不活跃的会话以释放资源。
        stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        //8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }
}

4、解决状态登录刷新问题 - 拦截器链

原方案中,Token的刷新依赖于登录校验拦截器。这意味着只有当用户访问需要登录的接口时,Token有效期才会被续期。如果用户长时间只访问公开接口(如首页、商品列表),即使他处于活跃状态,Token也可能因过期而失效,导致其在后续操作中意外退出。

优化点:

  • 我们解耦了【身份校验】和【Token刷新】这两个关注点,新增一个独立拦截器,并将其置于拦截器链的首位,以拦截所有路径。
  • 新拦截器职责:拦截所有路径,仅负责检查请求是否携带Token,以Token为key查询用户信息是否在Redis中。若Token有效且在Redis中,则将用户信息保存到ThreadLocal线程中,并刷新其有效期,无论该接口是否需要登录。
  • 原拦截器职责:拦截需要登录的路径,仅判断线程ThreadLocal中是否存在该用户,如果存在则放行,否则拦截。

改进优点

  • 此设计确保了只要用户在与应用进行任何交互,其登录状态就能得以保持,从而提供了连贯的用户体验,避免了活跃会话的中断。

(1)刷新Token拦截器

  • 从redis中获取token
    • 如果token不存在 → 放行(这时候用户信息并没有在线程ThreadLocal中,会被登录校验拦截器拦截)
  • 如果token存在,从Redis中获取用户信息
    • 如果用户不存在 → 放行(这时候用户信息并没有在线程ThreadLocal中,会被登录校验拦截器拦截)
  • 如果用户存在,将用户存入线程ThreadLocal
  • 刷新该用户的token
  • 放行
java 复制代码
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true; //这里先放行,交给下一个拦截器判断
        }

        // 2.基于TOKEN获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true; //这里先放行,交给下一个拦截器判断
        }

        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal(只有在redis中查到用户信息的才可以保存到ThreadLocal,其他的都会被第二个拦截器拦截)
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

(2)登录校验拦截器

  • 看线程ThreadLocal中是否存在该用户
    • 如果不存在 → 报错401,拦截
    • 如果存在 → 放行
java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断是否要拦截【ThreadLocal中是否有用户信息】
        if (UserHolder.getUser() == null) {
            //不存在该用户,拦截
            response.setStatus(401);
            return false;
        }
        //存在该用户,放行
        return true;
    }
}

(3)MvcConfig配置类

用于添加拦截器

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器
        //第一个拦截器:负责拦截需要【登录】的路径,做出是否拦截判断
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns( //排除不需要拦截的接口
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        //第二个拦截器:负责拦截【所有】路径,负责将存在redis的用户存入ThreadLocal,并刷新token
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

五、复习回顾 - Redis实现短信登录

这个课程相比起苍穹外卖真的很烧脑,所以把今天学习的重点【Redis实现短信登录】功能总结回顾一下,主要梳理【发送验证码】【短信验证码登录、注册】【登录校验】三个模块的开发业务逻辑。

1、发送验证码 - redis流程

2、短信验证码登录、注册 - redis流程

3、登录校验 - 拦截器链流程

相关推荐
占疏19 小时前
dify API访问工作流/聊天
开发语言·数据库·python
气π20 小时前
【JavaWeb】——(若依 + AI)-基础学习笔记
java·spring boot·笔记·学习·java-ee·mybatis·ruoyi
Cat God 00720 小时前
SQL使用及注意事项
数据库·sql·mysql
@老蝴20 小时前
MySQL数据库 - 约束和联合查询
android·数据库·mysql
程序猿202320 小时前
MySQL索引使用--最左前缀法则
数据库·mysql
老华带你飞20 小时前
列车售票|基于springboot 列车售票系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·学习·spring
IvorySQL21 小时前
PostgreSQL 中的“脏页(Dirty Pages)”是什么?
数据库·postgresql·开源
汝生淮南吾在北21 小时前
SpringBoot+Vue在线考试系统
vue.js·spring boot·后端·毕业设计·毕设
script.boy21 小时前
基于spring boot校园二手交易平台的设计与实现
java·spring boot·后端