KekeBlog项目实战前台模块(已完结)

目录

一、前言

1. 项目简介

2. 技术栈

3. 运行环境

4. 开发软件

二、项目搭建

1. 思考

2. 工程创建

2.1 创建父模块(父)

2.2 创建公共模块(子)

2.3 创建博客后台模块(子)

2.4 创建博客前台模块(子)

2.5 Maven聚合工程总结

三、准备工作

1. SQL导入

2. 前台yml配置

3. EasyCode插件

3.1 插件安装

3.2 插件使用

3.3 自定义模板

4. 代码(可用生成器)

5. 博客前台测试

四、博客前台模块-热门文章列表

1. 文章表字段分析

2. 需求分析

3. 统一响应格式

4. 代码实现

5. 前端工程启动

6. 前后端联调

7. 后端解决跨域

8. 响应格式优化

8.1 创建VO 

8.2 BeanCopy工具类

8.3 字面量处理

8.3 修改impl类

五、博客前台模块-分类列表

1. 分类表字段

2. 需求分析

3. 代码实现

六、博客前台模块-分页查询文章

1. 文章表字段

2. 需求分析

3. 代码实现

4. 解决-普通循环方式

5. 解决-stream流优化

6. stream流再优化

7. FastJson配置

七、博客前台模块-文章详情

1. 需求分析

2. 请求格式详解

3. 代码

八、博客前台模块-友联功能

1. 友联表字段

2. 需求分析

3. 代码实现

九、博客前台模块-登录功能

1. 用户表字段

2. 需求分析

3. 思路分析

4. 添加相关依赖

5. 配置类和工具类

6. 代码实现-自定义登录接口

7. 代码实现-认证过滤器

 十、 博客前台模块-异常处理

1. 认证的异常处理

2. 授权的异常处理

3. 认证授权异常处理配置到框架

4. 测试自定义异常处理

5. 统一异常处理

5.1 自定义异常

5.2 全局异常处理

5.3 Controller层逻辑

5.3 测试

5.4 总结

十一、博客前台模块-退出登录

1. 接口分析

2. 思路分析

3. 代码实现

4. 测试

十二、博客前台模块-评论列表

1. 评论表的字段

2. 接口分析

3. 准备代码

4. 代码实现-不考虑子评论

优化

5. 实现-考虑子评论

十三、博客前台模块-发文章评论

1. 接口分析

2. 代码实现

十四、友联评论列表 + 评论优化

1. 接口分析

2. 代码实现

 3. 测试

4. 友链页面的登录bug

 十五、前台模块-个人信息

1. 接口分析

 2. 代码实现

十六、OSS-文件上传

1. 为什么要使用OSS

2. 基础-七牛云基本使用

3. Demo-上传文件到七牛云

4. OSS-指定文件存放目录

十七、前台模块-头像上传

1. 接口分析

2. 代码实现

十八、前台模块-更新个人信息

1. 接口分析

2. 代码实现

3. 总结

十九、前台模块-注册功能

1. 注册页面展示

2. 接口分析

3. 代码实现

二十、前台模块-日志记录

1. 需求分析

2. AOP实现日志记录的分析

3. AOP实现日志记录的代码

二十一、前台模块-浏览次数

1. 项目-思路分析

2. 基础-启动预处理

3. 基础-定时任务

4. 项目-接口分析

5. 项目-代码实现

5.1 启动预处理

5.2 更新浏览量至Redis

5.3 Redis更新至MySQL

5.4 从Redis中查浏览量


一、前言

1. 项目简介

本项目是前后端分离项目,而我们所做的只有完整的后端开发工作,前端已经写好,故不做任何开发,仅开发后端。项目包含完整的后端中前台和后台的代码编写

2. 技术栈

前端(不做实现)

Vue

ElementUI 

后端

SpringBoot

SpringSecurity

Maven

MybatisPlus

Mysql

Redis

EasyExcel

Swagger2

Echarts  

3. 运行环境

jdk 1.8.0_381

Apache Maven 3.8.6

mysql 8.0.29

node.js v18.16.1

npm 9.5.1

4. 开发软件

Intellj IDEA 2022.1.4

Navicat Premium 15

RedisDesktopManager

Postman

Typora

二、项目搭建

1. 思考

由于项目后端分为前台和后台两个部分,且都有用到相同的实体等等,所以考虑到代码的复用性,我们把后端的前台和后台管理的两个模块的相同代码抽取出来放在一个公共模块里。即两个子模块,一个父模块的多模块项目

两个子模块分别是:博客前台模块keke-blog,博客后台模块keke-admin

公共模块:keke-framework

2. 工程创建

在F盘中创建一个目录BlogProject然后在该目录下创建模块

2.1 创建父模块(父)

将默认的项目结构修改如下

我们把这个工程,作为我们的父级工程,所以src目录就不再需要了,仅需要pom.xml配置文件即可。故把src目录删除,在pom.xml中添加至以下内容(注意要删除上面的properties标签)

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.keke</groupId>
    <artifactId>KekeBlog</artifactId>
    <version>1.0-SNAPSHOT</version>

    <!--编码方式、jdk版本-->
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
    </properties>

    <!--依赖的版本控制,不是真正的依赖,是对依赖版本的锁定-->
    <dependencyManagement>
        <dependencies>
            <!-- SpringBoot的依赖配置-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.5.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!--fastjson依赖-->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>1.2.33</version>
            </dependency>

            <!--jwt依赖-->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.9.0</version>
            </dependency>

            <!--mybatisPlus依赖-->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>3.4.3</version>
            </dependency>

            <!--阿里云OSS-->
            <dependency>
                <groupId>com.aliyun.oss</groupId>
                <artifactId>aliyun-sdk-oss</artifactId>
                <version>3.10.2</version>
            </dependency>

<!--            excel-->
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>easyexcel</artifactId>
                <version>3.0.5</version>
            </dependency>

<!--            swaggerUI-->
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger2</artifactId>
                <version>2.9.2</version>
            </dependency>
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger-ui</artifactId>
                <version>2.9.2</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <!--配置maven版本-->
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <!--配置jdk版本-->
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <encoding>${project.build.sourceEncoding}</encoding>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

2.2 创建公共模块(子)

创建公共模块keke-framework,该模块实现依赖统一添加

公共模块的pom.xml文件配置如下,注意不要指定版本,因为其继承了父模块,版本已锁定

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>KekeBlog</artifactId>
        <groupId>com.keke</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>keke-framework</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--junit-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--SpringSecurity启动器-->
        <!--<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>-->

        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--fastjson依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
        <!--jwt依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>
        <!--mybatisPlus依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--mysql数据库驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--阿里云OSS-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
        </dependency>

        <!--AOP-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

<!--        excel-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
        </dependency>
<!--        swaggerUI-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
        </dependency>
    </dependencies>
</project>

2.3 创建博客后台模块(子)

后台模块依赖于公共模块,所以我们修改pom.xml文件至如下

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>KekeBlog</artifactId>
        <groupId>com.keke</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>keke-admin</artifactId>

    <!--用keke-framework模块里面的依赖-->
    <dependencies>
        <dependency>
            <groupId>com.keke</groupId>
            <artifactId>keke-framework</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

可以看到,依赖都加载进入该子模块

2.4 创建博客前台模块(子)

操作同博客后台模块一样,在pom.xml文件中写入

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>KekeBlog</artifactId>
        <groupId>com.keke</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>keke-blog</artifactId>
    
    <dependencies>
        <dependency>
            <groupId>com.keke</groupId>
            <artifactId>keke-framework</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>

2.5 Maven聚合工程总结

以上操作概括为一个项目为父模块,在父模块中锁定依赖的版本。父模块又有三个子模块,分别为公共模块,博客后台模块和博客前台模块。公共模块中添加项目所需要的模块,博客后台模块和博客前台模块去依赖公共模块,从而达到依赖的继承

可以看到,父模块中多了三个子module,并且KekeBlog为root(根),这样的Maven聚合工程,使得在Maven生命周期中,只需要操作父模块,其余所有模块就都会一起进行操作,省去了逐个模块操作的复杂工作

三、准备工作

1. SQL导入

数据库sql文件不需要自己去写,解压sql.zip文件后有11个sql脚本,全部导入数据库即可

链接:https://pan.baidu.com/s/1DQCGN4wISSDlOkqnVWYwxA 
提取码:mfkw

2. 前台yml配置

server:
  port: 7777

spring:
  # 数据库连接信息
  datasource:
    url: jdbc:mysql://localhost:3306/keke_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

  servlet:
    # 文件上传
    multipart:
      # 单个上传文件的最大允许大小
      max-file-size: 20MB
      # HTTP请求中包含的所有文件的总大小的最大允许值
      max-request-size: 20MB

mybatis-plus:
  configuration:
    # 日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 逻辑删除的字段
      logic-delete-field: delFlag
      # 代表已删除的值
      logic-delete-value: 1
      # 代表未删除的值
      logic-not-delete-value: 0
      # 主键自增策略,以mysql数据库为准
      id-type: auto

3. EasyCode插件

3.1 插件安装

搜索EasyCode,安装这个插件

3.2 插件使用

第一步连接数据库

第二步右键某个表,选中easycode,generate code

我选择在keke-framework模块下创建实体类,点击确定,查看生成的实体类如下

3.3 自定义模板

但它生成的实体类通常跟我们预期的不太符合,比如get/set方法我们期望用lombok去代替,那么我们应该去配置一下(图片上的是我配置好的)

可以在idea的settings里面修改

把entity.java.vm修改为如下,点击应用即可

##导入宏定义
$!{define.vm}

##保存文件(宏定义)
#save("/entity", ".java")

##包路径(宏定义)
#setPackageSuffix("entity")

##自动导入包(全局变量)
$!{autoImport.vm}
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

##表注释(宏定义)
#tableComment("表实体类")
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("$!{tableInfo.obj.name}")
public class $!{tableInfo.name} {
#foreach($column in $tableInfo.fullColumn)
    #if(${column.comment})//${column.comment}#end

    private $!{tool.getClsNameByFullName($column.type)} $!{column.name};
#end

}

把dao.java.vm修改为如下,点击应用即可

##导入宏定义
$!{define.vm}

##设置表后缀(宏定义)
#setTableSuffix("Dao")

##保存文件(宏定义)
#save("/dao", "Dao.java")

##包路径(宏定义)
#setPackageSuffix("dao")

import com.baomidou.mybatisplus.core.mapper.BaseMapper;


##表注释(宏定义)
#tableComment("表数据库访问层")
public interface $!{tableName} extends BaseMapper<$!tableInfo.name> {

}

把service.java.vm修改为如下,点击应用即可

##导入宏定义
$!{define.vm}

##设置表后缀(宏定义)
#setTableSuffix("Service")

##保存文件(宏定义)
#save("/service", "Service.java")

##包路径(宏定义)
#setPackageSuffix("service")

import com.baomidou.mybatisplus.extension.service.IService;


##表注释(宏定义)
#tableComment("表服务接口")
public interface $!{tableName} extends IService<$!tableInfo.name> {

}

把serviceimpl.java.vm修改为如下,点击应用即可

##导入宏定义
$!{define.vm}

##设置表后缀(宏定义)
#setTableSuffix("ServiceImpl")

##保存文件(宏定义)
#save("/service/impl", "ServiceImpl.java")

##包路径(宏定义)
#setPackageSuffix("service.impl")

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

##表注释(宏定义)
#tableComment("表服务实现类")
@Service("$!tool.firstLowerCase($tableInfo.name)Service")
public class $!{tableName} extends ServiceImpl<$!{tableInfo.name}Mapper, $!{tableInfo.name}> implements $!{tableInfo.name}Service {

}

4. 代码(可用生成器)

第一步: 在keke-framework工程的src/main/java目录新建com.keke.domain.entity.Article类,写入如下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 文章表(Article)表实体类
 *
 * @author makejava
 * @since 2023-10-10 09:48:35
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Article {
    
    private Long id;
    //标题
    private String title;
    //文章内容
    private String content;
    //文章摘要
    private String summary;
    //所属分类id
    private Long categoryId;
    //缩略图
    private String thumbnail;
    //是否置顶(0否,1是)
    private String isTop;
    //状态(0已发布,1草稿)
    private String status;
    //访问量
    private Long viewCount;
    //是否允许评论 1是,0否
    private String isComment;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步: 在keke-framework工程的src/main/java目录新建com.keke.mapper.ArticleMapper接口,写入如下

package com.keke.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.keke.domain.entity.entity.Article;


/**
 * 文章表(Article)表数据库访问层
 *
 * @author makejava
 * @since 2023-10-10 09:59:36
 */
public interface ArticleMapper extends BaseMapper<Article> {

}

第三步: 在keke-framework工程的src/main/java目录新建com.huanf.service.ArticleService接口,写入如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.entity.entity.Article;


/**
 * 文章表(Article)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 09:59:37
 */
public interface ArticleService extends IService<Article> {

}

第四步: 在keke-framework工程的src/main/java目录新建com.keke.service.impl.ArticleServiceImpl类,写入如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.entity.entity.Article;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import org.springframework.stereotype.Service;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

}

我们做完上面的一系列代码操作后,包结构应如下

5. 博客前台测试

第一步: 在keke-blog工程的src/main/java目录新建com.keke.KeBlogApplication类,作为启动类,写入如下

package com.keke;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class KeBlogApplication {
     public static void main(String[] args) {
          SpringApplication.run(KeBlogApplication.class,args);
     }
}

第二步: 在keke-blog工程的resources目录新建File,文件名为application.yml文件,写入如下

server:
  port: 7777
spring:
  # 数据库连接信息
  datasource:
    url: jdbc:mysql://localhost:3306/keke_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

    servlet:
      # 文件上传
      multipart:
        # 单个上传文件的最大允许大小
        max-file-size: 20MB
        # HTTP请求中包含的所有文件的总大小的最大允许值
        max-request-size: 20MB

  mybatis-plus:
    configuration:
        # mp日志
      log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
      global-config:
        db-config:
          # 逻辑删除的字段
          logic-delete-field: delFlag
          # 代表已删除的值
          logic-delete-value: 1
          # 代表未删除的值
          logic-not-delete-value: 0
          # 主键自增策略,以mysql数据库为准,而不是mp默认的雪花算法
          id-type: auto

第三步: 在keke-blog工程的src/main/java目录新建com.keke.controller.ArticleController类,写入如下

package com.keke.controller;

import com.keke.domain.entity.entity.Article;
import com.keke.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/article")
public class ArticleController {

     @Autowired
     private ArticleService articleService;

     @GetMapping("/list")
     public List<Article> test(){
          return articleService.list();
     }
}

第四步: 在keke-blog工程的KeBlogApplication引导类,修改为如下,主要就是添加了一个Mapper包扫描。不然我们用不了(注入不了)公共模块的类(bean)

package com.keke;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.keke.mapper")
public class KeBlogApplication {
     public static void main(String[] args) {
          SpringApplication.run(KeBlogApplication.class,args);
     }
}

第五步,由于之前keke-blog还是依赖keke-framework公共类的老版本jar包,所以我们需要需要在maven中选择父工程重新install一下

第六步我们启动后访问还是报错,查看错误提示发现是实体类和数据库表没有映射到

果断在实体类添加@TableName注解,代码如下

package com.keke.domain.entity.entity;

import java.util.Date;
import java.io.Serializable;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 文章表(Article)表实体类
 *
 * @author makejava
 * @since 2023-10-10 10:06:59
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_article")  //添加表名注解
public class Article {
    
    private Long id;
    //标题
    private String title;
    //文章内容
    private String content;
    //文章摘要
    private String summary;
    //所属分类id
    private Long categoryId;
    //缩略图
    private String thumbnail;
    //是否置顶(0否,1是)
    private String isTop;
    //状态(0已发布,1草稿)
    private String status;
    //访问量
    private Long viewCount;
    //是否允许评论 1是,0否
    private String isComment;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

启动keke-blog工程,访问list接口成功

四、博客前台模块-热门文章列表

1. 文章表字段分析

2. 需求分析

需要查询浏览量最高的前10篇文章的信息。要求展示文章标题和浏览量。把能让用户自己点击跳转到具体的文章详情进行浏览

注意:不能把草稿展示出来,不能把删除了的文章查询出来。要按照浏览量进行降序排序

3. 统一响应格式

第一步: 在keke-framework公共模块的src/main/java目录新建com.keke.enums.AppHttpCodeEnum类,写入如下,作用是封装code和msg

package com.keke.enums;


public enum AppHttpCodeEnum {
    // 成功
    SUCCESS(200,"操作成功"),
    // 登录
    NEED_LOGIN(401,"需要登录后操作"),
    NO_OPERATOR_AUTH(403,"无权限操作"),
    SYSTEM_ERROR(500,"出现错误"),
    USERNAME_EXIST(501,"用户名已存在"),
    PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
    REQUIRE_USERNAME(504, "必需填写用户名"),
    LOGIN_ERROR(505,"用户名或密码错误");
    int code;
    String msg;

    AppHttpCodeEnum(int code, String errorMessage){
        this.code = code;
        this.msg = errorMessage;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

第二步: 在keke-framework公共模块的domain目录新建ResponseResult类,写入如下,作为统一响应格式的类

package com.keke.domain;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.keke.enums.AppHttpCodeEnum;
import java.io.Serializable;


//统一响应格式。实体类,或者这个类严格来说叫响应体
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> implements Serializable {
    private Integer code;
    private String msg;
    private T data;

    public ResponseResult() {
        this.code = AppHttpCodeEnum.SUCCESS.getCode();
        this.msg = AppHttpCodeEnum.SUCCESS.getMsg();
    }

    public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public ResponseResult(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public ResponseResult(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public static ResponseResult errorResult(int code, String msg) {
        ResponseResult result = new ResponseResult();
        return result.error(code, msg);
    }
    //空参构造方法,这里会创建一个对象,msg和code都默认是成功的值,适用处理一些响应简单的接口
    public static ResponseResult okResult() {
        ResponseResult result = new ResponseResult();
        return result;
    }
    public static ResponseResult okResult(int code, String msg) {
        ResponseResult result = new ResponseResult();
        return result.ok(code, null, msg);
    }

    public static ResponseResult okResult(Object data) {
        ResponseResult result = setAppHttpCodeEnum(AppHttpCodeEnum.SUCCESS, AppHttpCodeEnum.SUCCESS.getMsg());
        if(data!=null) {
            result.setData(data);
        }
        return result;
    }

    public static ResponseResult errorResult(AppHttpCodeEnum enums){
        return setAppHttpCodeEnum(enums,enums.getMsg());
    }

    public static ResponseResult errorResult(AppHttpCodeEnum enums, String msg){
        return setAppHttpCodeEnum(enums,msg);
    }

    public static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums){
        return okResult(enums.getCode(),enums.getMsg());
    }

    private static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums, String msg){
        return okResult(enums.getCode(),msg);
    }

    public ResponseResult<?> error(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
        return this;
    }

    public ResponseResult<?> ok(Integer code, T data) {
        this.code = code;
        this.data = data;
        return this;
    }

    public ResponseResult<?> ok(Integer code, T data, String msg) {
        this.code = code;
        this.data = data;
        this.msg = msg;
        return this;
    }

    public ResponseResult<?> ok(T data) {
        this.data = data;
        return this;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

}

4. 代码实现

第一步: 把keke-framework公共模块的ArticleService修改为如下,定义了hotArticleList方法

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.entity.Article;


/**
 * 文章表(Article)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 09:59:37
 */
public interface ArticleService extends IService<Article> {


     ResponseResult hotArticleList();
}

第二步: 把keke-framework公共模块的ArticleServiceImpl修改为如下,实现了hotArticleList方法

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.entity.Article;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus,0);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(1,10);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //返回ResponseResult对象
          return ResponseResult.okResult(articles);
     }
}

第三步: 把keke-blog工程的ArticleController类,修改为如下,增加了文章列表的统一响应格式的代码

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.entity.Article;
import com.keke.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/article")
public class ArticleController {

