前言  

视频:《学成在线》微服务项目学习视频

准备工作:学习之前需要准备好对应的开发环境如git、docker、mysql数据库等等

项目:侧重后端,资料可以从机构公众号里的网盘获取

技术栈:Spring Cloud Alibaba、Nginx、MQ、Redis、Elasticsearch、Minio、XXL-job、Nacos....

(标红为本篇中运用到的技术栈)


本文按视频章节划分记录我的学习内容和中间遇到的问题解决方法,如有文中未提到的问题可以留言,我会不时的查看帮助


项目背景

在开始写项目之前需要了解其背景有助于我们更好的了解之后的业务逻辑
基础知识:ToC市场:面向个人的市场        ToB市场:面向企业的市场

《学成在线》项目是基于B2B2C的业务模式
B(Business):企业        C(Consumer):消费者
供应商/运营商企业 + 商务企业 + 消费者 
以本项目为例就是:网站运营 + 培训机构 + 消费者
所以项目会存在三端:用户端、机构端、运营端

项目技术架构图

环境准备:mysql数据库;git版本控制可以不使用gogs管理,giteegithub都可,主要是为了了解和熟悉项目的管理,其余的中间件暂时不需要


一、Day-1-内容管理模块


1.1 创建内容管理模块

        《学成在线》项目的微服务模块划分规则与Spring MVC 相似,就是将模块划分为 controllerapi)、domainmode)、service这三层模块,依赖关系如下图所示:

内容管理模块依赖关系

        项目的内容管理工程结构如下图所示:

        其和其他模块由项目的父工程xuecheng-plus-parent统一进行管理

        在xuecheng-plus-content模块中统一管理其子模块并继承父工程xuecheng-plus-parent,其父工程中统一管理各个依赖版本,这类用于聚合管理子模块的工程的packageing方式必须是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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>xuecheng-plus-parent</artifactId>
        <groupId>com.xuecheng</groupId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath>../xuecheng-plus-parent</relativePath>
    </parent>
    <artifactId>xuecheng-plus-content</artifactId>
    <packaging>pom</packaging>
    <modules>
        <module>xuecheng-plus-content-api</module>
        <module>xuecheng-plus-content-model</module>
        <module>xuecheng-plus-content-service</module>
    </modules>
</project>

1.2 内容管理——基础工程搭建

        按照上图处理好内容管理模块的依赖后,就需要进行需求分析和对应接口的开发

        根据上图分析可以知道课程查询的参数有课程名称、审核状态和课程的发布状态,需要返回的数据有课程Id(课程名称任务数创建时间是否付费审核状态类型等等;同时因为是分页查询还需要返回总记录数当前每页显示记录数

        根据上面分析出的字段来创建对应的DTOVO类来实现数据的传输,而常用的PO对象mapper层代码则可以通过代码生成器来生成(xuecheng-plus-generator模块在资料中

        在导入工程后可以通过右键单击xuecheng-plus-generator模块的pom文件找到添加为Maven项目按钮点击识别工程

        修改generator工程下的ContentCodeGenerator类中的需要生成的表

private static final String[] TABLE_NAMES = new String[]{
	"course_base",
	"course_market",
	"course_teacher",
	"course_category",
	"teachplan",
	"teachplan_media",
	"course_publish",
	"course_publish_pre"
};

(如果你的数据库是自己配置的,需要修改其中数据库的配置)

        准备完成后在ContentCodeGenerator类中运行main方法生成基础代码

        将生成的model目录移动到xuecheng-plus-content-model模块中后在pom文件中添加mplombok的相关依赖

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-core</artifactId>
            <version>${mybatis-plus-boot-starter.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-annotation</artifactId>
            <version>${mybatis-plus-boot-starter.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

(注意:版本已在父工程中统一管理了)

        依据接口文档设计接口,其中的分页参数通过url传递,条件查询参数通过请求体用json格式传递,所以分页参数用@PathVariable注解接收,分页参数使用@RequestBody注解接收

POST /content/course/list?pageNo=2&pageSize=1
Content-Type: application/json

{
  "auditStatus": "202002",
  "courseName": "",
  "publishStatus":""
}
###成功响应结果
{
  "items": [
    {
      "id": 26,
      "companyId": 1232141425,
      "companyName": null,
      "name": "spring cloud实战",
      "users": "所有人",
      "tags": null,
      "mt": "1-3",
      "mtName": null,
      "st": "1-3-2",
      "stName": null,
      "grade": "200003",
      "teachmode": "201001",
      "description": "本课程主要从四个章节进行讲解: 1.微服务架构入门 2.spring cloud 基础入门 3.实战Spring Boot 4.注册中心eureka。",
      "pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
      "createDate": "2019-09-04 09:56:19",
      "changeDate": "2021-12-26 22:10:38",
      "createPeople": null,
      "changePeople": null,
      "auditStatus": "202002",
      "auditMind": null,
      "auditNums": 0,
      "auditDate": null,
      "auditPeople": null,
      "status": 1,
      "coursePubId": null,
      "coursePubDate": null
    }
  ],
  "counts": 23, 
  "page": 2,
  "pageSize": 1
}

        创建一个分页参数DTO和VO模型类用于传输查询参数和查询结果,分页参数和分页查询的结果,因为是所有模块都可能用到的类,所以将其放到xuecheng-plus-base模块

@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class PageParams {

    /**
     * 当前页码
     */
    @ApiModelProperty("页码")
    private long pageNo = 1L;

    /**
     * 每页记录数
     */
    @ApiModelProperty("每页记录数")
    private long pageSize = 20L;
}
@Data
@ToString
@AllArgsConstructor
public class PageResult<T> implements Serializable {

    /**
     * 数据列表
     */
    private List<T> items;

    /**
     * 总记录数
     */
    private long counts;

    /**
     * 当前页码
     */
    private long page;

    /**
     * 每页记录数
     */
    private long pageSize;
}

        其中的PageResult分页查询结果使用泛型来确保适配于不同类型的分页查询

        之后的创建条件查询需要的DTO查询类并在api工程中导入对应的依赖

@Data
@ToString
public class QueryCourseParamsDto {

    /**
     * 审核状态
     */
    private String auditStatus;

    /**
     * 课程名称
     */
    private String courseName;

    /**
     * 发布状态
     */
    private String publishStatus;
}
    <!-- cloud 的基础环境包 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-context</artifactId>
        </dependency>
        <!-- Spring Boot 的 Spring Web MVC 集成 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 排除 Spring Boot 依赖的日志包冲突 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- 添加 Spring Boot 验证功能 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Spring Boot 集成 log4j2 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>

        <!-- Spring Boot 集成 swagger -->
        <dependency>
            <groupId>com.spring4all</groupId>
            <artifactId>swagger-spring-boot-starter</artifactId>
            <version>1.9.0.RELEASE</version>
        </dependency>

        由于我们使用的日志是Log4j2,所以需要将Spring Boot自带的logging日志依赖排除,之后将资料中的log4j2配置文件导入并创建项目的配置文件bootstrap.yml
注意:使用swagger需要在启动类上加@EnableSwagger2Doc注解

server:
  servlet:
    context-path: /content
  port: 63040
#微服务配置
spring:
  application:
    name: content-api
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://你的数据库地址:3306/xcplus_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: mysql

# 日志文件配置路径
logging:
  config: classpath:log4j2-dev.xml

# swagger 配置
swagger:
  title: "学成在线项目内容管理系统" 
  description: "内容管理系统对课程相关信息进行管理"
  base-package: com.xuecheng.content # 类所在包路径
  enabled: true
  version: 1.0.0

swagger常用注解

@Api:修饰整个类,描述Controller的作用
@ApiOperation:描述一个类的一个方法,或者说一个接口
@ApiParam:单个参数描述
@ApiModel:用对象来接收参数
@ApiModelProperty:用对象接收参数时,描述对象的一个字段
@ApiResponse:HTTP响应其中1个描述
@ApiResponses:HTTP响应整体描述
@ApiIgnore:使用该注解忽略这个API
@ApiError:发生错误返回的信息
@ApiImplicitParam:一个请求参数
@ApiImplicitParams:多个请求参数

        同时创建Controller类后,在其类上加@RustController = @ResponseBody(响应json数据) +  @Controller

@Api(value = "课程信息管理接口", tags = "课程信息管理接口")
@RestController
public class CourseBaseInfoController {

    @Autowired
    private CourseBaseInfoService courseBaseInfoService;

    @ApiOperation("课程分页查询接口")
    @PostMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParam, @RequestBody(required = false) QueryCourseParamsDto queryCourseParamsDto) {
        return null;
    }
}

        由于本项目有三端,如PC端和手机端访问时由于性能问题,当接口有多个前端调用接口时就需要多个VO类来返回结果给不同的客户端(后面),这里返回完整信息给PC端所以直接用CourseBase类即可

(注意:在配置文件中已经配置了服务的根路径,所以@RustController没有设置根路径)

其中的@ResponseBody(required = false)表示条件查询参数不是必填

启动后可以通过http://localhost:63040/content/swagger-ui.html#/来访问swagger接口文档

        为了统一序列化/反序列化的日期格式,需要在base工程中导入LocalDateTimeConfig配置类统一日期格式

@Configuration
public class LocalDateTimeConfig {

    /*
     * 序列化内容
     *   LocalDateTime -> String
     * 服务端返回给客户端内容
     * */
    @Bean
    public LocalDateTimeSerializer localDateTimeSerializer() {
        return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    /*
     * 反序列化内容
     *   String -> LocalDateTime
     * 客户端传入服务端数据
     * */
    @Bean
    public LocalDateTimeDeserializer localDateTimeDeserializer() {
        return new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }


    // 配置
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            builder.serializerByType(LocalDateTime.class, localDateTimeSerializer());
            builder.deserializerByType(LocalDateTime.class, localDateTimeDeserializer());
        };
    }

}

二、Day-2-完善内容管理模块


2.1 实现课程查询接口

        写接口从持久层开始写,使用在之前生成的代码的mapper,移到content-service模块

        完成后需要对mapper进行单元测试,我们需要引入测试需要的依赖和相关配置:

        <!-- mybatis 依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-context</artifactId>
        </dependency>
        <!-- 测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- log4j2 日志 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
spring:
  application:
    name: content-service
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://Mysql地址:3306/xcplus_content?serverTimezone=UTC&useUnicode=true&useSSL=false
    username: root
    password: mysql

logging:
  config: classpath:log4j2-dev.xml

(注意:在测试中需要区分main包和test包下的resources中的配置文件不互通)

        注意在使用分页查询前,需要先配置好分页插件,其原理是拦截查询的sql语句,根据数据库类型添加对应的分页语句重写sql,我们将其定义在content-service模块
       

@Configuration
@MapperScan("com.xuecheng.content.mapper")
public class MybatisPlusConfig {

    /**
     * 定义分页拦截器
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

(跳过单元测试)

        之后需要实现CourseBaseInfoService课程管理接口中的queryCourseBaseList方法分页查询课程信息

public interface CourseBaseInfoService {

    /**
     * 课程查询接口
     *
     * @param pageParam 分页参数
     * @param queryCourseParamsDto 查询参数
     * @return {@link PageResult<CourseBase>}
     */
    PageResult<CourseBase> queryCourseBaseList(PageParams pageParam, QueryCourseParamsDto queryCourseParamsDto);
}
@Slf4j
@Service
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {

    @Autowired
    private CourseBaseMapper courseBaseMapper;

    /**
     * 课程分页查询
     *
     * @param pageParam       分页参数
     * @param courseParamsDto 查询参数
     * @return {@link PageResult<CourseBase>}
     */
    @Override
    public PageResult<CourseBase> queryCourseBaseList(PageParams pageParam, QueryCourseParamsDto courseParamsDto) {
        // 拼接查询条件
        LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(StringUtils.isNotEmpty(courseParamsDto.getCourseName()),
                CourseBase::getName, courseParamsDto.getCourseName());
        queryWrapper.eq(StringUtils.isNotEmpty(courseParamsDto.getAuditStatus()),
                CourseBase::getAuditStatus, courseParamsDto.getAuditStatus());
        queryWrapper.eq(StringUtils.isNotEmpty(courseParamsDto.getPublishStatus()),
                CourseBase::getStatus, courseParamsDto.getPublishStatus());
        // 按更新时间和创建时间降序排序
        queryWrapper.orderByDesc(CourseBase::getChangeDate)
                    .orderByDesc(CourseBase::getCreateDate);

        // 分页参数对象
        Page<CourseBase> page = new Page<>(pageParam.getPageNo(), pageParam.getPageSize());
        Page<CourseBase> basePage = courseBaseMapper.selectPage(page, queryWrapper);
        // 数据列表
        List<CourseBase> records = basePage.getRecords();
        // 总记录数
        long total = basePage.getTotal();
        return new PageResult<>(records, total, page.getCurrent(), page.getSize());
    }
}

        通过LambdaQueryWrapper拼接查询条件,根据课程名称模糊查询、状态查询(这里我加了通过更新时间和修改时间来倒序排序),设置分页参数通过selectPage方法查询获取到课程基本信息列表,封装为PageResult后返回数据

        接下来完善课程查询Controller接口层,将获取的参数传入到queryCourseBaseList方法中获取分页结果返回到前端:

@ApiOperation("课程分页查询接口")
    @PostMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParam, @RequestBody(required = false) QueryCourseParamsDto queryCourseParamsDto) {
        PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParam, queryCourseParamsDto);
        return courseBasePageResult;
    }

        之后我们可以通过swagger接口文档来对接口进行测试,输入好分页参数和条件参数即可点击 Execute 测试接口是否正常:

        但是这种测试方式存在缺陷,测试结果和数据无法保存,在页面刷新后就消失了,这里更推荐使用IDEA的测试插件和Postman/Apifox等测试工具,能够更方便的管理测试接口


