前后端分离博客项目

前后端分离博客项目

使用技术栈

SpringBoot,MybatisPlus,SpringSecurity,EasyExcel,Swagger2,Redis…

环境搭建

创建工程

多模块项目

博客项目的前台和后台都有相同的用户,文章等概念,如果分为两个独立的工程那么出现的重复代码会很多,代码复用性不高

所以我们要创建多模块项目,两套系统可能都会用到的代码写到一个公共模块中,让前台系统和后台系统分别依赖公共模块

此项目我们使用的是三个模块

  • os467-framework:存放前台后台共同需要的业务与实体
  • os467-front:前台的控制器,独立业务
  • os467-admin:后台的控制器,以及独立业务

创建父模块

idea选择创建pom工程,删除此模块下的src目录

配置父pom文件

<?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.os467</groupId>
    <artifactId>os467-blog</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </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.11.2</version>
            </dependency>


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

            <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>3.0.0</version>
            </dependency>
        </dependencies>

    </dependencyManagement>

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

</project>

创建公共子模块

os467-framework

子模块依赖添加,继承自父工程

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

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>easyexcel</artifactId>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
    </dependency>

</dependencies>

为前台和后台子模块添加坐标

前台

<?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>os467-blog</artifactId>
        <groupId>com.os467</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>os467-front</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.os467</groupId>
            <artifactId>os467-framework</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

后台

<?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>os467-blog</artifactId>
        <groupId>com.os467</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>os467-admin</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.os467</groupId>
            <artifactId>os467-framework</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

创建启动类

前台启动类创建

@SpringBootApplication
public class Os467FrontApplication {

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

}

配置文件

server:
  port: 7777
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/sg_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  servlet:
    multipart:
      max-file-size: 2MB
      max-request-size: 5MB
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
      id-type: auto

IDEA插件自动生成实体类

1、使用插件Easy Code

2、使用IDEA自动生成数据库表对应实体类

详细请参考博客:https://blog.csdn.net/qq_34371461/article/details/80571281

自定义生成器

如果不需要对表名特殊处理,请将此代码中调用的javaClassName方法改为javaName方法

注释使用mybatisPlus的注释,如果有其它需要可以自行修改

import com.intellij.database.model.DasTable
import com.intellij.database.model.ObjectKind
import com.intellij.database.util.Case
import com.intellij.database.util.DasUtil
import java.io.*
import java.text.SimpleDateFormat

/*
 * Available context bindings:
 *   SELECTION   Iterable<DasObject>
 *   PROJECT     project
 *   FILES       files helper
 */
packageName = ""
typeMapping = [
        (~/(?i)tinyint|smallint|mediumint/)      : "Integer",
        (~/(?i)int/)                             : "Long",
        (~/(?i)bool|bit/)                        : "Boolean",
        (~/(?i)float|double|decimal|real/)       : "Double",
        (~/(?i)datetime|timestamp|date|time/)    : "Date",
        (~/(?i)blob|binary|bfile|clob|raw|image/): "InputStream",
        (~/(?i)/)                                : "String"
]


FILES.chooseDirectoryAndSave("Choose directory", "Choose where to store generated files") { dir ->
    SELECTION.filter { it instanceof DasTable && it.getKind() == ObjectKind.TABLE }.each { generate(it, dir) }
}

def generate(table, dir) {
    def className = javaName(table.getName(), true)
    def fields = calcFields(table)
    packageName = getPackageName(dir)
    PrintWriter printWriter = new PrintWriter(new OutputStreamWriter(new FileOutputStream(new File(dir, className + ".java")), "UTF-8"))
    printWriter.withPrintWriter {out -> generate(out, className, fields,table)}

//    new File(dir, className + ".java").withPrintWriter { out -> generate(out, className, fields,table) }
}

// 获取包所在文件夹路径
def getPackageName(dir) {
    return dir.toString().replaceAll("\\\\", ".").replaceAll("/", ".").replaceAll("^.*src(\\.main\\.java\\.)?", "") + ";"
}

def generate(out, className, fields,table) {
    out.println "package $packageName"
    out.println ""
    out.println "import com.baomidou.mybatisplus.annotation.TableField;"
    out.println "import com.baomidou.mybatisplus.annotation.TableName;"
    out.println "import java.io.Serializable;"
    out.println "import lombok.Getter;"
    out.println "import lombok.Setter;"
    out.println "import lombok.ToString;"
    Set types = new HashSet()

    fields.each() {
        types.add(it.type)
    }

    if (types.contains("Date")) {
        out.println "import java.util.Date;"
    }

    if (types.contains("InputStream")) {
        out.println "import java.io.InputStream;"
    }
    out.println ""
    out.println "/**\n" +
            " * @Description  \n" +
            " * @Author  os467\n" +
            " * @Date "+ new SimpleDateFormat("yyyy-MM-dd").format(new Date()) + " \n" +
            " */"
    out.println ""
    out.println "@Setter"
    out.println "@Getter"
    out.println "@ToString"
    out.println "@TableName ( value =\""+table.getName() +"\" )"
    out.println "public class $className  implements Serializable {"
    out.println ""
    out.println genSerialID()
    fields.each() {
        out.println ""
        // 输出注释
        if (isNotEmpty(it.commoent)) {
            out.println "\t/**"
            out.println "\t * ${it.commoent.toString()}"
            out.println "\t */"
        }

        if (it.annos != "") out.println "   ${it.annos.replace("[@Id]", "")}"

        // 输出成员变量
        out.println "\tprivate ${it.type} ${it.name};"
    }

    // 输出get/set方法
//    fields.each() {
//        out.println ""
//        out.println "\tpublic ${it.type} get${it.name.capitalize()}() {"
//        out.println "\t\treturn this.${it.name};"
//        out.println "\t}"
//        out.println ""
//
//        out.println "\tpublic void set${it.name.capitalize()}(${it.type} ${it.name}) {"
//        out.println "\t\tthis.${it.name} = ${it.name};"
//        out.println "\t}"
//    }
    out.println ""
    out.println "}"
}