     @Autowired
     private ArticleService articleService;

     //测试
     @GetMapping("/list")
     public List<Article> test(){
          return articleService.list();
     }
     //热门文章
     @GetMapping("/hotArticleList")
     public ResponseResult hotArticleList(){
          return articleService.hotArticleList();
     }
}

第四步用postman发送请求,可以看到后端返回的响应数据,在控制台也打印出了mp的sql日志

5. 前端工程启动

第一步,解压前端工程,创建web目录结构如下,解压至如下目录

第二步: 运行前端项目,请确保电脑有安装node.js,然后以管理员身份打开命令行窗口,输入如下

cd f:
cd /BlogProject/web/keke-blog-vue
npm install 
npm run dev

执行完上述操作后就会出现如下

6. 前后端联调

前端项目下载链接:

https://pan.baidu.com/s/1TdFs4TqxlHh4DXyLwYuejQ 
提取码:mfkw

打开前端页面http://localhost:8080后,发现并没有显示出来热门文章列表,F12打开控制台发现是CORS报错,原因是后端没有解决跨域问题,没有放行一些请求

7. 后端解决跨域

在keke-framework工程的src/main/java目录新建com.keke.config.WebConfig类,写入如下,然后重新运行keke-blog工程的启动类

package com.keke.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;


@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }

}

再次启动工程,访问前端页面,就可以看到热门文章列表了

8. 响应格式优化

我们在查询热门文章列表时,后端返回给前端的数据中,可以看到是返回了所有字段的数据

但通常前端是不需要所有字段的数据的,仅仅需要表中某几个字段的数据即可,并且在实际项目中, 有些敏感字段是不能返回给前端使得前台用户看到,为了满足这一需求,我们通常把响应给前端的数据(前端需要的数据所封装而成的对象)称为VO(是Value Object的缩写,表示值对象

下面来创建热门文章VO,并把完善ServiceImpl代码

8.1 创建VO 

在keke-frameword工程的src/main/java目录新建com.keke.vo.HotArticleVO类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class HotArticleVo {
     //id
     private Long id;
     //标题
     private String title;
     //访问量
     private Long viewCount;
}

8.2 BeanCopy工具类

这个工具类主要是实现不同Bean之间的相互转换,把一个bean对象中的字段拷贝到另一个bean对象的字段

它在spring项目中常用来封装VO对象向前端传递数据。

两对象中对应字段名和类型应完全相同,否则无法拷贝

在keke-framework工程的src/main/java目录新建com.keke.utils.BeanCopyUtils类,写入如下

package com.keke.utils;

import org.springframework.beans.BeanUtils;

import java.util.List;
import java.util.stream.Collectors;


//这个类用到很多泛型知识,可以对应去补一下
public class BeanCopyUtils {

    //私有的空参构造方法
    private BeanCopyUtils() {
    }

    //1.单个实体类的拷贝(暂时还用不上)。第一个参数是要拷贝的对象,第二个参数是类的字节码对象
    public static <V> V copyBean(Object source,Class<V> clazz) {
        //创建目标对象
        V result = null;
        try {
            result = clazz.newInstance();
            //实现属性拷贝。也就是把source的属性拷贝到这个目标对象。BeanUtils是spring提供的工具类
            BeanUtils.copyProperties(source, result);
        } catch (Exception e) {
            e.printStackTrace();
        }
        //返回结果
        return result;
    }


    //2.集合的拷贝(在ArticleServiceImpl类里面会使用到)。第一个参数是要拷贝的集合,第二个参数是类的字节码对象
    public static <O,V> List<V> copyBeanList(List<O> list,Class<V> clazz){
        //不使用for循环,使用stream流进行转换
        return list.stream()
                .map(o -> copyBean(o, clazz))
                //把结果转换成集合
                .collect(Collectors.toList());
    }
}

8.3 字面量处理

实际项目中都不允许直接在代码中使用字面值(代码中的固定值)。都需要定义成常量来使用。这种方式有利于提高代码的可维护性。字面值如下图

在keke-framework工程的src/main/java目录新建com.keke.constants.SystemConstants类,写入如下,作用是定义常量

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     *  文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;
    
    /**
     *  文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;
    
    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;
    
}

8.3 修改impl类

把keke-framework工程的ArticleServiceImpl类修改为如下,调用BeanCopyUtils类的copyBeanList方法,将Article和HotArticlVo之间进行转换

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.entity.Article;
import com.keke.domain.vo.HotArticleVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }
}

五、博客前台模块-分类列表

1. 分类表字段

2. 需求分析

效果图如下

页面上需要展示分类列表,用户可以点击具体的分类查看该分类下的文章列表。要求只展示有发布正式文章的分类 。要求必须是正常(非禁用)状态的分类

下面是文章表

通过分析,前端只需要拿到分类的id和分类名称,其余字段内容不需要

我们可以去文章表中查状态是已发布的文章,然后获取到id,然后可以通过去重等操作,拿到有效的文章分类id,再去分类表中去查相应的分类名称

3. 代码实现

第一步: 在keke-framework工程的domain/entity目录新建Category类,写入如下 

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 分类表(Category)表实体类
 *
 * @author makejava
 * @since 2023-10-10 20:42:21
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_category")
public class Category {
    @TableId
    private Long id;
    //分类名
    private String name;
    //父分类id,如果没有父分类为-1
    private Long pid;
    //描述
    private String description;
    //状态0:正常,1禁用
    private String status;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步: 在keke-framework工程的mapper目录新建CategoryMapper接口,写入如下

package com.keke.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.keke.domain.entity.Category;


/**
 * 分类表(Category)表数据库访问层
 *
 * @author makejava
 * @since 2023-10-10 20:42:22
 */
public interface CategoryMapper extends BaseMapper<Category> {

}

第三步: 在keke-framework工程的service目录新建CategoryService接口,写入如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.entity.Category;


/**
 * 分类表(Category)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 20:42:22
 */
public interface CategoryService extends IService<Category> {

}

第四步: 在keke-framework工程的service目录新建impl.CategoryServiceImpl类,写入如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Category;
import com.keke.domain.vo.CategoryVo;
import com.keke.mapper.CategoryMapper;
import com.keke.service.ArticleService;
import com.keke.service.CategoryService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 分类表(Category)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 20:42:22
 */
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {

     @Autowired
     private ArticleService articleService;

     @Override
     public ResponseResult getCategoryList() {
          //查询文章表,状态已发布的文章,但是在CategoryService下,查询文章表,就要注入ArticleService
          LambdaQueryWrapper<Article> articleWrapper = new LambdaQueryWrapper<>();
          articleWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          List<Article> articleList = articleService.list(articleWrapper);
          //获取文章的分类id,并去重
          Set<Long> categoryIds = articleList.stream()
                  .map(article -> article.getCategoryId())
                  //toSet可以去除重复的id
                  .collect(Collectors.toSet());
          //查询分类表
          List<Category> categories = listByIds(categoryIds);
          //分类表中只获取正常状态非禁用的分类,用stream流过滤
          categories = categories.stream()
                  .filter(category -> SystemConstants.STATUS_NORMAL.equals(category.getStatus()))
                  .collect(Collectors.toList());
          //封装Vo
          List<CategoryVo> categoryVos = BeanCopyUtils.copyBeanList(categories, CategoryVo.class);
          //封装到响应体中,因为有数据,所以要调用有参okResult(),把参数传进去
          return ResponseResult.okResult(categoryVos);
     }
}

第五步:由于引入了新的字面量,需要在keke-framework的SystemCanstants类中新增'分类表分类状态是正常状态' 的常量,引入如下

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     *  文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;
    
    /**
     *  文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;
    
    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;

    /**
     * 分类表的分类状态是正常状态
     */
    public static final String STATUS_NORMAL = "0";
}

第六步:由于前端只需要分类id和分类名,所以在在keke-blog工程的vo目录新建CategoryVo

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class CategoryVo {
     private Long id;
     //分类名
     private String name;
}

第七步:在keke-blog工程的controller目录新建CategoryController类,写入如下

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/category")
public class CategoryController {

     @Autowired
     private CategoryService categoryService;

     @GetMapping("/getCategoryList")
     public ResponseResult getCategoryList(){
          return categoryService.getCategoryList();
     }
}

第八步:测试如下

在postman中

前后端联调中

六、博客前台模块-分页查询文章

1. 文章表字段

2. 需求分析

首页需要查询所有的文章列表

分类页面需要查询对应分类下的文章列表

只能查询正式发布的文章

置顶的文章要显示在最前面

我们在首页需要传页面显示需要的相应字段对应的数据,在分类页面中我们还需要传入分类id,当然两个页面中我们都需要进行分页查询,所以要还要传PageNum(页码),PageSize(每页大小)和total(总记录数)3个字段

3. 代码实现

由于请求参数是query类型,即用?和&拼接的参数值在请求路径上,如下

所以我们在写后端接口的时候要写上方法参数

第一步:在keke-framework工程的config目录新建MybatisPlusConfig类,作用是配置MyBatisPlus的分页插件,不然分页没有效果

package com.keke.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


@Configuration
//配置MyBatisPlus的分页插件,使得支持分页查询。这个类直接复制就能用
public class MybatisPlusConfig {

    /**
     * 3.4.0之后版本
     *
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return mybatisPlusInterceptor;
    }

}

第二步: 在keke-framework工程的vo目录新建ArticleListVo实体类,写入如下 

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleListVo {

     private Long id;
     //标题
     private String title;
     //文章摘要
     private String summary;
     //所属分类名
     private String categoryName;
     //缩略图
     private String thumbnail;
     //访问量
     private Long viewCount;

     private Date createTime;

}

第三步: 在keke-framework工程的vo目录新建PageVo实体类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageVo {
     private List rows;
     private Long total;
}

这里说一下为什么要封装一个PageVo,由于这里响应体中实际上还封装了total属性,data值是一个数组的形式,所以我们要重新封装一个Vo来响应给前端 ,图示如下

第四步: 把keke-framework工程的ArticleService接口,修改为如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;


/**
 * 文章表(Article)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 09:59:37
 */
public interface ArticleService extends IService<Article> {


     ResponseResult hotArticleList();

     ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId);
}

第五步: 把keke-framework工程的ArticleServiceImpl类,修改为如下,增加了分页查询的代码

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.vo.ArticleListVo;
import com.keke.domain.vo.HotArticleVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     //查询热门文章
     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }

     //分页查询文章列表,包含首页和分类页面的文章列表分页查询
     @Override
     public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
          //查询条件
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //首先明确categoryId在首页中不传,在分类文章页面中会传,所以我们要判断
          /*
          这里是lambdaQueryWrapper.eq三个参数的写法,第一个参数返回值是boolean类型,如果判断为true
          那么后续就会把后面的判断加入sql语句当中
           */
          lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0,Article::getCategoryId,categoryId);
          //状态是正式发布的,置顶的文章要显示在最前面,对isTop进行降序
          lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
          lambdaQueryWrapper.orderByDesc(Article::getIsTop);
          //分页查询
          Page<Article> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          //封装Vo
          List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
          //封装PageVo
          PageVo pageVo = new PageVo(articleListVos,page.getTotal());
          return ResponseResult.okResult(pageVo);
     }
}

第六步: 测试,运行keke-blog工程的启动类,postman测试如下

前后端联调中,也显示了文章

4. 解决-普通循环方式

在上面postman测试的时候,我们看到categoryName是Null,实际上我们是从文章表中进行BeanCopy的,而文章表压根没有categoryName字段仅仅有categoryId,所以我们现在的需求就是如何获取categoryName响应给前端

在分页查询后,先获取到article集合,然后通过categoryId去查询categoryName,然后再封装

第一步:把Article类修改为如下,增加了categoryName成员变量

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;


import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 文章表(Article)表实体类
 *
 * @author makejava
 * @since 2023-10-10 10:06:59
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_article")
public class Article {

    private Long id;
    //标题
    private String title;
    //文章内容
    private String content;
    //文章摘要
    private String summary;
    //所属分类id
    private Long categoryId;
    //分页查询文章列表时,新增的一个字段,为的是更好的封装,但是数据库中没有该字段,为了避免mp
    //在查询的时候查询这一列,可以添加如下注解
    @TableField(exist = false)//意思是这个字段在数据库表中实际上是不存在的
    private String categoryName;
    //缩略图
    private String thumbnail;
    //是否置顶(0否,1是)
    private String isTop;
    //状态(0已发布,1草稿)
    private String status;
    //访问量
    private Long viewCount;
    //是否允许评论 1是,0否
    private String isComment;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步:把ArticleServiceImpl类修改为如下,增加了普通循环的方式,实现从article表获取category_id字段,然后用来查询category表的name字段,最后封装到article对象中

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Category;
import com.keke.domain.vo.ArticleListVo;
import com.keke.domain.vo.HotArticleVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.service.CategoryService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Autowired
     private CategoryService categoryService;
     //查询热门文章
     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }

     //分页查询文章列表,包含首页和分类页面的文章列表分页查询
     @Override
     public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
          //查询条件
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //首先明确categoryId在首页中不传,在分类文章页面中会传,所以我们要判断
          /*
          这里是lambdaQueryWrapper.eq三个参数的写法,第一个参数返回值是boolean类型,如果判断为true
          那么后续就会把后面的判断加入sql语句当中
           */
          lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0,Article::getCategoryId,categoryId);
          //状态是正式发布的,置顶的文章要显示在最前面,对isTop进行降序
          lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
          lambdaQueryWrapper.orderByDesc(Article::getIsTop);
          //分页查询
          Page<Article> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          //查询categoryName
          List<Article> articles = page.getRecords();//有categoryId,但无categoryName
          //拿着categoryId去查询categoryName,然后封装到article中
          for (Article article : articles) {
               Category category = categoryService.getById(article.getCategoryId());
               article.setCategoryName(category.getName());
          }
          //封装Vo
          List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
          //封装PageVo
          PageVo pageVo = new PageVo(articleListVos,page.getTotal());
          return ResponseResult.okResult(pageVo);
     }
}

postman测试如下 

5. 解决-stream流优化

普通循环方式可以替换成如下代码

      articles.stream()
                  .map(new Function<Article, Article>() {
                       @Override
                       public Article apply(Article article) {
                            //article.getCategoryId()表示从article表获取category_id字段,然后作为查询category表的name字段
                            Category category = categoryService.getById(article.getCategoryId());
                            String categoryName = category.getName();
                            //把查询出来的category表的name字段值,也就是article,设置给Article实体类的categoryName成员变量
                            article.setCategoryName(categoryName);
                            //新article返回
                            return article;
                       }
                  })
                  .collect(Collectors.toList());

6. stream流再优化

让setter方法的返回值不是void的,是对象,我们应该在实体类中加入lombok的注解 

@Accessors(chain = true)

修改Article实体类如下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;


import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

/**
 * 文章表(Article)表实体类
 *
 * @author makejava
 * @since 2023-10-10 10:06:59
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_article")
@Accessors(chain = true)
public class Article {

    private Long id;
    //标题
    private String title;
    //文章内容
    private String content;
    //文章摘要
    private String summary;
    //所属分类id
    private Long categoryId;
    //分页查询文章列表时,新增的一个字段,为的是更好的封装,但是数据库中没有该字段,为了避免mp
    //在查询的时候查询这一列,可以添加如下注解
    @TableField(exist = false)//意思是这个字段在数据库表中实际上是不存在的
    private String categoryName;
    //缩略图
    private String thumbnail;
    //是否置顶(0否,1是)
    private String isTop;
    //状态(0已发布,1草稿)
    private String status;
    //访问量
    private Long viewCount;
    //是否允许评论 1是,0否
    private String isComment;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

上面的for以及stream流处理可以换成一行代码,如下

 articles.stream()
                  //setter返回的是对象
                  .map(article -> article.setCategoryName(categoryService.getById(article.getCategoryId()).getName()))
                  .collect(Collectors.toList());

7. FastJson配置

由于我们前端展示的时间格式不是我们想要的,所以需要进行一些配置

分析可知,这个创建时间是文章表的create_time所引发的

实体类中createTime是Date类型

由于ArticleListVO类的createTime成员变量是Date类型,默认是由java的Jackson来处理,使用 ISO-8601 规范来处理日期时间格式。ISO-8601 是一种国际标准的日期时间表示法,例如:"2023-09-21T06:53:24"。我们不希望时间被处理成这种格式,如下图。解决: 使用FastJson

第一步:(已做可跳过): 在keke-framework的pom.xml添加如下

<!--fastjson依赖-->
<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>fastjson</artifactId>
</dependency>

第二步:在keke-framework的config目录的WebConfig类,新增如下代码


    @Bean//使用@Bean注入fastJsonHttpMessageConvert
    public HttpMessageConverter fastJsonHttpMessageConverters() {
        //1.需要定义一个Convert转换消息的对象
        FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
        fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");

        SerializeConfig.globalInstance.put(Long.class, ToStringSerializer.instance);

        fastJsonConfig.setSerializeConfig(SerializeConfig.globalInstance);
        fastConverter.setFastJsonConfig(fastJsonConfig);
        HttpMessageConverter<?> converter = fastConverter;
        return converter;
    }

    @Override
    //配置消息转换器
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        //增加我们的消息转换器
        converters.add(fastJsonHttpMessageConverters());
    }

添加后WebConfig类如下

package com.keke.config;


import com.alibaba.fastjson.serializer.SerializeConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.serializer.ToStringSerializer;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;


@Configuration
public class WebConfig implements WebMvcConfigurer {

    //配置跨域
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }


    //FastJson配置
    @Bean//使用@Bean注入fastJsonHttpMessageConvert
    public HttpMessageConverter fastJsonHttpMessageConverters() {
        //1.需要定义一个Convert转换消息的对象
        FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
        fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");

        SerializeConfig.globalInstance.put(Long.class, ToStringSerializer.instance);

        fastJsonConfig.setSerializeConfig(SerializeConfig.globalInstance);
        fastConverter.setFastJsonConfig(fastJsonConfig);
        HttpMessageConverter<?> converter = fastConverter;
        return converter;
    }

    @Override
    //配置消息转换器
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        //增加我们的消息转换器
        converters.add(fastJsonHttpMessageConverters());
    }


}

第三步:测试

postman中,时间格式显示和我们期望的一致了

前端联调也是

七、博客前台模块-文章详情

1. 需求分析

要求在文章列表点击阅读全文时能够跳转到文章详情页面,可以让用户阅读文章正文。并且要在文章详情中展示其分类名。响应格式:

{
  "code": 200,
  "data": {
    "categoryId": "1",
    "categoryName": "java",
    "content": "文章详情的具体文章内容",
    "createTime": "2022-01-23 23:20:11",
    "id": "1",
    "isComment": "0",
    "title": "SpringSecurity从入门到精通",
    "viewCount": "114"
  },
  "msg": "操作成功"
}

2. 请求格式详解

由于这里我们需要使用PathVariable类型的请求格式,所以controller层的写法与之前query格式由所区别

两种url请求格式的对比 :

常见Http请求形式-优快云博客

3. 代码

第一步:在keke-framework工程的vo目录新建ArticleDetailVo实体类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;


@Data
@NoArgsConstructor
@AllArgsConstructor
//封装文章详情的实体类,只把需要的字段返回给前端
public class ArticleDetailVo {

     private Long id;
     //标题
     private String title;
     //文章摘要
     private String summary;

     //文章详情代码实现,新增了文章的分类id
     private Long categoryId;

     //文章详情代码实现,新增了文章的内容,也就是详情
     private String content;

     //所属分类名
     private String categoryName;
     //缩略图
     private String thumbnail;
     //访问量
     private Long viewCount;

     private Date createTime;

}

第二步: 把keke-framework工程的ArticleService接口修改为如下,增加了文章详情的查询

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;


/**
 * 文章表(Article)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 09:59:37
 */