2.2 前后端联调课程查询接口

        在进行前后端联调前,需要确保电脑上有node.js环境,视频里的版本是16.x的,这里如果你下载的node版本过高(17.x +)会导致前端工程启动失败,需要在前端工程的package.json中将启动命令更改为:

"serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve"

        这条命令启用了OpenSSL旧版提供者模式,解决 Node.js 17+ 版本与旧版本 webpack 不兼容的问题,之后就可正常启动前端工程,启动成功后访问前端会看到空白页面,原因是前端会首先请求回去数据字典列表以用于前端展示,但我们还没有实现该接口,所以访问页面是空白,后续我们就需要实现查询数据字典接口(资料中有直接导入即可)

        在项目中有一些字段内容会重复出现,而我们可以通过数据字典来更好的规范和修改这些字段如:课程审核状态、课程发布状态等等;由前端读取后进行展示

        创建数据字典表后并成功导入system模块后记得根据数据库地址修改配置
        启动服务后发现还是经典的CORS跨域问题,接下来就是去解决CORS错误,本质是因为浏览器为了保护资源安全而存在的同源策略:从一个地址请求另一个地址时如果协议(如http/https)、主机和端口三者有一个不一致就属于跨域请求

解决跨域的方法:

1. JsonP

        跨域问题原因之一是前端通过ajax请求服务url,现在可以通过前端的<script>标签去请求后端url地址来获取资源,后端需要返回:

fun("响应结果")

        而前端可以提前写一个与之对应的方法来执行后端返回的js代码


2. 手动添加响应头

        我们可以在服务端设置响应头来允许请求跨域访问服务端的接口

Access-Control-Allow-Origin:http://地址:端口

3. 通过Nginx代理跨域

        本质问题还是浏览器直接访问服务端会跨域,我们可以借助Nginx来间接访问服务端,Nginx作为代理与浏览器是同源的,而同时Nginx也作为服务端去访问服务端就不存跨域问题

实现CORS过滤器

        我们通过Spring MVC框架提供的过滤器来为每个请求加上允许跨域访问的请求头:

@Configuration
public class GlobalCorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        // 允许白名单域名进行跨域
        corsConfiguration.addAllowedOrigin("*");
        // 允许跨域发送 cookie
        corsConfiguration.setAllowCredentials(true);
        // 放行全部原始头信息
        corsConfiguration.addAllowedHeader("*");
        // 允许所有请求方法跨域调用
        corsConfiguration.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }
}

        可以看到管理端前端界面正常显示了出来(如果没有显示记得检查一下浏览器控制台(F12)中的网络请求是否正常)

        成功后可以测试一下条件查询接口是否正常:


2.3 实现查询课程分类接口

        在点击添加课程时,出现了课程分类没有显示的问题,这就是接下来要实现的接口来查询课程分类,其数据是类似于的分布,大节点下包含多个小节点,本项目中设计了三级节点存储课程分类信息,其中一级节点是根节点,不存储信息,各子节点通过parentid字段关联父节点

        分析课程分类查询接口得出返回结果类型是树状结构数据如下所示:

@Data
public class CourseCategoryTreeDto extends CourseCategory implements Serializable {

    List<CourseCategoryTreeDto> childrenTreeNodes;
}

        该类继承课程分类信息类,其中通过childrenTreeNodes属性来存储子节点信息

        根据接口文档创建新的Controller类来管理课程分类相关的接口:

@Api(value = "课程分类相关接口", tags = "课程分类相关接口")
@RestController
public class CourseCategoryController {

    @Autowired
    private CourseCategoryService courseCategoryService;

    @ApiOperation("查询课程分类信息")
    @GetMapping("/course-category/tree-nodes")
    public List<CourseCategoryTreeDto> queryTreeNodes() {
        return null;
    }
}

        继续实现courseCategoryService的方法queryTreeNodes来查询课程分类各节点信息,重点在于编写sql查询逻辑,而查询类似树状结构数据的方法有两种:

第一种:自连接查询
        通过自连接查询一级节点信息和其下的二级节点信息,或是三级节点信息等等...

select one.id one_id,
       one.label one_label,
       two.idtwo_id,
       two.label two_label 
from course_category one inner join course_category two 
on two.parentid = one.id
where one.parentid = '1'
and one.is_show = '1'
and two.is_show = '1'
order by one.orderby, two.orderby

        如上所示,在查询深度为二级时需要自连接查询一次,随着需要三级、四级节点信息自连接查询次数递增,所以自连接查询适合于查询同时分类比较固定的树状数据


第二种:递归查询

        Mysql 8 中存在递归的特性,通过 with [RECURSIVE] table_name AS () 语句可以实现递归方法:

with recursive tl as (
    select 1 as n
    union all
    select n+1 from t1 where n < 5
)

        其中select 1 as n是在为这个递归设置初始值,union all会将数据结果集放到上面的t1表中,而where n < 5就是这个递归的终止条件

        递归课程分类信息表:

with recursive t1 as (
    # 设置初始值为根节点
    select * from course_category where id='1'
    # 开始递归
    union all
    # 自连接从t1中的根节点开始查询t1表中节点其对应子节点
    select t2.* from course_category t2 inner join t1 on t1.id = t2.parentid
)

        上述递归从t1表中的根节点开始查询表中节点的子节点并继续放入表中接着查询,直至其下无对应子节点时终止递归;
        同时可以将初始值设为'1-1-1'并且条件改为t2.parentid = t1.id就可以来从下往上递归;而Mysql为了避免无限递归所以设置默认了默认递归参数为1000次,还可以通过设置cte_max_recursion_depthmax_execution_time参数来增加递归深度和限制执行时间
        虽然递归可能会执行效率差,但相较于多次连接数据库查询,递归查询仅与数据库连接一次,只要控制好递归的深度和次数即可;递归查询适合于没有规定深度和次数的更加灵活的树状数据查询

        随后在对应的CourseCategoryMapper中创建方法并在对应xml文件中把上述的递归查询实现