def calcFields(table) {
    DasUtil.getColumns(table).reduce([]) { fields, col ->
        def spec = Case.LOWER.apply(col.getDataType().getSpecification())

        def typeStr = typeMapping.find { p, t -> p.matcher(spec).find() }.value
        def comm =[
                colName : col.getName(),
                name :  javaName(col.getName(), false),
                type : typeStr,
                commoent: col.getComment(),
                annos: "\t@TableField(value = \""+col.getName()+"\" )"]
        if("id".equals(Case.LOWER.apply(col.getName())))
            comm.annos +=["@Id"]
        fields += [comm]
    }
}

// 处理类名(这里是因为我的表都是以t_命名的,所以需要处理去掉生成类名时的开头的T,
// 如果你不需要那么请查找用到了 javaClassName这个方法的地方修改为 javaName 即可)
def javaClassName(str, capitalize) {
    def s = com.intellij.psi.codeStyle.NameUtil.splitNameIntoWords(str)
            .collect { Case.LOWER.apply(it).capitalize() }
            .join("")
            .replaceAll(/[^\p{javaJavaIdentifierPart}[_]]/, "_")
    // 去除开头的T  http://developer.51cto.com/art/200906/129168.htm
    s = s[1..s.size() - 1]
    capitalize || s.length() == 1? s : Case.LOWER.apply(s[0]) + s[1..-1]
}

def javaName(str, capitalize) {
//    def s = str.split(/(?<=[^\p{IsLetter}])/).collect { Case.LOWER.apply(it).capitalize() }
//            .join("").replaceAll(/[^\p{javaJavaIdentifierPart}]/, "_")
//    capitalize || s.length() == 1? s : Case.LOWER.apply(s[0]) + s[1..-1]
    def s = com.intellij.psi.codeStyle.NameUtil.splitNameIntoWords(str)
            .collect { Case.LOWER.apply(it).capitalize() }
            .join("")
            .replaceAll(/[^\p{javaJavaIdentifierPart}[_]]/, "_")
    capitalize || s.length() == 1? s : Case.LOWER.apply(s[0]) + s[1..-1]
}

def isNotEmpty(content) {
    return content != null && content.toString().trim().length() > 0
}

static String changeStyle(String str, boolean toCamel){
    if(!str || str.size() <= 1)
        return str

    if(toCamel){
        String r = str.toLowerCase().split('_').collect{cc -> Case.LOWER.apply(cc).capitalize()}.join('')
        return r[0].toLowerCase() + r[1..-1]
    }else{
        str = str[0].toLowerCase() + str[1..-1]
        return str.collect{cc -> ((char)cc).isUpperCase() ? '_' + cc.toLowerCase() : cc}.join('')
    }
}

static String genSerialID()
{
    return "\tprivate static final long serialVersionUID =  "+Math.abs(new Random().nextLong())+"L;"
}

连接数据库并查询

1、创建Article实体类

2、创建对应的Mapper和Service

==问题1:==在多模块项目中,我们可能会互相引用不同模块之间的类,比如使用@Autowired注解时,我们需要保证其它模块的类在修改后使用maven的clean+install命令重新打包

==问题2:==我们只需要在一个模块的启动类上加@MapperScan注解就能扫描到其它模块下对应的mapper包中的Mapper接口

例如在前台模块中需要用到通用模块的Mapper接口

通用类

响应类

package com.os467.common;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.os467.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);
    }
    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;
    }



}

响应枚举类

package com.os467.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;
    }
}

1.热门文章列表

通过需求分析需要哪些字段

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

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

*解决跨域访问问题

spring允许跨域访问

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

}

SpringSecurity允许跨域访问

@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.cors().disable();

    }
}

实体类

我们在查询文章类时,如果只需要某些字段,这时候我们可以使用vo类来封装文章类的部分字段

vo类被设计出来只是为了封装提供给接口所需要的数据,一个接口对应一个vo,因此如果后期接口响应字段要修改,我们也只需要修改vo即可

文章类

package com.os467.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 2022-09-24 16:52:44
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
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;

}

热门文章数据类

package com.os467.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;
}

表现层

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

    @Autowired
    ArticleService articleService;

    @GetMapping("/hotArticleList")
    public ResponseResult getHotArticleList(){
        return articleService.getHotArticleList();
    }

}

业务逻辑

public ResponseResult getHotArticleList() {

    //构造查询
    LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();

    //查询条件,根据访问量降序排序
    queryWrapper.orderByDesc(Article::getViewCount);
    //不能将草稿查询出来,只查询status为0的
    queryWrapper.eq(Article::getStatus,0);
    //不能将已被删除的查询出来,只查询deleteFlag为0的
    queryWrapper.eq(Article::getDelFlag,0);

    //构造分页工具
    Page<Article> articlePage = new Page<>(1,10);

    //分页查询
    articleMapper.selectPage(articlePage, queryWrapper);

    //获取分页查询后的数据
    List<Article> articles = articlePage.getRecords();

    //存放vo数据的数组
    List<HotArticleVo> articleVos = new ArrayList<>();

    //for循环拷贝
    for (Article article : articles) {
        //创建vo对象
        HotArticleVo vo = new HotArticleVo();
        //使用实体字段拷贝
        BeanUtils.copyProperties(article,vo);
        //将vo对象添加进vo数组中
        articleVos.add(vo);
    }

    //返回封装好的response对象
    return ResponseResult.okResult(articleVos);

}

*字面值处理

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

package com.os467.common;

public class SystemConstants {

    public static final String ARTICLE_STATUS_DRAFT = "1";