public interface ArticleService extends IService<Article> {


     ResponseResult hotArticleList();

     ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId);

     ResponseResult getArticleDetail(Long id);
}

第三步: 把keke-framework工程的ArticleServiceImpl类修改为如下,增加了根据id查询文章详情的业务代码

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Category;
import com.keke.domain.vo.ArticleDetailVo;
import com.keke.domain.vo.ArticleListVo;
import com.keke.domain.vo.HotArticleVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.service.CategoryService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Autowired
     private CategoryService categoryService;
     //查询热门文章
     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }

     //分页查询文章列表,包含首页和分类页面的文章列表分页查询
     @Override
     public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
          //查询条件
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //首先明确categoryId在首页中不传,在分类文章页面中会传,所以我们要判断
          /*
          这里是lambdaQueryWrapper.eq三个参数的写法,第一个参数返回值是boolean类型,如果判断为true
          那么后续就会把后面的判断加入sql语句当中
           */
          lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0,Article::getCategoryId,categoryId);
          //状态是正式发布的,置顶的文章要显示在最前面,对isTop进行降序
          lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
          lambdaQueryWrapper.orderByDesc(Article::getIsTop);
          //分页查询
          Page<Article> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          //查询categoryName
          List<Article> articles = page.getRecords();//有categoryId,但无categoryName
          //拿着categoryId去查询categoryName,然后封装到article中
          /*
          for (Article article : articles) {
               Category category = categoryService.getById(article.getCategoryId());
               article.setCategoryName(category.getName());
          }
          */
          articles.stream()
                  //setter返回的是对象
                  .map(article -> article.setCategoryName(categoryService.getById(article.getCategoryId()).getName()))
                  .collect(Collectors.toList());
          //封装Vo
          List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
          //封装PageVo
          PageVo pageVo = new PageVo(articleListVos,page.getTotal());
          return ResponseResult.okResult(pageVo);
     }

     //查询文章详情
     @Override
     public ResponseResult getArticleDetail(Long id) {
          //根据id查询文章
          Article article = getById(id);
          //转化称Vo
          ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);
          //根据分类id查询分类名称
          Long categoryId = articleDetailVo.getCategoryId();
          Category category = categoryService.getById(categoryId);
          //如果没有获取到id,就不设置
          if(categoryId!=null){
               articleDetailVo.setCategoryName(category.getName());
          }
          //封装响应体
          return ResponseResult.okResult(articleDetailVo);
     }
}

第五步: 测试,运行keke-blog工程的启动类

postman中

前端联调

八、博客前台模块-友联功能

1. 友联表字段

2. 需求分析

在友链页面要查询出所有的审核通过的友链,响应格式如下

{
  "code": 200,
  "data": [
    {
      "address": "https://www.baidu.com",
      "description": "sda",
      "id": "1",
      "logo": "图片url1",
      "name": "sda"
    },
    {
      "address": "https://www.qq.com",
      "description": "dada",
      "id": "2",
      "logo": "图片url2",
      "name": "sda"
    }
  ],
  "msg": "操作成功"
}

3. 代码实现

第一步: 在keke-blog工程的controller目录新建LinkController实体类,写入如下

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.service.LinkService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/link")
public class LinkController {

     @Autowired
     private LinkService linkService;

     @GetMapping("/getAllLink")
     public ResponseResult getAllLink(){
          return linkService.getAllLink();
     }
}

第二步:在keke-framework工程下新建Link实体类,写入如下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 友链(Link)表实体类
 *
 * @author makejava
 * @since 2023-10-11 15:45:54
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_link")
public class Link {
    
    private Long id;
    
    private String name;
    
    private String logo;
    
    private String description;
    //网站地址
    private String address;
    //审核状态 (0代表审核通过,1代表审核未通过,2代表未审核)
    private String status;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第三步:在keke-framework工程下mapper包下新建LinkMapper类,写入如下

package com.keke.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.keke.domain.entity.Link;


/**
 * 友链(Link)表数据库访问层
 *
 * @author makejava
 * @since 2023-10-11 16:55:05
 */
public interface LinkMapper extends BaseMapper<Link> {

}

第四步:在keke-framework工程下service包下新建LinkService类,写入如下

package com.keke.service;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Link;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * 友链(Link)表服务接口
 *
 * @author makejava
 * @since 2023-10-11 15:46:23
 */
public interface LinkService extends IService<Link> {

     ResponseResult getAllLink();

}

第五步:在keke-framework工程下entity/vo包下新建LinkVo类

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;


@Data
@AllArgsConstructor
@NoArgsConstructor
public class LinkVo {

     private Long id;

     private String name;

     private String logo;

     private String description;
     //网站地址
     private String address;

}

第六步:在keke-framework的contants包的SystemConstants在新增审核通过的常量,新增至如下

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     *  文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;
    
    /**
     *  文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;
    
    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;

    /**
     * 分类表的分类状态是正常状态
     */
    public static final String STATUS_NORMAL = "0";

    /**
     * 友联审核通过
     */
    public static final String Link_STATUS_NORMAL = "0";
}

第七步:在keke-framework工程下service/impl包下新建LinkServiceImpl类,写入业务代码如下

package com.keke.service.impl;


import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Link;
import com.keke.domain.vo.LinkVo;
import com.keke.mapper.LinkMapper;
import com.keke.service.LinkService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 友链(Link)表服务实现类
 *
 * @author makejava
 * @since 2023-10-11 16:53:47
 */
@Service("linkService")
public class LinkServiceImpl extends ServiceImpl<LinkMapper, Link> implements LinkService {

     @Override
     public ResponseResult getAllLink() {
          //查询所有审核通过的
          LambdaQueryWrapper<Link> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          lambdaQueryWrapper.eq(Link::getStatus, SystemConstants.Link_STATUS_NORMAL);
          List<Link> links = list(lambdaQueryWrapper);
          //转换Vo
          List<LinkVo> linkVos = BeanCopyUtils.copyBeanList(links, LinkVo.class);
          //封装响应体
          return ResponseResult.okResult(linkVos);
     }
}

第八步:测试

postman中

前后端联调中

九、博客前台模块-登录功能

1. 用户表字段

2. 需求分析

需要实现登录功能,有些功能必须登录后才能使用,未登录状态是不能使用的。请求形式如下

{
    "userName":"sg",
    "password":"1234"
}

响应格式:

{
    "code": 200,
    "data": {
        "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0ODBmOThmYmJkNmI0NjM0OWUyZjY2NTM0NGNjZWY2NSIsInN1YiI6IjEiLCJpc3MiOiJzZyIsImlhdCI6MTY0Mzg3NDMxNiwiZXhwIjoxNjQzOTYwNzE2fQ.ldLBUvNIxQCGemkCoMgT_0YsjsWndTg5tqfJb77pabk",
        "userInfo": {
            "avatar": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fi0.hdslb.com%2Fbfs%2Farticle%2F3bf9c263bc0f2ac5c3a7feb9e218d07475573ec8.gi",
            "email": "23412332@qq.com",
            "id": 1,
            "nickName": "sg333",
            "sex": "1"
        }
    },
    "msg": "操作成功"
}

3. 思路分析

登录成功之后,要返回一个用于校验的token,userInfo这个用来展示一些用户的个人信息页面

登录

①自定义登录接口

调用ProviderManager的方法进行认证 如果认证通过根据id生成jwt

把用户信息存入redis中

②自定义UserDetailsService

在这个实现类中去查询数据库

注意配置passwordEncoder为BCryptPasswordEncoder

校验:

①定义Jwt认证过滤器

获取token

解析token获取其中的userid

从redis中获取用户信息

存入SecurityContextHolder

4. 添加相关依赖

已填加就无需操作了(注意放开SpringSecurity的依赖注释)

<!--SpringSecurity启动器。需要用到登录功能就解开,不然就注释掉-->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!--redis依赖-->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!--fastjson依赖-->
<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>

<!--jwt依赖-->
<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.0</version>
</dependency>

5. 配置类和工具类

一: 在keke-framework工程的config目录新建FastJsonRedisSerializer类,写入如下

package com.keke.config;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.util.Assert;
import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

二: 在keke-framework工程的config目录新建RedisConfig类,写入如下

package com.keke.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

三: 在keke-framework工程的utils目录新建JwtUtil类,写入如下

package com.keke.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
 * JWT工具类
 */
public class JwtUtil {

    //有效期为
    public static final Long JWT_TTL = 72*60 * 60 *1000L;// 60 * 60 *1000  一个小时
    //设置秘钥明文
    public static final String JWT_KEY = "huanfqc";

    public static String getUUID(){
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        return token;
    }

    /**
     * 生成jtw
     * @param subject token中要存放的数据(json格式)
     * @return
     */
    public static String createJWT(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * 生成jtw
     * @param subject token中要存放的数据(json格式)
     * @param ttlMillis token超时时间
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
        return builder.compact();
    }

    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if(ttlMillis==null){
            ttlMillis=JwtUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)              //唯一的ID
                .setSubject(subject)   // 主题  可以是JSON数据
                .setIssuer("HF")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 创建token
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        return builder.compact();
    }

    public static void main(String[] args) throws Exception {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
        Claims claims = parseJWT(token);
        System.out.println(claims);
    }

    /**
     * 生成加密后的秘钥 secretKey
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * 解析
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

四: 在keke-framework工程的utils目录新建RedisCache类,写入如下

package com.keke.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;


@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache {
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }
}

五: 在keke-framework工程的utils目录新建WebUtils类,写入如下

package com.huanf.utils;

import org.springframework.web.context.request.RequestContextHolder;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;


public class WebUtils {
    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static void renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
    }


    public static void setDownLoadHeader(String filename, ServletContext context, HttpServletResponse response) throws UnsupportedEncodingException {
        String mimeType = context.getMimeType(filename);//获取文件的mime类型
        response.setHeader("content-type",mimeType);
        String fname= URLEncoder.encode(filename,"UTF-8");
        response.setHeader("Content-disposition","attachment; filename="+fname);

//        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
//        response.setCharacterEncoding("utf-8");
    }
}

6. 代码实现-自定义登录接口

第一步: 在keke-framework工程的domain/entity目录新建User类,写入如下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 用户表(User)表实体类
 *
 * @author makejava
 * @since 2023-10-11 20:26:58
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class User {
    //主键
    private Long id;
    //用户名
    private String userName;
    //昵称
    private String nickName;
    //密码
    private String password;
    //用户类型:0代表普通用户,1代表管理员
    private String type;
    //账号状态(0正常 1停用)
    private String status;
    //邮箱
    private String email;
    //手机号
    private String phonenumber;
    //用户性别(0男,1女,2未知)
    private String sex;
    //头像
    private String avatar;
    //创建人的用户id
    private Long createBy;
    //创建时间
    private Date createTime;
    //更新人
    private Long updateBy;
    //更新时间
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步: 在keke-framework工程的mapper目录新建UserMapper接口,写入如下

package com.keke.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.keke.domain.entity.User;


/**
 * 用户表(User)表数据库访问层
 *
 * @author makejava
 * @since 2023-10-11 20:26:26
 */
@Mapper
public interface UserMapper extends BaseMapper<User> {

}

 第三步: 在keke-framework工程的service目录新建BlogLoginService接口,写入如下

package com.keke.service;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;

public interface BlogLoginService {
     ResponseResult login(User user);
}

第四步: 在keke-blog工程的src/main/java/com.keke目录新建config.SecurityConfig类,写入如下

package com.keke.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();


        http.logout().disable();
        //允许跨域
        http.cors();
    }

}

第五步: 在keke-framework工程的service目录新建impl.UserDetailsServiceImpl类,写入如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.keke.domain.entity.LoginUser;
import com.keke.domain.entity.User;
import com.keke.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

     @Autowired
     private UserMapper userMapper;

     @Override
     public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
          //因为我们自己创建的UserDetailsService注入到容器中,所以会调用我们自己创建的
          //根据用户名从数据库查询用户信息,这里注入userMapper进行查询
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          lambdaQueryWrapper.eq(User::getUserName,userName);
          //这里可以看到userMapper也可以传入wrapper进行条件查询
          User user = userMapper.selectOne(lambdaQueryWrapper);
          //判断是否查到用户,如果没查到,抛出异常
          if(Objects.isNull(user)){
               throw new RuntimeException("用户不存在");
          }
          //返回用户信息


          //TODO 查询权限信息封装(后台)
          return new LoginUser(user);
     }
}

第六步: 在keke-framework工程的domain/entity目录新建LoginUser类,目的是实现UserDetails写入如下

package com.keke.domain.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {

     private User user;
     //用于返回权限信息。现在我们正在实现'认证','权限'后面才用得到。所以返回null即可
     //当要查询用户信息的时候,我们不能单纯返回null,要重写这个方法,作用是返回权限信息
     @Override
     public Collection<? extends GrantedAuthority> getAuthorities() {
          return null;
     }

     @Override
     public String getPassword() {
          return user.getPassword();
     }

     @Override
     public String getUsername() {
          return user.getUserName();
     }

     @Override
     public boolean isAccountNonExpired() {
          return true;
     }

     @Override
     public boolean isAccountNonLocked() {
          return true;
     }

     @Override
     public boolean isCredentialsNonExpired() {
          return true;
     }

     @Override
     public boolean isEnabled() {
          return true;
     }
}

第七步: 在keke-framework工程的vo目录新建BlogUserLoginVo实体类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;


@Data
@AllArgsConstructor
@NoArgsConstructor
public class BlogLoginUserVo {

     private String token;
     private UserInfoVo userInfo;
}

第八步: 在keke-framework工程的vo目录新建UserInfoVo实体类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfoVo {
     private Long id;
     private String avatar;
     private String email;
     private String nickName;
     private String sex;
}

第十步: 在keke-framework工程的service目录新建impl.BlogLoginServiceImpl类,写入如下

package com.keke.service.impl;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.LoginUser;
import com.keke.domain.entity.User;
import com.keke.domain.vo.BlogLoginUserVo;
import com.keke.domain.vo.UserInfoVo;
import com.keke.service.BlogLoginService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.JwtUtil;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.Objects;


@Service
public class BlogLoginServiceImpl implements BlogLoginService {

     @Autowired
     private AuthenticationManager authenticationManager;

     @Autowired
     private RedisCache redisCache;

     @Override
     public ResponseResult login(User user) {
          UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
          Authentication authenticate = authenticationManager.authenticate(authenticationToken);
          //authenticationManager会默认调用UserDetailsService从内存中进行用户认证,我们实际需求是从数据库,因此我们要重新创建一个UserDetailsService的实现类
          //判断是否认证通过
          if(Objects.isNull(authenticate)){
               throw new RuntimeException("用户名或者密码错误");
          }
          //获取Userid,生成token
          LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
          String userId = loginUser.getUser().getId().toString();
          String jwt = JwtUtil.createJWT(userId);
          //把用户信息存入redis
          redisCache.setCacheObject("bloglogin:" + userId,loginUser);
          //把token和userInfo封装返回,因为响应回去的data有这两个属性,所以要封装Vo
          UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);
          BlogLoginUserVo blogLoginUserVo = new BlogLoginUserVo(jwt,userInfoVo);
          return ResponseResult.okResult(blogLoginUserVo);
     }
}

第十一步: 在keke-blog工程的controller目录新建BlogLoginController类,写入如下

package com.keke.controller;


import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;
import com.keke.service.BlogLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BlogLoginController {

     @Autowired
     private BlogLoginService blogLoginService;

     @PostMapping("/login")
     public ResponseResult login(@RequestBody User user){
          return blogLoginService.login(user);
     }
}

第十二步:启动redis,由于我们的yml配置文件中没有添加redis的相关配置,所以默认访问的是本机的默认端口

第十三步: 测试。运行keke-blog工程启动类,用postman测试,POST请求地址、请求体如下

7. 代码实现-认证过滤器

实现校验功能,也就是使用jwt认证过滤器,对用户的登录进行校验,是否有登录状态

分析

  • 获取token,由于我们在把token存入Redis的时候加了前缀,所以获取的时候注意前缀
  • 解析token获取其中的userid
  • 从redis中获取用户信息(LoginUser)
  • 存入SecurityContextHolder

因为之前我们把LoginUser存入到redis中,所以我们在校验的时候根据key来获取LoginUser,然后把其存入SecurityContextHolder,因为后面SpringSecurity其他的一些过滤器都会从SecurityContextHolder去判断是否有认证信息,如果没有代表未登录

第一步: 在keke-blog工程的src/main/java/com.keke目录新建filter.JwtAuthenticationTokenFilter类,写入如下,作用是自定义Jwt认证过滤器

package com.keke.filter;

import com.alibaba.fastjson.JSON;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.LoginUser;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.utils.JwtUtil;
import com.keke.utils.RedisCache;
import com.keke.utils.WebUtils;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;


@Component
//博客前台的登录认证过滤器。OncePerRequestFilter是springsecurity提供的类
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    //RedisCache是我们在keke-framework工程写的工具类,用于操作redis
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        //获取请求头中的token值
        String token = request.getHeader("token");
        //判断上面那行有没有拿到token值
        if(!StringUtils.hasText(token)){
            //说明该接口不需要登录,直接放行,不拦截
            filterChain.doFilter(request,response);
            return;
        }
        //JwtUtil是我们在keke-framework工程写的工具类。解析获取的token,把原来的密文解析为原文
        Claims claims = null;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (Exception e) {
            //当token过期或token被篡改就会进入下面那行的异常处理
            e.printStackTrace();
            //报异常之后,把异常响应给前端,需要重新登录。ResponseResult、AppHttpCodeEnum、WebUtils是我们在keke-framework工程写的类
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }
        String userid = claims.getSubject();

        //在redis中,通过key来获取value,注意key我们是加过前缀的,取的时候也要加上前缀
        LoginUser loginUser = redisCache.getCacheObject("bloglogin:" + userid);
        //如果在redis获取不到值,说明登录是过期了,需要重新登录
        if(Objects.isNull(loginUser)){
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }

        //把从redis获取到的value,存入到SecurityContextHolder(Security官方提供的类)
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(request,response);

    }
}

第二步: 把keke-blog工程的SecurityConfig类修改为如下,作用是把上一步的认证过滤器添加到springsecurity的过滤器链中

package com.keke.config;

import com.keke.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入我们在keke-blog工程写的JwtAuthenticationTokenFilter过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
                //为方便测试认证过滤器,我们把查询友链的接口设置为需要登录才能访问。然后我们去访问的时候就能测试登录认证功能了
                .antMatchers("/link/getAllLink").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();


        http.logout().disable();
        //将自定义filter加入security过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //允许跨域
        http.cors();
    }

}

第三步:记得打开redis

第四步: 运行keke-blog工程,用postman测试,由于我们在第二步给友链接口添加了登录拦截,所以先GET访问一下

可以看到是禁止访问403

先访问登录接口,拿着登录接口返回的token去访问友联接口

 十、博客前台模块-异常处理