<select id="queryTreeNodes" parameterType="string"
            resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto">
        with recursive t1 as (select *
                              from course_category
                              where id = #{id}
                              union all
                              select t.*
                              from course_category t
                                       inner join t1 on t1.id = t.parentid)
        select *
        from t1
        order by t1.id;
    </select>

        因为查询得到的数据格式与我们之前创建的CourseCategoryTreeDto类对应,所以这里可以使用resultType直接映射到CourseCategoryTreeDto(在查询结果字段与模型类不一致时需要使用resultMap手动映射字段)
        其中 #{} 表示占位符       
        ${} 表示拼接字符串(易导致sql注入)

        在单元测试中测试该方法成功后,可以实现Service层中的方法对查询获得的数据进行封装

@Slf4j
@Service
public class CourseCategoryServiceImpl implements CourseCategoryService {

    @Autowired
    private CourseCategoryMapper courseCategoryMapper;

    @Override
    public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
        List<CourseCategoryTreeDto> categoryTreeDtos = courseCategoryMapper.queryTreeNodes(id);
        Map<String, CourseCategoryTreeDto> categoryTreeDtoMap = categoryTreeDtos.stream()
                .filter(item -> !id.equals(item.getId()))
                .collect(Collectors.toMap(key -> key.getId(),
                value -> value,
                (key1, key2) -> key2));
        List<CourseCategoryTreeDto> courseCategoryList = new ArrayList<>();
        categoryTreeDtos.stream().filter(item -> !id.equals(item.getId())).forEach(item -> {
            if (item.getParentid().equals(id)) {
                courseCategoryList.add(item);
            }
            // 如果不是二级节点,就找到该节点的父节点加入
            CourseCategoryTreeDto courseCategoryTreeDto = categoryTreeDtoMap.get(item.getParentid());
            if (courseCategoryTreeDto != null) {
                if(courseCategoryTreeDto.getChildrenTreeNodes() == null) {
                    courseCategoryTreeDto.setChildrenTreeNodes(new ArrayList<>());
                }
                courseCategoryTreeDto.getChildrenTreeNodes().add(item);
            }
        });
        return courseCategoryList;
    }
}

        遍历查询结果,将其通过传入的参数Id作为父节点进行分类存入Map中,filter是为了把结果中的传入参数id对应的节点过滤掉,之后再次遍历查询以传入的id作为父节点的二级节点,存入封装的List集合中,如果不是二级节点就说明是二级节点的子节点(本项目只设计了共三级节点),在Map中查找到对应的二级父节点并添加到其子节点列表中,遍历排序结束后返回封转好的数据列表

        完成接口实现后,完善之前的Controller层代码,调用Service方法返回数据(参数固定为1查询根节点下的课程分类信息),前后端联调可以看到添加课程界面中课程分类信息成功显示:


2.4 实现新增课程接口

        新增课程涉及到多张表数据,其中基本信息包括课程基本信息和课程营销信息,两张表为一对一关系,将资料中对应的模型类(CourseBaseInfoDto、AddCourseDto)移动到model模块中,接着开始创建和实现接口:

@Api(value = "课程信息管理接口", tags = "课程信息管理接口")
@RestController
public class CourseBaseInfoController {

    @Autowired
    private CourseBaseInfoService courseBaseInfoService;

    ......

    @ApiOperation("新增课程接口")
    @PostMapping("/course")
    public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto) {
        Long companyId = 1232141425L;
        CourseBaseInfoDto courseBase = courseBaseInfoService.createCourseBase(companyId, addCourseDto);
        return courseBase;
    }
}

(注意:其中的companyId需要后续实现单点登录获取,这里暂时使用假数据)

        接着继续实现对应的Service层方法createCourseBase

    /**
     * 新增课程基本信息
     *
     * @param companyId    公司id
     * @param addCourseDto 添加课程信息
     * @return {@link CourseBaseInfoDto}
     */
    @Override
    @Transactional
    public CourseBaseInfoDto createCourseBase(Long companyId, AddCourseDto addCourseDto) {
        // 参数的合法性校验(后续会使用Validated在Controller层统一校验)
        ...

        CourseBase courseBase = new CourseBase();
        BeanUtils.copyProperties(addCourseDto, courseBase);
        courseBase.setCompanyId(companyId);
        courseBase.setCreateDate(LocalDateTime.now());
        // 审核 发布 状态默认为未 提交/发布
        courseBase.setAuditStatus("202002");
        courseBase.setStatus("203001");

        int insert = courseBaseMapper.insert(courseBase);
        if (insert <= 0) {
            throw new RuntimeException("添加课程失败");
        }

        CourseMarket courseMarket = new CourseMarket();
        BeanUtils.copyProperties(addCourseDto, courseMarket);

        Long courseId = courseBase.getId();
        courseMarket.setId(courseId);
        saveCourseMarket(courseMarket);
        // 返回拼接后的课程信息
        return getCourseBaseInfo(courseId);
    }

    private int saveCourseMarket(CourseMarket courseMarket) {
        String charge = courseMarket.getCharge();
        if (StringUtils.isEmpty(charge)) {
            throw new RuntimeException("收费规则为空");
        }
        // 如果课程收费
        if (charge.equals("201001")) {
            if (courseMarket.getPrice() == null || courseMarket.getPrice().floatValue() <= 0) {
                throw new RuntimeException("课程价格不能为空且必须大于0");
            }
        }
        CourseMarket market = courseMarketMapper.selectById(courseMarket.getId());
        if (market == null) {
            return courseMarketMapper.insert(courseMarket);
        } else {
            BeanUtils.copyProperties(courseMarket, market);
            return courseMarketMapper.updateById(market);
        }
    }

    public CourseBaseInfoDto getCourseBaseInfo(Long courseId) {
        CourseBase courseBase = courseBaseMapper.selectById(courseId);
        if (courseBase == null) {
            return null;
        }
        CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
        CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
        BeanUtils.copyProperties(courseBase, courseBaseInfoDto);
        if (courseMarket != null) {
            BeanUtils.copyProperties(courseMarket, courseBaseInfoDto);
        }
        String mtName = courseCategoryMapper.selectById(courseBase.getMt()).getName();
        String stName = courseCategoryMapper.selectById(courseBase.getSt()).getName();
        courseBaseInfoDto.setMtName(mtName);
        courseBaseInfoDto.setStName(stName);
        return courseBaseInfoDto;
    }

        由于涉及两张表的操作所以需要加@Transactional进行事务控制,的通过 BeanUtils 类中的copyProperties方法将原始对象拷贝到对应数据库的po对象中(需要属性名称一致),一些页面没有输入的属性需要手动设置如机构Id、状态等等);然后继续构造课程营销信息表对应的po对象,抽取为一个单独的方法(提高复用性),在对字段进行校验后分为已存在/不存在课程营销信息两种情况,分别需要新增营销信息和更新营销信息(这里视频中存在争议,重复设置了market的Id,而market就是通过courseMarket.getId()的Id查出来的,所以我去掉了)
        同时由getCourseBaseInfo获取新增的课程基本信息和营销信息组装后返回,视频最后的查询大分类和小分类的任务可以通过courseCategoryMapper根据课程分类Id查询获取
        前后端联调时点击保存后因为后续接口未实现所以报404错误是正常的,可以查询数据库或后端断点调试查看


2.5 自定义异常类

        在之前项目中的业务出现异常时都会选择抛出 throw new RuntimeException() 的方式来处理业务异常,但项目中都需要一个统一的处理异常的方法,我们可以通过全局异常处理类来统一处理异常类,而通过框架的@ControllerAdvice@ExceptionHandler可以处理异常信息

        首先需要对异常信息进行规范化,在base工程中定义一个统一返回的异常信息类:

@AllArgsConstructor
@NoArgsConstructor
@Setter
public class RestErrorResponse implements Serializable {

    private String errMessage;
}

        同时还要有一个统一的常见异常信息枚举类:

public enum CommonError {

    UNKOWN_ERROR("执行过程异常,请重试。"),
    PARAMS_ERROR("非法参数"),
    OBJECT_NULL("对象为空"),
    QUERY_NULL("查询结果为空"),
    REQUEST_NULL("请求参数为空");

    private final String errMessage;

    CommonError(String errMessage) {
        this.errMessage = errMessage;
    }

    public String getErrMessage() {
        return errMessage;
    }

}

        以及一个属于项目的异常类型:

public class XueChengPlusException extends RuntimeException {

    private String errMessage;

    public XueChengPlusException() {
    }

    public XueChengPlusException(String message) {
        super(message);
        this.errMessage = message;
    }

    public static void cast(String errMessage) {
        throw new XueChengPlusException(errMessage);
    }

    public static void cast(CommonError commonError) {
        throw new XueChengPlusException(commonError.getErrMessage());
    }
}

        接下来就可以实现对应的全局异常处理器了:

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ResponseBody
    @ExceptionHandler(XueChengPlusException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse customException(XueChengPlusException e) {
        log.error("系统异常{}", e.getMessage(), e);

        String message = e.getMessage();
        return new RestErrorResponse(message);
    }

    @ResponseBody
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse customException(Exception e) {
        log.error("系统异常{}", e.getMessage(), e);

        String message = e.getMessage();
        return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());
    }
}

@ControllerAdvice:用于定义全局控制器增强类
@ExceptionHandler():定义拦截的异常类型