    public static final String ARTICLE_STATUS_NORMAL = "0";

    public static final int DELETE_FLAG_TRUE = 1;

    public static final int DELETE_FLAG_FALSE = 0;

}

替换刚刚使用的字面量值

//不能将草稿查询出来,只查询status为0的
queryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
//不能将已被删除的查询出来,只查询deleteFlag为0的
queryWrapper.eq(Article::getDelFlag,SystemConstants.DELETE_FLAG_FALSE);

2.查询分类列表

需求:

页面上需要展示分类列表,用户可以点击具体的分类查看该分类下的文章列表

​ 注意: ①要求只展示有发布正式文章的分类

​ ②必须是正常状态的分类

查询文章表,找到文章表对应的类id,获取类id列表装换为set集合

*实体拷贝工具类

==踩坑:使用拷贝工具必须要为两个资源都提供set和get方法!!==

public class BeanCopyUtils {

    private BeanCopyUtils(){}

    public static <V> V copyBean(Object source,Class<V> clazz){

        //创建目标对象
        V result = null;

        try {
            result = clazz.newInstance();

            BeanUtils.copyProperties(source,result);

        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

        return result;

    }

    /**
    * 拷贝列表内所有对象
    */
    public static <O,V> List<V> copyBeanList(List<O> list,Class<V> clazz){

        return list.stream().map(o ->
            copyBean(o,clazz)
        ).collect(Collectors.toList());
    }

}

实体类

package com.os467.entity;

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

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

/**
 * 分类表(Os467Category)表实体类
 *
 * @author makejava
 * @since 2022-09-25 17:58:51
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
public class Category {
    
    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;

表现层

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

    @Autowired
    private CategoryService categoryService;

    @GetMapping("/getCategoryList")
    public ResponseResult getCategoryList(){

        return categoryService.getCategoryList();
    }

}

业务逻辑

    /**
     * 获取到分类列表,并且此分类下必须有文章
     * @return
     */
    @Override
    public ResponseResult getCategoryList() {

        LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();

        //查询categoryId字段
        //不能将草稿查询出来,只查询status为0的
        queryWrapper.eq(Article::getStatus, SystemConstants.ARTICLE_STATUS_NORMAL);
        //不能将已被删除的查询出来,只查询deleteFlag为0的
        queryWrapper.eq(Article::getDelFlag,SystemConstants.DELETE_FLAG_FALSE);

        //获取到信息
        List<Article> articles = articleMapper.selectList(queryWrapper);

        //将列表转换为集合,进行去重
        Set<Long> categoryIds = articles.stream().map(Article::getCategoryId).collect(Collectors.toSet());

        //获取查询到的分类结果
        List<Category> categories = categoryMapper.selectBatchIds(categoryIds);

        //筛选查询结果
        List<Category> categoryList = categories.stream().filter(category -> {
            //正常状态下的分类
            return category.getStatus().equals(SystemConstants.ARTICLE_STATUS_NORMAL);
        }).collect(Collectors.toList());

        List<CategoryVo> categoryVos = BeanCopyUtils.copyBeanList(categoryList, CategoryVo.class);

        //返回查询结果
        return ResponseResult.okResult(categoryVos);
    }

2.1根据分类id查询文章

在首页和分类页面都需要查询文章列表

​ 首页:查询所有的文章

​ 分类页面:查询对应分类下的文章

​ 要求:①只能查询正式发布的文章 ②置顶的文章要显示在最前面

*MybatisPlus分页配置

package com.os467.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
public class MybatisPlusConfig {

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

}

实体类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageVo {
    private List rows;
    private Long total;
}
@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;


}

业务逻辑

还需要返回当前文章对应的分类信息

    /**
     * 根据文章分类获取文章内容
     * @param pageNum
     * @param pageSize
     * @param categoryId
     * @return
     */
    @Override
    public ResponseResult getArticleList(Integer pageNum, Integer pageSize, Long categoryId) {

        LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();

        //此分类下的非草稿,未删除文章
        queryWrapper
                .eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);

        //根据分类id来查询
        queryWrapper.eq(Objects.nonNull(categoryId)&&categoryId > 0,Article::getCategoryId,categoryId);

        //根据置顶文章字段降序排序
        queryWrapper.orderByDesc(Article::getIsTop);

        //分页查询构建
        Page<Article> articlePage = new Page<>(pageNum,pageSize);

        //查询
        List<Article> articleList = articleMapper.selectPage(articlePage, queryWrapper).getRecords();

        //调用分类字段查询,根据文章id获取分类名
        articleList.stream().map(
                article -> {

                    Category category = categoryMapper.selectById(article.getCategoryId());

                    article.setCategoryName(category.getName());

                    return article;
                }
        ).collect(Collectors.toList());

        //构建文章列表Vo对象
        List<ArticleListVo> articleListVos = BeanCopyUtils.copyBeanList(articleList, ArticleListVo.class);

        //构建pageVo对象
        PageVo pageVo = new PageVo(articleListVos, articlePage.getTotal());

        //生成响应结果对象
        ResponseResult responseResult = ResponseResult.okResult(pageVo);

        return responseResult;
    }

*通用fastJson配置

配置在配置类中

com.alibaba.fastjson的api

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

3.文章详情

需求

要求在文章列表点击阅读全文时能够跳转到文章详情页面,可以让用户阅读文章正文。

要求:①要在文章详情中展示其分类名

请求方式 请求路径
Get /article/{id}

业务逻辑

  /**
     * 根据文章id获取文章详情页信息
     * @param id
     * @return
     */
    @Override
    public ResponseResult getArticleDetail(Long id) {

        //获得文章信息
        Article article = articleMapper.selectById(id);

        //查询分类信息
        Category category = categoryMapper.selectById(article.getCategoryId());

        //将分类信息存放到文章信息中
        if (category != null){
            article.setCategoryName(category.getName());
        }

        //拷贝文章信息到Vo上
        ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);

        return ResponseResult.okResult(articleDetailVo);
    }