目前我们的项目在认证出错或者权限不足的时候响应回来的Json,默认是使用Security官方提供的响应的格式,但是这种响应的格式肯定是不符合我们项目的接口规范的。所以需要自定义异常处理

我们需要去实现AuthenticationEntryPoint(官方提供的认证失败处理器)类、AccessDeniedHandler(官方提供的授权失败处理器)类,然后配置给Security

由于我们前台和后台的异常处理是一样的,所以我们在framework包下创建异常处理类

1. 认证的异常处理

 在keke-framework工程的src/main/java/com.keke目录新建handler.security.AuthenticationEntryPointImpl类,写入如下

package com.keke.handler.security;

import com.alibaba.fastjson.JSON;
import com.keke.domain.ResponseResult;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.utils.WebUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
     @Override
     public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException authException) throws IOException, ServletException {
          authException.printStackTrace();
          ResponseResult result = null;
          //BadCredentialsException 这个是我们测试输入错误账号密码出现的异常
          if(authException instanceof BadCredentialsException){
              result = ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_ERROR.getCode(),authException.getMessage());
          } else if (authException instanceof InsufficientAuthenticationException) {
               //InsufficientAuthenticationException 这个是我们测试不携带token出现的异常
               result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
          }else {
               result = ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR,"认证或者授权失败");
          }
          //响应给前端
          WebUtils.renderString(httpServletResponse, JSON.toJSONString(result));
     }
}

2. 授权的异常处理

在keke-framework工程的src/main/java/com.keke目录新建handler.security.AccessDeniedHandlerImpl类,写入如下

package com.keke.handler.security;

import com.alibaba.fastjson.JSON;
import com.keke.domain.ResponseResult;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.utils.WebUtils;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
     @Override
     public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException accessDeniedException) throws IOException, ServletException {
          accessDeniedException.printStackTrace();
          ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NO_OPERATOR_AUTH);
          //响应给前端
          WebUtils.renderString(httpServletResponse, JSON.toJSONString(result));
     }
}

3. 认证授权异常处理配置到框架

把keke-blog工程的SecurityConfig类修改为如下

package com.keke.config;

import com.keke.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入我们在keke-blog工程写的JwtAuthenticationTokenFilter过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    AccessDeniedHandler accessDeniedHandler;



    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
                //为方便测试认证过滤器,我们把查询友链的接口设置为需要登录才能访问。然后我们去访问的时候就能测试登录认证功能了
                .antMatchers("/link/getAllLink").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        //配置我们自己写的认证和授权的异常处理
        http.exceptionHandling()
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler);



        http.logout().disable();
        //将自定义filter加入security过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //允许跨域
        http.cors();
    }

}

4. 测试自定义异常处理

第一步:打开redis,启动工程

第二步:向login接口发送用户名或者密码错误的post请求

第三步:向link/getAllLink接口发送不携带token的get请求

5. 统一异常处理

实际我们在开发过程中可能需要做很多的判断校验,如果出现了非法情况我们是期望响应对应的提示的。但是如果我们每次都自己手动去处理就会非常麻烦。我们可以选择直接抛出异常的方式,然后对异常进行统一处理。把异常中的信息封装成ResponseResult响应给前端

5.1 自定义异常

在keke-framework工程的src/main/java/com.keke目录新建exception.SystemException类,写入如下

package com.keke.exception;


import com.keke.enums.AppHttpCodeEnum;

//统一异常处理
public class SystemException extends RuntimeException{

    private int code;

    private String msg;

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

    //定义一个构造方法,接收的参数是枚举类型,AppHttpCodeEnum是我们在huanf-framework工程定义的枚举类
    public SystemException(AppHttpCodeEnum httpCodeEnum) {
        super(httpCodeEnum.getMsg());
        //把某个枚举类里面的code和msg赋值给异常对象
        this.code = httpCodeEnum.getCode();
        this.msg = httpCodeEnum.getMsg();
    }
}

5.2 全局异常处理

在keke-framework的com.keke包下新建handler.exception.GlobalExceptionHandler 写入如下,登录和其他地方出现的异常都会被这里捕获,然后响应返回

package com.keke.handler.exception;

import com.keke.domain.ResponseResult;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.exception.SystemException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;


//@ControllerAdvice //对controller层的增强
//@ResponseBody

//或者用下面一个注解代替上面的两个注解
@RestControllerAdvice
//使用Lombok提供的Slf4j注解,实现日志功能
@Slf4j
//全局异常处理。最终都会在这个类进行处理异常
public class GlobalExceptionHandler {

    //SystemException是我们写的类。用户登录的异常交给这里处理
    @ExceptionHandler(SystemException.class)
    public ResponseResult systemExceptionHandler(SystemException e){

        //打印异常信息,方便我们追溯问题的原因。{}是占位符,具体值由e决定
        log.error("出现了异常! {}",e);

        //从异常对象中获取提示信息封装,然后返回。ResponseResult是我们写的类
        return ResponseResult.errorResult(e.getCode(),e.getMsg());
    }

    //其它异常交给这里处理
    @ExceptionHandler(Exception.class)
    public ResponseResult exceptionHandler(Exception e){

        //打印异常信息,方便我们追溯问题的原因。{}是占位符,具体值由e决定
        log.error("出现了异常! {}",e);

        //从异常对象中获取提示信息封装,然后返回。ResponseResult、AppHttpCodeEnum是我们写的类
        return ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),e.getMessage());//枚举值是500
    }
}

5.3 Controller层逻辑

package com.keke.controller;


import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.exception.SystemException;
import com.keke.service.BlogLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BlogLoginController {

     @Autowired
     private BlogLoginService blogLoginService;

     @PostMapping("/login")
     public ResponseResult login(@RequestBody User user){
          if(!StringUtils.hasText(user.getUserName())){
               //提示必须要传用户名
               throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
          }
          return blogLoginService.login(user);
     }
}

5.3 测试

向login接口发送一个没有用户名只有密码的post,响应如下

可以看到,响应回来的信息是正确的

5.4 总结

首相前端发送请求,controller层先判断是否携带用户名,如果没有携带,抛出SystemException异常,并把响应的枚举信息传给异常对象,然后全局异常类中的systemExceptionHandler处理器处理就会捕获到该异常,然后在这个位置去封装响应体返回

其他异常则是由exceptionHandler处理

这就是异常统一处理

十一、博客前台模块-退出登录

1. 接口分析

请求方式

请求地址

请求头

POST

/logout

需要token请求头

响应格式

{
    "code": 200,
    "msg": "操作成功"
}

2. 思路分析

获取token解析出userId

删除redis中的用户信息

3. 代码实现

第一步: 把keke-blog工程的BlogLoginController类修改为如下,新增了退出登录的接口

package com.keke.controller;


import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.exception.SystemException;
import com.keke.service.BlogLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BlogLoginController {

     @Autowired
     private BlogLoginService blogLoginService;

     @PostMapping("/login")
     public ResponseResult login(@RequestBody User user){
          if(!StringUtils.hasText(user.getUserName())){
               //提示必须要传用户名
               throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
          }
          return blogLoginService.login(user);
     }

     @PostMapping("/logout")
     public ResponseResult logout(){
          return blogLoginService.logout();
     }
}

第二步: 把keke-framework工程的BlogLoginService接口修改为如下,新增了退出登录的方法

package com.keke.service;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;

public interface BlogLoginService {
     ResponseResult login(User user);

     ResponseResult logout();

}

第三步: 把keke-framework工程的BlogLoginServiceImpl类修改为如下,新增了退出登录的核心代码

package com.keke.service.impl;

import com.keke.domain.ResponseResult;
import com.keke.domain.entity.LoginUser;
import com.keke.domain.entity.User;
import com.keke.domain.vo.BlogLoginUserVo;
import com.keke.domain.vo.UserInfoVo;
import com.keke.service.BlogLoginService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.JwtUtil;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.Objects;


@Service
public class BlogLoginServiceImpl implements BlogLoginService {

     @Autowired
     private AuthenticationManager authenticationManager;

     @Autowired
     private RedisCache redisCache;

     @Override
     public ResponseResult login(User user) {
          UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
          Authentication authenticate = authenticationManager.authenticate(authenticationToken);
          //authenticationManager会默认调用UserDetailsService从内存中进行用户认证,我们实际需求是从数据库,因此我们要重新创建一个UserDetailsService的实现类
          //判断是否认证通过
          if(Objects.isNull(authenticate)){
               throw new RuntimeException("用户名或者密码错误");
          }
          //获取Userid,生成token
          LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
          String userId = loginUser.getUser().getId().toString();
          String jwt = JwtUtil.createJWT(userId);
          //把用户信息存入redis
          redisCache.setCacheObject("bloglogin:" + userId,loginUser);
          //把token和userInfo封装返回,因为响应回去的data有这两个属性,所以要封装Vo
          UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);
          BlogLoginUserVo blogLoginUserVo = new BlogLoginUserVo(jwt,userInfoVo);
          return ResponseResult.okResult(blogLoginUserVo);
     }

     @Override
     public ResponseResult logout() {
          //获取token解析获取userId
          Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
          LoginUser loginUser = (LoginUser) authentication.getPrincipal();
          Long userId = loginUser.getUser().getId();
          //删除redis中的信息(根据key删除)
          redisCache.deleteObject("bloglogin:" + userId);
          return ResponseResult.okResult();
     }
}

第四步: 把keke-blog工程的SecurityConfig类修改为如下,增加了需要有登录状态才能执行退出登录,否则就报'401 需要登录后操作'

package com.keke.config;

import com.keke.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入我们在keke-blog工程写的JwtAuthenticationTokenFilter过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    AccessDeniedHandler accessDeniedHandler;



    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
//这里新增必须要是登录状态才能访问退出登录的接口,即是认证过的状态
                .antMatchers("/logout").authenticated()
                //为方便测试认证过滤器,我们把查询友链的接口设置为需要登录才能访问。然后我们去访问的时候就能测试登录认证功能了
                .antMatchers("/link/getAllLink").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        //配置我们自己写的认证和授权的异常处理
        http.exceptionHandling()
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler);



        http.logout().disable();
        //将自定义filter加入security过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //允许跨域
        http.cors();
    }

}

4. 测试

首先测试logout是否真的实现了退出登录的效果,即删除了token在redis中的缓存,使得携带原token的请求失效

第一步,先登录 JSON格式的body可复制如下代码,登录成功

{
    "userName":"sg",
    "password":"1234"
}

第二步,拿着登录成功的token,去访问getAllLink接口,访问成功,到这里一切正常

第三步:携带该token向logout接口发送post请求,为什么要携带token呢,因为我们之前在SecurityConfig中配置过了,必须是已认证的状态,已认证的状态意味着必须是请求头携带token

postman结果如下,操作成功意味着退出登录成功

第四步:拿token再次访问getAllLink接口,发现已经不能访问

并且我们可以看到redis中也没有缓存的信息了

十二、博客前台模块-评论列表

1. 评论表的字段

2. 接口分析

请求方式

请求地址

请求头

GET

/comment/commentList

不需要token请求头(未登录也能看到评论信息)

请求格式为query格式,参数如下

articleId:文章id
pageNum:页码
pageSize:每页条数

响应格式如下

{
    "code": 200,
    "data": {
        "rows": [
            {
                "articleId": "1",
                "children": [
                    {
                        "articleId": "1",
                        "content": "评论内容(子评论)",
                        "createBy": "1",
                        "createTime": "2022-01-30 10:06:21",
                        "id": "20",
                        "rootId": "1",
                        "toCommentId": "1",
                        "toCommentUserId": "1",
                        "toCommentUserName": "这条评论(子评论)回复的是哪个人",
                        "username": "发这条评论(子评论)的人"
                    }
                ],
                "content": "评论内容(根评论)",
                "createBy": "1",
                "createTime": "2022-01-29 07:59:22",
                "id": "1",
                "rootId": "-1",
                "toCommentId": "-1",
                "toCommentUserId": "-1",
                "username": "发这条评论(根评论)的人"
            }
        ],
        "total": "15"
    },
    "msg": "操作成功"
}

3. 准备代码

第一步:实体类Comment创建在keke-framework的com.keke.domain.entity下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 评论表(Comment)表实体类
 *
 * @author makejava
 * @since 2023-10-12 20:20:14
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_comment")
public class Comment {
    
    private Long id;
    //评论类型(0代表文章评论,1代表友链评论)
    private String type;
    //文章id
    private Long articleId;
    //根评论id
    private Long rootId;
    //评论内容
    private String content;
    //所回复的目标评论的userid
    private Long toCommentUserId;
    //回复目标评论id
    private Long toCommentId;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步:创建CommentMapper

package com.keke.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.keke.domain.entity.Comment;


/**
 * 评论表(Comment)表数据库访问层
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
public interface CommentMapper extends BaseMapper<Comment> {

}

第三步:创建CommentService

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.entity.Comment;


/**
 * 评论表(Comment)表服务接口
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
public interface CommentService extends IService<Comment> {

}

第四步:创建CommentServiceImpl

package com.keke.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.entity.Comment;
import com.keke.mapper.CommentMapper;
import com.keke.service.CommentService;
import org.springframework.stereotype.Service;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {

}

4. 代码实现-不考虑子评论

先实现查询根评论,子评论先不实现

第一步: 在keke-framework工程的domain.entity目录新建User类,写入如下

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 用户表(User)表实体类
 *
 * @author makejava
 * @since 2023-10-11 20:26:58
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class User {
    //主键
    private Long id;
    //用户名
    private String userName;
    //昵称
    private String nickName;
    //密码
    private String password;
    //用户类型:0代表普通用户,1代表管理员
    private String type;
    //账号状态(0正常 1停用)
    private String status;
    //邮箱
    private String email;
    //手机号
    private String phonenumber;
    //用户性别(0男,1女,2未知)
    private String sex;
    //头像
    private String avatar;
    //创建人的用户id
    private Long createBy;
    //创建时间
    private Date createTime;
    //更新人
    private Long updateBy;
    //更新时间
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第二步: 在keke-framework工程的service目录新建UserService接口,写入如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.entity.User;


/**
 * 用户表(User)表服务接口
 *
 * @author makejava
 * @since 2023-10-13 09:08:38
 */
public interface UserService extends IService<User> {

}

 第三步: 在keke-framework工程的service.impl目录新建UserServiceImpl类,写入如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.entity.User;
import com.keke.mapper.UserMapper;
import com.keke.service.UserService;
import org.springframework.stereotype.Service;

/**
 * 用户表(User)表服务实现类
 *
 * @author makejava
 * @since 2023-10-13 10:12:51
 */
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

}

第四步: 在keke-framework工程的vo目录新建CommentVo类,写入如下

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;


@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommentVo {

     private Long id;

     //文章id
     private Long articleId;
     //根评论id
     private Long rootId;
     //评论内容
     private String content;
     //发根评论的userid
     private Long toCommentUserId;
     //发根评论的userName
     private String toCommentUserName;
     //回复目标评论id
     private Long toCommentId;
     //当前评论的创建人id
     private Long createBy;

     private Date createTime;

     //评论是谁发的
     private String username;

}

第五步: 把keke-framework工程的SystemCanstants类修改为如下,增加了判定为根评论的常量

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     *  文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;
    
    /**
     *  文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;
    
    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;

    /**
     * 分类表的分类状态是正常状态
     */
    public static final String STATUS_NORMAL = "0";

    /**
     * 友联审核通过
     */
    public static final String Link_STATUS_NORMAL = "0";

    /**
     * 评论区的某条评论是根评论
     */
    public static final String COMMENT_ROOT = "-1";
}

第六步: 在keke-blog工程的controller目录新建CommentController类,写入如下

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/comment")
public class CommentController {

     @Autowired
     private CommentService commentService;

     @GetMapping("/commentList")
     public ResponseResult commentList(Long articleId,Integer pageNum,Integer pageSize){
          return commentService.commentList(articleId,pageNum,pageSize);
     }
}

第七步: 在keke-framework工程的service目录新建CommentService接口,写入如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Comment;


/**
 * 评论表(Comment)表服务接口
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
public interface CommentService extends IService<Comment> {

     ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize);

}

 第八步: 在keke-framework工程的service目录新建impl.CommentServiceImpl类,写入如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Comment;
import com.keke.domain.vo.CommentVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.CommentMapper;
import com.keke.service.ArticleService;
import com.keke.service.CommentService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {

     @Autowired
     private ArticleService articleService;

     @Override
     public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
          //查询对应文章的根评论
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //对articleId进行判断
          lambdaQueryWrapper.eq(Comment::getArticleId,articleId);
          //查询根评论
          lambdaQueryWrapper.eq(Comment::getRootId, SystemConstants.COMMENT_ROOT);
          //分页查询
          Page<Comment> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          List<Comment> comments = page.getRecords();
          List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(comments, CommentVo.class);
          return ResponseResult.okResult(new PageVo(commentVos,page.getTotal()));
     }
}

测试如下

优化

由于我们上面BeanCopy虽然copy了大量的字段,但是username(昵称)和toCommentUserName(根评论的昵称)这两个字段无法进行copy,需要我们手动进行处理

将 CommentServiceImpl修改如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Comment;
import com.keke.domain.vo.CommentVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.CommentMapper;
import com.keke.service.ArticleService;
import com.keke.service.CommentService;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {


     @Autowired
     private UserService userService;

     @Override
     public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
          //查询对应文章的根评论
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //对articleId进行判断
          lambdaQueryWrapper.eq(Comment::getArticleId,articleId);
          //查询根评论
          lambdaQueryWrapper.eq(Comment::getRootId, SystemConstants.COMMENT_ROOT);
          //分页查询
          Page<Comment> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          List<Comment> comments = page.getRecords();
          List<CommentVo> commentVos = toCommentVoList(comments);
          return ResponseResult.okResult(new PageVo(commentVos,page.getTotal()));
     }

     private List<CommentVo> toCommentVoList(List<Comment> comments){
          List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(comments, CommentVo.class);

          for (CommentVo commentVo : commentVos) {
               //首先获取Comment的创始人id,通过create_by字段,然后获取到创始人,再获取到昵称
               String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
               commentVo.setUsername(nickName);
               //这里要加判断,如果to_comment_user_id不为-1才表示这个评论是有根评论的,才可以查
               if(commentVo.getToCommentUserId()!=-1) {
                    //根据to_comment_user_id获取根评论创始人的userid,然后查到其昵称
                    String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
                    commentVo.setToCommentUserName(toCommentUserName);
               }
          }
          return commentVos;
     }
}

测试通过

5. 实现-考虑子评论

查询子评论

第一步: 把CommentVo类修改为如下,增加了子评论字段

package com.keke.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;
import java.util.List;


@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommentVo {
     private Long id;
     //文章id
     private Long articleId;
     //根评论id
     private Long rootId;
     //评论内容
     private String content;
     //发根评论的userid
     private Long toCommentUserId;
     //发根评论的userName
     private String toCommentUserName;
     //回复目标评论id
     private Long toCommentId;
     //当前评论的创建人id
     private Long createBy;
     private Date createTime;
     //评论是谁发的,注意这里是昵称
     private String username;
     //子评论们
     private List<CommentVo> children;
}

第二步: 把CommentServiceImpl修改为如下,增加了查询子评论、子评论按照时间排序

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Comment;
import com.keke.domain.vo.CommentVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.CommentMapper;
import com.keke.service.ArticleService;
import com.keke.service.CommentService;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {


     @Autowired
     private UserService userService;

     @Override
     public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
          //查询对应文章的根评论
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //对articleId进行判断
          lambdaQueryWrapper.eq(Comment::getArticleId,articleId);
          //查询根评论
          lambdaQueryWrapper.eq(Comment::getRootId, SystemConstants.COMMENT_ROOT);
          //分页查询
          Page<Comment> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          List<Comment> comments = page.getRecords();



          List<CommentVo> commentVos = toCommentVoList(comments);
          //查询所有根评论对应的子评论集合,并且赋值给对应的属性
          for (CommentVo commentVo : commentVos) {
               //查询对应的子评论
               List<CommentVo> children = getChildren(commentVo.getId());
               //赋值
               commentVo.setChildren(children);
          }
          return ResponseResult.okResult(new PageVo(commentVos,page.getTotal()));
     }

     private List<CommentVo> toCommentVoList(List<Comment> comments){
          List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(comments, CommentVo.class);

          for (CommentVo commentVo : commentVos) {
               //首先获取Comment的创始人id,通过create_by字段,然后获取到创始人,再获取到昵称
               String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
               commentVo.setUsername(nickName);
               //这里要加判断,如果to_comment_user_id不为-1才表示这个评论是有根评论的,才可以查
               if(commentVo.getToCommentId()!=-1) {
                    //根据to_comment_user_id获取根评论创始人的userid,然后查到其昵称
                    String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
                    commentVo.setToCommentUserName(toCommentUserName);
               }
          }
          return commentVos;
     }

     /**
      * 根据根评论id查询所对应的子评论的集合
      * @param id
      * @return
      */
     private List<CommentVo> getChildren(Long id) {
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //传过来的id,然后查询所有评论表中,根评论是该id的评论
          lambdaQueryWrapper.eq(Comment::getRootId,id);
          //对评论的时间进行升序排序,发表的早的显示在前面,符合逻辑
          lambdaQueryWrapper.orderByAsc(Comment::getCreateTime);
          List<Comment> comments = list(lambdaQueryWrapper);
          //调用称我们之前封装好的方法
          List<CommentVo> children = toCommentVoList(comments);
          //返回
          return children;
     }
}

 第三步:测试