@ResponseStatus():定义异常时响应给前端的状态码

        我们通过Slf4j记录异常日志,分别对项目自定义异常、系统异常进行捕获处理
        完成后可以将之前的异常处理改为XueChengPlusException.cast("错误信息")处理


三、Day-3-完善课程管理接口


3.1 JSR303校验

        在项目中我们可以通过JSR-303规范来结合@Validated注解快捷校验参数,我们可以在Controller层接受参数时统一对参数进行合法性校验,开始之前首先要添加依赖:

        <!-- 添加 Spring Boot 验证功能 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        以新增课程接口为例,通过@Validated注解激活在Dto类中的校验注解

    @ApiOperation("新增课程接口")
    @PostMapping("/course")
    public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto) {
        Long companyId = 1232141425L;
        CourseBaseInfoDto courseBase = courseBaseInfoService.createCourseBase(companyId, addCourseDto);
        return courseBase;
    }
@Data
@ApiModel(value = "AddCourseDto", description = "新增课程基本信息")
public class AddCourseDto {

    @NotEmpty(message = "新增课程名称不能为空", groups = {ValidationGroups.Insert.class})
    @ApiModelProperty(value = "课程名称", required = true)
    private String name;

    @NotEmpty(message = "适用人群不能为空")
    @Size(message = "适用人群内容过少", min = 10)
    @ApiModelProperty(value = "适用人群", required = true)
    private String users;

    @ApiModelProperty(value = "课程标签")
    private String tags;
    
    ...
}

        通过测试我们可以得知@Validated校验失败的报错异常为MethodArgumentNotValidException,所以我们可以在全局异常处理器中新增一个处理此异常的方法,通过stream流解析错误信息拼接后返回错误响应:

    @ResponseBody
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        List<String> errors = new ArrayList<>();
        // 存储错误信息
        bindingResult.getFieldErrors().forEach(fieldError -> errors.add(fieldError.getDefaultMessage()));
        // 拼接
        String errorString = StringUtils.join(errors, ",");
        log.error("系统异常{}", errorString);
        return new RestErrorResponse(errorString);
    }

        而通过给校验条件分组可以根据业务类型的不同(如新增/修改)返回不同的校验错误信息,如我们可以定义一个类,其中有各种接口代表不同的校验组别:

public class ValidationGroups {
    // 新增
    public interface Insert{}
    // 更新
    public interface Update{}
    // 删除
    public interface Delete{}
}

        通过在@Validated(value = ValidationGroups.Insert.class)注解中声明组别和DTO类上添加不同组别的错误信息可以分组给出不同的错误信息
        但是如果在@Validated中指定了组别,而类上类似@NotEmpty的类没有指定组别,那么其不会生效,因为在其没有指定组别时属于Default默认)组,所以不会在定义的组别中生效

@NotEmpty(message = "新增课程名称不能为空", groups = {ValidationGroups.Insert.class})     
@NotEmpty(message = "修改课程名称不能为空", groups = {ValidationGroups.Update.class})

        如果上述的注解仍无法满足要求,可以选择自定义校验规则代码和注解(自行查阅)


3.2 实现修改课程

        老样子,修改接口需要先根据Id查询课程,根据查询出的信息展示以进行修改,实现修改前先实现课程查询Controller接口:

    @ApiOperation("根据 Id 查询课程接口")
    @GetMapping("course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
        CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.getCourseBaseInfo(courseId);
        return courseBaseInfo;
    }

        mapper之前已经自动生成,而根据Id查询课程信息在之前的新增课程信息中已经实现过了,所以只需在接口层声明即可直接使用:

    public CourseBaseInfoDto getCourseBaseInfo(Long courseId) {

        CourseBase courseBase = courseBaseMapper.selectById(courseId);
        if (courseBase == null) {
            return null;
        }
        CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
        CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
        BeanUtils.copyProperties(courseBase, courseBaseInfoDto);
        if (courseMarket != null) {
            BeanUtils.copyProperties(courseMarket, courseBaseInfoDto);
        }
        String mtName = courseCategoryMapper.selectById(courseBase.getMt()).getName();
        String stName = courseCategoryMapper.selectById(courseBase.getSt()).getName();
        courseBaseInfoDto.setMtName(mtName);
        courseBaseInfoDto.setStName(stName);
        return courseBaseInfoDto;
    }

        然后就可以继续写修改接口了,重新定义一个修改课程的模型类接受参数,因为修改仅比新增多一个课程Id参数,所以让新模型类继承之前的AddCourseDto后新加一个Id字段即可:

@Data
public class EditCourseDto extends AddCourseDto{

    @ApiModelProperty(value = "课程id", required = true)
    private Long id;
}

(注意:视频中定义的是courseId,后续接口调试会出错,因为前端传入的字段是Id)

        接口代码如下,仍然需要companyId参数:

    @ApiOperation("修改课程接口")
    @PutMapping("course")
    public CourseBaseInfoDto modifyCourseBase(@RequestBody @Validated(value = ValidationGroups.Update.class) EditCourseDto editCourseDto) {
        Long companyId = 1232141425L;
        CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.updateCourseBase(companyId, editCourseDto);
        return courseBaseInfo;
    }

        紧接着实现Service接口的方法:

    @Override
    @Transactional
    public CourseBaseInfoDto updateCourseBase(Long companyId, EditCourseDto editCourseDto) {
        Long courseId = editCourseDto.getId();
        CourseBase courseBase = courseBaseMapper.selectById(courseId);
        if (courseBase == null) {
            XueChengPlusException.cast("课程不存在");
        }
        if (!companyId.equals(courseBase.getCompanyId())) {
            XueChengPlusException.cast("只允许修改本机构的课程");
        }
        BeanUtils.copyProperties(editCourseDto, courseBase);
        courseBase.setChangeDate(LocalDateTime.now());
        int saveBase = courseBaseMapper.updateById(courseBase);
        if (saveBase <= 0) {
            XueChengPlusException.cast("修改课程失败");
        }
        // 更新课程营销信息
        CourseMarket courseMarket = new CourseMarket();
        BeanUtils.copyProperties(editCourseDto, courseMarket);
        int saveMarket = saveCourseMarket(courseMarket);
        if (saveMarket <= 0) {
            XueChengPlusException.cast("修改课程失败");
        }
        return getCourseBaseInfo(courseId);
    }

        同样的,因为存在对两张表的操作,所以需要加@Transactional注解;同时,会存在一些业务逻辑的校验不能通过JSR303规范校验,需要结合具体业务实现,如修改课程规定机构能修改属于自己机构的课程,这需要我们通过比较机构IdEditCourseDto类中的机构Id来判断业务是否异常
        使用copyProperties拷贝数据到CourseBase类中,并手动设置数据更新时间在更新课程基本信息后继续更新课程营销信息,最终返回查询获得修改后的新课程信息

        注意,前后端联调前注意检查前端工程中的.env文件中关于后端服务地址的配置,需要改为
http://localhost:内容管理服务端口保证前端能够访问后端接口


3.3 实现课程计划查询与增/改

        分析课程计划表,同样是与课程分类相同的树状结构,通过parentId关联父子节点(大小章节),创建一个模型类来存放对应的课程计划信息,同样有一个属性存放子节点:

@Data
@ToString
public class TeachPlanDto extends Teachplan {

    private TeachplanMedia teachplanMedia;

    private List<TeachPlanDto> teachPlanTreeNodes;
}

        继承Teachplan课程计划类,其中的TeachplanMedia是与课程计划关联的媒资信息,也就是每节课程都有对应的视频或文档资料需要记录其媒资信息,之后同样是Controller—Mapper—Service流程开始定义接口,从下往上实现接口方法:

@Api(value = "课程计划接口", tags = "课程计划接口")
@RestController
public class TeachPlanController {

    @Autowired
    private TeachPlanService teachPlanService;

    @ApiOperation("查询课程计划接口")
    @GetMapping("/teachplan/{courseId}/tree-nodes")
    public List<TeachPlanDto> getTreeNodes(@PathVariable Long courseId) {
        return teachPlanService.findTeachPlanTree(courseId);
    }
}
    /**
     * 查询课程计划树形结构
     *
     * @param courseId 课程id
     * @return {@link List<TeachPlanDto>}
     */
    List<TeachPlanDto> findTeachPlanTree(Long courseId);

        同理,查询树状数据可以使用自连接和递归两种方式,这里使用自连接查询获取课程计划信息:

<select id="selectTreeNodes" parameterType="long" resultMap="treeNodeResultMap">
        select one.id one_id,
               one.pname one_pname,
               one.parentid one_parentid,
               one.grade one_grade,
               one.media_type one_media_type,
               one.start_time one_start_time,
               one.end_time one_end_time,
               one.orderby one_orderby,
               one.course_id one_course_id,
               one.course_pub_id one_course_pub_id,
               two.id two_id,
               two.pname two_pname,
               two.parentid two_parentid,
               two.grade two_grade,
               two.media_type two_media_type,
               two.start_time two_start_time,
               two.end_time two_end_time,
               two.orderby two_orderby,
               two.course_id two_course_id,
               two.course_pub_id two_course_pub_id,
               m1.media_fileName mediaFilename,
               m1.id teachplanMediaId,
               m1.media_id mediaId
        from teachplan one
                 left join teachplan two on two.parentid = one.id
                 left join teachplan_media m1 on m1.teachplan_id=two.id
        where one.parentid = 0 and one.course_id=#{courseId}
        order by one.orderby, two.orderby
    </select>

        分析表结构,可以得知parentId0的为章,通过parentid关联的two表为章下的小节,同时因为需要媒资信息,所以再通过小节的主键Id关联媒资信息表