4.友链功能

实体类

package com.os467.entity;

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

/**
 * 友链(Link)表实体类
 *
 * @author makejava
 * @since 2022-09-27 22:06:20
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
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;

}
package com.os467.vo;

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

import java.util.Date;

/**
 * 友链(Link)表实体类
 *
 * @author makejava
 * @since 2022-09-27 22:06:20
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
public class LinkVo {
    
    private Long id;
    
    private String name;
    
    private String logo;
    
    private String description;
    //网站地址
    private String address;

}

表现层

@Autowired
private CategoryService categoryService;

@GetMapping("/getCategoryList")
public ResponseResult getCategoryList(){

    return categoryService.getCategoryList();
}

业务层

/**
 * 获取到所有友链信息
 * @return
 */
@Override
public ResponseResult getAllLink() {

    LambdaQueryWrapper<Link> queryWrapper = new LambdaQueryWrapper<>();

    //查询审核通过的
    queryWrapper.eq(Link::getStatus, SystemConstants.LINK_STATUS_NORMAL);

    List<Link> linkList = linkMapper.selectList(queryWrapper);

    //封装成Vo对象
    List<LinkVo> linkVos = BeanCopyUtils.copyBeanList(linkList, LinkVo.class);

    ResponseResult responseResult = ResponseResult.okResult(linkVos);

    return responseResult;

}

5.SpringSecurity实现登录认证

基本流程:

重写配置类configure逻辑,弃用UsernamePasswordAuthenticationFilter过滤器

为login接口配置认证流程

配置认证管理器AuthenticationManager用于提供后续的认证业务

重新配置账号解码器,改为使用BCryptPasswordEncoder

定义LoginUser实现UserDetails接口

实现UserDetailsService接口,从数据库中查询用户信息并且封装用户信息(授权信息)

返回封装好的UserDetails信息后会调用解码器进行密码校验

认证成功后会在登录业务中调用AuthenticationManager处返回用户认证信息

将用户认证信息存入缓存中,用于后续请求用token访问时能在过滤器中获取到认证信息

在登录业务中根据用户认证信息获取到用户id,根据用户id生成token并封装到响应对象中返回给前端使用

配置JwtAuthenticationTokenFilter用于每次请求的获取认证信息处理,并且将认证信息存储到存入SecurityContextHolder中去,作为此次请求的线程共享变量

配置类

@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

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

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //允许跨域
        http.cors().and()
                //关闭csrf
                .csrf().disable()
                //不从session获取SpringSecurity
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                //允许匿名访问的资源
                .antMatchers("/login").anonymous()
                // 除上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        http.logout().disable();

        //把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);

    }
}

实体类

package com.os467.vo;

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class UserInfoVo {
    /**
     * 主键
     */
    private Long id;

    /**
     * 昵称
     */
    private String nickName;

    /**
     * 头像
     */
    private String avatar;

    private String sex;

    private String email;


}
package com.os467.vo;

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

@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlogUserLoginVo {

    private String token;
    private UserInfoVo userInfo;
}

业务逻辑

BlogLoginService

package com.os467.service.impl;

import com.os467.common.ResponseResult;
import com.os467.entity.LoginUser;
import com.os467.entity.User;
import com.os467.service.BlogLoginService;
import com.os467.utils.BeanCopyUtils;
import com.os467.utils.JwtUtil;
import com.os467.utils.RedisCache;
import com.os467.vo.BlogUserLoginVo;
import com.os467.vo.UserInfoVo;
import org.springframework.beans.BeanUtils;
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;

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

        //获取到LoginUser信息
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();

        //从LoginUser中获取用户信息
        Long userid = loginUser.getUser().getId();

        //生成token
        String token = JwtUtil.createJWT(userid.toString());

        //将用户信息存入redis
        redisCache.setCacheObject("bloglogin:"+userid,loginUser);

        UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);

        //将用户Vo数据封装进登录用户Vo中
        BlogUserLoginVo loginVo = new BlogUserLoginVo(token, userInfoVo);

        return ResponseResult.okResult(loginVo);
    }
}

UserDetailsService

package com.os467.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.os467.entity.LoginUser;
import com.os467.entity.User;
import com.os467.mapper.UserMapper;
import com.sun.org.apache.xerces.internal.xs.StringList;
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.Arrays;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @SuppressWarnings("all")
    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();

        //根据用户名获取到用户信息
        queryWrapper.eq(User::getUserName,username);

        User user = userMapper.selectOne(queryWrapper);

        if (Objects.isNull(user)){
            throw new RuntimeException("用户不存在");
        }

        //封装loginUser信息
        LoginUser loginUser = new LoginUser(user, Arrays.asList("123","22"));

        return loginUser;
    }

}

*5.1异常处理(必须)

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

  • AuthenticationEntryPoint 认证失败处理器

  • AccessDeniedHandler 授权失败处理器

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        authException.printStackTrace();
        //InsufficientAuthenticationException
        //BadCredentialsException
        ResponseResult result = null;
        if(authException instanceof BadCredentialsException){
            result = ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_ERROR.getCode(),authException.getMessage());
        }else if(authException instanceof InsufficientAuthenticationException){
            result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
        }else{
            result = ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),"认证或授权失败");
        }
        //响应给前端
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        accessDeniedException.printStackTrace();
        ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NO_OPERATOR_AUTH);
        //响应给前端
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}

配置类配置

//配置异常处理器
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);

5.2统一异常处理

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

异常类

package com.os467.exception;

import com.os467.enums.AppHttpCodeEnum;

public class SystemException extends RuntimeException{

    private int code;

    private String msg;

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