postman中

前后端联通中,效果符合预期

十三、博客前台模块-发文章评论

1. 接口分析

用户登录后可以对文章发表评论,也可以对已有的评论进行回复

请求方式

请求地址

请求头

POST

/comment

需要token头

请求体:

回复了文章,type为0表示文章评论

{    
    "articleId":1,
    "type":0,
    "rootId":-1,
    "toCommentId":-1,
    "toCommentUserId":-1,
    "content":"评论了文章"
}

回复了文章的评论,type为0表示文章评论

{    
    "articleId":1,
    "type":0,
    "rootId":-1,
    "toCommentId":-1,
    "toCommentUserId":-1,
    "content":"回复了文章的某条评论"
}

响应体:

{
	"code":200,
	"msg":"操作成功"
}

2. 代码实现

第一步: 在keke-framework工程的utils目录新建SecurityUtils类,写入如下

package com.keke.utils;

import com.keke.domain.entity.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;


//在'发送评论'功能那里会用到的工具类
public class SecurityUtils {

    /**
     * 获取用户的userid
     **/
    public static LoginUser getLoginUser() {
        return (LoginUser) getAuthentication().getPrincipal();
    }

    /**
     * 获取Authentication
     */
    public static Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    /**
     * 指定userid为1的用户就是网站管理员
     * @return
     */
    public static Boolean isAdmin(){
        Long id = getLoginUser().getUser().getId();
        return id != null && 1L == id;
    }

    public static Long getUserId() {
        return getLoginUser().getUser().getId();
    }
}

这个类创建的原因是在添加评论的时候,comment字段里面的创建人和创建时间,更新人更新时间无法赋值过来,而创建人userid前端传过来肯定不安全,所以我们只能从token中解析出来,然后进行赋值操作

这一步可以封装成一个工具类,因为我们之后肯定要经常进行从token中解析出userid来获取用户信息的操作

第二步: 在keke-framework工程的handler目录新建mybatisplus.MyMetaObjectHandler类,写入如下

package com.keke.handler.mybatisplus;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.keke.utils.SecurityUtils;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;


@Component
//这个类是用来配置mybatis的字段自动填充。用于'发送评论'功能,由于我们在评论表无法对下面这四个字段进行插入数据(原因是前端在发送评论时,没有在
//请求体提供下面四个参数,所以后端在往数据库插入数据时,下面四个字段是空值),所有就需要这个类来帮助我们往下面这四个字段自动的插入值,
//只要我们更新了评论表的字段,那么无法插入值的字段就自动有值了
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    //只要对数据库执行了插入语句,那么就会执行到这个方法
    public void insertFill(MetaObject metaObject) {
        Long userId = null;
        try {
            //获取用户id
            userId = SecurityUtils.getUserId();
        } catch (Exception e) {
            e.printStackTrace();
            userId = -1L;//如果异常了,就说明该用户还没注册,我们就把该用户的userid字段赋值d为-1
        }
        //自动把下面四个字段新增了值。
        this.setFieldValByName("createTime", new Date(), metaObject);
        this.setFieldValByName("createBy",userId , metaObject);
        this.setFieldValByName("updateTime", new Date(), metaObject);
        this.setFieldValByName("updateBy", userId, metaObject);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("updateTime", new Date(), metaObject);
        this.setFieldValByName(" ", SecurityUtils.getUserId(), metaObject);
    }
}

mp在进行save操作时候会插入数据库信息,而创建人和创建时间,更新人和更新时间这些字段我们不愿意每次都自己进行手动赋值,我们这里配置mp的处理器,自动帮我们封装这个操作

第三步: 把keke-framework工程的Comment类修改为如下,增加了具体的自动填充规则

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 评论表(Comment)表实体类
 *
 * @author makejava
 * @since 2023-10-12 20:20:14
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_comment")
public class Comment {
    @TableId
    private Long id;
    //评论类型(0代表文章评论,1代表友链评论)
    private String type;
    //文章id
    private Long articleId;
    //根评论id
    private Long rootId;
    //评论内容
    private String content;
    //所回复的目标评论的userid
    private Long toCommentUserId;
    //回复目标评论id
    private Long toCommentId;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Long createBy;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateBy;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;

}

第四步: 把keke-framework工程的AppHttpCodeEnum类修改为如下,增加了一个枚举变量来限制用户要发送的评论内容不能为空

package com.keke.enums;


public enum AppHttpCodeEnum {
    // 成功
    SUCCESS(200,"操作成功"),
    // 登录
    NEED_LOGIN(401,"需要登录后操作"),
    NO_OPERATOR_AUTH(403,"无权限操作"),
    SYSTEM_ERROR(500,"出现错误"),
    USERNAME_EXIST(501,"用户名已存在"),
    PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
    REQUIRE_USERNAME(504, "必需填写用户名"),
    LOGIN_ERROR(505,"用户名或密码错误"),
    
    CONTENT_NOT_NULL(506, "发送的评论内容不能为空");
    int code;
    String msg;

    AppHttpCodeEnum(int code, String errorMessage){
        this.code = code;
        this.msg = errorMessage;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

第五步: 把keke-blog工程的CommentController修改为如下,增加了发送评论的请求路径 

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Comment;
import com.keke.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/comment")
public class CommentController {

     @Autowired
     private CommentService commentService;

     //展示评论列表
     @GetMapping("/commentList")
     public ResponseResult commentList(Long articleId,Integer pageNum,Integer pageSize){
          return commentService.commentList(articleId,pageNum,pageSize);
     }

     //发表评论
     @PostMapping
     //标准点的话,这里应该用一个dto去接受,dto是后端接受前端传送的Json所封装称的对象
     public ResponseResult addComment(@RequestBody CommentDto commentDto){
          return commentService.addComment(commentDto);
     }
}

 第六步:在keke-framework中新增domain/dto/Commentdto

package com.keke.domain.dto;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommentDto {
     //评论类型(0代表文章评论,1代表友链评论)
     private String type;
     //文章id
     private Long articleId;
     //根评论id
     private Long rootId;
     //评论内容
     private String content;
     //所回复的目标评论的userid
     private Long toCommentUserId;
     //回复目标评论id
     private Long toCommentId;
}

第七步:把keke-framework工程的CommentService接口修改为如下,增加了发送评论的接口

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Comment;


/**
 * 评论表(Comment)表服务接口
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
public interface CommentService extends IService<Comment> {

     ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize);

     ResponseResult addComment(CommentDto commentDto);
}

第八步: 把keke-framework工程的CommentServiceImpl类,增加了发送评论的代码实现

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Comment;
import com.keke.domain.vo.CommentVo;
import com.keke.domain.vo.PageVo;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.exception.SystemException;
import com.keke.mapper.CommentMapper;
import com.keke.service.ArticleService;
import com.keke.service.CommentService;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.util.List;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {


     @Autowired
     private UserService userService;

     @Override
     public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
          //查询对应文章的根评论
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //对articleId进行判断
          lambdaQueryWrapper.eq(Comment::getArticleId,articleId);
          //查询根评论
          lambdaQueryWrapper.eq(Comment::getRootId, SystemConstants.COMMENT_ROOT);
          //分页查询
          Page<Comment> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          List<Comment> comments = page.getRecords();



          List<CommentVo> commentVos = toCommentVoList(comments);
          //查询所有根评论对应的子评论集合,并且赋值给对应的属性
          for (CommentVo commentVo : commentVos) {
               //查询对应的子评论
               List<CommentVo> children = getChildren(commentVo.getId());
               //赋值
               commentVo.setChildren(children);
          }
          return ResponseResult.okResult(new PageVo(commentVos,page.getTotal()));
     }

     @Override
     public ResponseResult addComment(CommentDto commentDto) {
          //注意前端在调用这个发送评论接口时,在请求体是没有向我们传入createTime、createId、updateTime、updateID字段,所以
          //我们这里往后端插入数据时,就会导致上面那行的四个字段没有值
          //为了解决这个问题,我们在keke-framework工程新增了MyMetaObjectHandler类、修改了Comment类。详细可自己定位去看一下代码

          //限制发送评论不能为空
          if(!StringUtils.hasText(commentDto.getContent())){
               throw new SystemException(AppHttpCodeEnum.CONTENT_NOT_NULL);
          }

          //解决了四个字段没有值的情况,就可以直接调用mybatisplus提供的save方法往数据库插入数据(用户发送的评论的各个字段)了
          Comment comment = BeanCopyUtils.copyBean(commentDto, Comment.class);
          save(comment);
          //封装返回
          return ResponseResult.okResult();
     }

     private List<CommentVo> toCommentVoList(List<Comment> comments){
          List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(comments, CommentVo.class);

          for (CommentVo commentVo : commentVos) {
               //首先获取Comment的创始人id,通过create_by字段,然后获取到创始人,再获取到昵称
               String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
               commentVo.setUsername(nickName);
               //这里要加判断,如果to_comment_user_id不为-1才表示这个评论是有根评论的,才可以查
               if(commentVo.getToCommentId()!=-1) {
                    //根据to_comment_user_id获取根评论创始人的userid,然后查到其昵称
                    String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
                    commentVo.setToCommentUserName(toCommentUserName);
               }
          }
          return commentVos;
     }

     /**
      * 根据根评论id查询所对应的子评论的集合
      * @param id
      * @return
      */
     private List<CommentVo> getChildren(Long id) {
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //传过来的id,然后查询所有评论表中,根评论是该id的评论
          lambdaQueryWrapper.eq(Comment::getRootId,id);
          //对评论的时间进行升序排序,发表的早的显示在前面,符合逻辑
          lambdaQueryWrapper.orderByAsc(Comment::getCreateTime);
          List<Comment> comments = list(lambdaQueryWrapper);
          //调用称我们之前封装好的方法
          List<CommentVo> children = toCommentVoList(comments);
          //返回
          return children;
     }
}

第九步:测试

可以看到,评论和回复都响应正常数据库也新增成功

十四、友联评论列表 + 评论优化

1. 接口分析

用户登录后可以对友链发表评论,也可以对已有的评论进行回复

请求方式

请求地址

请求头

GET

/comment/linkCommentList

不需要token请求头

Query格式请求参数:

pageNum: 页码
pageSize: 每页条数

响应格式

{
    "code": 200,
    "data": {
        "rows": [
            {
                "articleId": "1",
                "children": [
                    {
                        "articleId": "1",
                        "content": "回复友链评论3",
                        "createBy": "1",
                        "createTime": "2022-01-30 10:08:50",
                        "id": "23",
                        "rootId": "22",
                        "toCommentId": "22",
                        "toCommentUserId": "1",
                        "toCommentUserName": "sg333",
                        "username": "sg333"
                    }
                ],
                "content": "友链评论2",
                "createBy": "1",
                "createTime": "2022-01-30 10:08:28",
                "id": "22",
                "rootId": "-1",
                "toCommentId": "-1",
                "toCommentUserId": "-1",
                "username": "sg333"
            }
        ],
        "total": "1"
    },
    "msg": "操作成功"
}

2. 代码实现

第一步: 把keke-framework工程的SystemCanstants类修改为如下,增加了区分文章、友链的评论类型
 

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     *  文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;
    
    /**
     *  文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;
    
    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;

    /**
     * 分类表的分类状态是正常状态
     */
    public static final String STATUS_NORMAL = "0";

    /**
     * 友联审核通过
     */
    public static final String Link_STATUS_NORMAL = "0";

    /**
     * 评论区的某条评论是根评论
     */
    public static final String COMMENT_ROOT = "-1";

    /**
     * 文章评论
     */
    public static final String ARTICLE_COMMENT = "0";

    /**
     * 文章评论
     */
    public static final String LINK_COMMENT = "1";
}

第二步: 把keke-framework工程的CommentService接口修改为如下,为CommentList方法增加了commentType参数类型 

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Comment;


/**
 * 评论表(Comment)表服务接口
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
public interface CommentService extends IService<Comment> {

     ResponseResult commentList(String commentType,Long articleId, Integer pageNum, Integer pageSize);

     ResponseResult addComment(CommentDto commentDto);
}

第三步: 把keke-framework工程的CommentServiceImpl类修改为如下,稍微修改了commentList方法

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Comment;
import com.keke.domain.vo.CommentVo;
import com.keke.domain.vo.PageVo;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.exception.SystemException;
import com.keke.mapper.CommentMapper;
import com.keke.service.ArticleService;
import com.keke.service.CommentService;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.util.List;

/**
 * 评论表(Comment)表服务实现类
 *
 * @author makejava
 * @since 2023-10-12 20:20:41
 */
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {


     @Autowired
     private UserService userService;

     @Override
     public ResponseResult commentList(String commentType,Long articleId, Integer pageNum, Integer pageSize) {
          //查询对应文章的根评论
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //对articleId进行判断
          //这里要新增一条对评论类型的判断,如果是0的话才能进行文章评论的封装
          lambdaQueryWrapper.eq(SystemConstants.ARTICLE_COMMENT.equals(commentType),Comment::getArticleId,articleId);
          //查询根评论
          lambdaQueryWrapper.eq(Comment::getRootId, SystemConstants.COMMENT_ROOT);
          //评论类型进行判断
          lambdaQueryWrapper.eq(Comment::getType,commentType);
          //分页查询
          Page<Comment> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          List<Comment> comments = page.getRecords();


          List<CommentVo> commentVos = toCommentVoList(comments);
          //查询所有根评论对应的子评论集合,并且赋值给对应的属性
          for (CommentVo commentVo : commentVos) {
               //查询对应的子评论
               List<CommentVo> children = getChildren(commentVo.getId());
               //赋值
               commentVo.setChildren(children);
          }
          return ResponseResult.okResult(new PageVo(commentVos,page.getTotal()));
     }

     @Override
     public ResponseResult addComment(CommentDto commentDto) {
          //注意前端在调用这个发送评论接口时,在请求体是没有向我们传入createTime、createId、updateTime、updateID字段,所以
          //我们这里往后端插入数据时,就会导致上面那行的四个字段没有值
          //为了解决这个问题,我们在keke-framework工程新增了MyMetaObjectHandler类、修改了Comment类。详细可自己定位去看一下代码

          //限制发送评论不能为空
          if(!StringUtils.hasText(commentDto.getContent())){
               throw new SystemException(AppHttpCodeEnum.CONTENT_NOT_NULL);
          }

          //解决了四个字段没有值的情况,就可以直接调用mybatisplus提供的save方法往数据库插入数据(用户发送的评论的各个字段)了
          Comment comment = BeanCopyUtils.copyBean(commentDto, Comment.class);
          save(comment);
          //封装返回
          return ResponseResult.okResult();
     }

     private List<CommentVo> toCommentVoList(List<Comment> comments){
          List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(comments, CommentVo.class);

          for (CommentVo commentVo : commentVos) {
               //首先获取Comment的创始人id,通过create_by字段,然后获取到创始人,再获取到昵称
               String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
               commentVo.setUsername(nickName);
               //这里要加判断,如果to_comment_user_id不为-1才表示这个评论是有根评论的,才可以查
               if(commentVo.getToCommentId()!=-1) {
                    //根据to_comment_user_id获取根评论创始人的userid,然后查到其昵称
                    String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
                    commentVo.setToCommentUserName(toCommentUserName);
               }
          }
          return commentVos;
     }

     /**
      * 根据根评论id查询所对应的子评论的集合
      * @param id
      * @return
      */
     private List<CommentVo> getChildren(Long id) {
          LambdaQueryWrapper<Comment> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //传过来的id,然后查询所有评论表中,根评论是该id的评论
          lambdaQueryWrapper.eq(Comment::getRootId,id);
          //对评论的时间进行升序排序,发表的早的显示在前面,符合逻辑
          lambdaQueryWrapper.orderByAsc(Comment::getCreateTime);
          List<Comment> comments = list(lambdaQueryWrapper);
          //调用称我们之前封装好的方法
          List<CommentVo> children = toCommentVoList(comments);
          //返回
          return children;
     }
}

 第四步: 把keke-blog工程的CommentController类修改为如下,增加了linkCommentList方法

package com.keke.controller;

import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.CommentDto;
import com.keke.domain.entity.Comment;
import com.keke.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/comment")
public class CommentController {

     @Autowired
     private CommentService commentService;

     //展示评论列表
     @GetMapping("/commentList")
     public ResponseResult commentList(Long articleId,Integer pageNum,Integer pageSize){
          //调用文章评论的接口,但增加传入文章类型的判断,从而实现友链和文章评论的列表分页查询
          return commentService.commentList(SystemConstants.ARTICLE_COMMENT,articleId,pageNum,pageSize);
     }

     //发表评论
     @PostMapping
     //标准点的话,这里应该用一个dto去接受,dto是后端接受前端传送的Json所封装称的对象
     public ResponseResult addComment(@RequestBody CommentDto commentDto){
          return commentService.addComment(commentDto);
     }

     //友链评论
     @GetMapping("/linkCommentList")
     public ResponseResult linkCommentList(Integer pageNum,Integer pageSize){
          //调用文章评论的接口,但增加传入文章类型的判断,从而实现友链和文章评论的列表分页查询
          return commentService.commentList(SystemConstants.LINK_COMMENT,null,pageNum,pageSize);
     }


}

 3. 测试

前后端联调

4. 友链页面的登录bug

每次点击打开友链页面都会弹出重新登录的弹窗,原因是友链接口被后端拦截了,前端是不携带token来访问友链接口,原本沟通好是不需要认证就可以访问友链接口的,而后端却写着要认证

package com.keke.config;