(注意:为避免在媒资信息或课程计划小节不存在时无法查询到存在的小节信息和章信息,这里使用left join左外连接,无论右表是否有对应数据,都会显示)

        后通过courseId查询具体课程下的课程计划信息,并通过章的排序和小节的排序字段进行排序;由于查询获得的数据字段与定义的模型类不对应,所以这里需要resultMap来自定义字段映射情况:

<resultMap id="treeNodeResultMap" type="com.xuecheng.content.model.dto.TeachPlanDto">
        <id column="one_id" property="id"/>
        <result column="one_pname" property="pname" />
        <result column="one_parentid" property="parentid" />
        <result column="one_grade" property="grade" />
        <result column="one_mediaType" property="mediaType" />
        <result column="one_startTime" property="startTime" />
        <result column="one_endTime" property="endTime" />
        <result column="one_orderby" property="orderby" />
        <result column="one_courseId" property="courseId" />
        <result column="one_coursePubId" property="coursePubId" />
        <!-- 一对多映射 -->
        <collection property="teachPlanTreeNodes" ofType="com.xuecheng.content.model.dto.TeachPlanDto">
            <id column="two_id" property="id"/>
            <result column="two_pname" property="pname" />
            <result column="two_parentid" property="parentid" />
            <result column="two_grade" property="grade" />
            <result column="two_mediaType" property="mediaType" />
            <result column="two_startTime" property="startTime" />
            <result column="two_endTime" property="endTime" />
            <result column="two_orderby" property="orderby" />
            <result column="two_courseId" property="courseId" />
            <result column="two_coursePubId" property="coursePubId" />
            <!-- 一对一映射 -->
            <association property="teachplanMedia" javaType="com.xuecheng.content.model.po.TeachplanMedia">
                <id column="teachplanMediaId" property="id" />
                <result column="mediaFilename" property="mediaFilename" />
                <result column="mediaId" property="mediaId" />
            </association>
        </collection>
    </resultMap>

映射父节点(章)

id映射主键属性

result映射普通属性

column对应查询出的字段名

property对应type指定类中属性名
一对多映射子节点(小节)
collection负责映射子节点(一章对多节)
ofType指列表中的对象类型

一对一映射媒资信息
association负责映射媒资信息(一节对一个)

        断点调试可以看到成功获取到了对应课程的计划信息:

        接下来,继续实现课程计划接口的基础增加/修改功能,新增功能分为添加大章和小节,而由于新增方法的数据结构相同,可以使用一个接口实现,定义一个通用的DTO类同时用于新增和更改:

@Data
@ToString
public class SaveTeachplanDto {


    /**
     * 课程计划id
     */
    public Long id;

    /**
     * 课程计划名称
     */
    public String pname;

    /**
     * 课程计划父级Id
     */
    private Long parentid;

    /**
     * 层级 1 2 3 级
     */
    private Integer grade;

    /**
     * 课程类型 1视频 2文档
     */
    private String mediaType;

    /**
     * 课程标识
     */
    private Long courseId;

    /**
     * 课程发布标识
     */
    private Long coursePubId;

    /**
     * 是否支持试看
     */
    private String isPreview;
}

        接口定义:

    @ApiOperation("课程计划创建或修改接口")
    @PostMapping("/teachplan")
    public void saveTeachplan(@RequestBody SaveTeachplanDto saveTeachplanDto) {
        teachPlanService.saveTeachplan(saveTeachplanDto);
    }

        我们可以通过参数中是否存在课程计划Id(courseId)来判断是新增或是修改:

    @Override
    public void saveTeachplan(SaveTeachplanDto saveTeachplanDto) {
        Long teachplanId = saveTeachplanDto.getId();
        if (teachplanId == null) {
            // 新增课程计划
            Teachplan teachplan = new Teachplan();
            BeanUtils.copyProperties(saveTeachplanDto, teachplan);
            // 确定同级最后一条章节的排序字段
            Long parentid = saveTeachplanDto.getParentid();
            Long courseId = saveTeachplanDto.getCourseId();
            LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(Teachplan::getCourseId, courseId).eq(Teachplan::getParentid, parentid);
            Teachplan lastTeachplan = teachplanMapper.selectOne(queryWrapper.orderByDesc(Teachplan::getOrderby).last("LIMIT 1"));
            int orderBy = (lastTeachplan != null && lastTeachplan.getOrderby() != null) ? lastTeachplan.getOrderby() : 0;
            teachplan.setOrderby(orderBy + 1);
            // 插入新课程计划
            teachplanMapper.insert(teachplan);

        } else {
            // 修改课程计划
            Teachplan teachplan = teachplanMapper.selectById(teachplanId);
            BeanUtils.copyProperties(saveTeachplanDto, teachplan);
            teachplanMapper.updateById(teachplan);
        }
    }

        如果courseId为空则需要新增课程计划,反之需要更新课程计划;在新增课程时需要注意课程计划有排序字段,需要从数据库中获取同级章节最后一个课程计划的排序字段+1作为新计划的排序字段,否则在删除课程计划的业务前提下如果按计划数量分配排序字段会导致排序混乱


四、Day-4-项目实战-完善课程计划管理


        因为是个人学习,所以这一节就直接把我写好的代码放上了,如有错误,请指出:


4.1 课程管理

Controller层

@Api(value = "课程计划接口", tags = "课程计划接口")
@RestController
public class TeachPlanController {

    @Autowired
    private TeachPlanService teachPlanService;

    @ApiOperation("查询课程计划接口")
    @GetMapping("/teachplan/{courseId}/tree-nodes")
    public List<TeachPlanDto> getTreeNodes(@PathVariable Long courseId) {
        return teachPlanService.findTeachPlanTree(courseId);
    }

    @ApiOperation("课程计划创建或修改接口")
    @PostMapping("/teachplan")
    public void saveTeachplan(@RequestBody SaveTeachplanDto saveTeachplanDto) {
        teachPlanService.saveTeachplan(saveTeachplanDto);
    }

    @ApiOperation("课程计划删除接口")
    @DeleteMapping("/teachplan/{courseId}")
    public void deleteTeachplan(@PathVariable Long courseId) {
        teachPlanService.deleteTeachplan(courseId);
    }

    @ApiOperation("课程计划排序接口")
    @PostMapping("/teachplan/{moveType}/{courseId}")
    public void moveupTeachplan(@PathVariable Long courseId, @PathVariable String moveType) {
        teachPlanService.moveTeachplan(courseId, moveType);
    }
}

Srervice层

public interface TeachPlanService {

    /**
     * 查询课程计划树形结构
     *
     * @param courseId 课程id
     * @return {@link List<TeachPlanDto>}
     */
    List<TeachPlanDto> findTeachPlanTree(Long courseId);

    /**
     * 新增/修改课程计划
     *
     * @param saveTeachplanDto 课程计划信息
     */
    void saveTeachplan(SaveTeachplanDto saveTeachplanDto);

    /**
     * 删除课程计划
     *
     * @param courseId 课程计划id
     */
    void deleteTeachplan(Long courseId);

    /**
     * 移动课程计划
     *
     * @param courseId 课程id
     * @param moveType 移动类型
     */
    void moveTeachplan(Long courseId, String moveType);
}

Impl实现类

@Slf4j
@Service
public class TeachPlanServiceImpl implements TeachPlanService {

    @Autowired
    private TeachplanMapper teachplanMapper;

    @Override
    public List<TeachPlanDto> findTeachPlanTree(Long courseId) {
        return teachplanMapper.selectTreeNodes(courseId);
    }

    @Override
    public void saveTeachplan(SaveTeachplanDto saveTeachplanDto) {
        Long teachplanId = saveTeachplanDto.getId();
        if (teachplanId == null) {
            // 新增课程计划
            Teachplan teachplan = new Teachplan();
            BeanUtils.copyProperties(saveTeachplanDto, teachplan);
            // 确定章节的排序字段
            Long parentid = saveTeachplanDto.getParentid();
            Long courseId = saveTeachplanDto.getCourseId();
            LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(Teachplan::getCourseId, courseId).eq(Teachplan::getParentid, parentid);
            Teachplan lastTeachplan = teachplanMapper.selectOne(queryWrapper.orderByDesc(Teachplan::getOrderby).last("LIMIT 1"));
            int orderBy = (lastTeachplan != null && lastTeachplan.getOrderby() != null) ? lastTeachplan.getOrderby() : 0;
            teachplan.setOrderby(orderBy + 1);
            teachplanMapper.insert(teachplan);

        } else {
            // 修改课程计划
            Teachplan teachplan = teachplanMapper.selectById(teachplanId);
            BeanUtils.copyProperties(saveTeachplanDto, teachplan);
            teachplanMapper.updateById(teachplan);
        }
    }

    @Override
    public void deleteTeachplan(Long courseId) {
        Teachplan teachplan = teachplanMapper.selectById(courseId);
        if (teachplan.getParentid() == 0) {
            // 查询该课程计划是否有子课程计划
            LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(Teachplan::getParentid, courseId);
            Integer subCourseCount = teachplanMapper.selectCount(queryWrapper);
            if (subCourseCount > 0) {
                XueChengPlusException.cast("该课程计划有子课程计划,无法操作");
            }
        }
        // 删除课程计划
        teachplanMapper.deleteById(courseId);
    }

    @Override
    public void moveTeachplan(Long courseId, String moveType) {
        // 查询课程计划, 如果没有课程计划,则不能操作
        Teachplan teachplan = teachplanMapper.selectById(courseId);
        if (ObjectUtils.isEmpty(teachplan)) {
            XueChengPlusException.cast("该课程计划不存在,请重试");
        }
        Long totalCourseId = teachplan.getCourseId();
        Teachplan newTeachplan = getTeachPlan(totalCourseId, teachplan, moveType);
        swapOrderBy(teachplan, newTeachplan);
    }