    public SystemException(AppHttpCodeEnum httpCodeEnum) {
        super(httpCodeEnum.getMsg());
        this.code = httpCodeEnum.getCode();
        this.msg = httpCodeEnum.getMsg();
    }
    
}

表现层

package com.os467.controller;

import com.os467.common.ResponseResult;
import com.os467.enums.AppHttpCodeEnum;
import com.os467.exception.SystemException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class GlobalExceptionHandler {

    @ExceptionHandler(SystemException.class)
    public ResponseResult systemExceptionHandler(SystemException e){
        log.error("出现了异常{}",e);
        return ResponseResult.errorResult(e.getCode(),e.getMsg());
    }

    @ExceptionHandler(Exception.class)
    public ResponseResult exceptionHandler(SystemException e){
        log.error("出现了异常{}",e);
        return ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR,e.getMsg());
    }

}

5.3登出接口

这里出现了一个异常,在登出接口时出现了fastjosn序列化的报错

报错原因来自从redis反序列化工具中获取LoginUser实例时没有给LoginUser提供默认有参构造,分析可能是在底层先创建出实例再给属性注入数据

解决方案:给LoginUser类加@NoArgsConstructor注解提供无参构造

表现层

@RestController
public class BlogLogoutController {

    @Autowired
    private BlogLogoutService blogLogoutService;

    @PostMapping("/logout")
    public ResponseResult logout(){

        return blogLogoutService.logout();
    }

}

业务层

@Service
public class BlogLogoutServiceImpl implements BlogLogoutService {

    @Autowired
    private RedisCache redisCache;

    /**
     * 从SecurityContext中获取到用户信息,删除redis缓存中的用户信息
     * @return
     */
    @Override
    public ResponseResult logout() {

        //从SecurityContext中获取到用户认证信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        //获取用户认证信息
        LoginUser loginUser = (LoginUser)authentication.getPrincipal();

        //从LoginUser中获取userid
        Long userid = loginUser.getUser().getId();

        //根据userid删除redis缓存中的用户数据
        redisCache.deleteObject("bloglogin:" + userid);

        return ResponseResult.okResult("登出成功");
    }
}

6.评论功能

评论功能,有两种评论为根评论和子评论

通过根评论id可以查询子评论

所有评论都需要设置其用户昵称信息

如果是根评论还要查询其子评论信息

如果是子评论需要查询其回复评论信息(根评论信息)

实体类

package com.os467.entity;

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

/**
 * 评论表(Comment)表实体类
 *
 * @author makejava
 * @since 2022-09-29 22:18:53
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
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;

}
package com.os467.vo;

import lombok.Data;

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

@Data
public class CommentVo {

    private Long id;
    //文章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 String toCommentUserName;

    //用于存放用户的nickName
    private String username;

    //用于存放词条评论的子评论信息
    private List<CommentVo> children;

}

表现层

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

}

业务层

@Service("commentService")
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {

    @SuppressWarnings("all")
    @Autowired
    private CommentMapper commentMapper;

    @SuppressWarnings("all")
    @Autowired
    private UserMapper userMapper;

    /**
     * 根据文章id获取文章评论信息
     * @param articleId
     * @param pageNum
     * @param pageSize
     * @return
     */
    @Override
    public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {

        //构造查询条件
        LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();

        //查询此文章下的评论信息
        queryWrapper.eq(Comment::getArticleId,articleId);
        //查询根评论
        queryWrapper.eq(Comment::getRootId,-1);

        Page<Comment> commentPage = new Page<>(pageNum, pageSize);

        //分页查询到该文章下的评论信息
        page(commentPage,queryWrapper);

        //转换成评论Vo数据
        List<CommentVo> commentVos = commentVoList(commentPage.getRecords());

        //为根评论设置子评论信息
        for (CommentVo commentVo : commentVos) {

            //根据根评论id获取子评论列表
            List<CommentVo> childrenCommentList = getChildrenCommentList(commentVo.getId());

            //存放子评论信息
            commentVo.setChildren(childrenCommentList);

        }

        //设置pageVo对象
        PageVo pageVo = new PageVo(commentVos, commentPage.getTotal());

        return ResponseResult.okResult(pageVo);
    }

    /**
     * 为所有评论数据存入用户昵称信息
     * 如果是子评论还需要设置回复用户信息
     * @param records
     * @return
     */
    private List<CommentVo> commentVoList(List<Comment> records) {

        //获取评论列表信息
        List<CommentVo> commentVos = BeanCopyUtils.copyBeanList(records, CommentVo.class);

        for (CommentVo commentVo : commentVos) {

            //根据评论中的创建者userid获取对应用户昵称
            String nickName = userMapper.selectById(commentVo.getCreateBy()).getNickName();

            //设置创建评论的用户昵称信息
            commentVo.setUsername(nickName);

            //如果不是根评论还需要设置子评论所回复目标用户的名称
            if (commentVo.getToCommentUserId() != -1){

                //根据toCommentUserId查询用户并获取用户名称
                String toCommentUserName = userMapper.selectById(commentVo.getToCommentUserId()).getNickName();

                //设置回复用户名称信息
                commentVo.setToCommentUserName(toCommentUserName);

            }

        }

        return commentVos;
    }

    /**
     * 根据根评论id获取根评论对应的所有子评论信息
     * @param id
     * @return
     */
    private List<CommentVo> getChildrenCommentList(Long id) {

        LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();

        //查询此根评论下的子评论,按照创建时间降序排序
        queryWrapper.eq(Comment::getRootId,id);
        queryWrapper.orderByDesc(Comment::getCreateTime);

        List<Comment> childrenCommentList = commentMapper.selectList(queryWrapper);

        //继续获取该评论列表的Vo信息
        List<CommentVo> commentVos = commentVoList(childrenCommentList);

        return commentVos;

    }
}