import com.keke.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入我们在keke-blog工程写的JwtAuthenticationTokenFilter过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    AccessDeniedHandler accessDeniedHandler;



    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
//这里新增必须要是登录状态才能访问退出登录的接口,即是认证过的状态
                .antMatchers("/logout").authenticated()
                //为方便测试认证过滤器,我们把查询友链的接口设置为需要登录才能访问。然后我们去访问的时候就能测试登录认证功能了
//                .antMatchers("/link/getAllLink").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        //配置我们自己写的认证和授权的异常处理
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);



        http.logout().disable();
        //将自定义filter加入security过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //允许跨域
        http.cors();
    }

}

 十五、前台模块-个人信息

1. 接口分析

进入个人中心的时候需要能够查看当前用户信息。请求不需要参数

请求方式

请求地址

请求头

GET

/user/userInfo

需要token请求头

响应格式

{
	"code":200,
	"data":{
		"avatar":"头像的网络地址",
		"email":"123@qq.com",
		"id":"1",
		"nickName":"用户名",
		"sex":"1"
	},
	"msg":"操作成功"
}

 2. 代码实现

第一步: 把keke-blog工程的UserController新增如下

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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


     @Autowired
     private UserService userService;

     @GetMapping("/userInfo")
     public ResponseResult userInfo() {
          return userService.userInfo();
     }
}

第二步: 把keke-framework工程的UserService接口修改为如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;
import org.springframework.stereotype.Service;


/**
 * 用户表(User)表服务接口
 *
 * @author makejava
 * @since 2023-10-13 09:08:38
 */

public interface UserService extends IService<User> {

     ResponseResult userInfo();

}

第三步: 把keke-framework工程的UserServiceImpl类修改为如下

package com.keke.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.User;
import com.keke.domain.vo.UserInfoVo;
import com.keke.mapper.UserMapper;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.SecurityUtils;
import org.springframework.stereotype.Service;

/**
 * 用户表(User)表服务实现类
 *
 * @author makejava
 * @since 2023-10-13 10:12:51
 */
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

     @Override
     public ResponseResult userInfo() {
          Long userId = SecurityUtils.getUserId();
          User user = getById(userId);
          UserInfoVo userInfoVo = BeanCopyUtils.copyBean(user, UserInfoVo.class);
          return ResponseResult.okResult(userInfoVo);
     }
}

第四步: 把keke-blog工程的SecurityConfig类修改为如下,增加了/user/userInfo接口的访问限制

这是由于个人信息页面必须是登录后才能展示的 

package com.keke.config;

import com.keke.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
//WebSecurityConfigurerAdapter是Security官方提供的类
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入我们在keke-blog工程写的JwtAuthenticationTokenFilter过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    AccessDeniedHandler accessDeniedHandler;



    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    //把官方的PasswordEncoder密码加密方式替换成BCryptPasswordEncoder
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/login").anonymous()
//这里新增必须要是登录状态才能访问退出登录的接口,即是认证过的状态
                .antMatchers("/logout").authenticated()
             //   为方便测试认证过滤器,我们把查询友链的接口设置为需要登录才能访问。然后我们去访问的时候就能测试登录认证功能了
             //   .antMatchers("/link/getAllLink").authenticated()
                .antMatchers("/user/userInfo").authenticated()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        //配置我们自己写的认证和授权的异常处理
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);



        http.logout().disable();
        //将自定义filter加入security过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //允许跨域
        http.cors();
    }

}

第五步:测试

打开redis,登录后,然后打开个人中心,发现信息已经渲染到页面

十六、OSS-文件上传

1. 为什么要使用OSS

因为如果把图片视频等文件上传到自己的应用的Web服务器的某个目录下,在读取图片的时候会占用比较多的资源。影响应用服务器的性能。所以我们一般使用OSS(Object Storage Service对象存储服务)存储图片或视频

2. 基础-七牛云基本使用

认证后

进入开发者中心,点击对象存储

把依赖添加到keke-framework中

3. Demo-上传文件到七牛云

把 'F:\MyUploadFile' 目录的 'myAvatar.jpg' 文件上传到七牛云的对象存储的keke-blog桶

第一步: 在keke-framework工程的pom.xml修改为如下,添加了相关坐标,修改了springboottest依赖

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>KekeBlog</artifactId>
        <groupId>com.keke</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>keke-framework</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--junit-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
<!--            <scope>test</scope>-->
        </dependency>

        <!--SpringSecurity启动器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--fastjson依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
        <!--jwt依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>
        <!--mybatisPlus依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--mysql数据库驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--阿里云OSS-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
        </dependency>

        <!--AOP-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

<!--        excel-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
        </dependency>
<!--        swaggerUI-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
        </dependency>
<!--      七牛云OSS-->
      <dependency>
          <groupId>com.qiniu</groupId>
          <artifactId>qiniu-java-sdk</artifactId>
          <version>[7.7.0, 7.7.99]</version>
      </dependency>

  </dependencies>
</project>

第二步: 结合七牛云官方文档(直接把 '数据流'代码 拷贝过来进行相应修改)。在keke-blog工程的src/test/java目录创建com.keke.OSSTest类,写入如下

package com.keke;

import com.google.gson.Gson;
import com.qiniu.common.QiniuException;
import com.qiniu.http.Response;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.Region;
import com.qiniu.storage.UploadManager;
import com.qiniu.storage.model.DefaultPutRet;
import com.qiniu.util.Auth;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;

@SpringBootTest
@ConfigurationProperties(prefix = "oss")
public class OSSTest {

     private String accessKey;
     private String secretKey;
     private String bucket;

     public String getAccessKey() {
          return accessKey;
     }

     public void setAccessKey(String accessKey) {
          this.accessKey = accessKey;
     }

     public String getSecretKey() {
          return secretKey;
     }

     public void setSecretKey(String secretKey) {
          this.secretKey = secretKey;
     }

     public String getBucket() {
          return bucket;
     }

     public void setBucket(String bucket) {
          this.bucket = bucket;
     }



     @Test
     public void testOSSS(){
          //构造一个带指定 Region 对象的配置类
          Configuration cfg = new Configuration(Region.autoRegion());//自动去找存储空间的区域
          cfg.resumableUploadAPIVersion = Configuration.ResumableUploadAPIVersion.V2;// 指定分片上传版本
//...其他参数参考类注释

          UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传


          //
          //为避免这3行暴露信息,我们会把信息写到application.yml里面,
          // 然后添加ConfigurationProperties注解、3个成员变量即可读取
//          String accessKey = "your access key";
//          String secretKey = "your secret key";
//          String bucket = "keke-blog";

          //默认不指定key的情况下,以文件内容的hash值作为文件名
          String key = "keke.png";

          try {
//               byte[] uploadBytes = "hello qiniu cloud".getBytes("utf-8");
//               ByteArrayInputStream byteInputStream=new ByteArrayInputStream(uploadBytes);
               InputStream inputStream = new FileInputStream("F:\\BlogProject\\myUploadFile\\myAvatar.png");
               Auth auth = Auth.create(accessKey, secretKey);
               String upToken = auth.uploadToken(bucket);

               try {
                    Response response = uploadManager.put(inputStream,key,upToken,null, null);
                    //解析上传成功的结果
                    DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
                    System.out.println(putRet.key);
                    System.out.println(putRet.hash);
               } catch (QiniuException ex) {
                    ex.printStackTrace();
                    if (ex.response != null) {
                         System.err.println(ex.response);

                         try {
                              String body = ex.response.toString();
                              System.err.println(body);
                         } catch (Exception ignored) {
                         }
                    }
               }
          } catch (Exception ex) {
               //ignore
          }

     }
}

第三步: 把keke-blog工程的application.yml修改为如下,增加了自定义属性,也就是oss

server:
  port: 7777

spring:
  # 数据库连接信息
  datasource:
    url: jdbc:mysql://localhost:3306/keke_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

  servlet:
    # 文件上传
    multipart:
      # 单个上传文件的最大允许大小
      max-file-size: 20MB
      # HTTP请求中包含的所有文件的总大小的最大允许值
      max-request-size: 20MB

mybatis-plus:
  configuration:
    # 日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 逻辑删除的字段
      logic-delete-field: delFlag
      # 代表已删除的值
      logic-delete-value: 1
      # 代表未删除的值
      logic-not-delete-value: 0
      # 主键自增策略,以mysql数据库为准
      id-type: auto

OSS:
  accessKey: j3UpxqswO4F07NnFOm_QmO8e2LDm55wmdj21mIAB
  secretKey: Dea8CYCfptyu6Aar7bnvQ48UCFYsadw43RY0PSA6
  bucket: keke-blog

第四步: 运行OSSTest类的testOss方法,打开七牛云,查看对象存储的文件管理那里,有新文件

4. OSS-指定文件存放目录

刚刚我们实现了,把本地文件上传到了七牛云的keke-blog桶,并且是直接存放在桶的根目录,我们能不能通过代码在桶里面新建目录,然后把上传的文件放到指定目录呢,很简单,我们把key写为 "目录1/目录2/文件名" 这种格式即可,上传到七牛云的文件就会自动有自己的所属目录了

package com.keke;

import com.google.gson.Gson;
import com.qiniu.common.QiniuException;
import com.qiniu.http.Response;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.Region;
import com.qiniu.storage.UploadManager;
import com.qiniu.storage.model.DefaultPutRet;
import com.qiniu.util.Auth;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;

@SpringBootTest
@ConfigurationProperties(prefix = "oss")
public class OSSTest {

     private String accessKey;
     private String secretKey;
     private String bucket;

     public String getAccessKey() {
          return accessKey;
     }

     public void setAccessKey(String accessKey) {
          this.accessKey = accessKey;
     }

     public String getSecretKey() {
          return secretKey;
     }

     public void setSecretKey(String secretKey) {
          this.secretKey = secretKey;
     }

     public String getBucket() {
          return bucket;
     }

     public void setBucket(String bucket) {
          this.bucket = bucket;
     }



     @Test
     public void testOSS(){
          //构造一个带指定 Region 对象的配置类
          Configuration cfg = new Configuration(Region.autoRegion());//自动去找存储空间的区域
          cfg.resumableUploadAPIVersion = Configuration.ResumableUploadAPIVersion.V2;// 指定分片上传版本
//...其他参数参考类注释

          UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传


          //
          //为避免这3行暴露信息,我们会把信息写到application.yml里面,
          // 然后添加ConfigurationProperties注解、3个成员变量即可读取
//          String accessKey = "your access key";
//          String secretKey = "your secret key";
//          String bucket = "keke-blog";

          //默认不指定key的情况下,以文件内容的hash值作为文件名
          String key = "2023/10/16/keke.png";

          try {
//               byte[] uploadBytes = "hello qiniu cloud".getBytes("utf-8");
//               ByteArrayInputStream byteInputStream=new ByteArrayInputStream(uploadBytes);
               InputStream inputStream = new FileInputStream("F:\\BlogProject\\myUploadFile\\myAvatar.png");
               Auth auth = Auth.create(accessKey, secretKey);
               String upToken = auth.uploadToken(bucket);

               try {
                    Response response = uploadManager.put(inputStream,key,upToken,null, null);
                    //解析上传成功的结果
                    DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
                    System.out.println(putRet.key);
                    System.out.println(putRet.hash);
               } catch (QiniuException ex) {
                    ex.printStackTrace();
                    if (ex.response != null) {
                         System.err.println(ex.response);

                         try {
                              String body = ex.response.toString();
                              System.err.println(body);
                         } catch (Exception ignored) {
                         }
                    }
               }
          } catch (Exception ex) {
               //ignore
          }

     }
}

十七、前台模块-头像上传

1. 接口分析

上面我们学习了文件上传的相关知识和操作。下面就回到我们的博客项目,实现头像上传的功能

在个人中心点击编辑的时候可以上传头像图片。上传完头像后,可以用于更新个人信息接口

请求方式

请求地址

请求头

POST

/upload

需要token

参数:

img,值为要上传的文件

请求头:

Content-Type :multipart/form-data;

响应格式: 

{
    "code": 200,
    "data": "文件的访问网址",
    "msg": "操作成功"
}

2. 代码实现

第一步:  在keke-blog工程的controller目录新建UploadController类,写入如下,作用是暴露接口给前端访问

package com.keke.controller;


import com.keke.domain.ResponseResult;
import com.keke.service.OssUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;


@RestController

public class UploadController {

    @Autowired
    //UploadService是我们在keke-framework写的接口
    private OssUploadService ossUploadService;

    @PostMapping("/upload")
    //MultipartFile是spring提供的接口,ResponseResult是我们在huanf-framework写的实体类
    public ResponseResult uploadImg(MultipartFile img){
        //图片上传到七牛云
        return ossUploadService.uploadImg(img);
    }
}

第二步: 把keke-framework工程的AppHttpCodeEnum类修改为如下,增加了提示文件类型错误的枚举和文件大小超过限制的枚举

package com.keke.enums;


public enum AppHttpCodeEnum {
    // 成功
    SUCCESS(200,"操作成功"),
    // 登录
    NEED_LOGIN(401,"需要登录后操作"),
    NO_OPERATOR_AUTH(403,"无权限操作"),
    SYSTEM_ERROR(500,"出现错误"),
    USERNAME_EXIST(501,"用户名已存在"),
    PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
    REQUIRE_USERNAME(504, "必需填写用户名"),
    LOGIN_ERROR(505,"用户名或密码错误"),

    CONTENT_NOT_NULL(506, "发送的评论内容不能为空"),

    FILE_TYPE_ERROR(507, "文件类型错误,请上传png类型"),
    FILE_SIZE_ERROR(508,"文件大小超过限制");
    int code;
    String msg;

    AppHttpCodeEnum(int code, String errorMessage){
        this.code = code;
        this.msg = errorMessage;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

第三步: 在keke-framework工程的utils目录新建PathUtils类,写入如下,作用是把某个文件名重命名为我们指定的文件名,且修改文件存放路径

package com.keke.utils;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;


//对原始文件名进行修改文件名,并修改存放目录
public class PathUtils {

    public static String generateFilePath(String fileName){
        //根据日期生成路径   2022/1/15/
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd/");
        String datePath = sdf.format(new Date());
        //uuid作为文件名
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        //后缀和文件后缀一致
        int index = fileName.lastIndexOf(".");
        // test.jpg -> .jpg
        String fileType = fileName.substring(index);
        return new StringBuilder().append(datePath).append(uuid).append(fileType).toString();
    }
}

第四步: 在keke-framework工程的service目录新建OssUploadService接口,写入如下

package com.keke.service;

import com.keke.domain.ResponseResult;
import org.springframework.web.multipart.MultipartFile;

public interface OssUploadService {
     ResponseResult uploadImg(MultipartFile img);
}

第五步: 在huanf-framework工程的service目录新建impl.OssUploadServiceImpl类,写入如下,文件上传的具体代码

package com.keke.service.impl;

import com.google.gson.Gson;
import com.keke.domain.ResponseResult;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.handler.exception.exception.SystemException;
import com.keke.service.OssUploadService;
import com.keke.utils.PathUtils;
import com.qiniu.common.QiniuException;
import com.qiniu.http.Response;
import com.qiniu.storage.Configuration;
import com.qiniu.storage.Region;
import com.qiniu.storage.UploadManager;
import com.qiniu.storage.model.DefaultPutRet;
import com.qiniu.util.Auth;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;

@Service
@Data//为成员变量生成get和set方法
@ConfigurationProperties(prefix = "oss") //把OSSTest测试类的这一行注释掉,不然myoss被两个类读取会报错
public class OssUploadServiceImpl implements OssUploadService {

     private String accessKey;
     private String secretKey;
     private String bucket;
     @Override
     public ResponseResult uploadImg(MultipartFile img) {
          //获取原始文件名
          String originalFilename = img.getOriginalFilename();

          // 获取文件大小
          long fileSize = img.getSize();

          // 判断文件大小是否超过2MB(2MB=2*1024*1024 bytes)
          if (fileSize > 2 * 1024 * 1024) {
               // 抛出文件大小超过限制的异常
               throw new SystemException(AppHttpCodeEnum.FILE_SIZE_ERROR);
          }
          //对原始文件名进行判断大小。只能上传png或jpg文件
          if(!originalFilename.endsWith(".png") && !originalFilename.endsWith(".jpg")){
               //AppHttpCodeEnum是我们在keke-framework写的枚举类,FILE_TYPE_ERROR代表文件类型错误的提示
               throw new SystemException(AppHttpCodeEnum.FILE_TYPE_ERROR);
          }
          //生成文件存放路径
          String filePath = PathUtils.generateFilePath(originalFilename);
          String url = uploadOSS(img,filePath);
          //判断通过上传到OSS
          return ResponseResult.okResult(url);
     }

     public String uploadOSS(MultipartFile img, String filePath){
          //构造一个带指定 Region 对象的配置类
          Configuration cfg = new Configuration(Region.autoRegion());//自动去找存储空间的区域
          cfg.resumableUploadAPIVersion = Configuration.ResumableUploadAPIVersion.V2;// 指定分片上传版本
//...其他参数参考类注释

          UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传


          //
          //为避免这3行暴露信息,我们会把信息写到application.yml里面,
          // 然后添加ConfigurationProperties注解、3个成员变量即可读取
//          String accessKey = "your access key";
//          String secretKey = "your secret key";
//          String bucket = "keke-blog";

          //默认不指定key的情况下,以文件内容的hash值作为文件名
          String key = filePath;

          try {
//               byte[] uploadBytes = "hello qiniu cloud".getBytes("utf-8");
//               ByteArrayInputStream byteInputStream=new ByteArrayInputStream(uploadBytes);
               InputStream inputStream = img.getInputStream();
               Auth auth = Auth.create(accessKey, secretKey);
               String upToken = auth.uploadToken(bucket);


               try {
                    Response response = uploadManager.put(inputStream,key,upToken,null, null);
                    //解析上传成功的结果
                    DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
                    System.out.println(putRet.key);
                    System.out.println(putRet.hash);
                    return "http://s2lji2xzo.hb-bkt.clouddn.com/" + key;
               } catch (QiniuException ex) {
                    ex.printStackTrace();
                    if (ex.response != null) {
                         System.err.println(ex.response);

                         try {
                              String body = ex.response.toString();
                              System.err.println(body);
                         } catch (Exception ignored) {
                         }
                    }
               }
          } catch (Exception ex) {
               //ignore
          }
          return "upload fail";
     }
}

第六步:测试

打开前端工程,点击个人信息,点击编辑,在本地选择一个图片,上传

七牛云个人仓库 可以看到路径符合我们预期

十八、前台模块-更新个人信息

在编辑完个人资料后点击保存会对个人资料进行更新

1. 接口分析

请求方式

请求地址

请求头

PUT

/user/userInfo

需要token请求头

请求体:

{
    "avatar":"图片url地址",
    "email":"23412332@qq.com",
    "id":"1",
    "nickName":"用户昵称",
    "sex":"1"
}

响应格式:

{
	"code":200,
	"msg":"操作成功"
}

2. 代码实现

第一步: 把keke-blog工程的UserController类修改为如下,增加了更新个人信息的访问方式

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.domain.dto.UserInfoDto;
import com.keke.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

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


     @Autowired
     private UserService userService;

     @GetMapping("/userInfo")
     public ResponseResult userInfo() {
          return userService.userInfo();
     }

     @PutMapping("/userInfo")
     public ResponseResult updateUserInfo(@RequestBody UserInfoDto userInfoDto){
          //更新个人信息
          return userService.updateUserInfo(userInfoDto);
     }
}

第二步: 把keke-framework工程的domain/dto中新增UserInfoDto

package com.keke.domain.dto;

import java.util.Date;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;

/**
 * 用户表(User)表实体类
 *
 * @author makejava
 * @since 2023-10-11 20:26:58
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class UserInfoDto {
     private Long id;
     //昵称
     private String nickName;
     //邮箱
     private String email;
     //用户性别(0男,1女,2未知)
     private String sex;
     //头像
     private String avatar;
}

第三步:把keke-framework工程的UserService接口修改为如下,增加了更新个人信息的接口

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.UserInfoDto;
import com.keke.domain.entity.User;
import org.springframework.stereotype.Service;


/**
 * 用户表(User)表服务接口
 *
 * @author makejava
 * @since 2023-10-13 09:08:38
 */

public interface UserService extends IService<User> {