    private Teachplan getTeachPlan(Long courseId, Teachplan teachplan, String moveType) {
        // 查询上一个课程计划, 如果没有责不能操作(orderby 比当前小且最接近的)
        LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
        Long parentid = teachplan.getParentid();
        Integer orderby = teachplan.getOrderby();
        queryWrapper.eq(Teachplan::getParentid, parentid)
                .eq(Teachplan::getCourseId, courseId);
        // 初始化错误信息
        String errorString = "";
        // 判断是向上移动还是向下移动
        if ("moveup".equals(moveType)) {
            queryWrapper.lt(Teachplan::getOrderby, orderby);
            errorString = "没有上一个课程计划,请重试";
        } else if ("movedown".equals(moveType)) {
            queryWrapper.gt(Teachplan::getOrderby, orderby);
            errorString = "没有下一个课程计划,请重试";
        } else {
            XueChengPlusException.cast("移动参数异常,请重试");
        }
        queryWrapper.orderByDesc(Teachplan::getOrderby)
                .last("LIMIT 1");
        Teachplan newTeachplan = teachplanMapper.selectOne(queryWrapper);
        if (ObjectUtils.isEmpty(newTeachplan)) {
            XueChengPlusException.cast(errorString);
        }
        return newTeachplan;
    }

    private void swapOrderBy(Teachplan teachplan, Teachplan newTeachplan) {
        // 互换排序字段
        Integer newOrderBy = newTeachplan.getOrderby();
        Integer orderBy = teachplan.getOrderby();
        teachplan.setOrderby(newOrderBy);
        newTeachplan.setOrderby(orderBy);
        teachplanMapper.updateById(teachplan);
        teachplanMapper.updateById(newTeachplan);
    }
}

4.2 师资管理

需要注意接口文档中新增和更新是俩个接口,实际上前端都只会请求这个:@PostMapping("/courseTeacher"),所以需要在其中对teacherId进行判断是新增/更新

Controller层

@Api(value = "课程教师接口", tags = "课程教师接口")
@RestController
public class CourseTeacherController {

    @Autowired
    private CourseTeacherService courseTeacherService;

    @ApiOperation("查询课程教师接口")
    @GetMapping("/courseTeacher/list/{courseId}")
    public List<CourseTeacher> listCourseTeacher(@PathVariable Long courseId) {
        return courseTeacherService.listCourseTeacher(courseId);
    }

    @ApiOperation("更新课程教师接口")
    @PostMapping("/courseTeacher")
    public CourseTeacher addCourseTeacher(@RequestBody @Validated CourseTeacher courseTeacher) {
        Long companyId = 1232141425L;
        return courseTeacherService.updateCourseTeacher(courseTeacher, companyId);
    }

    @ApiOperation("删除课程教师接口")
    @DeleteMapping("/courseTeacher/course/{courseId}/{teacherId}")
    public void deleteCourseTeacher(@PathVariable Long courseId, @PathVariable Long teacherId) {
        Long companyId = 1232141425L;
        courseTeacherService.deleteCourseTeacher(courseId, teacherId, companyId);
    }
}

Service层

public interface CourseTeacherService {

    /**
     * 查询课程分类
     *
     * @param courseId 课程 id
     * @return {@link List<CourseTeacher>}
     */
    List<CourseTeacher> listCourseTeacher(Long courseId);

    /**
     * 更新课程教师接口
     *
     * @param courseTeacher 课程教师
     * @param companyId 机构id
     * @return {@link CourseTeacher}
     */
    CourseTeacher updateCourseTeacher(CourseTeacher courseTeacher, Long companyId);

    /**
     * 删除课程教师接口
     *
     * @param courseId  课程 id
     * @param teacherId 教师 id
     * @param companyId 机构 id
     */
    void deleteCourseTeacher(Long courseId, Long teacherId, Long companyId);
}

Impl实现

@Slf4j
@Service
public class CourseTeacherServiceImpl implements CourseTeacherService {

    @Autowired
    private CourseTeacherMapper courseTeacherMapper;

    @Autowired
    private CourseBaseMapper courseBaseMapper;

    @Override
    public List<CourseTeacher> listCourseTeacher(Long courseId) {
        if (courseId == null) {
            XueChengPlusException.cast("课程不存在,请重试");
        }
        LambdaQueryWrapper<CourseTeacher> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(CourseTeacher::getCourseId, courseId);
        return courseTeacherMapper.selectList(queryWrapper);
    }

    @Override
    public CourseTeacher updateCourseTeacher(CourseTeacher courseTeacher, Long companyId) {
        Long courseId = courseTeacher.getCourseId();
        Long teacherId = courseTeacher.getId();
        isOtherCompany(companyId, courseId);
        if (teacherId == null) {
            // teacherId为null新增
            courseTeacherMapper.insert(courseTeacher);
        } else {
            // 反之则更新现有
            CourseTeacher teacher = courseTeacherMapper.selectById(courseTeacher.getId());
            BeanUtils.copyProperties(courseTeacher, teacher);
            courseTeacherMapper.updateById(teacher);
        }
        return courseTeacher;
    }

    @Override
    public void deleteCourseTeacher(Long courseId, Long teacherId, Long companyId) {
        isOtherCompany(companyId, courseId);
        courseTeacherMapper.selectById(teacherId);
        if (teacherId == null) {
            XueChengPlusException.cast("该教师不存在");
        }
        courseTeacherMapper.deleteById(teacherId);
    }


    private void isOtherCompany(Long companyId, Long courseId) {
        // 判断是否是本机构对应的课程
        CourseBase courseBase = courseBaseMapper.selectById(courseId);
        if (courseBase == null) {
            XueChengPlusException.cast("课程不存在");
        }
        if (!companyId.equals(courseBase.getCompanyId())) {
            XueChengPlusException.cast("只允许操作本机构的课程信息");
        }
    }
}

4.3 课程删除

Impl实现

    @Override
    @Transactional
    public void deleteCourseBase(Long courseId, Long companyId) {
        // 首先判断课程是否属于当前机构
        CourseBase courseBase = courseBaseMapper.selectById(courseId);
        if (courseBase == null) {
            XueChengPlusException.cast("课程不存在");
        }
        if (!companyId.equals(courseBase.getCompanyId())) {
            XueChengPlusException.cast("只允许删除本机构的课程");
        }
        // 审核状态未提交时可删除
        if ("202002".equals(courseBase.getAuditStatus())) {
            // 删除营销信息
            courseMarketMapper.deleteById(courseId);
            // 删除课程计划信息 + 子课程媒资信息
            teachplanMediaMapper.delete(new LambdaQueryWrapper<TeachplanMedia>().eq(TeachplanMedia::getCourseId, courseId));
            teachplanMapper.delete(new LambdaQueryWrapper<Teachplan>().eq(Teachplan::getCourseId, courseId));
            // 删除教师信息
            courseTeacherMapper.delete(new LambdaQueryWrapper<CourseTeacher>().eq(CourseTeacher::getCourseId, courseId));
            // 删除课程基本信息
            courseBaseMapper.deleteById(courseId);
        } else {
            XueChengPlusException.cast("课程审核中,不允许删除");
        }
    }

五、Day-5-媒资管理-上传图片


5.1 引入Nacos与网关服务

        在微服务架构中,会存在多个服务,具有不同的端口供前端去调用服务接口,但是在前端工程中的system.ts文件中关于系统服务的请求url是写死的,这会导致一个问题:在不同环境中要求不同,本地开发可以是写死的绝对路径,但是在局域网、公网、部署项目时,具体的url需要变成域名等等,而前端需要和所有的微服务进行对接,这太麻烦了
        网关的出现正是为了解决前端工程过多的与后端微服务的重复对接,这样前端只需和网关对接,由网关对接微服务来进行路由转发,而不同的服务下会有一些实例,而网关可以进行负载均衡,将相同的请求分配给不同的服务实例等等功能
        而想要实现网关就需要网关提前获取到其他微服务的地址,所以就需要一个服务注册中心来注册服务,而Nacos服务注册中心中间件)服务就可以满足这个需求

(使用资料中的虚拟机需要先启动Mysql)

Nacos

nacos中的两个重要概念:

  • namespace:命名空间,用于区分不同的开发环境(如开发、测试、生产环境)
  • group:用于区分不同的组别(常用于区分项目如:苍穹外卖、学成在线)

        我们需要在项目中引入Nacos依赖

        <!-- nacos 配置管理 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>
  • discovery:用于服务注册到Nacos
  • config:用于读取Nacos中的配置

        接下来需要在bootstrap.yml配置文件中配置Nacos的四个相关信息即可注册:

...
#微服务配置
spring:
  application:
    name: content-api
  cloud:
    nacos:
      server-addr: nacos地址:端口
      # 服务注册
      discovery:
        namespace: 命名空间
        group: xuecheng-plus-project
...

(注意:这里的组名推荐使用xuecheng-plus-project,因为后续需要导入的大量配置都是xuecheng-plus-project组,如果不一样的话需要一个一个修改,非常麻烦)

        配置好后启动服务,即可在Nacos的网页控制台中对应命名空间的服务列表中看到服务content-api
        创建一个新的命名空间:

        在完成服务注册后可以进行配置中心的搭建,可以通过Nacos统一管理微服务的公共配置和特有配置,不仅能高效的修改配置,还减少了项目配置文件的内容

        在服务的配置中,需要通过配置对应信息确定Nacos中的配置文件,分别是:

  • namespace:命名空间
  • group:组名
  • file-extension:配置文件扩展名
  • dataid:由 (服务名)-(环境名).(文件扩展名)组成

        然后在Nacos控制台的配置管理的创建的对应空间(环境)中添加配置发布供服务读取:

        配置后就可以将原本配置文件中的部分去掉了,启动服务检查配置是否生效

       (注意:失败的可以一步步检查,首先保证Nacos中的配置是在对应namespece下创建的,同时注意需要引入配置中心的依赖,确保配置与Nacos中的文件名、group以及data-id能够对应)

        但是,作为服务端的api模块,其不需要配置数据库,只需引用Service工程的配置即可,通过创建Service工程的配置文件并在api模块中引用来实现
        Service模块中添加配置中心依赖,并在Service工程中配置Nacos配置中心的相关配置

        完成后需要在api模块的配置文件中配置引入Service模块在Nacos中的配置文件