7.发表评论接口

需求:

用户登录后可以对文章发表评论,也可以对评论发表回复

用户登录后也可以在友链页面进行评论

实体类

package com.os467.entity;

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

/**
 * 评论表(Comment)表实体类
 *
 * @author makejava
 * @since 2022-09-29 22:18:53
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("serial")
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;

}
package com.os467.vo;

import lombok.Data;

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

@Data
public class CommentVo {

    private Long id;
    //文章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 String toCommentUserName;

    //用于存放用户的nickName
    private String username;

    //用于存放词条评论的子评论信息
    private List<CommentVo> children;

}

表现层

@PostMapping("/comment")
public ResponseResult comment(@RequestBody Comment comment){
    return commentService.comment(comment);
}

业务层

/**
 * 为文章发表评论,或者回复某条评论
 * @param comment
 * @return
 */
@Override
public ResponseResult comment(Comment comment) {

    //判断是否带有评论信息
    if (!StringUtils.hasText(comment.getContent())){

        throw new SystemException(AppHttpCodeEnum.CONTEXT_NOT_NULL);

    }

    //保存评论信息到数据库
    save(comment);

    return ResponseResult.okResult();
}

*MybatisPlus自动填充功能

为实体类需要被自动填充的属性加上注解,指定在什么模式下填充字段

    @TableField(fill = FieldFill.INSERT)
    private Long createBy;

    @TableField(fill = FieldFill.INSERT)
    private Date createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateBy;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;

创建元数据处理器

==需要加上@Component注解交由Spring容器管理==

setFieldValByName

参数:

需要提供

package com.os467.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.os467.utils.SecurityUtils;
import org.apache.ibatis.reflection.MetaObject;

import java.util.Date;

@Component
public class BlogMetaObjectHandler implements MetaObjectHandler {

    /**
     * 插入时需要执行的代码
     * @param metaObject
     */
    @Override
    public void insertFill(MetaObject metaObject) {

        Long userId = SecurityUtils.getUserId();

        this.setFieldValByName("createBy",userId,metaObject);
        this.setFieldValByName("createTime",new Date(),metaObject);
        this.setFieldValByName("updateBy",userId,metaObject);
        this.setFieldValByName("updateTime",new Date(),metaObject);
    }

    /**
     * 更新时需要执行的代码
     * @param metaObject
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("updateBy",SecurityUtils.getUserId(),metaObject);
        this.setFieldValByName("updateTime",new Date(),metaObject);
    }
}

8.个人信息查询

实体类

package com.os467.vo;

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class UserInfoVo {
    /**
     * 主键
     */
    private Long id;

    /**
     * 昵称
     */
    private String nickName;

    /**
     * 头像
     */
    private String avatar;

    private String sex;

    private String email;


}

表现层

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

    @Autowired
    private UserService userService;

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

}

业务层

/**
 * 用户表(User)表服务实现类
 *
 * @author makejava
 * @since 2022-09-30 17:35:15
 */
@Service("userService")
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @SuppressWarnings("all")
    @Autowired
    private UserMapper userMapper;

    /**
     * 查询到用户信息
     * @return
     */
    @Override
    public ResponseResult userInfo() {

        //从SecurityContext中获取用户id
        Long userId = SecurityUtils.getUserId();

        //根据用户id查询到该用户的信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getId,userId);
        User user = userMapper.selectOne(queryWrapper);

        //封装成Vo数据
        UserInfoVo userInfoVo = BeanCopyUtils.copyBean(user, UserInfoVo.class);

        ResponseResult result = ResponseResult.okResult(userInfoVo);

        return result;
    }
}

9.头像上传接口

需求

​ 在个人中心点击编辑的时候可以上传头像图片,上传完头像后,可以用于更新个人信息接口

*OOS

​ 因为如果把图片视频等文件上传到自己的应用的Web服务器,在读取图片的时候会占用比较多的资源,影响应用服务器的性能

所以我们一般使用OSS(Object Storage Service对象存储服务)存储图片或视频

引入maven坐标

<dependency>
    <groupId>com.qiniu</groupId>
    <artifactId>qiniu-java-sdk</artifactId>
    <version>[7.7.0, 7.7.99]</version>
</dependency>

配置文件

spring:
  servlet:
    multipart:
      max-file-size: 2MB #文件最大大小限制
      max-request-size: 5MB #请求大小限制
oss:
  accessKey: xxxx
  secretKey: xxxx
  bucket: os467-blog-img #对应七牛云的命名空间

表现层

@RestController
public class UploadController {

    @Autowired
    private UploadService uploadService;

    @RequestMapping("/upload")
    public ResponseResult uploadImg(MultipartFile img){
        return uploadService.uploadImg(img);
    }

}

业务层

使用ConfigurationProperties注解来为类的属性注入配置的属性值

必须提供set方法

@Service
@Data
@ConfigurationProperties(prefix = "oss")
public class UploadServiceImpl implements UploadService {

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

    /**
     * 上传图片
     * @param img
     * @return
     */
    @Override
    public ResponseResult uploadImg(MultipartFile img) {

        //判断原始文件名
        String originalFilename = img.getOriginalFilename();

        if (!originalFilename.endsWith(".png")){

            throw new SystemException(AppHttpCodeEnum.FILE_TYPE_ERROR);

        }

        //通过文件校验则上传到oss
        String filePath = PathUtils.generateFilePath(originalFilename);

        String url = uploadOSS(img,filePath);

        return ResponseResult.okResult(url);
    }