     ResponseResult userInfo();

     ResponseResult updateUserInfo(UserInfoDto userInfoDto);
}

第四步:把keke-framework工程的UserServiceImpl类修改为如下,增加了更新个人信息的具体代码

package com.keke.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.UserInfoDto;
import com.keke.domain.entity.User;
import com.keke.domain.vo.UserInfoVo;
import com.keke.mapper.UserMapper;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.SecurityUtils;
import org.springframework.stereotype.Service;

/**
 * 用户表(User)表服务实现类
 *
 * @author makejava
 * @since 2023-10-13 10:12:51
 */
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

     @Override
     public ResponseResult userInfo() {
          Long userId = SecurityUtils.getUserId();
          User user = getById(userId);
          UserInfoVo userInfoVo = BeanCopyUtils.copyBean(user, UserInfoVo.class);
          return ResponseResult.okResult(userInfoVo);
     }

     @Override
     public ResponseResult updateUserInfo(UserInfoDto userInfoDto) {
          User user = BeanCopyUtils.copyBean(userInfoDto, User.class);
          updateById(user);
          return ResponseResult.okResult();
     }
}

第五步: 测试,运行keke-blog工程的启动类

登录,点击个人信息,编辑,然后点击保存,效果如下

3. 总结

  • Entity就是一般和数据库对应的实体类,需要与数据库一一对应
  • DTO是数据传输对象,用来接收前端传过来的参数,一般会把前端传过来的参数封装进DTO,然后在Service中复制给新new的entity对象,然后进行持久化
  • VO就是返回给前端的视图对象

我们之前以及之后的开发,会严格按照这个标准进行

十九、前台模块-注册功能

1. 注册页面展示

2. 接口分析

要求用户能够在注册界面完成用户的注册。要求用户名,昵称,邮箱不能和数据库中原有的数据重复。如果某项重复了注册失败并且要有对应的提示。并且要求用户名,密码,昵称,邮箱都不能为空。注意:密码必须密文存储到数据库中

请求方式

请求地址

请求头

POST

/user/register

不需要token请求头

请求体中json格式数据:

{
  "email": "string",
  "nickName": "string",
  "password": "string",
  "userName": "string"
}

响应格式:

{
	"code":200,
	"msg":"操作成功"
}

3. 代码实现

第一步:在keke-blog的UserController中新增注册接口

package com.keke.controller;

import com.keke.domain.ResponseResult;
import com.keke.domain.dto.RegisterUserDto;
import com.keke.domain.dto.UserInfoDto;
import com.keke.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

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


     @Autowired
     private UserService userService;

     @GetMapping("/userInfo")
     public ResponseResult userInfo() {
          return userService.userInfo();
     }

     @PutMapping("/userInfo")
     public ResponseResult updateUserInfo(@RequestBody UserInfoDto userInfoDto){
          //更新个人信息
          return userService.updateUserInfo(userInfoDto);
     }


     @PostMapping("/register")
     public ResponseResult register(@RequestBody RegisterUserDto registerUserDto){
          return userService.register(registerUserDto);
     }

}

第二步:把keke-framework工程的AppHttpCodeEnum枚举类修改为如下,增加了用户的相关信息为空、不存在等等提示

package com.keke.enums;


public enum AppHttpCodeEnum {
    // 成功
    SUCCESS(200,"操作成功"),
    // 登录
    NEED_LOGIN(401,"需要登录后操作"),
    NO_OPERATOR_AUTH(403,"无权限操作"),
    SYSTEM_ERROR(500,"出现错误"),
    USERNAME_EXIST(501,"用户名已存在"),
    PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
    REQUIRE_USERNAME(504, "必需填写用户名"),
    LOGIN_ERROR(505,"用户名或密码错误"),

    CONTENT_NOT_NULL(506, "发送的评论内容不能为空"),

    FILE_TYPE_ERROR(507, "文件类型错误,请上传png类型"),
    FILE_SIZE_ERROR(508,"文件大小超过限制"),

    USERNAME_NOT_NULL(508, "用户名不能为空"),
    NICKNAME_NOT_NULL(509, "昵称不能为空"),
    PASSWORD_NOT_NULL(510, "密码不能为空"),
    EMAIL_NOT_NULL(511, "邮箱不能为空"),
    NICKNAME_EXIST(512, "昵称已经存在"),
    PHONENUMBER_NOT_NULL(521, "手机号码不能为空");


    int code;
    String msg;

    AppHttpCodeEnum(int code, String errorMessage){
        this.code = code;
        this.msg = errorMessage;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

第三步: 在数据库执行如下,让主键id使用自增策略,原因是我们等下不使用mybatisplus提供的雪花算法。执行之后重启mysql

alter table `sys_user` drop `id`;
alter table `sys_user` add `id` int not null primary key auto_increment first;

关于mp雪花算法,和数据库自增id,详见:数据库自增id与雪花算法

第四步:在keke-framework的domain/dto包下创建RegisterUserDto

package com.keke.domain.dto;

import java.util.Date;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableName;


@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class RegisterUserDto {
     //用户名
     private String userName;
     //昵称
     private String nickName;
     //密码
     private String password;
     //邮箱
     private String email;
}

第五步:在keke-framework的domain/entity包的在User表中的id新增注释表示用数据库自增id,不用mp雪花算法。updateby,updateTime,createby,createTime新增自动填充的注解

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;

import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 用户表(User)表实体类
 *
 * @author makejava
 * @since 2023-10-11 20:26:58
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class User {

    //主键
    //禁用雪花算法,使用mysql的主键自增策略
    @TableId(type = IdType.AUTO)
    private Long id;
    //用户名
    private String userName;
    //昵称
    private String nickName;
    //密码
    private String password;
    //用户类型:0代表普通用户,1代表管理员
    private String type;
    //账号状态(0正常 1停用)
    private String status;
    //邮箱
    private String email;
    //手机号
    private String phonenumber;
    //用户性别(0男,1女,2未知)
    private String sex;
    //头像
    private String avatar;
    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Long createBy;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Date createTime;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateBy;

    //由于我们在MyMetaObjectHandler类配置了mybatisplus的字段自动填充
    //所以就能指定哪个字段在什么时候进行自动填充,例如该类其它字段新增了数据,那么createBy字段就会自动填充值
    @TableField(fill = FieldFill.INSERT)
    private Date updateTime;

    private Integer delFlag;

}

第六步:keke-framework的Service/UserSerivce新增如下

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.RegisterUserDto;
import com.keke.domain.dto.UserInfoDto;
import com.keke.domain.entity.User;
import org.springframework.stereotype.Service;


/**
 * 用户表(User)表服务接口
 *
 * @author makejava
 * @since 2023-10-13 09:08:38
 */

public interface UserService extends IService<User> {

     ResponseResult userInfo();

     ResponseResult updateUserInfo(UserInfoDto userInfoDto);

     ResponseResult register(RegisterUserDto registerUserDto);
}

 第六步:keke-framework的Service/impl/UserSerivceImpl新增如下,写登录的具体逻辑

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.domain.ResponseResult;
import com.keke.domain.dto.RegisterUserDto;
import com.keke.domain.dto.UserInfoDto;
import com.keke.domain.entity.User;
import com.keke.domain.vo.UserInfoVo;
import com.keke.enums.AppHttpCodeEnum;
import com.keke.handler.exception.exception.SystemException;
import com.keke.mapper.UserMapper;
import com.keke.service.UserService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.SecurityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

/**
 * 用户表(User)表服务实现类
 *
 * @author makejava
 * @since 2023-10-13 10:12:51
 */
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

     @Autowired
     private PasswordEncoder passwordEncoder;

     @Override
     public ResponseResult userInfo() {
          Long userId = SecurityUtils.getUserId();
          User user = getById(userId);
          UserInfoVo userInfoVo = BeanCopyUtils.copyBean(user, UserInfoVo.class);
          return ResponseResult.okResult(userInfoVo);
     }

     @Override
     public ResponseResult updateUserInfo(UserInfoDto userInfoDto) {
          User user = BeanCopyUtils.copyBean(userInfoDto, User.class);
          updateById(user);
          return ResponseResult.okResult();
     }

     @Override
     public ResponseResult register(RegisterUserDto registerUserDto) {
          //对前端传过来的用户名进行非空判断,例如null、"",就抛出异常
          if(!StringUtils.hasText(registerUserDto.getUserName())){
               //SystemException是我们写的异常类、AppHttpCodeEnum是我们写的枚举类
               throw new SystemException(AppHttpCodeEnum.USERNAME_NOT_NULL);
          }
          //密码
          if(!StringUtils.hasText(registerUserDto.getPassword())){
               throw new SystemException(AppHttpCodeEnum.PASSWORD_NOT_NULL);
          }
          //邮箱
          if(!StringUtils.hasText(registerUserDto.getEmail())){
               throw new SystemException(AppHttpCodeEnum.EMAIL_NOT_NULL);
          }
          //昵称
          if(!StringUtils.hasText(registerUserDto.getNickName())){
               throw new SystemException(AppHttpCodeEnum.NICKNAME_NOT_NULL);
          }

          //判断用户传给我们的用户名是否在数据库已经存在。userNameExist方法是下面定义的
          if(userNameExist(registerUserDto.getUserName())){
               //SystemException是我们写的异常类、AppHttpCodeEnum是我们写的枚举类
               throw new SystemException(AppHttpCodeEnum.USERNAME_EXIST);
          }
          //判断用户传给我们的昵称是否在数据库已经存在。NickNameExist方法是下面定义的
          if(nickNameExist(registerUserDto.getNickName())){
               //SystemException是我们写的异常类、AppHttpCodeEnum是我们写的枚举类
               throw new SystemException(AppHttpCodeEnum.NICKNAME_EXIST);
          }
          //判断用户传给我们的邮箱是否在数据库已经存在。NickNameExist方法是下面定义的
          if(emailExist(registerUserDto.getEmail())){
               //SystemException是我们写的异常类、AppHttpCodeEnum是我们写的枚举类
               throw new SystemException(AppHttpCodeEnum.EMAIL_EXIST);
          }

          //经过上面的判断,可以确保用户传给我们的用户名和昵称是数据库不存在的,且相关字段都不为空。就可以存入数据库
          //注意用户传给我们的密码是明文,对于密码我们要转成密文之后再存入数据库。注意加密要和解密用同一套算法
          //keke-blog工程的securityConfig类里面有解密算法,当时我们写了一个passwordEncoder方法,并且注入到了spring容器

          //解密
          String encodePassword = passwordEncoder.encode(registerUserDto.getPassword());
          //封装成user存数据库中
          User user = BeanCopyUtils.copyBean(registerUserDto, User.class);
          //设置密码为加密的密码
          user.setPassword(encodePassword);
          //存入数据库中
          save(user);
          //封装返回
          return ResponseResult.okResult();
     }



     //'判断用户传给我们的用户名是否在数据库已经存在' 的方法
     public boolean userNameExist(String userName){
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //查询数据库中有用户名相同的数据没有
          lambdaQueryWrapper.eq(User::getUserName,userName);
          //如果查出来有,就说明存在,返回true
          int count = count(lambdaQueryWrapper);
          return count>0;
     }

     //'判断用户传给我们的昵称是否在数据库已经存在' 的方法
     public boolean nickNameExist(String nickName){
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //查询数据库中有用户名相同的数据没有
          lambdaQueryWrapper.eq(User::getNickName,nickName);
          //如果查出来有,就说明存在,返回true
          int count = count(lambdaQueryWrapper);
          return count>0;
     }


     //'判断用户传给我们的邮箱是否在数据库已经存在' 的方法
     public boolean emailExist(String email){
          LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //查询数据库中有用户名相同的数据没有
          lambdaQueryWrapper.eq(User::getEmail,email);
          //如果查出来有,就说明存在,返回true
          int count = count(lambdaQueryWrapper);
          return count>0;
     }
}

第七步:测试如下,输入注册信息,注册成功数据库新增一条用户信息

测试登录 

登录成功,个人信息页面吻合 

二十、前台模块-日志记录

1. 需求分析

需要通过日志记录接口调用信息。便于后期调试排查。并且可能有很多接口都需要进行日志的记录。接口被调用时日志打印格式如下:

log.info("======================Start======================");
// 打印请求 URL
log.info("请求URL   : {}",);
// 打印描述信息
log.info("接口描述   : {}", );
// 打印 Http method
log.info("请求方法   : {}", );
// 打印调用 controller 的全路径以及执行方法
log.info("请求类名   : {}.{}", );
// 打印请求的 IP
log.info("访问IP    : {}",);
// 打印请求入参
log.info("传入参数   : {}",);
// 打印出参
log.info("返回参数   : {}", );
// 结束后换行
log.info("=======================end=======================" + System.lineSeparator());

相当于是对原有的功能进行增强。并且是批量的增强,这个时候就非常适合用AOP来进行实现,不对业务代码进行侵入,完全解耦

2. AOP实现日志记录的分析

定义切面类,在切面类通过 '切点表达式' 或 '自定义注解',来指定切点

切面类: 指定要增强哪个切点,里面写通知的方法,通知的方法里面写具体的增强代码

AOP中的通知方法有五种,如下

通知方法

描述

前置通知

在一个方法执行之前的阶段,执行通知。可以在目标方法执行前做一些预处理操作

后置通知

在一个方法执行之后的阶段,执行通知。通常用于执行一些清理操作或日志记录

异常通知

在方法抛出异常退出时执行的通知。用于处理目标方法抛出的异常情况

最终通知

无论目标方法是否成功执行,最终通知总会被执行,常用于释放资源

环绕通知

环绕通知是AOP中最灵活的通知类型。能在目标方法前后完全控制连接点,决定是否执行目标方法并进行额外处理

3. AOP实现日志记录的代码

第一步(已做可跳过): 在keke-framework工程的pom.xml添加如

  <!--AOP-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

第二步: 在keke-framework工程的src/main/java/com/keke目录新建annotation.KekeSystemLog接口,写入如下,作为自定义注解的接口

package com.keke.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)//表示MySystemLog注解类会保持到runtime阶段
@Target({ElementType.METHOD})//表示MySystemLog注解类的注解功能只能用于方法上
//'自定义注解'类,以后就可以使用@MySystemLog注解了,注解名就是下面那行的接口名
public @interface KekeSystemLog{
     String businessName();
}

第三步: 在keke-framework工程的src/main/java/com/keke目录新建aspect.MyLogAspect类,写入如下,作为切面类

package com.keke.aspect;

import com.alibaba.fastjson.JSON;
import com.keke.annotation.MySystemLog;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;


@Component
@Aspect//告诉spring容器,MyLogAspect是切面类
@Slf4j//用于在控制台打印日志信息
public class MyLogAspect {

     //确定切点
     //我们期望方法加上@MySystemLog注解后,就会打印相应的日志信息
     @Pointcut("@annotation(com.keke.annotation.MySystemLog)")
     public void pt(){

     }

     //环绕通知
     @Around("pt()")
     //获取对应增强方法的信息,joinPoint就相当于被增强方法封装成的对象
     public Object pointLog(ProceedingJoinPoint joinPoint) throws Throwable{
          Object ret;
          try {
               handleBefore(joinPoint);
               //joint.proceed()方法返回的是方法的返回体对象
               ret = joinPoint.proceed();
               handleAfter(ret);
          } finally {
               //结束后
               // 结束后换行
               log.info("=======================end=======================" + System.lineSeparator());
          }
          return ret;
     }


     private void handleBefore(ProceedingJoinPoint joinPoint) {
          ServletRequestAttributes requestAttributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
          HttpServletRequest request = requestAttributes.getRequest();

          //获取被增强方法上从注解对象,传入方法对象
          MySystemLog mySystemLog = getSystemLog(joinPoint);

          log.info("======================Start======================");
          // 打印请求 URL
          log.info("请求URL   : {}",request.getRequestURI());
          // 打印描述信息
          log.info("接口描述   : {}",mySystemLog.businessName());
          // 打印 Http method
          log.info("请方法   : {}", request.getMethod());
          // 打印调用 controller 的全路径以及执行方法
          log.info("请求类名   : {}.{}",joinPoint.getSignature().getDeclaringTypeName(),((MethodSignature)joinPoint.getSignature()).getName());
          // 打印请求的 IP
          log.info("访问IP    : {}",request.getRemoteHost());
          // 打印请求入参
          log.info("传入参数   : {}", JSON.toJSONString(joinPoint.getArgs()));
     }

     private MySystemLog getSystemLog(ProceedingJoinPoint joinPoint) {
          MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
          MySystemLog mySystemLog = methodSignature.getMethod().getAnnotation(MySystemLog.class);
          return mySystemLog;
     }

     private void handleAfter(Object ret) {
          // 打印出参
          log.info("返回参数   : {}",ret);
     }

}

第四步: 把keke-blog工程的application.yml修改为如下,作用是注释掉mybatisplus的日志信息,避免对我们的日志信息造成干扰

server:
  port: 7777

spring:
  # 数据库连接信息
  datasource:
    url: jdbc:mysql://localhost:3306/keke_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver

  servlet:
    # 文件上传
    multipart:
      # 单个上传文件的最大允许大小
      max-file-size: 20MB
      # HTTP请求中包含的所有文件的总大小的最大允许值
      max-request-size: 20MB

mybatis-plus:
#  configuration:
#    # 日志
#    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 逻辑删除的字段
      logic-delete-field: delFlag
      # 代表已删除的值
      logic-delete-value: 1
      # 代表未删除的值
      logic-not-delete-value: 0
      # 主键自增策略,以mysql数据库为准
      id-type: auto

OSS:
  accessKey: j3UpxqswO4F07NnFOm_QmO8e2LDm55wmdj21mIAB
  secretKey: Dea8CYCfptyu6Aar7bnvQ48UCFYsadw43RY0PSA6
  bucket: keke-blog

第五步: 把keke-blog工程的UserController类修改为如下,作用是在每一个业务访问接口添加自定义的@MySystemLog注解,并添加businessName属性,属性值就是你这个业务接口的作用描述(看着业务自己写描述就行)。拓展: 在每一个Controller都这样做

 @PutMapping("/userInfo")
     @KekeSystemLog(businessName = "更新用户信息")
     public ResponseResult updateUserInfo(@RequestBody UserInfoDto userInfoDto){
          //更新个人信息
          return userService.updateUserInfo(userInfoDto);
     }

第六步:测试,可以看到打印出了日志信息

二十一、前台模块-浏览次数

1. 项目-思路分析

在用户浏览博文时要实现对应博客浏览量的增加。我们只需要在每次用户浏览博客时更新对应的浏览数即可

但是如果直接操作博客表的浏览量的话,在并发量大的情况下会出现什么问题呢?如何去优化呢?如下四点

①在应用启动时把博客的浏览量存储到redis中 - 项目启动的预处理功能