spring:
  application:
    name: content-api
  cloud:
    nacos:
      server-addr: nacos地址:端口
      # 服务注册
      discovery:
        namespace: dev-personal
        group: xuecheng-plus-project
      # 配置文件
      config:
        namespace: dev-personal
        group: xuecheng-plus-project
        file-extension: yaml
        refresh-enabled: true
        extension-configs:
          - data-id: content-service-${spring.profiles.active}.yaml
            group: xuecheng-plus-project
            refresh: true
  # 配置环境
  profiles:
    active: dev

        通过extension-configs可以配置扩展引入了Service的配置文件,refresh配置了动态刷新配置文件;相应的,除了可以将数据库配置从配置文件中抽出来,还可以将一些公用的配置进行抽取,如swaggerlogging配置:

        在配置文件中可以使用shared-configs配置引用公用配置:

...
extension-configs:
  - data-id: content-service-${spring.profiles.active}.yaml
    group: xuecheng-plus-project
    refresh: true
shared-configs:
  - data-id: swagger-${spring.profiles.active}.yaml
    group: xuecheng-plus-common
    refresh: true
  - data-id: logging-${spring.profiles.active}.yaml
    group: xuecheng-plus-common
    refresh: true
...

Spring Cloud Alibaba Nacos配置中,extension-configsshared-configs的主要区别如下:

  • hared-configs:

用途:用于存放多个微服务共用的配置(如日志 logging、Swagger 文档等)。
特点:与当前应用名称无关,通常放在公共 Group(如 xuecheng-plus-common)下,便于统一管理和复用。 

  • extension-configs:

用途:用于存放当前应用特有但需要拆分出来的额外配置文件。
特点:通常与当前应用强相关(如示例中的 content-service-...),作为主配置文件的补充,优先级高于共享配置。

  • 默认优先级:

项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件

如果需要本地配置文件优先,需要在Nacos中的配置中配置本地优先

# 配置本地配置优先
spring:
  cloud:
    config:
      override-none: true

(后续的配置系统工程与配置内容管理模块相似;导入其余配置文件记得选择跳过已有配置,如果有信息如数据库地址对不上记得手动修改,注意依赖和配置信息对应即可)

   继续实现网关服务,首先,创建一个微服务作为网关服务,其需要启动类和配置文件,结构如下:

依赖主要是网关和Nacos相关依赖:

<dependencies>
    <!--网关-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <!--服务发现中心-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!-- 排除 Spring Boot 依赖的日志包冲突 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-logging</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <!-- Spring Boot 集成 log4j2 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
</dependencies>

        配置文件中配置了Nacos的相关信息和服务名:

#微服务配置
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: nacos地址:端口
      discovery:
        namespace: dev-personal
        group: xuecheng-plus-project
      config:
        namespace: dev-personal
        group: xuecheng-plus-project
        file-extension: yaml
        refresh-enabled: true
        shared-configs:
          - data-id: logging-${spring.profiles.active}.yaml
            group: xuecheng-plus-common
            refresh: true
  profiles:
    active: dev

        在之前导入的配置中就有网关的配置:

配置完成网关服务后,就可以将前端的访问地址改为网关的服务地址了:
.env文件中启用VUE_APP_SERVER_API_URL为网关的服务地址

systen.ts文件中改为使用'/system/dictionary/all'地址即可

(注意:在服务启动后存在窗口期,网关服务需要一些时间才能够成功获取其他服务)


5.2 媒资管理系统与Minio

        而在实现网关服务后,就可以继续实现媒资管理系统,而媒资管理涉及到了文件的存储与读取,这就需要了解一种叫做文件系统的概念,如Windows电脑的文件系统就是NTFS系统,或是阿里云的OSS对象存储服务

        而下面要了解的Minio就是一个轻量级的去中心化分布式文件系统中间件,其下每个节点都是对等关系,减少了数据丢失和损坏的几率;使用纠删码技术保护上传的数据,当上传文件是会将其分块存储,分为数据块和校验块分散存储,即便丢失一半数量的硬盘也可以恢复数据,不影响上传和下载,如果多于一半则无法恢复
        通过Docker启动Minio可以通过 http://虚拟机地址:9001/login 去访问Minio的控制台,而其也提供了供不同语言调用的SDK,可以通过Java操作Minio进行上传、删除和下载文件等操作

        首先需要创建Media媒资管理模块,在资料中有压缩包所以直接解压导入maven项目后即可

        引入Minio的SDK和http请求依赖:

<!-- minio -->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.4.3</version>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.8.1</version>
</dependency>

使用Mioin的配置信息已在Nacos上传的配置中配置好了

@Test
public void testMinio() throws Exception {
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
    String mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
    if (extensionMatch != null) {
        mediaType = extensionMatch.getMimeType();
    }
    minioClient.uploadObject(UploadObjectArgs.builder()
            .bucket("testbucket")
            .object("test/01/1.mp4")
            .filename("E:\\game\\1.mp4")
            .contentType(mediaType)
            .build());
}

通过测试了解到上传文件需要四个参数:

  • bucket:桶名
  • object:上传的文件目录+文件名(使用 / 分割目录)
  • filename:本地文件路径
  • contentType:文件类型(通过第三方包simplemagic通过扩展名查找Type)
@Test
public void testRemoveMinio2() throws Exception {

    minioClient.removeObject(RemoveObjectArgs.builder()
            .bucket("testbucket")
            .object("test/01/1.mp4")
            .build());
}

删除方法通过bucketobject定位到需要删除的文件删除

@Test
public void testGetFile() throws  Exception {
    GetObjectArgs objectArgs = GetObjectArgs.builder()
            .bucket("testbucket")
            .object("test/01/1.mp4")
            .build();

    FilterInputStream fileInputStream = minioClient.getObject(objectArgs);

    // 先计算源文件的 MD5(在流被读取之前)
    String source_md5 = DigestUtils.md5Hex(fileInputStream);

    // 重新获取流用于下载
    FilterInputStream downloadStream = minioClient.getObject(objectArgs);
    FileOutputStream fileOutputStream = new FileOutputStream("E:\\12.mp4");
    IOUtils.copy(downloadStream, fileOutputStream);

    // 完整性校验
    String local_md5 = DigestUtils.md5Hex(new FileInputStream("E:\\12.mp4"));
    if (source_md5.equals(local_md5)) {
        System.out.println("=======下载成功=======");
    } else {
        System.out.println("=======下载失败,MD5 不匹配=======");
        System.out.println("源文件 MD5: " + source_md5);
        System.out.println("本地文件 MD5: " + local_md5);
    }
    // 关闭流
    fileInputStream.close();
    downloadStream.close();
    fileOutputStream.close();
}

下载(查询)方法同理定位到对应文件后通过文件流接收,因为流是一次性的无法复用,所以先获取一次计算md5值用于后续完整性校验,后续重新读取新的文件流拷贝到本地计算比较才能正常匹配

        接下来需要实现媒资管理系统的服务接口,注意在前端工程.env文件中注意根据Minio服务的地址修改,否则图片无法正常显示;而由于前端已经有文件地址的前部分,所以数据库只需存储访问地址,访问时进行拼接即可,增加了复用性

        分析媒资管理服务,通过接收前端上传的图片并保存到数据库和Minio中:

        Minio中有两个桶,分别用于存储文件/文档和视频,注意需要在控制台中把访问权限改为public公开;之后需要创建Minio的配置文件配置基本信息:

@Configuration
public class MinioConfig {

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.accessKey}")
    private String accessKey;

    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient(){
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

        使用@Value读取配置信息创建MinioClient注册为Bean,后续注入使用即可

        继续实现图片上传接口与实现:

@Api(value = "媒资文件管理接口", tags = "媒资文件管理接口")
@RestController
public class MediaFilesController {


    @Autowired
    MediaFileService mediaFileService;

    ......

    @ApiOperation("上传图片接口")
    @RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
        return null
    }

}

        创建上传图片接口,@RequestMapping注解中用counsumes指定接收的参数类型为 multipart/form-data二进制文件,如图片、视频
        单独创建用于上传成功后返回数据的类:

@Data
@ToString
public class UploadFileResultDto extends MediaFiles {
}

        而实现图片上传接口可以分两步进行,首先需要将文件上传到Minio,然后保存信息到数据库中实现文件信息管理;创建一个类来存储上传文件需要的信息:

@Data
@ToString
public class UploadFileParamsDto {

    /**
     * 文件名称
     */
    private String filename;

    /**
     * 文件类型(文档,音频,视频)
     */
    private String fileType;
    /**
     * 文件大小
     */
    private Long fileSize;

    /**
     * 标签
     */
    private String tags;

    /**
     * 上传人
     */
    private String username;

    /**
     * 备注
     */
    private String remark;
}

        而通过测试上传方法,可以知道需要上传文件路径和一些文件基本信息,同时还需要机构Id来判断属于哪个机构,所以接口方法如下:

/**
 * 上传文件信息保存
 *
 * @param companyId           机构id
 * @param paramsDto           上传文件信息参数
 * @param loadFilePath        上传文件路劲
 * @return com.xuecheng.media.model.dto.UploadFileResultDto
 */
UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath);

ServiceImpl实现接口

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
    String filename = paramsDto.getFilename();
    String extension = filename.substring(filename.lastIndexOf("."));
    String mediaType = getMediaType(extension);
    ...
}

private static String getMediaType(String extension) {
    if (extension == null) {
        extension = "";
    }
    // 根据扩展名获取媒资类型
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
    String mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
    if (extensionMatch != null) {
        mediaType = extensionMatch.getMimeType();
    }
    return mediaType;
}

        首先通过文件名获取到文件的扩展名后通过扩展名获取到文件对应的媒资类型

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
    ...
    // 年月日拼接目录路径
    String folderPath = getDefaultFolderPath();
    String fileMd5 = getFileMd5(new File(loadFilePath));
    String objectName = folderPath + fileMd5 + extension;
    ...
}

private String getDefaultFolderPath() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    // 2026-03-14  ==> 2026/03/14/
    return sdf.format(new Date()).replace("-", "/") + "/";
}

        第二步通过字符串操作获取对应当前日期时间的目录结构用于拼接在Minio中存储文件的路径,这里计算的Md5值作为文件名

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
    ...
    // 上传到 minio 中
    boolean result = addMediaFileToMinio(bucket_mediaFiles, loadFilePath, mediaType, objectName);
    if (!result) {
        XueChengPlusException.cast("上传文件失败");
    }
    String objectName = folderPath + fileMd5 + extension;
    ...
}

private boolean addMediaFileToMinio(String backet, String loadFilePath, String mediaType, String objectName) {
        try {
            minioClient.uploadObject(UploadObjectArgs.builder()
                    .bucket(backet)
                    .object(objectName)
                    .filename(loadFilePath)
                    .contentType(mediaType)
                    .build());
            log.debug("上传文件成功 backet:{}, objectName:{}", backet, objectName);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            log.error("上传文件出错 backet:{}, objectName:{},错误信息:{}", backet, objectName, e.getMessage());
        }
        return false;
    }

        第三步将文件上传至Minio中,调用Minio的SDK进行上传操作
 

@Autowired
private MediaFileService selfProxy;

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
    ...
    // 保存到数据库中
    MediaFiles mediaFiles = selfProxy.addMediaFilesToDb(companyId, fileMd5, paramsDto, bucket_mediaFiles, objectName);
    if (ObjectUtils.isEmpty(mediaFiles)) {
        XueChengPlusException.cast("文件上传后信息保存失败");
    }
    UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
    BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
    return uploadFileResultDto;
}

@Transactional
@Override
public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto paramsDto, String bucket, String objectName) {
    // 保存到数据库中
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
    if (mediaFiles == null) {
        mediaFiles = new MediaFiles();
        BeanUtils.copyProperties(paramsDto, mediaFiles);
        mediaFiles.setId(fileMd5);
        mediaFiles.setCompanyId(companyId);
        mediaFiles.setBucket(bucket);
        mediaFiles.setFilePath(objectName);
        mediaFiles.setFileId(fileMd5);
        mediaFiles.setUrl("/" + bucket + "/" + objectName);
        mediaFiles.setCreateDate(LocalDateTime.now());
        mediaFiles.setStatus("1");
        mediaFiles.setAuditStatus("002003");
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert <= 0) {
            log.debug("向数据库保存信息失败, bucket:{}, objectName:{}", bucket, objectName);
            return null;
        }
    }
    return mediaFiles;
}

        第四步将文件信息保存到数据库中,因为原方法中存在网络请求的过程,时间过长会导致占用数据库资源,将保存媒资信息到数据库的方法抽取为一个方法供重复使用,提高复用性;同时因为后续不仅需要写入一张表,所以需要进行事务控制,但是由于Spring事务底层是通过代理对象实现的,而代理对象失效的情况有七种

  1. 非public方法上使用@Transactional(只有public方法才能够被代理捕获)
  2. 同类中一个无事务的方法调用带@Transactional事务控制的方法(Spring事务的代理对象只拦截外部调用)
  3. 事务方法内部调用事务方法
  4. 数据库本身不支持事务(如Mysql的MyISAM)
  5. 方法中捕获了异常却未抛出(事务回滚默认需要触发RuntimeException 及其子类,如果捕获了异常没有抛出,Spring会认为方法执行成功,不回滚)
  6. @Transactional默认只对RuntimeException和Error回滚,一些网络异常等未配置回滚规则导致事务失效
  7. Spring的传播行为导致事务失效,如PROPAGATION_NEVER等

        像上述更新数据库方法就同示例2一样由一个无事务的方法调用带事务的方法,这会导致事务失效,因为在方法中调用相当于this.addMediaFilesToDb(...)所以事务会失效,这就需要通过手动创建一个代理对象在无事务方法中调用带事务方法,也就是这一段:

@Autowired
private MediaFileService selfProxy;

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
    ...
    // 保存到数据库中
    MediaFiles mediaFiles = selfProxy.addMediaFilesToDb(companyId, fileMd5, paramsDto, bucket_mediaFiles, objectName);
    ...
}

        通过代理对象调用带事务方法解决事务失效的问题

完整代码如下:

@Slf4j
@Service
public class MediaFileServiceImpl implements MediaFileService {

    @Autowired
    private MediaFilesMapper mediaFilesMapper;

    @Autowired
    private MinioClient minioClient;

    @Value("${minio.bucket.files}")
    private String bucket_mediaFiles;

    @Value("${minio.bucket.videofiles}")
    private String videoFiles;

    @Autowired
    private MediaFileService selfProxy;

    ...

    @Override
    public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto paramsDto, String loadFilePath) {
        String filename = paramsDto.getFilename();
        String extension = filename.substring(filename.lastIndexOf("."));
        String mediaType = getMediaType(extension);
        // 年月日拼接目录路径
        String folderPath = getDefaultFolderPath();
        String fileMd5 = getFileMd5(new File(loadFilePath));
        String objectName = folderPath + fileMd5 + extension;
        // 上传到 minio 中
        boolean result = addMediaFileToMinio(bucket_mediaFiles, loadFilePath, mediaType, objectName);
        if (!result) {
            XueChengPlusException.cast("上传文件失败");
        }
        // 保存到数据库中
        MediaFiles mediaFiles = selfProxy.addMediaFilesToDb(companyId, fileMd5, paramsDto, bucket_mediaFiles, objectName);
        if (ObjectUtils.isEmpty(mediaFiles)) {
            XueChengPlusException.cast("文件上传后信息保存失败");
        }
        UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
        BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
        return uploadFileResultDto;
    }

    private static String getMediaType(String extension) {
        if (extension == null) {
            extension = "";
        }
        // 根据扩展名获取媒资类型
        ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
        String mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
        if (extensionMatch != null) {
            mediaType = extensionMatch.getMimeType();
        }
        return mediaType;
    }

    @Transactional
    @Override
    public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto paramsDto, String bucket, String objectName) {
        // 保存到数据库中
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        if (mediaFiles == null) {
            mediaFiles = new MediaFiles();
            BeanUtils.copyProperties(paramsDto, mediaFiles);
            mediaFiles.setId(fileMd5);
            mediaFiles.setCompanyId(companyId);
            mediaFiles.setBucket(bucket);
            mediaFiles.setFilePath(objectName);
            mediaFiles.setFileId(fileMd5);
            mediaFiles.setUrl("/" + bucket + "/" + objectName);
            mediaFiles.setCreateDate(LocalDateTime.now());
            mediaFiles.setStatus("1");
            mediaFiles.setAuditStatus("002003");
            int insert = mediaFilesMapper.insert(mediaFiles);
            if (insert <= 0) {
                log.debug("向数据库保存信息失败, bucket:{}, objectName:{}", bucket, objectName);
                return null;
            }
        }
        return mediaFiles;
    }

    private String getFileMd5(File file) {
        try (FileInputStream fileInputStream = new FileInputStream(file)) {
            return DigestUtils.md5Hex(fileInputStream);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    private String getDefaultFolderPath() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        // 2026-03-14  ==> 2026/03/14/
        return sdf.format(new Date()).replace("-", "/") + "/";
    }

    private boolean addMediaFileToMinio(String backet, String loadFilePath, String mediaType, String objectName) {
        try {
            minioClient.uploadObject(UploadObjectArgs.builder()
                    .bucket(backet)
                    .object(objectName)
                    .filename(loadFilePath)
                    .contentType(mediaType)
                    .build());
            log.debug("上传文件成功 backet:{}, objectName:{}", backet, objectName);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            log.error("上传文件出错 backet:{}, objectName:{},错误信息:{}", backet, objectName, e.getMessage());
        }
        return false;
    }
}

接下来就是Controller层的数据处理部分:

@ApiOperation("上传图片接口")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
    UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
    uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
    uploadFileParamsDto.setFileSize(filedata.getSize());
    uploadFileParamsDto.setFileType("001001");
    File tempFile = File.createTempFile("minio", ".temp");
    filedata.transferTo(tempFile);
    String localFilePath = tempFile.getAbsolutePath();
    Long companyId = 1232141425L;
    return mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath);
}

        接收到文件后依次获取文件名文件大小,并设置文件的类型,因为是客户端本地传输的文件,所以服务端本地对应路径下并没有,所以需要将接收到的文件缓存创建为服务端下的临时文件,使用临时文件的路径进行保存;最后用UploadFileParamsDto传入uploadFile方法中

前后端联调可以看到成功上传了图片信息到Minio数据库


上篇记录了《学成在线》项目的前5大节内容,主要内容是内容管理模块和媒资管理模块的上传图片方法,文中一定会存在一些问题没有提到,如果发现感谢指正🙏🙏🙏
「完」

更多推荐