    /**
     * 上传到oss
     * @param img
     * @param filePath
     * @return
     */
    private String uploadOSS(MultipartFile img, String filePath) {

        //构造一个带指定Region对象的配置类
        Configuration cfg = new Configuration(Region.autoRegion());

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

        String key = filePath;

        try {

            //获得输入流对象
            InputStream inputStream = img.getInputStream();

            //创建上传凭证信息
            Auth auth = Auth.create(accessKey, secretKey);

            String uploadToken = auth.uploadToken(bucket);

            Response response = uploadManager.put(inputStream, key, uploadToken, null, null);

            //解析上传成功的结果
            DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
            System.out.println(putRet.key);
            System.out.println(putRet.hash);
        } catch (QiniuException ex) {
            Response r = ex.response;
            System.err.println(r.toString());
            try {
                System.err.println(r.bodyString());
            } catch (QiniuException ex2) {
                //ignore
            }

        } catch (IOException e) {
            e.printStackTrace();
        }

        //返回图片所在网址
        return domainName+"/"+key;

    }
}

10.注册功能

需求

要求用户能够在注册界面完成用户的注册,要求用户名,昵称,邮箱不能和数据库中原有的数据重复

如果某项重复了注册失败并且要有对应的提示,并且要求用户名,密码,昵称,邮箱都不能为空

密码明文加密为秘文后存入数据库

表现层

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

业务层

/**
 * 注册用户信息
 * @param user
 * @return
 */
@Override
public ResponseResult register(User user) {

    //对数据进行非空判断
    if (!StringUtils.hasText(user.getUserName())){
        throw new SystemException(AppHttpCodeEnum.USERNAME_NOT_NULL);
    }
    if (!StringUtils.hasText(user.getEmail())){
        throw new SystemException(AppHttpCodeEnum.EMAIL_NOT_NULL);
    }
    if (!StringUtils.hasText(user.getPassword())){
        throw new SystemException(AppHttpCodeEnum.PASSWORD_NOT_NULL);
    }
    if (!StringUtils.hasText(user.getNickName())){
        throw new SystemException(AppHttpCodeEnum.NICKNAME_NOT_NULL);
    }


    //对数据是否存在进行判断
    if (userNameExist(user.getUserName())){
        throw new SystemException(AppHttpCodeEnum.USERNAME_EXIST);
    }

    if (nickNameExist(user.getNickName())){
        throw new SystemException(AppHttpCodeEnum.NICKNAME_EXIST);
    }

    //使用BCrypt加密原始密码
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    String encodePassword = passwordEncoder.encode(user.getPassword());

    //将加密好的密码存入数据库
    user.setPassword(encodePassword);

    //存入数据库
    save(user);

    return ResponseResult.okResult();
}

/**
 * 查询判断用户昵称是否存在
 * @param nickName
 * @return
 */
private boolean nickNameExist(String nickName) {

    User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getNickName, nickName));

    if (Objects.nonNull(user)){
        return true;
    }

    return false;

}

/**
 * 查询判断用户名是否存在
 * @param userName
 * @return
 */
private boolean userNameExist(String userName) {

    User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, userName));

    if (Objects.nonNull(user)){
        return true;
    }

    return false;

}

11.AOP实现日志收集

需要通过日志记录接口调用信息,便于后期调试排查,并且可能有很多接口都需要进行日志的记录

日志打印格式

    log.info("=======Start=======");
    // 打印请求 URL
    log.info("URL            : {}",);
    // 打印描述信息
    log.info("BusinessName   : {}", );
    // 打印 Http method
    log.info("HTTP Method    : {}", );
    // 打印调用 controller 的全路径以及执行方法
    log.info("Class Method   : {}.{}", );
    // 打印请求的 IP
    log.info("IP             : {}",);
    // 打印请求入参
    log.info("Request Args   : {}",);
    // 打印出参
    log.info("Response       : {}", );
    // 结束后换行
    log.info("=======End=======" + System.lineSeparator());

实现aop通常我们可以通过切入点表达式或者自定义注解的方式来配置切面

自定义注解

@Pointcut("@annotation(com.os467.annotation.SystemLog)")
public void pt(){

}

配置切面

package com.os467.ascept;

import com.alibaba.fastjson.JSON;
import com.os467.annotation.SystemLog;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.FieldSignature;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import sun.reflect.generics.tree.ClassSignature;

import javax.servlet.http.HttpServletRequest;

@Component
@Aspect
@Slf4j
public class LogAspect {

    @Pointcut("@annotation(com.os467.annotation.SystemLog)")
    public void pt(){

    }

    /**
     * 配置切面
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around("pt()")
    public Object printLog(ProceedingJoinPoint joinPoint) throws Throwable {

        //获取目标方法的返回值
        Object ret;
        try {

            //前置通知
            printBefore(joinPoint);

            //执行目标方法
            ret = joinPoint.proceed();

            //后置通知
            printAfter(ret);

        } finally {
            // 结束后换行,获取到当前系统的换行符
            log.info("=======End=======" + System.lineSeparator());

        }

        //返回切入点方法执行结果
        return ret;

    }

    /**
     * 执行方法后需要打印的通知
     * @param ret
     */
    private void printAfter(Object ret) {
        // 打印出参
        log.info("Response       : {}",JSON.toJSONString(ret));
    }

    /**
     * 执行方法之前需要打印的通知
     */
    private void printBefore(ProceedingJoinPoint joinPoint) {

        //获取到当前线程的请求对象
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();

        HttpServletRequest request = null;

        //向下转型
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes)attributes;

        //获取到当前线程请求对象
        request = requestAttributes.getRequest();

        //通过反射获取被增强方法上的注解对象
        SystemLog systemLog = getSystemLog(joinPoint);