②更新浏览量时去更新redis中的数据

③每隔3分钟把Redis中的浏览量更新到数据库中 - 定时任务功能

④读取文章浏览量时从redis读取

在实现 '浏览次数' 功能之前,我们先学习一下必要的基础知识,如下

2. 基础-启动预处理

如果希望在SpringBoot应用启动时进行一些初始化操作可以选择使用CommandLineRunner接口来进行处理。

我们只需要实现CommandLineRunner接口,并且把对应的bean注入容器。把相关初始化的代码重新到需要重新的方法中。

这样就会在应用启动的时候执行对应的代码。

第一步: 在keke-blog工程的src/main/java/com.keke目录新建runner.myCommandLineRunner类,写入如下

package com.keke.runner;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;


@Component
//项目启动时,该类负责预处理一些代码。CommandLineRunner是spring提供的接口
public class MyCommandLineRunner implements CommandLineRunner {


    //所有bean初始化后执行的代码
    @Override
    public void run(String... args) throws Exception {
        System.out.println("==================程序初始化啦===================");
    }
}

第二步: 运行keke-blog工程,查看控制台。只是了解基础知识,跟项目暂时无关

3. 基础-定时任务

每隔一段时间执行一段代码

这里就先用SpringBoot为我们提供的定时任务的API来实现一个简单的定时任务,先对定时任务里面的一些核心概念有个大致的了解

第一步: 在keke-blog工程的启动类添加如下,作用是在配置类添加@EnableScheduling注解,就能开启定时任务功能

package com.keke;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@MapperScan("com.keke.mapper")
@EnableScheduling
public class KeBlogApplication {
     public static void main(String[] args) {
          SpringApplication.run(KeBlogApplication.class,args);
     }
}

第二步: 在keke-blog工程src/main/java/com.keke目录新建job.MyJob类,写入如下

package com.keke.job;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class MyJob {

     @Scheduled(cron = "0/5 * * * * ?")//在哪个方法添加了@Scheduled注解,哪个方法就会定时去执行
     //从每一分钟的0秒开始,每隔5秒钟就会执行下面方法
     public void ScheduleTask(){
          System.out.println("定时任务执行了" + LocalDateTime.now());
     }
}

第三步: 运行keke-blog工程,查看控制台。只是了解基础知识,跟项目暂时无关

cron表达式不会可以去这个网站生成

quartz/Cron/Crontab表达式在线生成工具-BeJSON.com

4. 项目-接口分析

在用户浏览博文时要实现对应博客浏览量的增加。我们只需要在每次用户浏览博客时更新对应的浏览数即可

请求方式

请求地址

请求头

PUT

/article/updateViewCount/{id}

不需要token请求头

参数: 请求路径中携带文章id

响应格式:

{
	"code":200,
	"msg":"操作成功"
}

5. 项目-代码实现

下面是 '浏览次数' 功能的具体实现

5.1 启动预处理

在项目启动时把博客浏览量存储到redis中

第一步: 在keke-blog工程的src/main/java/com.keke目录新建runner.ViewCountRunner类,写入如下

package com.keke.runner;

import com.keke.domain.entity.Article;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Component
public class ViewCountRunner implements CommandLineRunner {

     @Autowired
     private ArticleMapper articleMapper;

     @Autowired
     private RedisCache redisCache;

     @Override
     public void run(String... args) throws Exception {
          //查询文章 id viewCount,并且希望存储到map中,id为key,viewCount是value
          List<Article> articles = articleMapper.selectList(null);
          //封装一下数据,用流函数式编程,封装一个map
          Map<String, Integer> map = articles.stream()
                  .collect(Collectors.toMap(new Function<Article, String>() {
                       @Override
                       public String apply(Article article) {
                            return article.getId().toString();
                       }
                  }, new Function<Article, Integer>() {
                       @Override
                       public Integer apply(Article article) {
                            //这里因为redis中Long类型无法进行自增所以转化为int类型
                            return article.getViewCount().intValue();
                       }
                  }));

          redisCache.setCacheMap("article:viewCount",map);


     }
}

第二步: 本地打开你的redis

第三步: 测试。运行huanf-blog工程类,然后去redis看一下数据

5.2 更新浏览量至Redis

我们现在要做的是在阅读文章的时候,实现redis中浏览量value的自增操作

实现接口,更新redis中数据

请求方式

请求地址

请求头

PUT

/article/updateViewCount/{id}

不需要token请求头

第一步:在keke-blog中controller的ArticleController中新增updateViewCount方法,可以看到请求方式是PathVariable方式

package com.keke.controller;

import com.keke.annotation.KekeSystemLog;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/article")
public class ArticleController {

     @Autowired
     private ArticleService articleService;

     //测试
     @GetMapping("/list")
     public List<Article> test(){
          return articleService.list();
     }

     //热门文章
     @GetMapping("/hotArticleList")
     @KekeSystemLog(businessName = "查询热门文章")
     public ResponseResult hotArticleList(){
          return articleService.hotArticleList();
     }

     //文章列表
     @GetMapping("/articleList")
     @KekeSystemLog(businessName = "分页查询文章列表")
     public ResponseResult articleList(Integer pageNum,Integer pageSize,Long categoryId){
          return articleService.articleList(pageNum,pageSize,categoryId);
     }

     //文章详情
     @GetMapping("/{id}")
     @KekeSystemLog(businessName = "查询文章详情")
     public ResponseResult getArticleDetail(@PathVariable("id") Long id){
          return articleService.getArticleDetail(id);
     }

     @PutMapping("/updateViewCount/{id}")
     @KekeSystemLog(businessName = "更新浏览量到redis")
     public ResponseResult updateViewCount(@PathVariable("id") Long id){
          return articleService.updateViewCount(id);
     }

}

第二步:在keke-framework的contants的SystemContants中新增redis中的文章浏览量key,和自增的值

package com.keke.constants;


//字面值(代码中的固定值)处理,把字面值都在这里定义成常量
public class SystemConstants {

    /**
     * 文章是草稿
     */
    public static final int ARTICLE_STATUS_DRAFT = 1;

    /**
     * 文章是正常发布状态
     */
    public static final int ARTICLE_STATUS_NORMAL = 0;

    /**
     * 文章列表当前查询页数
     */
    public static final int ARTICLE_STATUS_CURRENT = 1;

    /**
     * 文章列表每页显示的数据条数
     */
    public static final int ARTICLE_STATUS_SIZE = 10;

    /**
     * 分类表的分类状态是正常状态
     */
    public static final String STATUS_NORMAL = "0";

    /**
     * 友联审核通过
     */
    public static final String Link_STATUS_NORMAL = "0";

    /**
     * 评论区的某条评论是根评论
     */
    public static final String COMMENT_ROOT = "-1";

    /**
     * 文章评论
     */
    public static final String ARTICLE_COMMENT = "0";

    /**
     * 友链评论
     */
    public static final String LINK_COMMENT = "1";

    /**
     * redis中的文章浏览量key
     */
    public static final String REDIS_ARTICLE_KEY = "article:viewCount";

    /**
     * 浏览量自增1
     */
    public static final int REDIS_ARTICLE_VIEW_COUNT_INCREMENT = 1;

}

第三步:在keke-framework的service的ArticleService中新增

package com.keke.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;


/**
 * 文章表(Article)表服务接口
 *
 * @author makejava
 * @since 2023-10-10 09:59:37
 */
public interface ArticleService extends IService<Article> {


     ResponseResult hotArticleList();

     ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId);

     ResponseResult getArticleDetail(Long id);

     ResponseResult updateViewCount(Long id);
}

第四步:在keke-framework的service.impl的ArticleServiceImpl实现未实现的方法

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Category;
import com.keke.domain.vo.ArticleDetailVo;
import com.keke.domain.vo.ArticleListVo;
import com.keke.domain.vo.HotArticleVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.service.CategoryService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Autowired
     private CategoryService categoryService;

     @Autowired
     private ArticleService articleService;

     //显然redisCache是对redisTemplate的封装
     @Autowired
     private RedisCache redisCache;
     //查询热门文章
     @Override
     public ResponseResult hotArticleList() {
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }

     //分页查询文章列表,包含首页和分类页面的文章列表分页查询
     @Override
     public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
          //查询条件
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //首先明确categoryId在首页中不传,在分类文章页面中会传,所以我们要判断
          /*
          这里是lambdaQueryWrapper.eq三个参数的写法,第一个参数返回值是boolean类型,如果判断为true
          那么后续就会把后面的判断加入sql语句当中
           */
          lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0,Article::getCategoryId,categoryId);
          //状态是正式发布的,置顶的文章要显示在最前面,对isTop进行降序
          lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
          lambdaQueryWrapper.orderByDesc(Article::getIsTop);
          //分页查询
          Page<Article> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          //查询categoryName
          List<Article> articles = page.getRecords();//有categoryId,但无categoryName
          //拿着categoryId去查询categoryName,然后封装到article中
          /*
          for (Article article : articles) {
               Category category = categoryService.getById(article.getCategoryId());
               article.setCategoryName(category.getName());
          }
          */
          articles.stream()
                  //setter返回的是对象
                  .map(article -> article.setCategoryName(categoryService.getById(article.getCategoryId()).getName()))
                  .collect(Collectors.toList());
          //封装Vo
          List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
          //封装PageVo
          PageVo pageVo = new PageVo(articleListVos,page.getTotal());
          return ResponseResult.okResult(pageVo);
     }

     //查询文章详情
     @Override
     public ResponseResult getArticleDetail(Long id) {
          //根据id查询文章
          Article article = getById(id);
          //转化称Vo
          ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);
          //根据分类id查询分类名称
          Long categoryId = articleDetailVo.getCategoryId();
          Category category = categoryService.getById(categoryId);
          //如果没有获取到id,就不设置
          if(categoryId!=null){
               articleDetailVo.setCategoryName(category.getName());
          }
          //封装响应体
          return ResponseResult.okResult(articleDetailVo);
     }

     @Override
     public ResponseResult updateViewCount(Long id) {
          //更新浏览量(自增)
          redisCache.incrementCacheMapValue(SystemConstants.REDIS_ARTICLE_KEY,id.toString(),SystemConstants.REDIS_ARTICLE_VIEW_COUNT_INCREMENT);
          return ResponseResult.okResult();
     }
}

第五步:在keke-framework的utils的RedisCache中封装自增方法

可以看到RedisCache就是对redisTemplate的进一步封装

package com.keke.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;


@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache {
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }


    /**
     * 对redis中浏览量的更新
     * @param key
     * @param hKey
     * @param v
     */
    public void incrementCacheMapValue(String key,String hKey,int v){
        redisTemplate.opsForHash().increment(key,hKey,v);
    }
}

六、测试

启动redis

启动前端工程

刷新文章详情页面,可以看到redis中的浏览量(value)自增1

5.3 Redis更新至MySQL

这里我们要实现redis中的浏览量定时更新到数据库中

我们这里实现每隔5秒定时任务更新(便于测试)

第一步:在keke-blog的job包下新建UpdateViewCountJob

package com.keke.job;

import com.keke.constants.SystemConstants;
import com.keke.domain.entity.Article;
import com.keke.service.ArticleService;
import com.keke.service.UserService;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class UpdateViewCountJob {

     @Autowired
     private RedisCache redisCache;

     //userService实现了IService,里面封装了大量的批量操作的方法
     @Autowired
     private ArticleService articleService;


     @Scheduled(cron = "0/5 * * * * ?")//在哪个方法添加了@Scheduled注解,哪个方法就会定时去执行
     //从每一分钟的0秒开始,每隔5秒钟就会执行下面方法
     public void updateViewCountTask(){
          //获取到map集合
          Map<String, Integer> viewCountMap = redisCache.getCacheMap(SystemConstants.REDIS_ARTICLE_KEY);
          //对map集合进行处理,转换成一个article的list集合
          //先获取每一个键值对
          List<Article> articles = viewCountMap.entrySet()
                  //流处理
                  .stream()
                  //转换,把键(文章id) 和 值(浏览量) 赋给实体类对象
                  //由于Article没有这个构造方法,我们要在实体类中创建
                  .map(entry -> new Article(Long.valueOf(entry.getKey()), entry.getValue().longValue()))
                  //收集成list集合
                  .collect(Collectors.toList());

          articleService.updateBatchById(articles);
     }

}

 第二步:在keke-framework的domain.entity.Article类下新增构造方法

package com.keke.domain.entity;

import java.util.Date;
import java.io.Serializable;


import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

/**
 * 文章表(Article)表实体类
 *
 * @author makejava
 * @since 2023-10-10 10:06:59
 */
@SuppressWarnings("serial")
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("ke_article")
@Accessors(chain = true)
public class Article {

    private Long id;
    //标题
    private String title;
    //文章内容
    private String content;
    //文章摘要
    private String summary;
    //所属分类id
    private Long categoryId;
    //分页查询文章列表时,新增的一个字段,为的是更好的封装,但是数据库中没有该字段,为了避免mp
    //在查询的时候查询这一列,可以添加如下注解
    @TableField(exist = false)//意思是这个字段在数据库表中实际上是不存在的
    private String categoryName;
    //缩略图
    private String thumbnail;
    //是否置顶(0否,1是)
    private String isTop;
    //状态(0已发布,1草稿)
    private String status;
    //访问量
    private Long viewCount;
    //是否允许评论 1是,0否
    private String isComment;
    
    private Long createBy;
    
    private Date createTime;
    
    private Long updateBy;
    
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;


    public Article(Long id,Long viewCount){
        this.id = id;
        this.viewCount = viewCount;
    }
}

第步:测试

启动redis mysql

启动RedisManager Navicat 

启动前端工程

刷新页面

redis中自增了1

隔5秒后mysql中,也自增了1

5.4 从Redis中查浏览量

我们的接口查询文章的相关信息的时候,都是通过查询数据库去展示在前端,这样会导致数据时效性较差,我们现在要做的就是直接从redis中拿文章浏览量数据,文章浏览量可以保证是较新的数据

这里我们要修改热门文章文章详情两个接口的实现方式

第一步: 把keke-framework工程的ArticleServiceImpl类修改为如下,修改了hotArticleList、getArticleDetail方法,让用户查询文章浏览量时从redis查

package com.keke.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.keke.constants.SystemConstants;
import com.keke.domain.ResponseResult;
import com.keke.domain.entity.Article;
import com.keke.domain.entity.Category;
import com.keke.domain.vo.ArticleDetailVo;
import com.keke.domain.vo.ArticleListVo;
import com.keke.domain.vo.HotArticleVo;
import com.keke.domain.vo.PageVo;
import com.keke.mapper.ArticleMapper;
import com.keke.service.ArticleService;
import com.keke.service.CategoryService;
import com.keke.utils.BeanCopyUtils;
import com.keke.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * 文章表(Article)表服务实现类
 *
 * @author makejava
 * @since 2023-10-10 09:59:39
 */
@Service("articleService")
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {

     @Autowired
     private CategoryService categoryService;

     @Autowired
     private ArticleService articleService;

     //显然redisCache是对redisTemplate的封装
     @Autowired
     private RedisCache redisCache;
     //查询热门文章
     @Override
     public ResponseResult hotArticleList() {

          //---------------------------------------每调用这个方法就从redis查询文章的浏览量,展示在热门文章列表--------------------------------------------------------------
          Map<String, Integer> viewCountMap = redisCache.getCacheMap(SystemConstants.REDIS_ARTICLE_KEY);
          List<Article> articleList = viewCountMap.entrySet()
                  .stream()
                  .map(entry -> new Article(Long.valueOf(entry.getKey()), entry.getValue().longValue()))
                  .collect(Collectors.toList());
          articleService.updateBatchById(articleList);
          //-----------------------------------------------------------------------------------------------------
          //查询热门文章 封装成ResponseResult返回
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //必须是正式文章
          lambdaQueryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
          //按照浏览量进行排序
          lambdaQueryWrapper.orderByDesc(Article::getViewCount);
          //最多查询10条,设置mp分页对象的参数分别为1和10
          Page<Article> page = new Page<>(SystemConstants.ARTICLE_STATUS_CURRENT,SystemConstants.ARTICLE_STATUS_SIZE);
          //将page对象和lambdaQueryWrapper查询条件封装成page
          page(page,lambdaQueryWrapper);
          //page.getRecords()获取到所有符合条件的数据(也就是文章)
          List<Article> articles = page.getRecords();
          //BeanCopy
          List<HotArticleVo> hotArticleVos = BeanCopyUtils.copyBeanList(articles, HotArticleVo.class);
          //返回ResponseResult对象
          return ResponseResult.okResult(hotArticleVos);
     }

     //分页查询文章列表,包含首页和分类页面的文章列表分页查询
     @Override
     public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
          //查询条件
          LambdaQueryWrapper<Article> lambdaQueryWrapper = new LambdaQueryWrapper<>();
          //首先明确categoryId在首页中不传,在分类文章页面中会传,所以我们要判断
          /*
          这里是lambdaQueryWrapper.eq三个参数的写法,第一个参数返回值是boolean类型,如果判断为true
          那么后续就会把后面的判断加入sql语句当中
           */
          lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0,Article::getCategoryId,categoryId);
          //状态是正式发布的,置顶的文章要显示在最前面,对isTop进行降序
          lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
          lambdaQueryWrapper.orderByDesc(Article::getIsTop);
          //分页查询
          Page<Article> page = new Page<>(pageNum,pageSize);
          page(page,lambdaQueryWrapper);
          //查询categoryName
          List<Article> articles = page.getRecords();//有categoryId,但无categoryName
          //拿着categoryId去查询categoryName,然后封装到article中
          /*
          for (Article article : articles) {
               Category category = categoryService.getById(article.getCategoryId());
               article.setCategoryName(category.getName());
          }
          */
          articles.stream()
                  //setter返回的是对象
                  .map(article -> article.setCategoryName(categoryService.getById(article.getCategoryId()).getName()))
                  .collect(Collectors.toList());
          //封装Vo
          List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
          //封装PageVo
          PageVo pageVo = new PageVo(articleListVos,page.getTotal());
          return ResponseResult.okResult(pageVo);
     }

     //查询文章详情
     @Override
     public ResponseResult getArticleDetail(Long id) {
          //根据id查询文章
          Article article = getById(id);
//          //--------------------------------------从redis中获取viewCount--------------------------------------------------------------
//          //获取到的是redis当中的Integer类型的viewCount
//          Integer viewCount = redisCache.getCacheMapValue(SystemConstants.REDIS_ARTICLE_KEY, id.toString());
//          //设置article的viewCount为从redis中查出来的数据
//          article.setViewCount(viewCount.longValue());

          //转化称Vo
          ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);
          //根据分类id查询分类名称
          Long categoryId = articleDetailVo.getCategoryId();
          Category category = categoryService.getById(categoryId);
          //如果没有获取到id,就不设置
          if(categoryId!=null){
               articleDetailVo.setCategoryName(category.getName());
          }
          //封装响应体
          return ResponseResult.okResult(articleDetailVo);
     }

     @Override
     public ResponseResult updateViewCount(Long id) {
          //更新浏览量(自增)
          redisCache.incrementCacheMapValue(SystemConstants.REDIS_ARTICLE_KEY,id.toString(),SystemConstants.REDIS_ARTICLE_VIEW_COUNT_INCREMENT);
          return ResponseResult.okResult();
     }
}

第二步:测试

启动redis mysql

启动RedisManager Navicat 

启动前端工程,点击浏览器刷新按钮

可以看到前端页面,redis,mysql中都显示正常 

至此,KekeBlog的前台模块开发全部结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值