        log.info("=======Start=======");
        // 打印请求 URL
        log.info("URL            : {}",request.getRequestURL());
        // 打印描述信息
        log.info("BusinessName   : {}",systemLog.businessName());
        // 打印 Http method
        log.info("HTTP Method    : {}", request.getMethod());
        // 打印调用 controller 的全路径以及执行方法
        log.info("Class Method   : {}.{}", joinPoint.getSignature().getDeclaringType(),((MethodSignature)joinPoint.getSignature()).getName());
        // 打印请求的 IP
        log.info("IP             : {}",request.getRemoteHost());
        // 打印请求入参
        log.info("Request Args   : {}", JSON.toJSONString(joinPoint.getArgs()));

    }

    /**
     * 获取到被增强方法上的注解信息
     * @param joinPoint
     * @return
     */
    private SystemLog getSystemLog(ProceedingJoinPoint joinPoint) {

        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();

        SystemLog systemLog = methodSignature.getMethod().getAnnotation(SystemLog.class);

        return systemLog;

    }

}

表现层

@SystemLog(businessName = "用户信息修改")
    @PutMapping("/userInfo")
    public ResponseResult updateUser(@RequestBody User user){
        return userService.updateUser(user);
    }

12.更新浏览次数

Question:在用户浏览博文时需要对博客浏览量进行新增,但是如果在高并发场景下,每次浏览对数据库数据的更新会产生写锁,会对其它用户的访问造成影响

因此我们选择用redis来缓存我们的浏览量数据

为了防止redis关闭造成的数据流失,我们需要定时将redis中的数据同步到mysql数据库中

需求

①在应用启动时把博客的浏览量存储到redis中

②更新浏览量时去更新redis中的数据

③每隔10分钟把Redis中的浏览量更新到数据库中

④读取文章浏览量时从redis读取

将浏览量存储到redis

实现CommandLineRunner接口,在应用启动时初始化缓存

@Component
public class BlogCommendLineRunner implements CommandLineRunner {

    @SuppressWarnings("all")
    @Autowired
    private ArticleMapper articleMapper;

    @Autowired
    private RedisCache redisCache;

    /**
     * springboot项目启动后执行的方法
     * @param args
     * @throws Exception
     */
    @Override
    public void run(String... args) throws Exception {
        //获取浏览数据列表
        List<Article> articles = articleMapper.selectList(null);

        //存储为map集合
        Map<String, Integer> map = articles.stream().collect(Collectors
                .toMap(article -> article.getId().toString(),
                        article -> article.getViewCount().intValue()));

        //存入redis缓存
        redisCache.setCacheMap("article:viewCount",map);
    }
}

redis缓存工具类添加方法

increment() 为某个指定key的long或double型数据加减多少数值

/**
 * 更新缓存中的集合数据
 * @param key
 * @param hKey
 * @param value
 */
public void incrementCacheMap(String key,String hKey,Long value){
    redisTemplate.boundHashOps(key).increment(hKey,value);
}

表现层

@PutMapping("/updateViewCount/{id}")
public ResponseResult updateViewCount(@PathVariable("id") Long id){
    return articleService.updateViewCount(id);
}

业务层

/**
 * 上传更新数据
 * @param id
 * @return
 */
@Override
public ResponseResult updateViewCount(Long id) {

    //更新缓存内数据
    redisCache.incrementCacheMap("article:viewCount",id.toString(),1L);

    return ResponseResult.okResult();
}

修改文章查询时浏览量数据

//根据缓存中的数据更新浏览量
article.setViewCount(redisCache.getCacheMapValue("article:viewCount",article.getId().toString()));

==这里其实有问题,因为redis在存入long类型数据的时候会转化为int类型,所以取出数据的时候需要进行强转==

修改方案

article.setViewCount(getViewCount(article.getId().toString()));
    /**
     * 根据文章id从redis获取浏览量
     * @param articleId
     * @return
     */
    private Long getViewCount(String articleId) {
        Object viewCount = redisCache.getCacheMapValue("article:viewCount", articleId);
        return Long.parseLong(viewCount.toString());
    }

*定时任务

定时任务的实现方式有很多,比如XXL-job,但是其核心功能和概念都是类似的,很多情况下只是调用的API不同而已

使用Springboot提供的定时任务API来实现一个简单的定时任务

在配置类上加上注释 @EnableScheduling 开启定时任务功能

@Component
public class BlogScheduleJob {

    @Autowired
    private ArticleService articleService;

    @Autowired
    private RedisCache redisCache;

    @Scheduled(cron = "0 0/10 * * * ?")
    public void scheduleJob(){

        //从缓存中获取到文章浏览量数据
        Map<String, Object> map = redisCache.getCacheMap("article:viewCount");

        //获取id集合
        Set<String> idSet = map.keySet();

        //构建用于存放文章浏览信息的数组
        List<Article> articles = new ArrayList<>();

        //获取id集合迭代对象
        Iterator<String> ids = idSet.iterator();

        //遍历id集合
        while (ids.hasNext()){
            String id = ids.next();

            Article article = new Article(Long.parseLong(id),Long.parseLong(map.get(id).toString()));
            //向数组中添加文章数据
            articles.add(article);
        }

        //更新数据库中的数据
        articleService.updateBatchById(articles);


    }

}

这里还有一种更加简洁的写法

//也可以采用下面的方式
List<Article> articles = map.entrySet().stream().map((item) -> {
    String key = item.getKey();
    Object value = item.getValue();
    return new Article(Long.valueOf(key), Long.valueOf(value.toString()));
}).collect(Collectors.toList());

注意从redis中取出的int数据需要强转为long型,集合需要获取其迭代对象来遍历集合

==知识点==: ValueOf方法得到的是Long包装类,parseLong方法得到的是long数据类型


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以邮件至 1300452403@qq.com

文章标题:前后端分离博客项目

字数:11.9k

本文作者:Os467

发布时间:2022-10-05, 00:52:35

最后更新:2022-10-19, 23:01:25

原始链接:https://os467.github.io/2022/10/05/%E5%8D%9A%E5%AE%A2%E9%A1%B9%E7%9B%AE/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

×

喜欢就点赞,疼爱就打赏