内容管理模块关键技术点:HttpClient接口测试、树形表开发、JSR303校验

1 模块需求分析

1.1 什么是需求分析

百度百科汇总对需求分析的定义如下

需求分析也称为软件需求分析、系统需求分析或需求分析工程等,是开发人员经过深入细致的调研和分析,准确理解用户和项目的功能、性能、可靠性等具体需求,将用户非形式的需求表叔转化为完整的需求定义,从而确定系统必须做什么的过程

简单理解就是要高清问题域,问题域就是用户的需求,软件要为用户解决什么问题,实现哪些业务功能,满足什么样的性能要求

那么如何做需求分析? 1. 首先确认用户需求 - 用户需求即用户的原始需求。通过用户访谈、问卷调查、开会讨论、查阅资料等调研手段梳理用户的原始需求,产品人员根据用户需求绘制界面原型,再通过界面原型让用户确认需求是否符合预期 2. 确认关键问题 - 用户的原始需求可能是含糊不清的,需求分析要从繁杂的问题中梳理出关键问题。比如:俭学机构的老师想要将课程发布到网上,这是原始需求,根据这个用户需求我们需要进行扩展分析,扩展出以下几个点: 1. 课程发布需要哪些信息? 2. 如果用户发布了不良信息怎么办? 3. 课程发布后用户怎么查看? - 课程发布需要课程名称、价格、介绍、图片(封面)、师资信息等。继续延伸分析:这么多课程信息进行归类,方便用户编辑,可以分为课程基本信息、课程营销信息、课程师资信息。 - 按照这样的思路对用户需求逐项分析,梳理出若干问题,再从中找到关键信息。比如:上边对课程信息分类后,哪些是关键信息,课程名称、课程图片、课程介绍扥基本信息为关键信息,所以发布课程的第一步要编写课程基本信息。 - 找到了关键问题,下一步就是进行数据建模,创建课程基本信息表,并设计其中的字段 3. 数据建模 - 数据建模要根据分析的关键问题将其相关的信息全部建模。比如:根据发布课程的用户需求,可创建课程基本信息表、课程营销信息表、课程师资表、课程发布记录表、课程审核记录表等 4. 编写需求规格说明书 - 针对每一个关键问题最终都需要编写需求规格说明书,包括:功能名称、功能描述、参与者、基本时间流程、可选事件流、数据描述、前置条件、后置条件等 - 比如添加课程的需求规格如下

项目添加课程
功能名称添加课程
功能描述添加课程基本信息
参与者教学机构管理员
前置条件教学机构管理只允许向自己机构添加课程 拥有添加课程的权限
基本事件流程1、登录教学机构平台 2、进入课程列表页面 3、点击添加课程按钮进入添加课程界面 4、填写课程基本信息 5、点击提交。
可选事件流程成功:提示添加成功,跳转到课程营销信息添加界面 失败:提示具体的失败信息,用户根据失败信息进行修改。
数据描述课程基本信息:课程id、课程名称、课程介绍、课程大分类、课程小分类、课程等级、课程图片、所属机构、课程创建时间、课程修改时间、课程状态
后置条件向课程基本信息插入一条记录
补充说明

1.2 模块介绍

  • 内容管理这个词存在于很多软件系统,什么是内容管理?
  • 内容管理系统(content management system,CMS),是一种位于WEB前端(Web服务器)和后端办公系统或流程(内容创作、编辑)之间的软件系统。内容的创作人员,编辑人员、发布人员使用内容管理系统来提交、修改、审批、发布内容。这里的内容可能包括文件、表格、图片、数据库中的数据甚至视频等一切你想法不到网站的信息
  • 本项目作为一个大型的在线教育平台,其内容管理模块主要对课程及相关内容进行管理,包括:课程的基本信息、课程图片、课程师资信息、课程的授课计划、课程视频、课程文档等内容的管理

1.3 业务流程

  • 内容管理由教学机构人员和平台的运营人员共同完成。
  • 教学机构人员的业务流程如下:
    1. 登录教学机构
    2. 维护课程信息,添加一门课程需要编辑课程的基本信息、上床课程图片、课程营销信息、课程计划、上传课程视频、课程师资信息等内容
    3. 课程信息编辑完成,通过课程预览确认无误后提交课程审核。
    4. 待运营人员课程审核通过后方可进行课程发布
  • 运用人员的业务流程如下:
    1. 查询待审核的课程信息
    2. 审核课程信息
    3. 提交审核结果

1.4 界面原型

  • 产品工程师根据用户需求制作产品界面原型,开发工程师除了根据用户需求进行需求分析以外,还会根据界面原型上的元素信息进行需求分析

1.5 数据模型

  • 数据模型就是对应的数据库表

2 创建模块工程

2.1 模块工程结构

在之前我们已经创建好了项目父工程和基础工程

那下面我们继续来创建内容管理模块的工程结构。本项目是一个前后端分离项目,前端与后端开发人员之间主要依据接口进行开发,前后端交互流程如下 1. 前端请求后端服务提供的接口 2. 后端服务的Controller层接收前端的请求 3. Controller层调用Service层进行业务处理 4. Service层调用Dao持久层对数据持久化

流程分为前端、接口层、业务层三部分,所以模块工程结构如下图所示 - xuecheng-plus-content-api:接口工程,为前端提供接口

  • xuecheng-plus-content-service:业务工程,为接口工程提供业务支撑

  • xuecheng-plus-content-model:数据模型工程,存储数据模型类、数据传输类型等

结合项目父工程、项目基础工程后,如下图

xuecheng-plus-content:内容管理模块工程,负责聚合xuecheng-plus-content-api、xuecheng-plus-content-service、xuecheng-plus-content-model

2.2 创建模块工程

  1. 创建内容管理模块父工程xuecheng-plus-content,修改pom.xml,声明为聚合工程,且有三个子模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <?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>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-parent</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <relativePath>../xuecheng-plus-parent</relativePath>
    </parent>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>xuecheng-plus-content</name>
    <description>xuecheng-plus-content</description>
    <packaging>pom</packaging>
    <properties>
    <java.version>1.8</java.version>
    </properties>
    <modules>
    <module>xuecheng-plus-content-api</module>
    <module>xuecheng-plus-content-model</module>
    <module>xuecheng-plus-content-service</module>
    </modules>

    </project>
  2. 创建xuecheng-plus-content-api工程,设置父工程为

    1
    xuecheng-plus-content

    ,按照上图中的依赖关系修改pom文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    <?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>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </parent>
    <artifactId>xuecheng-plus-content-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>xuecheng-plus-content-api</name>
    <description>xuecheng-plus-content-api</description>
    <properties>
    <java.version>1.8</java.version>
    </properties>
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    <dependency>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </dependency>
    <dependency>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content-model</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    </project>
  3. 创建xuecheng-plus-content-model工程,设置父工程为

    1
    xuecheng-plus-content

    ,按照上图中的依赖关系修改pom文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    XML
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </parent>
    <artifactId>xuecheng-plus-content-model</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>xuecheng-plus-content-model</name>
    <description>xuecheng-plus-content-model</description>
    <properties>
    <java.version>1.8</java.version>
    </properties>
    <dependencies>
    <dependency>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-base</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    </project>
  4. 创建xuecheng-plus-content-service工程,设置父工程为

    1
    xuecheng-plus-content

    ,按照上图中的依赖关系修改pom文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    <?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>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </parent>
    <artifactId>xuecheng-plus-content-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>xuecheng-plus-content-service</name>
    <description>xuecheng-plus-content-service</description>
    <properties>
    <java.version>1.8</java.version>
    </properties>
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    </dependency>
    <dependency>
    <groupId>com.xuecheng</groupId>
    <artifactId>xuecheng-plus-content-model</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    </project>
  • 创建好的目录结构如下图所示

3 课程查询

3.1 需求分析

3.1.1 业务流程

  1. 教学机构人员点击课程管理,进入课程查询界面

  1. 在课程查询页面输入查询条件查询课程信息
  • 当不输入查询条件时,输出全部课程信息
  • 输入查询条件,查询符合条件的课程信息
  • 约束:教学机构只允许查询本教学机构的课程信息

3.1.2 数据模型

下边从查询条件、查询列表两个方面分析数据模型

1、查询条件:

包括:课程名称、课程审核状态、课程发布状态

课程名称:可以模糊搜索

课程审核状态:未提交、已提交、审核通过、审核未通过

课程发布状态:未发布、已发布、已下线

因为是分页查询所以查询条件中还要包括当前页码、每页显示记录数。

2、查询结果:

查询结果中包括:课程id、课程名称、任务数、创建时间、是否付费、审核状态、类型,操作

任务数:该课程所包含的课程计划数,即课程章节数。

是否付费:课程包括免费、收费两种。

类型:录播、直播。

因为是分页查询所以查询结果中还要包括总记录数、当前页、每页显示记录数。

课程查询功能涉及到的数据表有;课程基本信息表,教学计划表


3.2 创建数据库表及PO类型

3.2.1 创建数据库表

1、创建内容管理数据库

2、向创建的内容管理数据库导入数据

选择课程资料中的xc_content.sql脚本。

3.2.2 生成PO

  • PO即持久对象(Persistent Object),它们是由一组属性和属性的get/set方法组成的。PO对应于数据库的表

  • 在开发持久层代码需要根据数据表编写PO类,在实际开发中通常使用代码生成器工具来生成PO类代码(人人开源、MP代码生成器等)

  • 这里是使用的MP的generator工程生成PO类,详细操作可以参考我这篇文章

    https://cyborg2077.github.io/2022/09/20/MyBatisPlus/

在企业开发中通常使用代码生成工具去自动生成这些文件,

本项目使用mybatis-plus的generator工程生成PO类、Mapper接口、Mapper的xml文件,地址在:https://github.com/baomidou/generator

将课程资料目录下的xuecheng-plus-generator.zip解压后拷贝至项目工程根目录,如下图:

打开IDEA将其导入项目工程 ,打开xuecheng-plus-generator工程的pom.xml,右键 点击“Add as Maven Project” 自动识别maven工程。

如下图:

本次生成内容管理模块的PO类、Mapper接口和Mapper的xml文件 ,找到ContentCodeGenerator类,如下图:

修改ContentCodeGenerator类中的信息,包括:数据库地址、数据库账号、数据库密码、生成的表、生成路径,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
public class ContentCodeGenerator {

// TODO 修改服务名以及数据表名
private static final String SERVICE_NAME = "content";

//数据库账号
private static final String DATA_SOURCE_USER_NAME = "root";
//数据库密码
private static final String DATA_SOURCE_PASSWORD = "123456";
//生成的表
private static final String[] TABLE_NAMES = new String[]{
// "mq_message",
// "mq_message_history"
"course_base",
"course_market",
"course_teacher",
"course_category",
"teachplan",
"teachplan_media",
"course_publish",
"course_publish_pre"

};

// TODO 默认生成entity,需要生成DTO修改此变量
// 一般情况下要先生成 DTO类 然后修改此参数再生成 PO 类。
private static final Boolean IS_DTO = false;

public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 选择 freemarker 引擎,默认 Velocity
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
// 全局配置
GlobalConfig gc = new GlobalConfig();
gc.setFileOverride(true);
//生成路径
gc.setOutputDir(System.getProperty("user.dir") + "/xuecheng-plus-generator/src/main/java");
gc.setAuthor("itcast");
gc.setOpen(false);
gc.setSwagger2(false);
gc.setServiceName("%sService");
gc.setBaseResultMap(true);
gc.setBaseColumnList(true);

if (IS_DTO) {
gc.setSwagger2(true);
gc.setEntityName("%sDTO");
}
mpg.setGlobalConfig(gc);

// 数据库配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setDbType(DbType.MYSQL);
dsc.setUrl("jdbc:mysql://127.0.0.1:3306/xc_" + SERVICE_NAME
+ "?serverTimezone=UTC&useUnicode=true&useSSL=false&characterEncoding=utf8");
// dsc.setDriverName("com.mysql.jdbc.Driver");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername(DATA_SOURCE_USER_NAME);
dsc.setPassword(DATA_SOURCE_PASSWORD);
mpg.setDataSource(dsc);

// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(SERVICE_NAME);
pc.setParent("com.xuecheng");

pc.setServiceImpl("service.impl");
pc.setXml("mapper");
pc.setEntity("model.po");
mpg.setPackageInfo(pc);


// 设置模板
TemplateConfig tc = new TemplateConfig();
mpg.setTemplate(tc);

// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
strategy.setInclude(TABLE_NAMES);
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix(pc.getModuleName() + "_");
// Boolean类型字段是否移除is前缀处理
strategy.setEntityBooleanColumnRemoveIsPrefix(true);
strategy.setRestControllerStyle(true);

// 自动填充字段配置
strategy.setTableFillList(Arrays.asList(
new TableFill("create_date", FieldFill.INSERT),
new TableFill("change_date", FieldFill.INSERT_UPDATE),
new TableFill("modify_date", FieldFill.UPDATE)
));
mpg.setStrategy(strategy);

mpg.execute();
}

}

修改完成,执行该类的main方法,自动生成content包,如下:

在该包下自动生成了内容管理模块的controller、mapper、po及service相关代码,这里我们只需要po类。

将po类拷贝到model工程

打开一个PO类发现编译报错,这是缺少依赖包导致,本项目使用的持久层框架是MyBatisPlus,在生成的po类中加了一些MyBatisPlus框架的注解,这里需要添加MyBatisPlus框架的依赖,消除错误。

下边在model工程添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Java
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-base</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!--存在mybatisplus注解添加相关注解保证不报错-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-annotation</artifactId>
<version>${mybatis-plus-boot-starter.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-core</artifactId>
<version>${mybatis-plus-boot-starter.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

3.3 接口定义

3.3.1 接口定义分析

定义一个接口需要包括以下几个方面

  1. 协议

    • 通常使用HTTP协议,查询类的接口请求方式通常为GET或POST,查询条件较少的时候使用GET,较多的时候使用POST
    • 本接口使用http post
    • 同时也要确定content-type,参数以什么数据格式提交,结果以什么数据格式响应
    • 一般情况下都以json格式响应
  2. 分析请求参数

    • 根据前面对数据模型的分析,请求参数为:课程名称、课程审核状态、当前页码、每页显示的记录数
    • 根据分析的请求参数定义模型类
  3. 分析响应结果

    • 根据前面对数据模型的分析,响应结果为数据列表和一些分页信息(总记录数、当前页码、每页显示记录数)

    • 数据列表中数据的属性包括:课程id、课程名称、任务数、创建时间、审核状态、类型

      注意:查询结果中的审核状态为数据字典中的代码字段,前端会根据审核状态代码字段找到对应的名称显示(例如错误响应码404/401/502等)

    • 根据分析的相应结果定义模型类

  4. 分析完成,使用SpringBoot注解开发一个Http接口

  5. 使用接口文档工具查看接口的内容

  6. 接口中调用Service方法完成业务处理

接口请求示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
POST /content/course/list?pageNo=2&pageSize=1
Content-Type: application/json

{
"auditStatus": "202002",
"courseName": ""
}
//成功响应结果
{
"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
}

3.3.2 课程查询接口定义

  1. 定义请求模型类

    对于查询条件较多的接口定义单独的模型类接收参数

    由于分页查询这一类的接口在项目中很多地方都会用到,这里针对分页查询的参数(当前页码、每页显示的记录数)单独在xuecheng-plus-base基础工程中定义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    /**
    * 分页查询通用参数
    */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PageParams {

    private Long pageNo = 1L;

    private Long pageSize = 10L;

    }

    除了分页查询参数,剩下的就是课程查询的特有参数,此时需要在内容管理的model工程中定义课程查询的参数模型类

    定义DTO包,DTO即数据传输对象(Data Transfer Object),用于接口层和业务层之间传输数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package com.xuecheng.content.model.dto;

    import lombok.AllArgsConstructor;
    import lombok.Data;

    @Data
    @AllArgsConstructor
    public class QueryCourseParamDto {
    // 审核状态
    private String auditStatus;
    // 课程名称
    private String courseName;
    // 发布状态
    private String publishStatus;
    }
  2. 定义响应模型类

    根据接口分析,下面定义响应结果模型类

    针对分页查询结果经过分析,也存在固定的数据和格式,所以还是在base工程定义一个基础的结果模型类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PageResult<T> implements Serializable {

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

    // 总记录数
    private long counts;

    // 当前页码
    private long page;

    // 每页记录数
    private long pageSize;
    }
  3. 定义接口

    • 根据分析,此接口提供http post协议,查询条件以json格式提交,响应结果为json格式

    • 首先咋xuecheng-plus-content-api中添加依赖

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      <dependencies>
      <dependency>
      <groupId>com.xuecheng</groupId>
      <artifactId>xuecheng-plus-content-model</artifactId>
      <version>0.0.1-SNAPSHOT</version>
      </dependency>
      <dependency>
      <groupId>com.xuecheng</groupId>
      <artifactId>xuecheng-plus-content-service</artifactId>
      <version>0.0.1-SNAPSHOT</version>
      </dependency>
      <!--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>
      <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>

      </dependencies>

之后定义Controller方法

  • 说明:pageParams分页参数通过url的key/value传入,queryCourseParams通过json数据传入,所以queryCourseParams前面需要用@RequestBody注解将json转为QueryCourseParamDto对象。这里的两个@Api注解是swagger的,用于描述接口的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@Api(value = "课程信息编辑接口", tags = "课程信息编辑接口")
public class CourseBaseInfoController {
@PostMapping("/course/list")
@ApiOperation("课程查询接口")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {
CourseBase courseBase = new CourseBase();
courseBase.setId(15L);
courseBase.setDescription("测试课程");
PageResult<CourseBase> result = new PageResult<>();
result.setItems(Arrays.asList(courseBase));
result.setPage(1);
result.setPageSize(10);
result.setCounts(1);
return result;
}
}

定义启动类,使用@EnableSwagger2Doc注解,启用Swagger

1
2
3
4
5
6
7
@SpringBootApplication
@EnableSwagger2Doc
public class ContentApplication {
public static void main(String[] args){
SpringApplication.run(ContentApplication.class);
}
}

添加配置文件

  • log4j2-dev.xml
  • bootstrap.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?xml version="1.0" encoding="UTF-8"?>
<Configuration monitorInterval="180" packages="">
<properties>
<property name="logdir">logs</property>
<property name="PATTERN">%date{YYYY-MM-dd HH:mm:ss,SSS} %level [%thread][%file:%line] - %msg%n%throwable</property>
</properties>
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${PATTERN}"/>
</Console>

<RollingFile name="ErrorAppender" fileName="${logdir}/error.log"
filePattern="${logdir}/$${date:yyyy-MM-dd}/error.%d{yyyy-MM-dd-HH}.log" append="true">
<PatternLayout pattern="${PATTERN}"/>
<ThresholdFilter level="ERROR" onMatch="ACCEPT" onMismatch="DENY"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true" />
</Policies>
</RollingFile>

<RollingFile name="DebugAppender" fileName="${logdir}/info.log"
filePattern="${logdir}/$${date:yyyy-MM-dd}/info.%d{yyyy-MM-dd-HH}.log" append="true">
<PatternLayout pattern="${PATTERN}"/>
<ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true" />
</Policies>
</RollingFile>

<!--异步appender-->
<Async name="AsyncAppender" includeLocation="true">
<AppenderRef ref="ErrorAppender"/>
<AppenderRef ref="DebugAppender"/>
</Async>
</Appenders>

<Loggers>
<!--过滤掉spring和mybatis的一些无用的debug信息-->
<logger name="org.springframework" level="INFO">
</logger>
<logger name="org.mybatis" level="INFO">
</logger>
<logger name="cn.itcast.wanxinp2p.consumer.mapper" level="DEBUG">
</logger>

<logger name="springfox" level="INFO">
</logger>
<logger name="org.apache.http" level="INFO">
</logger>
<logger name="com.netflix.discovery" level="INFO">
</logger>

<logger name="RocketmqCommon" level="INFO" >
</logger>

<logger name="RocketmqRemoting" level="INFO" >
</logger>

<logger name="RocketmqClient" level="WARN">
</logger>

<logger name="org.dromara.hmily" level="WARN">
</logger>

<logger name="org.dromara.hmily.lottery" level="WARN">
</logger>

<logger name="org.dromara.hmily.bonuspoint" level="WARN">
</logger>

<!--OFF 0-->
<!--FATAL 100-->
<!--ERROR 200-->
<!--WARN 300-->
<!--INFO 400-->
<!--DEBUG 500-->
<!--TRACE 600-->
<!--ALL Integer.MAX_VALUE-->
<Root level="DEBUG" includeLocation="true">
<AppenderRef ref="AsyncAppender"/>
<AppenderRef ref="Console"/>
<AppenderRef ref="DebugAppender"/>
</Root>
</Loggers>
</Configuration>

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server:
servlet:
context-path: /content
port: 63040
#微服务配置
spring:
application:
name: content-api
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/xc_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
username: root
password: 123456
# 日志文件配置路径
logging:
config: classpath:log4j2-dev.xml

swagger:
title: "学成在线内容管理系统"
description: "内容系统管理系统对课程相关信息进行管理"
base-package: com.xuecheng.content
enabled: true
version: 1.0.0
  • 运行启动类,访问http://localhost:63040/content/swagger-ui.html, 查看接口信息

  • 使用PostMan测试我们的接口也能返回我们的测试数据 img

3.3.3 Swagger介绍

什么是Swagger?

  • OpenAPI规范(OpenAPI Specification 简称OAS)是Linux基金会的一个项目,试图通过定义一种用来描述API格式或API定义的语言,来规范RESTful服务开发过程,并且已经发布并开源在GitHub上:https://github.com/OAI/OpenAPI-Specification

  • Swagger是全球最大的OpenAPI规范(OAS)API开发工具框架,Swagger是一个在线接口文档的生成工具,前后端开发人员依据接口文档进行开发,只要添加Swagger的依赖和配置信息即可使用它

    • pom.xml
    • bootstrap.yml

    Spring Boot 可以集成Swagger,Swaager根据Controller类中的注解生成接口文档 ,只要添加Swagger的依赖和配置信息即可使用它。

    1、在API工程添加swagger-spring-boot-starter依赖

    1
    2
    3
    4
    5
    <!-- Spring Boot 集成 swagger -->
    <dependency>
    <groupId>com.spring4all</groupId>
    <artifactId>swagger-spring-boot-starter</artifactId>
    </dependency>

    2、在 bootstrap.yml中配置swagger的扫描包路径及其它信息,base-package为扫描的包路径,扫描Controller类。

    1
    2
    3
    4
    5
    6
    swagger:
    title: "学成在线内容管理系统"
    description: "内容系统管理系统对课程相关信息进行管理"
    base-package: com.xuecheng.content
    enabled: true
    version: 1.0.0

    3、在启动类中添加@EnableSwagger2Doc注解

    SpringBoot可以集成Swagger,Swagger根据Controller类中的注解生成接口文档,在模型类上也可以添加注解对模型类的属性进行说明,方便对接口文档的阅读,例如在我们之前编写的PageParams模型类上添加注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PageParams {
    // 默认起始页码
    public static final long DEFAULT_PAGE_CURRENT = 1L;
    // 默认每页记录数
    public static final long DEFAULT_PAGE_SIZE = 10L;

    // 当前页码
    + @ApiModelProperty("当前页码")
    private Long pageNo = DEFAULT_PAGE_CURRENT;

    // 当前每页记录数
    + @ApiModelProperty("每页记录数")
    private Long pageSize = DEFAULT_PAGE_SIZE;
    }

重启服务,再次进入接口文档,可以看到添加的描述

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

3.3.4 模型类的作用

现在项目中有两类模型类:DTO数据传输对象、PO持久化对象,

DTO用于接口层向业务层之间传输数据,

PO用于业务层与持久层之间传输数据,

有些项目还会设置VO对象,VO对象用在前端与接口层之间传输数据

如下图:

当前端有多个平台且接口存在差异时就需要设置VO对象用于前端和接口层传输数据。

比如:

课程列表查询接口,根据需求用户在手机端也要查询课程信息,此时课程查询接口是否需要编写手机端和PC端两个接口呢?如果用户要求通过手机和PC的查询条件或查询结果不一样,此时就需要定义两个Controller课程查询接口,每个接口定义VO对象与前端传输数据。

手机查询:根据课程状态查询,查询结果只有课程名称和课程状态。

PC查询:可以根据课程名称、课程状态、课程审核状态等条件查询,查询结果也比手机查询结果内容多。

此时,Service业务层尽量提供一个业务接口,即使两个前端接口需要的数据不一样,Service可以提供一个最全查询结果,由Controller进行数据整合。

如下图:

  • 如果前端接口没有多样性,且比较固定,此时可以取消VO,只用DTO即可

3.4 接口开发

3.4.1 DAO开发

业务层为接口层提供业务处理支撑,本项目业务层包括了持久层代码,一些大型公司的团队职责划分更细,会将持久层和业务层分为两个工程,不过这需要增加成本

DAO即数据访问对象,通过DAO去访问数据库对数据进行持久化,本项目持久层使用MyBatis-Plus进行开发

持久层的基础代码我们使用MP提供的代码生成器生成,将生成的Mapper和对应的xml拷贝纸service工程的com.xuecheng.content.mapper包下

同时在service的pom.xml中添加MP和日志的一些依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-content-model</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>

<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

<!-- mybatis plus的依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<!-- Spring Boot 集成 Junit -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>

在com.xuecheng.content.config包下创建MP配置类,配置分页拦截器

  • 分页插件的原理:

    首先分页参数放到ThreadLocal中,拦截执行的sql,根据数据库类型添加对应的分页语句重写sql,例如:(select * from table where a) 转换为 (select count() from table where a)和(select from table where a limit ,)

    计算出了total总条数、pageNum当前第几页、pageSize每页大小和当前页的数据,是否为首页,是否为尾页,总页数等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * Mybatis-Plus配置
    */
    @Configuration
    // 因为这里的mapper都没加@mapper注解,要靠这个mapperScan来做bean的声明
    @MapperScan("com.xuecheng.content.mapper")
    public class MybatisPlusConfig {
    /**
    * 定义分页拦截器
    * @return
    */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
    }
    }

然后在yml中配置数据库连接信息和日志信息等

1
2
3
4
5
6
7
8
9
10
11
spring:
application:
name: content-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/xc_content?serverTimezone=UTC&userUnicode=true&useSSL=false
username: root
password: 123456
logging:
config: classpath:log4j2-dev.xml

最后编写测试方法并进行测试,控制台可以输出查到的数据(前提保证你的id真的对应有数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.xuecheng.content;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xuecheng.ContentApplication;
import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
import com.xuecheng.content.mapper.CourseBaseMapper;
import com.xuecheng.content.model.dto.QueryCourseParamDto;
import com.xuecheng.content.model.po.CourseBase;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.StringUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@Slf4j
@SpringBootTest(classes = ContentApplication.class)
public class CourseBaseMapperTests {

@Autowired
CourseBaseMapper courseBaseMapper;

@Test
public void CourseBaseMapperTests(){
CourseBase courseBase = courseBaseMapper.selectById(22);
log.info("查询到数据:{}", courseBase);
Assertions.assertNotNull(courseBase);

// 进行分页查询的单元测试
// 查询条件
QueryCourseParamDto courseParamDto = new QueryCourseParamDto();
courseParamDto.setCourseName("java");

// 拼装查询条件
LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
// 根据名称模糊查询
queryWrapper.like(ObjectUtils.isNotEmpty(courseParamDto.getCourseName()), CourseBase::getName, courseParamDto.getCourseName());
// 根据课程审核状态精准查询
queryWrapper.eq(ObjectUtils.isNotEmpty(courseParamDto.getAuditStatus()), CourseBase::getAuditStatus, courseParamDto.getAuditStatus());

//分页参数
PageParams pageParams = new PageParams();
pageParams.setPageNo(1L);//页码
pageParams.setPageSize(3L);//每页记录数
Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());

//分页查询E page 分页参数, @Param("ew") Wrapper<T> queryWrapper 查询条件
Page<CourseBase> pageResult = courseBaseMapper.selectPage(page, queryWrapper);

//数据
List<CourseBase> items = pageResult.getRecords();
//总记录数
long total = pageResult.getTotal();

//准备返回数据 List<T> items, long counts, long page, long pageSize
PageResult<CourseBase> courseBasePageResult = new PageResult<>(items, total, pageParams.getPageNo(), pageParams.getPageSize());
System.out.println(courseBasePageResult);
}
}

3.4.2 Service开发

1、数据字典表

  • 课程基本信息查询的主要数据来源是课程基本信息表,这里有一点需要注意:课程的审核状态、发布状态

  • 审核状态在查询条件和查询结果中都存在,包括:未审核、审核通过、审核未通过这三种

  • 那么我们思考一个问题:直接在数据库表中的字段填充

    1
    审核未通过

    这几个大字,合适吗?

    • 如果将审核未通过这五个字记录在课程基本信息表中,查询出来的状态就是审核未通过这几个字,那么如果有一天客户想把审核未通过改为未通过,怎么办?
    • 词汇我们可以批量处理数据库中的数据,写一个update语句,将所有的审核未通过更新为未通过。看起来解决了问题,但是万一后期客户抽风又想改呢?真实情况就是这样,但是这一类数据也有共同点:它有一些分类项,且这些分类项比较固定,大致的意思都是一样的,只是表述方式不一样。
  • 那么针对这一类数据,为了提高系统的可扩展性,专门定义数据字典去维护,例如

    1
    2
    3
    4
    5
    6
    JSON
    [
    {"code":"202001","desc":"审核未通过"},
    {"code":"202002","desc":"未审核"},
    {"code":"202003","desc":"审核通过"}
    ]
  • 那么我们创建系统管理数据库xc_system,在其中创建管理系统服务的数据表,导入黑马提供的SQL脚本就好了。这样查询出的数据在前端展示时,就根据代码取出它对应的内容显示给用户。如果客户需要修改审核未通过的显示内容,直接在数据字典中修改就好了,无需修改课程基本信息表

2、Service开发

首先创建Service接口

1
2
3
4
5
6
7
8
9
public interface CourseBaseInfoService {
/**
* 课程查询接口
* @param pageParams 分页参数
* @param queryCourseParams 查询条件
* @return
*/
PageResult<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamDto queryCourseParams);
}

然后创建实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
JAVA
@Service
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {
@Resource
CourseBaseMapper courseBaseMapper;

@Override
public PageResult<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamDto queryCourseParams) {
// 构建条件查询器
LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
// 构建查询条件:按照课程名称模糊查询
queryWrapper.like(StringUtils.isNotEmpty(queryCourseParams.getCourseName()), CourseBase::getCompanyName, queryCourseParams.getCourseName());
// 构建查询条件,按照课程审核状态查询
queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getAuditStatus()), CourseBase::getAuditStatus, queryCourseParams.getAuditStatus());
// 构建查询条件,按照课程发布状态查询
queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getPublishStatus()), CourseBase::getStatus, queryCourseParams.getPublishStatus());
// 分页对象
Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
// 查询数据内容获得结果
Page<CourseBase> pageInfo = courseBaseMapper.selectPage(page, queryWrapper);
// 获取数据列表
List<CourseBase> items = pageInfo.getRecords();
// 获取数据总条数
long counts = pageInfo.getTotal();
// 构建结果集
return new PageResult<>(items, counts, pageParams.getPageNo(), pageParams.getPageSize());
}
}

使用单元测试类进行测试,

1
2
3
4
5
6
7
8
@Resource
CourseBaseInfoService courseBaseInfoService;

@Test
void contextQueryCourseTest() {
PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(new PageParams(1L, 10L), new QueryCourseParamDto());
log.info("查询到数据:{}", result);
}

3.4.3 接口代码完善

控制层、业务层、持久层三层通常可以面向接口并行开发,比如:业务层开发的同时可以先只编写一个Service接口,接口层的同时即可以面向Service接口去开发,待接口层和业务层完成后进行联调。

下面是课程查询接口的实现

1
2
3
4
5
6
7
8
9
@Resource
CourseBaseInfoService courseBaseInfoService;

@ApiOperation("课程查询接口")
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {
PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);
return result;
}

我们可以在Swagger中进行测试,也可以在PostMan中进行测试

这里演示一下在Swagger中测试,输入参数,这里查询条件均设为空,当前页码1,页大小2

测试成功,查询到了数据

3.5 接口测试

3.5.1 HttpClient测试

  • Swagger是一个在线接口文档,虽然使用它也能测试,但是需要浏览器进入Swagger,最关键的是它不能保存测试数据。

  • PostMan对内存的消耗也比较大,而且需要下载客户端。

  • 在IDEA中有一个非常方便的http接口测试工具HTTP Client,下面介绍它的使用方法,后面我们使用它来进行接口测试

    先检查一下自己的IDEA是否已经安装了HTTP Client插件

  • 进入Controller类,找到HTTP接口对应的方法

  • 点击

    1
    在HTTP客户端中生成请求

    ,即可生成一个测试用例,IDEA会为我们生成一个.http结尾的文件,我们可以添加请求参数进行测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    JSON
    ### 课程查询列表
    POST http://localhost:63040/content/course/list?pageNo=1&pageSize=10
    Content-Type: application/json

    {
    "auditStatus": "",
    "courseName": "",
    "publishStatus": ""
    }
  • 同样通过测试,可以查询到数据

  • .http文件即测试用例文档,它可以随着项目工程一起保存(也可以提交git),这样测试数据就可以保存下来,方便进行测试

  • 为了方便保存.http文件,我们单独在项目工程的根目录下创建一个目录单独来存放他们

  • 同时为了将来方便和网关集成测试,这里把测试主机地址在配置文件http-client.env.json中配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "dev": {
    "host": "localhost:63010",
    "content_host": "localhost:63040",
    "system_host": "localhost:63110",
    "media_host": "localhost:63050",
    "cache_host": "localhost:63035"
    }
    }
  • 那么现在就可以用{{content_host}}替换掉原来的http://localhost:63040 了,同时环境改为dev

3.5.2 导入系统管理服务

  • 要进行前后端联调首先启动前端工程,浏览器访问http://localhost:8601/ ,此时会报错,因为还有一个接口我们还没有完成:http://localhost:63110/system/dictionary/all
  • 该接口指向的是系统管理服务,次链接是前端请求后端获取数据字典数据的接口地址
  • 拷贝黑马提供的xuecheng-plus-system工程到项目根目录即可,然后修改数据库连接配置,该工程仅包含数据字典对应的PO类,Mapper映射和一些配置类,然后提供了两个简单的接口,查询全部数据文档。
  • 启动系统管理服务,浏览器访问http://localhost:63110/system/dictionary/all ,如果可以正常读取数据字典的信息,则说明导入成功

3.5.3 解决跨域问题

启动前端工程,工程首页不能正常显示,查看浏览器报错如下

1
2
PLAINTEXT
Access to XMLHttpRequest at 'http://harib-eir.info/xuecheng-plus.com?adTagId=dbb6a410-0bec-11ec-8010-0a70670a1f67&fallbackUrl=ww87.xuecheng-plus.com' (redirected from 'http://localhost:8601/api/content/course/list?pageNo=1&pageSize=10') from origin 'http://localhost:8601' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

提示:从http://localhost:8601访问http://localhost:63110/system/dictionary/all被CORS policy阻止,因为没有Access-Control-Allow-Origin 头信息。CORS全称是 cross origin resource share 表示跨域资源共享。

比如:

注意:服务器之间不存在跨域请求。

  • 浏览器判断是跨域请求会在请求头上添加origin,表示这个请求来源哪里,例如

    1
    2
    3
    PLAINTEXT
    GET / HTTP/1.1
    Origin: http://localhost:8601
  • 服务器接收到请求判断这个Origin是否跨域,如果允许则在响应头中说明允许该来源的跨域请求,如下

    1
    2
    PLAINTEXT
    Access-Control-Allow-Origin:http://localhost:8601
  • 如果允许域名来源的跨域请求,则响应如下

    1
    2
    PLAINTEXT
    Access-Control-Allow-Origin:*

解决跨域的方法

  1. JSONP
    • 通过script标签的src属性进行跨域请求,如果服务端要响应内容,则先读取请求参数callback值,callback是一个回调函数的名称,服务端读取callback的值后将响应内容通过调用callback函数的方式告诉请求方
  2. 添加响应头
    • 服务端在响应头添加Access-Control-Allow-Origin: *
  3. 通过nginx代理跨域
  • 这里采用添加请求头的方式解决跨域问题。在xuecheng-plus-system-api模块下新建配置类GlobalCorsConfig

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Configuration
    public class GlobalCorsConfig {

    @Bean
    public CorsFilter getCorsFilter() {
    CorsConfiguration configuration = new CorsConfiguration();
    //添加哪些http方法可以跨域,比如:GET,Post,(多个方法中间以逗号分隔),*号表示所有
    configuration.addAllowedMethod("*");
    //添加允许哪个请求进行跨域,*表示所有,可以具体指定http://localhost:8601 表示只允许http://localhost:8601/跨域
    configuration.addAllowedOrigin("*");
    //所有头信息全部放行
    configuration.addAllowedHeader("*");
    //允许跨域发送cookie
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
    urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", configuration);
    return new CorsFilter(urlBasedCorsConfigurationSource);
    }
    }
  • 此配置类实现了跨域过滤器,在响应头添加Access-Control-Allow-Origin

  • 重启系统管理服务,前端工程可以正常进入http://localhost:8601 ,查看NetWork选项卡,跨域问题成功解决

3.5.4 前后端联调

  • 前端启动完毕,在启动内容管理服务端。

  • 前端默认连接的是项目的网关地址,由于现在网关工程还没有创建,这里需要更改前端工程的参数配置文件,修改网关地址为内容管理服务的地址

  • 编辑.env文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    PROPERTIES
    # 前台管理页面-端口
    VUE_APP_CLIENT_MANAGE_PORT=8601
    # 首页、列表、学习
    VUE_APP_CLIENT_PORTAL_URL=http://www.xuecheng-plus.com
    # 后台服务网关
    #VUE_APP_SERVER_API_URL=http://www.xuecheng-plus.com/api
    #VUE_APP_SERVER_API_URL=http://localhost:63010
    #VUE_APP_SERVER_API_URL=http://172.16.63.20:63010
    VUE_APP_SERVER_API_URL=http://localhost:63040
  • 启动前端工程,进入课程管理,可以看到界面显示的课程信息,那么到此就基本完成了前后端联调

4 课程分类查询

4.1 需求分析

下面我们进行添加课程的接口开发,在新增课程界面,有三处信息需要选择,课程分类、课程等级、课程类型

其中,课程等级和课程类型都来源于数字字典表,此部分的信息前端已从系统管理服务中读取。但是课程类型的数据是通过另外一个接口来读取的,现在还没有编写

1
2
3
4
5
PLAINTEXT
请求网址: http://localhost:8601/api/content/course-category/tree-nodes
请求方法: GET
状态代码: 404 Not Found
远程地址: 127.0.0.1:8601

课程分类信息没有在数据字典表中存储,而是有单独一张课程分类表,下面我们来看看课程分类表的结构

这张表是一个树形结构,通过父节点id将各元素组成一个树,下面是一部分数据

那么现在的需求就是:在内容管理服务中编写一个接口,读取课程分类表的数据,组成一个树形结构返回给前端

4.2 接口定义

通过查看前端的请求记录,可以得出该接口的协议为:HTTP GET,请求参数为空

1
2
3
4
5
PLAINTEXT
请求网址: http://localhost:8601/api/content/course-category/tree-nodes
请求方法: GET
状态代码: 404 Not Found
远程地址: 127.0.0.1:8601

此接口要返回全部课程分类,以树状结构返回,下面是示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
[
{
"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-1-1",
"isLeaf" : null,
"isShow" : null,
"label" : "HTML/CSS",
"name" : "HTML/CSS",
"orderby" : 1,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "JavaScript",
"name" : "JavaScript",
"orderby" : 2,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-3",
"isLeaf" : null,
"isShow" : null,
"label" : "jQuery",
"name" : "jQuery",
"orderby" : 3,
"parentid" : "1-1"
}
],
"id" : "1-1",
"isLeaf" : null,
"isShow" : null,
"label" : "前端开发",
"name" : "前端开发",
"orderby" : 1,
"parentid" : "1"
},
{
"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-2-1",
"isLeaf" : null,
"isShow" : null,
"label" : "微信开发",
"name" : "微信开发",
"orderby" : 1,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-2",
"isLeaf" : null,
"isShow" : null,
"label" : "iOS",
"name" : "iOS",
"orderby" : 2,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-3",
"isLeaf" : null,
"isShow" : null,
"label" : "其它",
"name" : "其它",
"orderby" : 8,
"parentid" : "1-2"
}
],
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1"
}
]

可以看到,上面的数据格式是一个数组结构,数组的元素即为分类信息,分类信息设计两级分类

第一级的分类信息示例如下,这部分字段其实就是课程分类信息表的属性,即我们之前生成的CourseCategory类

1
2
3
4
5
6
7
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1"

第二级的分类是第一级分类中的childrenTreeNode属性,它是一个数组结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-2-1",
"isLeaf" : null,
"isShow" : null,
"label" : "微信开发",
"name" : "微信开发",
"orderby" : 1,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-2",
"isLeaf" : null,
"isShow" : null,
"label" : "iOS",
"name" : "iOS",
"orderby" : 2,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-3",
"isLeaf" : null,
"isShow" : null,
"label" : "其它",
"name" : "其它",
"orderby" : 8,
"parentid" : "1-2"
}
]

那么我们可以定义一个DTO类表示分类信息的模型类,让其继承CourseCategory(对应一级分类),然后设置一个List属性(对应二级分类)

1
2
3
4
5
6
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CourseCategoryTreeDto extends CourseCategory {
List childrenTreeNodes;
}

这样模型类就定义好了,下面我们来定义接口

1
2
3
4
5
6
7
8
9
@Slf4j
@RestController("/course-category/tree-nodes")
@Api(value = "课程分类相关接口", tags = "课程分类相关接口")
public class CourseCategoryController {
@ApiOperation("课程分类相关接口")
public List<CourseCategoryTreeDto> queryTreeNodes() {
return null;
}
}

4.3 接口开发

4.3.1 树形表查询

课程分类表是一个树型结构,其中parentid字段为父结点ID,它是树型结构的标志字段。

如果树的层级固定可以使用表的自链接去查询,比如:我们只查询两级课程分类,可以用下边的SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
select
one.id one_id,
one.name one_name,
one.parentid one_parentid,
one.orderby one_orderby,
one.label one_label,
two.id two_id,
two.name two_name,
two.parentid two_parentid,
two.orderby two_orderby,
two.label two_label
from course_category one
inner join course_category two on one.id = two.parentid
where one.parentid = 1
and one.is_show = 1
and two.is_show = 1
order by one.orderby,
two.orderby

如果树的层级不确定,此时可以使用MySQL递归实现,使用with语法,如下:

1
2
3
WITH [RECURSIVE]
cte_name [(col_name [, col_name] ...)] AS (subquery)
[, cte_name [(col_name [, col_name] ...)] AS (subquery)] ...

cte_name :公共表达式的名称,可以理解为表名,用来表示as后面跟着的子查询

col_name :公共表达式包含的列名,可以写也可以不写

下边是一个递归的简单例子:

1
2
3
4
5
6
7
with RECURSIVE t1  AS
(
SELECT 1 as n
UNION ALL
SELECT n + 1 FROM t1 WHERE n < 5
)
SELECT * FROM t1;

输出:

说明:

t1 相当于一个表名

select 1 相当于这个表的初始值,这里使用UNION ALL 不断将每次递归得到的数据加入到表中。

n<5为递归执行的条件,当n>=5时结束递归调用。

下边我们使用递归实现课程分类的查询

1
2
3
4
5
6
with recursive t1 as (
select * from course_category p where id= '1'
union all
select t.* from course_category t inner join t1 on t1.id = t.parentid
)
select * from t1 order by t1.id, t1.orderby

查询结果如下:

t1表中初始的数据是id等于1的记录,即根结点。

通过inner join t1 t2 on t2.id = t.parentid 找到id='1'的下级节点 。

通过这种方法就找到了id='1'的所有下级节点,下级节点包括了所有层级的节点。

上边这种方法是向下递归,即找到初始节点的所有下级节点。

如何向上递归?

下边的sql实现了向上递归:

1
2
3
4
5
6
with recursive t1 as (
select * from course_category p where id= '1-1-1'
union all
select t.* from course_category t inner join t1 on t1.parentid = t.id
)
select * from t1 order by t1.id, t1.orderby

初始节点为1-1-1,通过递归找到它的父级节点,父级节点包括所有级别的节点。

以上是我们研究了树型表的查询方法,通过递归的方式查询课程分类比较灵活,因为它可以不限制层级。

mysql为了避免无限递归默认递归次数为1000,可以通过设置cte_max_recursion_depth参数增加递归深度,还可以通过max_execution_time限制执行时间,超过此时间也会终止递归操作。

mysql递归相当于在存储过程中执行若干次sql语句,java程序仅与数据库建立一次链接执行递归操作,所以只要控制好递归深度,控制好数据量性能就没有问题。

思考:如果java程序在递归操作中连接数据库去查询数据组装数据,这个性能高吗?

4.3.2 Mapper Service开发

  • 成功查询到数据了之后,我们现在就需要用Java代码将其组装成树形结构,在此之前,我们先来编写mapper(采用更灵活的递归查询)

      • 现在service中编写mapper对应的接口

        1
        2
        3
        public interface CourseCategoryMapper extends BaseMapper<CourseCategory> {
        List<CourseCategoryTreeDto> selectTreeNodes(String id);
        }
      • 编写xml

        1
        2
        3
        4
        5
        6
        7
        8
        9
        <select id="selectTreeNodes" parameterType="string" resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto">
        WITH RECURSIVE t1 AS (
        SELECT p.* FROM course_category p WHERE p.id = #{id}
        UNION ALL
        SELECT c.* FROM course_category c JOIN t1 WHERE c.parentid = t1.id
        )
        SELECT * FROM t1
        order by t1.id;
        </select>
    • mapper写好了之后,我们来编写Service,首先定义接口

      1
      2
      3
      4
      5
      6
      7
      8
      public interface CourseCategoryService {
      /**
      * 课程分类查询
      * @param id 根节点id
      * @return 根节点下面的所有子节点
      */
      List<CourseCategoryTreeDto> queryTreeNodes(String id);
      }
    • 具体实现,我们的mapper接口只返回了所有子节点的数据,那么现在我们要将这些子节点封装成一个树形结构

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      @Slf4j
      @Service
      public class CourseCategoryServiceImpl implements CourseCategoryService {
      @Autowired
      private CourseCategoryMapper courseCategoryMapper;

      @Override
      public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
      // 获取所有的子节点
      List<CourseCategoryTreeDto> categoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);
      // 定义一个List,作为最终返回的数据
      List<CourseCategoryTreeDto> result = new ArrayList<>();
      // 为了方便找子节点的父节点,这里定义一个HashMap,key是节点的id,value是节点本身
      HashMap<String, CourseCategoryTreeDto> nodeMap = new HashMap<>();
      // 将数据封装到List中,只包括根节点的下属节点(1-1、1-2 ···),这里遍历所有节点
      categoryTreeDtos.stream().forEach(item -> {
      // 这里寻找父节点的直接下属节点(1-1、1-2 ···)
      if (item.getParentid().equals(id)) {
      nodeMap.put(item.getId(), item);
      result.add(item);
      }
      // 获取每个子节点的父节点
      String parentid = item.getParentid();
      CourseCategoryTreeDto parentNode = nodeMap.get(parentid);
      // 判断HashMap中是否存在该父节点(按理说必定存在,以防万一)
      if (parentNode != null) {
      // 为父节点设置子节点(将1-1-1设为1-1的子节点)
      List childrenTreeNodes = parentNode.getChildrenTreeNodes();
      // 如果子节点暂时为null,则初始化一下父节点的子节点(给个空集合就行)
      if (childrenTreeNodes == null) {
      parentNode.setChildrenTreeNodes(new ArrayList<CourseCategoryTreeDto>());
      }
      // 将子节点设置给父节点
      parentNode.getChildrenTreeNodes().add(item);
      }
      });
      // 返回根节点的直接下属节点(1-1、1-2 ···)
      return result;
      }
      }
    • 编写测试方法,进行调试

      1
      2
      3
      4
      5
      6
      7
      8
      @Resource
      CourseCategoryService courseCategoryService;

      @Test
      void contextCourseCategoryTest() {
      List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryService.queryTreeNodes("1");
      System.out.println(courseCategoryTreeDtos);
      }

    • 使用HTTP Client测试接口

    • 编写Controller方法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Slf4j
      @RestController
      @Api(value = "课程分类相关接口", tags = "课程分类相关接口")
      public class CourseCategoryController {
      @Resource
      private CourseCategoryService courseCategoryService;
      @ApiOperation("课程分类相关接口")
      @GetMapping("/course-category/tree-nodes")
      public List<CourseCategoryTreeDto> queryTreeNodes() {
      return courseCategoryService.queryTreeNodes("1");
      }
      }
    • 重启服务,已经可以看到课程分类的信息了

5 新增课程

5.1 需求分析

5.1.1 业务流程

  • 根据前面对内容管理模块的数据模型分析,课程相关的信息有:课程基本信息、课程营销信息、课程图片信息、课程计划、课程师资信息,所以新增一门课程就需要完成这几部分的信息的编写
  • 以下是业务流程
    1. 进入课程查询列表
    2. 点击添加课程,选择课程类型是直播还是录播,课程类型不同,课程的授课方式也不同
    3. 选择完毕,点击下一步,进入课程基本信息添加界面
      • 本界面分为两部分信息,一部分是课程基本信息,一部分是课程营销信息(两张表是一对一关系,两张表的主键是相同的)
    4. 添加课程计划信息
      • 课程计划即课程的大纲目录
      • 课程计划分为两级,章节和小节
      • 每个小节需要上传课程视频,用户点击小节标题即可开始播放视频
      • 如果是直播课程,则会进入直播间
    5. 课程计划填写完毕,进入课程师资管理
      • 在课程师资界面维护该课程的授课老师
      • 到此,一门课程新增完成

5.1.2 数据模型

新增课程功能,只向课程基本信息表、课程营销信息表添加记录

课程基本信息表 course_base

课程营销信息表 course_market img

新建课程的初始审核状态为未提交,初始发布状态为未发布

5.2 接口定义

根据业务流程,这里先定义提交课程基本信息的接口

1
2
3
4
请求网址: http://localhost:8601/api/content/course
请求方法: POST
状态代码: 404 Not Found
远程地址: 127.0.0.1:8601

接口协议:HTTP POST

接口请求示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
### 创建课程
POST {{content_host}}/content/course
Content-Type: application/json

{

"mt": "",
"st": "",
"name": "",
"pic": "",
"teachmode": "200002",
"users": "初级人员",
"tags": "",
"grade": "204001",
"description": "",
"charge": "201000",
"price": 0,
"originalPrice":0,
"qq": "",
"wechat": "",
"phone": "",
"validDays": 365
}

###响应结果如下
#成功响应结果如下
{
"id": 109,
"companyId": 1,
"companyName": null,
"name": "测试课程103",
"users": "初级人员",
"tags": "",
"mt": "1-1",
"mtName": null,
"st": "1-1-1",
"stName": null,
"grade": "204001",
"teachmode": "200002",
"description": "",
"pic": "",
"createDate": "2022-09-08 07:35:16",
"changeDate": null,
"createPeople": null,
"changePeople": null,
"auditStatus": "202002",
"status": 1,
"coursePubId": null,
"coursePubDate": null,
"charge": "201000",
"price": null,
"originalPrice":0,
"qq": "",
"wechat": "",
"phone": "",
"validDays": 365
}

请求参数和CourseBase模型类不一致,所以我们需要定义一个模型类来接收请求参数;同时,响应结果中包含了课程基本信息和课程营销信息还有课程分类信息,所以我们也需要定义一个响应结果模型类

请求参数模型类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
JAVA
package com.xuecheng.content.model.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

/**
* @version 1.0
* @description 添加课程dto
*/
@Data
@ApiModel(value = "AddCourseDto", description = "新增课程基本信息")
public class AddCourseDto {

@ApiModelProperty(value = "课程名称", required = true)
private String name;

@ApiModelProperty(value = "适用人群", required = true)
private String users;

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

@ApiModelProperty(value = "大分类", required = true)
private String mt;

@ApiModelProperty(value = "小分类", required = true)
private String st;

@ApiModelProperty(value = "课程等级", required = true)
private String grade;

@ApiModelProperty(value = "教学模式(普通,录播,直播等)", required = true)
private String teachmode;

@ApiModelProperty(value = "课程介绍")
private String description;

@ApiModelProperty(value = "课程图片", required = true)
private String pic;

@ApiModelProperty(value = "收费规则,对应数据字典", required = true)
private String charge;

@ApiModelProperty(value = "价格")
private Float price;

@ApiModelProperty(value = "原价")
private Float originalPrice;

@ApiModelProperty(value = "qq")
private String qq;

@ApiModelProperty(value = "微信")
private String wechat;

@ApiModelProperty(value = "电话")
private String phone;

@ApiModelProperty(value = "有效期")
private Integer validDays;
}

响应结果模型类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.xuecheng.content.model.dto;

import com.xuecheng.content.model.po.CourseBase;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.math.BigDecimal;

/**
* @description 课程基本信息dto
* @author Mr.M
* @date 2022/9/7 17:44
* @version 1.0
*/
@Data
public class CourseBaseInfoDto extends CourseBase {


/**
* 收费规则,对应数据字典
*/
private String charge;

/**
* 价格
*/
private Float price;


/**
* 原价
*/
private Float originalPrice;

/**
* 咨询qq
*/
private String qq;

/**
* 微信
*/
private String wechat;

/**
* 电话
*/
private String phone;

/**
* 有效期天数
*/
private Integer validDays;

/**
* 大分类名称
*/
private String mtName;

/**
* 小分类名称
*/
private String stName;

}

定义接口如下,返回类型和请求参数类型均为我们刚刚创建的模型类

1
2
3
4
5
@ApiOperation("新增课程基础信息接口")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto) {
return null;
}

5.3 接口开发

1、定义service接口,这里额外需要一个机构id,因为我们的业务是教学机构登录账号,然后添加该教学机构的下属课程

1
2
3
4
5
6
7
/**
* 新增课程基本信息
* @param companyId 教学机构id
* @param addCourseDto 课程基本信息
* @return
*/
CourseBaseInfoDto createCourseBase(Long companyId, AddCourseDto addCourseDto);

2、编写service接口实现类

  • 首先我们需要对请求参数做合法性校验,判断一下用户是否输入了必填项,还有一些项的默认值

  • 然后对请求参数进行封装,调用mapper进行数据持久化

  • 组装返回结果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    @Transactional
    @Override
    public CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto dto) {

    //合法性校验
    if (StringUtils.isBlank(dto.getName())) {
    throw new RuntimeException("课程名称为空");
    }

    if (StringUtils.isBlank(dto.getMt())) {
    throw new RuntimeException("课程分类为空");
    }

    if (StringUtils.isBlank(dto.getSt())) {
    throw new RuntimeException("课程分类为空");
    }

    if (StringUtils.isBlank(dto.getGrade())) {
    throw new RuntimeException("课程等级为空");
    }

    if (StringUtils.isBlank(dto.getTeachmode())) {
    throw new RuntimeException("教育模式为空");
    }

    if (StringUtils.isBlank(dto.getUsers())) {
    throw new RuntimeException("适应人群为空");
    }

    if (StringUtils.isBlank(dto.getCharge())) {
    throw new RuntimeException("收费规则为空");
    }
    //新增对象
    CourseBase courseBaseNew = new CourseBase();
    //将填写的课程信息赋值给新增对象
    BeanUtils.copyProperties(dto,courseBaseNew);
    //设置审核状态
    courseBaseNew.setAuditStatus("202002");
    //设置发布状态
    courseBaseNew.setStatus("203001");
    //机构id
    courseBaseNew.setCompanyId(companyId);
    //添加时间
    courseBaseNew.setCreateDate(LocalDateTime.now());
    //插入课程基本信息表
    int insert = baseMapper.insert(courseBaseNew);
    if(insert<=0){
    throw new RuntimeException("新增课程基本信息失败");
    }
    //向课程营销表保存课程营销信息
    //课程营销信息
    CourseMarket courseMarketNew = new CourseMarket();
    Long courseId = courseBaseNew.getId();
    BeanUtils.copyProperties(dto,courseMarketNew);
    courseMarketNew.setId(courseId);
    int i = saveCourseMarket(courseMarketNew);
    if(i<=0){
    throw new RuntimeException("保存课程营销信息失败");
    }
    //查询课程基本信息及营销信息并返回
    return getCourseBaseInfo(courseId);

    }

    //保存课程营销信息
    private int saveCourseMarket(CourseMarket courseMarketNew){
    //收费规则
    String charge = courseMarketNew.getCharge();
    if(StringUtils.isBlank(charge)){
    throw new RuntimeException("收费规则没有选择");
    }
    //收费规则为收费
    if(charge.equals("201001")){
    if(courseMarketNew.getPrice() == null || courseMarketNew.getPrice().floatValue()<=0){
    throw new RuntimeException("课程为收费价格不能为空且必须大于0");
    }
    }
    //根据id从课程营销表查询
    CourseMarket courseMarketObj = courseMarketMapper.selectById(courseMarketNew.getId());
    if(courseMarketObj == null){
    return courseMarketMapper.insert(courseMarketNew);
    }else{
    BeanUtils.copyProperties(courseMarketNew,courseMarketObj);
    courseMarketObj.setId(courseMarketNew.getId());
    return courseMarketMapper.updateById(courseMarketObj);
    }
    }
    //根据课程id查询课程基本信息,包括基本信息和营销信息
    public CourseBaseInfoDto getCourseBaseInfo(long courseId){

    CourseBase courseBase = baseMapper.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);
    }

    //查询分类名称
    CourseCategory courseCategoryBySt = courseCategoryMapper.selectById(courseBase.getSt());
    courseBaseInfoDto.setStName(courseCategoryBySt.getName());
    CourseCategory courseCategoryByMt = courseCategoryMapper.selectById(courseBase.getMt());
    courseBaseInfoDto.setMtName(courseCategoryByMt.getName());

    return courseBaseInfoDto;

    }
  • 下面编写Controller方法

    1
    2
    3
    4
    5
    6
    7
    8
    JAVA
    @ApiOperation("新增课程基础信息接口")
    @PostMapping("/course")
    public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto) {
    // 机构id,暂时硬编码模拟假数据
    Long companyId = 22L;
    return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
    }

5.4 接口测试

  • 使用HTTP Client测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    ### 新增课程
    POST {{content_host}}/content/course
    Content-Type: application/json

    {
    "mt": "1-1",
    "st": "1-1-1",
    "name": "测试课程tmp",
    "pic": "",
    "teachmode": "200002",
    "users": "初级人员",
    "tags": "",
    "grade": "204001",
    "description": "这是一门测试课程",
    "charge": "201000",
    "price": 99,
    "originalPrice": 999,
    "qq": "123564",
    "wechat": "123654",
    "phone": "156213",
    "validDays": 365
    }
  • 测试成功,去数据库中查询,也可以看到数据

5.5 异常处理

5.5.1 异常问题分析

  • 在service方法中,有很多的参数合法性校验,当参数不合法的时候抛出异常,例如我们添加课程时,设置一个负数的课程价格,会报500异常

    1
    2
    3
    4
    5
    6
    7
    {
    "timestamp":"2023-02-02T14:42:36.820+00:00",
    "status":500,
    "error":"Internal Server Error",
    "message":"",
    "path":"/content/course"
    }
  • 现在存在一个问题:并没有输出我们抛出异常时的指定异常信息,只有查看控制台日志才能看到异常信息

    1
    java.lang.RuntimeException: 课程设置了收费,价格不能为空,且必须大于0
  • 所以我们现在要对异常信息进行处理,异常处理除了输出在日志中,还需要提示给用户。

  • 前端和后端需要做一些约定

    1. 错误体会信息统一以json格式返回给前端
    2. 以HTTP状态码决定当前是否出错,非200状态码为操作异常
  • 那么如何规范异常信息?

    • 代码中统一抛出项目的自定义异常类型,这样可以统一去捕获这一类或几类的异常
    • 规范了异常类型,就可以去获取异常信息
    • 如果捕获了非项目自定义的异常类型,则统一向用户提示执行过程异常,请重试的错误信息
  • 如何捕获异常?

    • 代码统一使用try/catch方式去捕获代码比较臃肿,可以通过SpringMVC提供的控制器增强类统一由一个类去完成异常的捕获

5.5.2 统一异常处理实现

  • 根据上面分析的方案,统一在base基础工程实现统一异常处理,各模块依赖了base基础工程,都可以使用

  • 统一异常处理的步骤如下

    在base工程中添加依赖

    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>

    定义一个枚举类,枚举一些通用的异常信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * @description 通用错误信息
    */
    public enum CommonError {
    UNKOWN_ERROR("执行过程异常,请重试"),
    PARAS_ERROR("非法参数"),
    OBJECT_NULL("对象为空"),
    QUERY_NULL("查询结果为空"),
    REQUEST_NULL("请求参数为空");

    private String errMessage;

    public String getErrMessage() {
    return errMessage;
    }

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

    自定义异常类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    /**
    * @description 学成在线项目异常类
    */
    public class XueChengPlusException extends RuntimeException {
    private String errMessage;

    public String getErrMessage() {
    return errMessage;
    }

    public XueChengPlusException() {
    super();
    }

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

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

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

    响应用户的统一类型

    1
    2
    3
    4
    5
    6
    7
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class RestErrorResponse implements Serializable {
    private String errMessage;

    }

    全局异常处理器

    从Spring 3.0-Spring 3.2

    版本之间,对Spring架构和SpringMVC的Controller的异常捕获提供了相应的异常处理

    • @ExceptionHandlerSpring3.0提供的标识,在方法上或类上的注解,用于表明方法的处理异常类型
    • @ControllerAdviceSpring3.2提供的新注解,用于增强SpringMVC中的Controller。通常与@ExceptionHandler结合使用,来处理SpringMVC的异常信息
    • @ResponseStatusSpring3.0提供的标识在方法或类上的注解,用状态码和应返回的原因标记方法或异常类。调用处理程序方法时,状态码将应用于HTTP响应

    通过上面的注解便可实现微服务全局异常处理,具体代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * @description 全局异常处理器
    */
    @Slf4j
    @ControllerAdvice
    public class GlobalExceptionHandler {
    @ResponseBody
    @ExceptionHandler(XueChengPlusException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 该异常枚举错误码为500,
    public RestErrorResponse customException(XueChengPlusException exception) {
    log.error("系统异常:{}", exception.getErrMessage());
    return new RestErrorResponse(exception.getErrMessage());
    }

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

5.5.3 异常处理测试

在ContentAPI的启动类上添加

1
scanBasePackages = "com.xuecheng"
1
2
3
4
5
6
7
8
9
10
JAVA
@EnableSwagger2Doc
@SpringBootApplication(scanBasePackages = "com.xuecheng")
public class ContentApiApplication {

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

}
  • 在异常处理测试之前,我们首先要将代码中抛出自定义类型的异常

    1
    2
    3
    4
    JAVA
    if (price == null || price.floatValue() <= 0) {
    XueChengPlusException.cast("课程设置了收费,价格不能为空,且必须大于0");
    }
  • 使用HTTP Client进行测试,故意将收费课程价格设置为负数,捕获到的响应信息如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    JSON
    POST http://localhost:53040/content/course

    HTTP/1.1 500
    Content-Type: application/json
    Transfer-Encoding: chunked
    Date: Fri, 03 Feb 2023 02:32:20 GMT
    Connection: close

    {
    "errMessage": "课程设置了收费,价格不能为空,且必须大于0"
    }
  • 前后端联调,测试前端会不会抛出我们自定义的异常信息 img

  • 那么至此,项目的异常处理测试完毕,我们在开发中对于业务分支中的错误,要抛出项目自定义的异常类型

5.5.4 面试

  1. 系统如何处理异常?
    • 我们自定义一个统一的异常处理器去捕获并处理异常
    • 使用控制器增强注解@ControllerAdvice(也可以用@RestControllerAdvice)和异常处理注解@ExceptionHandler来实现
  2. 如何处理自定义异常?
    • 程序在编写代码时,根据校验结果主动抛出自定义异常类对象,抛出异常时指定详细的异常信息,异常处理器捕获异常信息记录、异常日志,并响应给用户
  3. 如何处理未知异常?
    • 接口执行过程中的一些运行时异常也会被异常处理器统一捕获,记录异常日志,统一响应给用户500错误
    • 在异常处理其中还可以对某个异常类型进行单独处理(使用@ExceptionHandler来声明要捕获的异常类型)

5.6 JSR303校验

5.6.1 统一校验的需求

前端请求后端接口传输参数,是在Controller中校验还是Service中校验?

  • 答案是都需要校验,只是分工不同

Controller中校验请求参数的合法性,包括:必填项校验、数据格式校验,比如:参数是否符合一定的日期格式等

  • Controller中可以将校验的代码写成通用代码

Service中要校验的是业务规则相关的内容,比如:课程已经审核通过,所以提交失败等

  • Service中需要根据业务规则去校验,所以不方便写成通用代码

早在JavaEE6规范中,就定义了参数校验的规范,它就是JSR-303,它定义了Bean Validation,即对bean属性进行校验

SpringBoot提供了JSR-303的支持,它就是spring-boot-stater-validation,它的底层使用Hibernate ValidationHibernate ValidationBean Validation的参考实现

所以我们打算在Controller层使用spring-boot-stater-validation完成对参数的基本合法性进行校验

5.6.2 统一校验实现

首先在Base工程中引入spring-boot-starter-validation依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

现在准备对内容管理模块添加课程接口进行校验

1
2
3
4
5
6
7
@ApiOperation("新增课程基础信息接口")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto) {
// 机构id,暂时硬编码模拟假数据
Long companyId = 22L;
return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
}

此接口使用AddCourseDto模型对象接收参数,所以进入AddCourseDto模型类,在属性上添加校验规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Data
@ApiModel(value = "AddCourseDto", description = "新增课程基本信息")
public class AddCourseDto {

+ @NotEmpty(message = "课程名称不能为空")
@ApiModelProperty(value = "课程名称", required = true)
private String name;

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

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

+ @NotEmpty(message = "课程分类不能为空")
@ApiModelProperty(value = "大分类", required = true)
private String mt;

+ @NotEmpty(message = "课程分类不能为空")
@ApiModelProperty(value = "小分类", required = true)
private String st;

+ @NotEmpty(message = "课程等级不能为空")
@ApiModelProperty(value = "课程等级", required = true)
private String grade;

@ApiModelProperty(value = "教学模式(普通,录播,直播等)", required = true)
private String teachmode;

@ApiModelProperty(value = "课程介绍")
private String description;

@ApiModelProperty(value = "课程图片", required = true)
private String pic;

+ @NotEmpty(message = "收费规则不能为空")
@ApiModelProperty(value = "收费规则,对应数据字典", required = true)
private String charge;

@ApiModelProperty(value = "价格")
private Float price;

@ApiModelProperty(value = "原价")
private Float originalPrice;

@ApiModelProperty(value = "qq")
private String qq;

@ApiModelProperty(value = "微信")
private String wechat;

@ApiModelProperty(value = "电话")
private String phone;

@ApiModelProperty(value = "有效期")
private Integer validDays;
}

上面用到了@NotEmpty@Size两个注解,@NotEmpty表示属性不能为空,@Size表示限制属性内容的长度

在javax.validation.constraints包下有很多这样的校验注解

限制说明
@Nul限制只能为null
@NotNull限制制必须不为null
@AssertFalse限制必须为false
@AssertTrue限制必须为true
@DecimalMax(value)限制必须为一个不大于指定值的数字
@DecimalMin(value)限制必须为一个不小于指定值的数字
@Digits(integer fraction限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction
@Future限制必须是一个将来的日期
@Max(value)限制必须为一个不大于指定值的数字
@Min(value)限制必须为一个不小于指定值的数字
@Past限制必须是一个过去的日期
@Pattern(value)限制必须符合指定的正则表达式
@Size(max, min)限制字符长度必须在min到max之间
@NotEmpty验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@NotBlank验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格
@Email验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式

定义好了规则校验,还需要开启校验,在Controller方法中添加@Validated注解,如下

1
2
3
4
5
6
7
@ApiOperation("新增课程基础信息接口")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto) {
// 机构id,暂时硬编码模拟假数据
Long companyId = 22L;
return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
}

如果校验出错,Spring会抛出MethodArgumentNotValidException异常,我们需要在全局异常处理器中捕获异常,解析出异常信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse doMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
// 由于用户输入的内容可能存在多处错误,所以我们要将所有错误信息都提示给用户
BindingResult bindingResult = exception.getBindingResult();
// 获取错误集合
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
// 拼接字符串
StringBuffer stringBuffer = new StringBuffer();
fieldErrors.forEach(fieldError -> stringBuffer.append(fieldError.getDefaultMessage()).append(","));
// 记录日志
log.error(stringBuffer.toString());
// 响应给用户
return new RestErrorResponse(stringBuffer.toString());
}

重启内容管理服务,使用HTTP Client进行测试,将必填项设置为空,适用人群设置小于10个字,执行测试,接口响应结果如下

1
2
3
4
5
6
7
8
9
10
11
POST http://localhost:53040/content/course

HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 03 Feb 2023 04:52:30 GMT
Connection: close

{
"errMessage": "课程名称不能为空,适用人群内容过少,至少10个字符,"
}
  • 可以看到校验器已生效

5.6.3 分组校验

有时候在同一个属性上设置一个校验规则不能满足要求

  • 比如:订单标号是由系统生成,所以在添加订单时,要求订单编号为空;但是在更新订单时,要求订单编号不能为空。

此时就需要用到分组校验,在同一个属性定义多个校验规则属于不同的分组

比如:添加订单定义@NULL规则属于insert分组,更新订单定义@NotEmpty属于update分组,insert和update

是分组的名称,可以自定义的

1
2
3
@Null(message = "订单id需为空", groups = Insert.class)
@NotEmpty(message = "订单id不能为空", groups = Update.class)
private String id;

下面举例说明,我们用class类型来表示不同的分组,所以我们定义不同的接口类型(空接口)表示不同的分组,由于校验分组是公用的,所以定义在base工程中

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.xuecheng.base.exception;

/**
* @description 校验分组
*/
public class ValidationGroups {
public interface Insert{}

public interface Update{}

public interface Delete{}

}

下面在定义校验规则时指定分组

1
2
3
4
@NotEmpty(message = "添加课程名称不能为空", groups = ValidationGroups.Insert.class)
@NotEmpty(message = "修改课程名称不能为空", groups = ValidationGroups.Update.class)
@ApiModelProperty(value = "课程名称", required = true)
private String name;

在Controller方法中启动校验规则时指定要使用的分组名

1
2
3
4
5
6
@ApiOperation("新增课程基础信息接口")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated(ValidationGroups.Insert.class) AddCourseDto addCourseDto) {
Long companyId = 22L;
return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
}

重启服务,使用HTTP Client进行测试

1
2
3
4
5
6
7
8
9
10
11
POST http://localhost:53040/content/course

HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 03 Feb 2023 05:28:23 GMT
Connection: close

{
"errMessage": "添加课程名称不能为空,"
}
  • 由于这里指定的是Insert分组,所以抛出异常信息添加课程名称不能为空,如果修改分组为Update分组,则异常信息为修改课程名称不能为空,符合我们的预期

5.6.4 校验规则不满足?

如果javax.validation.constraints包下的校验规则满足不了需求怎么办?

  1. 手写校验代码
  2. 自定义校验规则注解

5.6.5 面试

请求参数的合法性如何校验? - 使用基于JSR-303的校验框架实现,SpringBoot提供了JSR-303的支持,它就是spring-boot-starter-validation,它包括了很多校验规则,只需要在模型类中通过注解指定校验规则,在Controller方法上开启校验。

6 修改课程

6.1 需求分析

6.1.1 业务流程

  1. 点击课程列表查询
  2. 点击编辑,此时应该看到对应数据的回显数据
  3. 点击保存,完成修改

6.1.2 数据模型

修改课程还是涉及到之前的课程基本信息表和课程营销信息表

但是修改课程提交的数据比新增课程多了一项课程id,因为修改课程需要对某个具体的课程进行修改,新增课程的课程id是系统生成的

修改完成保存数据时,还需要更新课程基本信息表中的修改时间

6.2 接口定义

6.2.1 查询课程信息

  • 定义根据课程id查询课程信息的接口,接口示例如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    GET /content/course/40
    Content-Type: application/json

    # 响应结果
    {
    "id": 40,
    "companyId": 1232141425,
    "companyName": null,
    "name": "SpringBoot核心",
    "users": "Spring Boot初学者",
    "tags": "Spring项目的快速构建",
    "mt": "1-3",
    "mtName": null,
    "st": "1-3-2",
    "stName": null,
    "grade": "200003",
    "teachmode": "201001",
    "description": "课程系统性地深度探讨 Spring Boot 核心特性,引导小伙伴对 Java 规范的重视,启发对技术原理性的思考,掌握排查问题的技能,以及学习阅读源码的方法和技巧,全面提升研发能力,进军架构师队伍。",
    "pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
    "createDate": "2019-09-10 16:05:39",
    "changeDate": "2022-09-09 07:27:48",
    "createPeople": null,
    "changePeople": null,
    "auditStatus": "202004",
    "status": "203001",
    "coursePubId": 21,
    "coursePubDate": null,
    "charge": "201001",
    "price": 0.01
    }
  • 查询结果为单挑课程信息,内容和新增课程的返回结果一一致,接口定义如下

    1
    2
    3
    4
    5
    @ApiOperation("根据课程id查询课程基础信息")
    @GetMapping("/course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
    return null;
    }

6.2.2 修改课程信息

  • 根据前面的数据模型分析,修改课程提交的数据比新增课程提交的数据多了一个课程id,接口示例如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    PUT /content/course
    Content-Type: application/json
    {
    "id": 40,
    "companyName": null,
    "name": "SpringBoot核心",
    "users": "Spring Boot初学者",
    "tags": "Spring项目的快速构建",
    "mt": "1-3",
    "st": "1-3-2",
    "grade": "200003",
    "teachmode": "201001",
    "description": "课程系统性地深度探讨 Spring Boot 核心特性,引导小伙伴对 Java 规范的重视,启发对技术原理性的思考,掌握排查问题的技能,以及学习阅读源码的方法和技巧,全面提升研发能力,进军架构师队伍。",
    "pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
    "charge": "201001",
    "price": 0.01
    }


    ###修改成功响应结果如下
    {
    "id": 40,
    "companyId": 1232141425,
    "companyName": null,
    "name": "SpringBoot核心",
    "users": "Spring Boot初学者",
    "tags": "Spring项目的快速构建",
    "mt": "1-3",
    "mtName": null,
    "st": "1-3-2",
    "stName": null,
    "grade": "200003",
    "teachmode": "201001",
    "description": "课程系统性地深度探讨 Spring Boot 核心特性,引导小伙伴对 Java 规范的重视,启发对技术原理性的思考,掌握排查问题的技能,以及学习阅读源码的方法和技巧,全面提升研发能力,进军架构师队伍。",
    "pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
    "createDate": "2019-09-10 16:05:39",
    "changeDate": "2022-09-09 07:27:48",
    "createPeople": null,
    "changePeople": null,
    "auditStatus": "202004",
    "status": "203001",
    "coursePubId": 21,
    "coursePubDate": null,
    "charge": "201001",
    "price": 0.01
    }
  • 那么我们这里就需要再定义一个模型类,用于接收修改课程提交的数据,定义一个课程id属性,然后继承AddCourseDto就好了

    1
    2
    3
    4
    5
    6
    @Data
    @ApiModel(value = "EditCourseDto", description = "修改课程基本信息")
    public class EditCourseDto extends AddCourseDto{
    @ApiModelProperty(value = "课程id", required = true)
    private Long id;
    }
  • 接口定义如下,请求路径与新增课程的一致,只是请求方式不同

    1
    2
    3
    4
    5
    6
    JAVA
    @ApiOperation("修改课程基础信息接口")
    @PutMapping("/course")
    public CourseBaseInfoDto modifyCourseBase(@RequestBody EditCourseDto editCourseDto) {
    return null;
    }

6.3 接口开发

6.3.1 查询课程信息

之前我们在做新增课程的时候,就已经编写过了查询课程的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private CourseBaseInfoDto getCourseBaseInfo(Long courseId) {
CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
// 1. 根据课程id查询课程基本信息
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if (courseBase == null)
return null;
// 1.1 拷贝属性
BeanUtils.copyProperties(courseBase, courseBaseInfoDto);
// 2. 根据课程id查询课程营销信息
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
// 2.1 拷贝属性
if (courseMarket != null)
BeanUtils.copyProperties(courseMarket, courseBaseInfoDto);
// 3. 查询课程分类名称,并设置属性
// 3.1 根据小分类id查询课程分类对象
CourseCategory courseCategoryBySt = courseCategoryMapper.selectById(courseBase.getSt());
// 3.2 设置课程的小分类名称
courseBaseInfoDto.setStName(courseCategoryBySt.getName());
// 3.3 根据大分类id查询课程分类对象
CourseCategory courseCategoryByMt = courseCategoryMapper.selectById(courseBase.getMt());
// 3.4 设置课程大分类名称
courseBaseInfoDto.setMtName(courseCategoryByMt.getName());
return courseBaseInfoDto;
}

现在只需要将查询课程信息的方法提到接口上,这样在Controller中就可以通过Service接口调用此方法

1
2
3
4
5
6
/**
* 根据课程id查询课程基本信息
* @param courseId 课程id
* @return
*/
CourseBaseInfoDto getCourseBaseInfo(Long courseId);

完善Controller层代码

1
2
3
4
5
@ApiOperation("根据课程id查询课程基础信息")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
return courseBaseInfoService.getCourseBaseInfo(courseId);
}

6.3.2 修改课程信息

在Service层新增修改课程的接口与方法

1
2
3
4
5
6
/**
* 修改课程信息
* @param companyId 机构id,本机构只能修改本机构课程
* @return
*/
CourseBaseInfoDto updateCourseBase(Long companyId, EditCourseDto editCourseDto);

对应的实现方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Override
@Transactional
public CourseBaseInfoDto updateCourseBase(Long companyId, EditCourseDto editCourseDto) {
// 判断当前修改课程是否属于当前机构
Long courseId = editCourseDto.getId();
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if (!companyId.equals(courseBase.getCompanyId())) {
XueChengPlusException.cast("只允许修改本机构的课程");
}
// 拷贝对象
BeanUtils.copyProperties(editCourseDto, courseBase);
// 更新,设置更新时间
courseBase.setChangeDate(LocalDateTime.now());
courseBaseMapper.updateById(courseBase);
// 查询课程营销信息
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
// 由于课程营销信息不是必填项,故这里先判断一下
if (courseMarket == null) {
courseMarket = new CourseMarket();
}
courseMarket.setId(courseId);
// 获取课程收费状态并设置
String charge = editCourseDto.getCharge();
courseMarket.setCharge(charge);
// 如果课程收费,则判断价格是否正常
if (charge.equals("201001")) {
Float price = editCourseDto.getPrice();
if (price <= 0 || price == null) {
XueChengPlusException.cast("课程设置了收费,价格不能为空,且必须大于0");
}
}
// 对象拷贝
BeanUtils.copyProperties(editCourseDto, courseMarket);
// 有则更新,无则插入
courseMarketService.saveOrUpdate(courseMarket);
return getCourseBaseInfo(courseId);
}

需要注意的一点是:saveOrUpdate方法是MP提供的,我们要事先编写courseMarketServiceImpl,并将其注入

1
2
3
4
5
6
@Service
public class CourseMarketServiceImpl extends ServiceImpl<CourseMarketMapper, CourseMarket> {
}
// -----
@Resource
CourseMarketServiceImpl courseMarketServiceImpl;

完善接口层代码

1
2
3
4
5
6
@ApiOperation("修改课程基础信息接口")
@PutMapping("/course")
public CourseBaseInfoDto modifyCourseBase(@RequestBody EditCourseDto editCourseDto) {
Long companyId = 22L;
return courseBaseInfoService.updateCourseBase(companyId, editCourseDto);
}

6.4 接口测试

使用HTTP Client进行测试查询课程,可以看到响应的正常数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
### 根据课程id查询课程基本信息
GET {{content_host}}/content/course/22
Content-Type: application/json

### 结果如下
GET http://localhost:53040/content/course/22

HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 04 Feb 2023 07:06:22 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
"id": 22,
"companyId": 1232141425,
"companyName": null,
"name": "大数据2",
"users": "具有一定的java基础",
"tags": null,
"mt": "1-6",
"st": "1-6-1",
"grade": "200001",
"teachmode": "200002",
"description": "111111大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据大数据",
"pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
"createDate": "2019-09-04 09:56:19",
"changeDate": "2022-09-16 07:59:57",
"createPeople": null,
"changePeople": null,
"auditStatus": "202001",
"status": "203001",
"charge": "201001",
"price": 11.0,
"originalPrice": 1111.0,
"qq": "334455",
"wechat": "223321",
"phone": "1333333",
"validDays": 33,
"mtName": "云计算/大数据",
"stName": "Spark"
}

使用HTTP Client测试修改课程信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
### 修改课程
PUT {{content_host}}/content/course/
Content-Type: application/json

{
"id":1,
"companyId":22,
"companyName":null,
"name":"JAVA8/9/10新特性讲解",
"users":"java爱好者,有一定java基础",
"tags":"有个java 版本变化的新内容,帮助大家使用最新的思想和工具",
"mt":"1",
"st":"1-3-2",
"grade":"204002",
"teachmode":"200002",
"description":null,
"pic":"https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
"createDate":"2019-09-03 17:48:19",
"changeDate":"2022-09-17 16:47:29",
"createPeople":"1",
"changePeople":null,
"auditStatus":"202004",
"status":"203001",
"charge":"201001",
"price":2,
"originalPrice":11,
"qq":"1",
"wechat":"1",
"phone":"1",
"validDays":1,
"mtName":"根结点",
"stName":"Java",
"uiCategoryTreeSelected":[
"1",
"1-3-2"
]
}
  • 修改成功后,去数据库中查看数据是否修改成功

6.5 代码优化

程序员写的代码不仅要完成功能实现,还要养成代码重构优化的习惯,这样久而久之在写代码的过程中就养成了代码抽取和封装的习惯

例如刚刚我们写的代码,在新增课程和修改课程中,都对课程营销信息进行了保存,且都校验了课程营销信息的价格字段,都是先判断收费状况,然后校验价格,最后保存,那么这里就可以将对课程营销信息的校验和保存相关代码进行抽取,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private int saveCourseMarket(CourseMarket courseMarket) {
String charge = courseMarket.getCharge();
if (StringUtils.isBlank(charge))
XueChengPlusException.cast("请设置收费规则");
if (charge.equals("201001")) {
Float price = courseMarket.getPrice();
if (price == null || price <= 0) {
XueChengPlusException.cast("课程设置了收费,价格不能为空,且必须大于0");
}
}
// 2.7 插入课程营销信息表
boolean flag = courseMarketServiceImpl.saveOrUpdate(courseMarket);
return flag ? 1 : -1;
}

7 查询课程计划

7.1 需求分析

7.1.1 业务流程

当我们添加/修改完课程基本信息后,将自动进入课程计划编辑界面

课程计划即课程的大纲目录

课程计划分为两级:章节和小节

本小节完成课程计划信息的查询

7.2 数据模型

课程计划查询也是一个树状结构,表结构如下

每个课程计划都有所属课程

每个课程的课程计划有两个级别 - 第一级为章,grade为1 - 第二季为小节,grade为2 - 第二级的parentid为第一级的id

根据业务流程中的界面原型,课程计划列表展示时还有课程计划关联的视频信息

课程计划关联的视频信息在teachplan_media表

两张表是一对一关系,每个课程计划只能在teachplan_media表中存在一个视频

7.3 接口定义

接口示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
GET /teachplan/22/tree-nodes
[
{
"changeDate": null,
"courseId": 74,
"cousePubId": null,
"createDate": null,
"endTime": null,
"grade": "2",
"isPreview": "0",
"mediaType": null,
"orderby": 1,
"parentid": 112,
"pname": "第1章基础知识",
"startTime": null,
"status": null,
"id": 113,
"teachPlanTreeNodes": [{
"changeDate": null,
"courseId": 74,
"cousePubId": null,
"createDate": null,
"endTime": null,
"grade": "3",
"isPreview": "1",
"mediaType": "001002",
"orderby": 1,
"parentid": 113,
"pname": "第1节项目概述",
"startTime": null,
"status": null,
"id": 115,
"teachPlanTreeNodes": null,
"teachplanMedia": {
"courseId": 74,
"coursePubId": null,
"mediaFilename": "2.avi",
"mediaId": 41,
"teachplanId": 115,
"id": null
}
}],
"teachplanMedia": null
}, {
"changeDate": null,
"courseId": 74,
"cousePubId": null,
"createDate": null,
"endTime": null,
"grade": "2",
"isPreview": "0",
"mediaType": "",
"orderby": 1,
"parentid": 112,
"pname": "第2章快速入门",
"startTime": null,
"status": null,
"id": 242,
"teachPlanTreeNodes": [{
"changeDate": null,
"courseId": 74,
"cousePubId": null,
"createDate": null,
"endTime": null,
"grade": "3",
"isPreview": "1",
"mediaType": "001002",
"orderby": 2,
"parentid": 242,
"pname": "第1节搭建环境",
"startTime": null,
"status": null,
"id": 244,
"teachPlanTreeNodes": null,
"teachplanMedia": {
"courseId": 74,
"coursePubId": null,
"mediaFilename": "3.avi",
"mediaId": 42,
"teachplanId": 244,
"id": null
}
},
{
"changeDate": null,
"courseId": 74,
"cousePubId": null,
"createDate": null,
"endTime": null,
"grade": "3",
"isPreview": "0",
"mediaType": "001002",
"orderby": 3,
"parentid": 242,
"pname": "第2节项目概述",
"startTime": null,
"status": null,
"id": 245,
"teachPlanTreeNodes": null,
"teachplanMedia": {
"courseId": 74,
"coursePubId": null,
"mediaFilename": "1a.avi",
"mediaId": 39,
"teachplanId": 245,
"id": null
}
}
],
"teachplanMedia": null
}
]

查询课程计划的请求参数:课程id

查询课程计划的响应结果为一个数组,数组中的元素除了包括Teachplan表中的内容,还关联了TeachplanMedia和teachPlanTreeNodes,即课程媒资信息和子目录

所以对于响应结果,我们需要自定义一个模型类,让该模型类继承Teachplan,然后其中再新增TeachplanMedia

List<TeachPlanTreeNodes> 属性(子目录不止一个,可以有多个)

1
2
3
4
5
@Data
public class TeachplanDto extends Teachplan {
private TeachplanMedia teachplanMedia;
private List<TeachplanDto> teachPlanTreeNodes;
}

接口定义如下

1
2
3
4
5
6
7
8
9
10
@Slf4j
@RestController
@Api(value = "课程计划编辑接口", tags = "课程计划编辑接口")
public class TeachplanController {
@ApiOperation("查询课程计划树形结构")
@GetMapping("/teachplan/{courseId}/tree-nodes")
public List<TeachplanDto> getTreeNodes(@PathVariable Long courseId) {
return null;
}
}

7.4 接口开发

使用SQL语句查询课程计划,组成一个树形结构

在TeachplanMapper中定义方法

1
2
3
public interface TeachplanMapper extends BaseMapper<Teachplan> {
List<TeachplanDto> selectTreeNodes(Long courseId);
}

编写SQL语句

  • 一级分类和二级分类通过teachplan表的自连接进行,如果一级分类旗下没有二级分类,此时也需要显示一级分类,所以这里使用左连接,左边是一级分类,右边是二级分类

  • 同时课程的媒资信息teachplan_media也需要和teachplan左连接,左边是teachplan,右边是媒资信息teachplan_media

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
SELECT
p.id p_id,
p.pname p_pname,
p.parentid p_parentid,
p.grade p_grade,
p.media_type p_mediaType,
p.start_time p_stratTime,
p.end_time p_endTime,
p.orderby p_orderby,
p.course_id p_courseId,
p.course_pub_id p_coursePubId,
c.id c_id,
c.pname c_pname,
c.parentid c_parentid,
c.grade c_grade,
c.media_type c_mediaType,
c.start_time c_stratTime,
c.end_time c_endTime,
c.orderby c_orderby,
c.course_id c_courseId,
c.course_pub_id c_coursePubId,
tm.media_fileName mediaFilename,
tm.id teachplanMeidaId,
tm.media_id mediaId
FROM
teachplan p
LEFT JOIN teachplan c ON c.parentid = p.id
LEFT JOIN teachplan_media tm ON tm.teachplan_id = c.id
WHERE
p.parentid = '0' AND p.course_id = #{value}
ORDER BY p.orderby, c.orderby

定义mapper.xml,我们需要按照响应结果手动配置查询结果的映射,最终查询的结果要返回的是TeachplanDto类型

响应结果json,编写mybatis映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<!-- 课程分类树型结构查询映射结果 -->
<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_stratTime" property="stratTime" />
<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_stratTime" property="stratTime" />
<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">
<result column="teachplanMeidaId" property="id" />
<result column="mediaFilename" property="mediaFilename" />
<result column="mediaId" property="mediaId" />
<result column="two_id" property="teachplanId" />
<result column="two_courseId" property="courseId" />
<result column="two_coursePubId" property="coursePubId" />
</association>
</collection>
</resultMap>
<!--课程计划树型结构查询-->
<select id="selectTreeNodes" resultMap="treeNodeResultMap" parameterType="long" >
select
one.id one_id,
one.pname one_pname,
one.parentid one_parentid,
one.grade one_grade,
one.media_type one_mediaType,
one.start_time one_stratTime,
one.end_time one_endTime,
one.orderby one_orderby,
one.course_id one_courseId,
one.course_pub_id one_coursePubId,
two.id two_id,
two.pname two_pname,
two.parentid two_parentid,
two.grade two_grade,
two.media_type two_mediaType,
two.start_time two_stratTime,
two.end_time two_endTime,
two.orderby two_orderby,
two.course_id two_courseId,
two.course_pub_id two_coursePubId,
m1.media_fileName mediaFilename,
m1.id teachplanMeidaId,
m1.media_id mediaId

from teachplan one
INNER JOIN teachplan two on one.id = two.parentid
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>

最后定义Service接口,ServiceImpl实现类,完善Controller层代码

  • TeachplanService
  • TeachplanServiceImpl
  • TeachplanController
1
2
3
public interface TeachplanService {
List<TeachplanDto> findTeachplanTree(Long courseId);
}

7.5 接口测试

  1. 使用HTTP Client测试

    1
    2
    3
    ### 根据课程id查询课程计划
    GET {{content_host}}/content/teachplan/22/tree-nodes
    Content-Type: application/json
  2. 前后端联调,进入课程编辑界面,点击保存,进入到课程计划编辑界面,可以看到已经存在的课程计划

8 新增/修改课程计划

8.1 需求分析

8.1.1 业务流程

进入课程计划界面

点击添加章,新增第一级课程计划

点击添加小节,可以向某第一级课程计划下添加小节

点击章/节的名称,可以修改名称、选择是否免费

8.1.2 数据模型

  1. 新增第一级课程计划
    • 默认名称:新章名称[点击修改]
    • grade:1
    • orderby:按添加顺序
  2. 新增第二级课程计划
    • 默认名称:新小节名称[点击修改]
    • grade:2
    • orderby:按添加顺序
  3. 修改第一级、第二级课程计划的名称,修改第二级课程计划是否免费

8.2 接口定义

接口示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
### 新增课程计划--章,当grade为1时parentid为0
POST {{content_host}}/content/teachplan
Content-Type: application/json
{
"courseId" : 74,
"parentid": 0,
"grade" : 1,
"pname" : "新章名称 [点击修改]"
}

### 新增课程计划--节
POST {{content_host}}/content/teachplan
Content-Type: application/json
{
"courseId" : 74,
"parentid": 247,
"grade" : 2,
"pname" : "新小节名称 [点击修改]"
}

### 修改课程计划--节
POST {{content_host}}/content/teachplan
Content-Type: application/json
{
"id": 293,
"pname": "3.1Nacos作配置中心",
"parentid": 292,
"grade": 2,
"mediaType": null,
"startTime": null,
"endTime": null,
"description": null,
"timelength": null,
"orderby": 1,
"courseId": 117,
"coursePubId": null,
"status": null,
"isPreview": null,
"createDate": null,
"changeDate": null,
"teachplanMedia": null,
"teachPlanTreeNodes": null,
"ctlEditTitle": true,
"ctlBarShow": false
}

我们可以通过同一个接口接收新增和修改两个业务的请求,以是否传递课程计划id来判断该请求是新增还是修改

  • 新增和修改的唯一区别就是是否有id,如果传递了课程计划id,说明当前是要修改该课程计划,否则是新增一个课程计划

  • 接收请求参数的模型类我这里用的是Teachplan,但黑马课件中用的是下面这个。该类中的所有属性,在Teachplan类中均有,且前端的请求载荷不止这些属性,Teachplan中的属性更全面,且本人测试使用Teachplan作为模型类也能完成相同的功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Data
    public class SaveTeachplanDto {
    /**
    * 教学计划id
    */
    private Long id;
    /**
    * 课程计划名称
    */
    private 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;
    }
  • 定义接口如下

    1
    2
    3
    4
    5
    @ApiOperation("课程计划创建或修改")
    @PostMapping("/teachplan")
    public void saveTeachplan(@RequestBody SaveTeachplanDto teachplan) {

    }

8.3 接口开发

定义保存课程计划的接口

1
void saveTeachplan(SaveTeachplanDto teachplan);

编写接口实现

  • 黑马课件中写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Transactional
@Override
public void saveTeachplan(Teachplan teachplan) {
Long teachplanId = teachplan.getId();
if (teachplanId == null) {
// 课程计划id为null,创建对象,拷贝属性,设置创建时间和排序号
Teachplan plan = new Teachplan();
BeanUtils.copyProperties(teachplan, plan);
plan.setCreateDate(LocalDateTime.now());
// 设置排序号
plan.setOrderby(getTeachplanCount(plan.getCourseId(), plan.getParentid()) + 1);
// 如果新增失败,返回0,抛异常
int flag = teachplanMapper.insert(plan);
if (flag <= 0) XueChengPlusException.cast("新增失败");
} else {
// 课程计划id不为null,查询课程,拷贝属性,设置更新时间,执行更新
Teachplan plan = teachplanMapper.selectById(teachplanId);
BeanUtils.copyProperties(teachplan, plan);
plan.setChangeDate(LocalDateTime.now());
// 如果修改失败,返回0,抛异常
int flag = teachplanMapper.updateById(plan);
if (flag <= 0) XueChengPlusException.cast("修改失败");
}
}

private int getTeachplanCount(Long courseId, Long parentId) {
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Teachplan::getCourseId, courseId);
queryWrapper.eq(Teachplan::getParentid, parentId);
return teachplanMapper.selectCount(queryWrapper);
}

8.4 接口测试

前后端联调,分别测试新增章、新增小节、修改章/小节

8.5 BUG修改

BUG是看不到添加的章节信息

我其实没遇到bug,因为代码是我自己敲的,看不到章节信息是因为SQL语句有问题,不应该用内连接,得用左外连接

  • 当初看视频的时候,感觉这个内连接就有问题,新增章节下面没有小节信息,用内连接必然查不出来,因为连接条件是c.parentid = p.id

  • 用左外连接(章节当左),会显示左表的全部内容,那么不管章节下有没有小节信息,都会显示章节

    1
    2
    3
    SELECT * FROM teachplan p
    LEFT JOIN teachplan c ON c.parentid = p.id
    LEFT JOIN teachplan_media tm ON tm.teachplan_id = c.id

9 内容管理模块实战

这部分要完成的内容包括 - 添加课程、添加课程计划、添加师资信息 - 修改课程、修改课程计划、修改师资信息 - 删除课程、删除课程计划、删除师资信息 - 课程计划上移、下移功能

9.1 删除课程计划

9.1.1 需求分析

  • 课程计划添加成功,如果课程还没有提交,可以删除课程计划
  • 删除第一级别的章时,要求章下边没有小节方可删除
  • 删除第二级别的小节的同时,也需要将其关联的媒资信息也删除
  • 删除课程计划需要传输课程计划的id

9.1.2 接口定义

删除课程计划的接口示例如下

1
2
3
4
5
6
删除结点
Request URL: /content/teachplan/246
Request Method: DELETE
如果失败:
{"errCode":"120409","errMessage":"课程计划信息还有子级信息,无法操作"}
如果成功:状态码200,不返回信息

我这里没管这个错误状态码,因为与之前编写的异常模型类不符合

定义接口如下

1
2
3
4
@ApiOperation("课程计划删除")
@DeleteMapping("/content/teachplan/{teachplanId}")
public void deleteTeachplan(@PathVariable Long teachplanId) {
}

9.1.3 接口开发

定义删除课程计划的接口

1
void deleteTeachplan(Long teachplanId);

对应的接口实现

  • 方案一
  • 方案二
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public void deleteTeachplan(Long teachplanId) {
if (teachplanId == null)
XueChengPlusException.cast("课程计划id为空");
Teachplan teachplan = teachplanMapper.selectById(teachplanId);
// 判断当前课程计划是章还是节
Integer grade = teachplan.getGrade();
// 当前课程计划为章
if (grade == 1) {
// 查询当前课程计划下是否有小节
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
// select * from teachplan where parentid = {当前章计划id}
queryWrapper.eq(Teachplan::getParentid, teachplanId);
// 获取一下查询的条目数
Integer count = teachplanMapper.selectCount(queryWrapper);
// 如果当前章下还有小节,则抛异常
if (count > 0)
XueChengPlusException.cast("课程计划信息还有子级信息,无法操作");
teachplanMapper.deleteById(teachplanId);
} else {
// 课程计划为节
teachplanMapper.deleteById(teachplanId);
LambdaQueryWrapper<TeachplanMedia> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(TeachplanMedia::getTeachplanId, teachplanId);
teachplanMediaMapper.delete(queryWrapper);
}
}

完善Controller层接口

1
2
3
4
5
@ApiOperation("课程计划删除")
@DeleteMapping("/teachplan/{teachplanId}")
public void deleteTeachplan(@PathVariable Long teachplanId) {
teachplanService.deleteTeachplan(teachplanId);
}

9.1.4 接口测试

删除有子计划的章节

删除没有子计划的章节和小节

删除有媒资信息的小节后,去数据库中查看对应的媒资信息是否真的被删除

9.2 课程计划排序

9.2.1 需求分析

  • 课程计划新增后默认排在同级别后面,课程计划排序的功能是可以灵活调整课程计划的显示顺序
  • 上移表示将该课程计划向上移动
  • 下移表示将该课程计划向下移动
  • 向上移动表示和上面的课程计划交换位置,将两个课程计划的排序字段值交换
  • 向下移动表示和下面的课程计划交换位置,将两个课程计划的排序字段值交换

9.2.2 接口定义

接口示例如下

  • 向下移动

    1
    2
    3
    Request URL: http://localhost:8601/api/content/teachplan/movedown/43
    Request Method: POST
    43为课程计划id
  • 向上移动

    1
    2
    3
    Request URL: http://localhost:8601/api/content/teachplan/moveup/43
    Request Method: POST
    43为课程计划id
  • 每次传递两个参数

    1. 移动类型:movedown和moveup
    2. 课程计划id
  • 定义接口如下

    1
    2
    3
    4
    5
    @ApiOperation("课程计划排序")
    @PostMapping("/teachplan/{moveType}/{teachplanId}")
    public void orderByTeachplan(@PathVariable String moveType, @PathVariable Long teachplanId) {

    }

9.2.3 接口开发

定义课程计划排序接口

1
void orderByTeachplan(String moveType, Long teachplanId);

编写对应的实现方法

写的稍微复杂了些,其实也可以简化,在lt和orderby的前面加上判断条件就可以,但是可读性会降低

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Transactional
@Override
public void orderByTeachplan(String moveType, Long teachplanId) {
Teachplan teachplan = teachplanMapper.selectById(teachplanId);
// 获取层级和当前orderby,章节移动和小节移动的处理方式不同
Integer grade = teachplan.getGrade();
Integer orderby = teachplan.getOrderby();
// 章节移动是比较同一课程id下的orderby
Long courseId = teachplan.getCourseId();
// 小节移动是比较同一章节id下的orderby
Long parentid = teachplan.getParentid();
if ("moveup".equals(moveType)) {
if (grade == 1) {
// 章节上移,找到上一个章节的orderby,然后与其交换orderby
// SELECT * FROM teachplan WHERE courseId = 117 AND grade = 1 AND orderby < 1 ORDER BY orderby DESC LIMIT 1
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Teachplan::getGrade, 1)
.eq(Teachplan::getCourseId, courseId)
.lt(Teachplan::getOrderby, orderby)
.orderByDesc(Teachplan::getOrderby)
.last("LIMIT 1");
Teachplan tmp = teachplanMapper.selectOne(queryWrapper);
exchangeOrderby(teachplan, tmp);
} else if (grade == 2) {
// 小节上移
// SELECT * FROM teachplan WHERE parentId = 268 AND orderby < 5 ORDER BY orderby DESC LIMIT 1
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Teachplan::getParentid, parentid)
.lt(Teachplan::getOrderby, orderby)
.orderByDesc(Teachplan::getOrderby)
.last("LIMIT 1");
Teachplan tmp = teachplanMapper.selectOne(queryWrapper);
exchangeOrderby(teachplan, tmp);
}

} else if ("movedown".equals(moveType)) {
if (grade == 1) {
// 章节下移
// SELECT * FROM teachplan WHERE courseId = 117 AND grade = 1 AND orderby > 1 ORDER BY orderby ASC LIMIT 1
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Teachplan::getCourseId, courseId)
.eq(Teachplan::getGrade, grade)
.gt(Teachplan::getOrderby, orderby)
.orderByAsc(Teachplan::getOrderby)
.last("LIMIT 1");
Teachplan tmp = teachplanMapper.selectOne(queryWrapper);
exchangeOrderby(teachplan, tmp);
} else if (grade == 2) {
// 小节下移
// SELECT * FROM teachplan WHERE parentId = 268 AND orderby > 1 ORDER BY orderby ASC LIMIT 1
LambdaQueryWrapper<Teachplan> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Teachplan::getParentid, parentid)
.gt(Teachplan::getOrderby, orderby)
.orderByAsc(Teachplan::getOrderby)
.last("LIMIT 1");
Teachplan tmp = teachplanMapper.selectOne(queryWrapper);
exchangeOrderby(teachplan, tmp);
}
}
}

/**
* 交换两个Teachplan的orderby
* @param teachplan
* @param tmp
*/
private void exchangeOrderby(Teachplan teachplan, Teachplan tmp) {
if (tmp == null)
XueChengPlusException.cast("已经到头啦,不能再移啦");
else {
// 交换orderby,更新
Integer orderby = teachplan.getOrderby();
Integer tmpOrderby = tmp.getOrderby();
teachplan.setOrderby(tmpOrderby);
tmp.setOrderby(orderby);
teachplanMapper.updateById(tmp);
teachplanMapper.updateById(teachplan);
}
}

完善Controller

1
2
3
4
5
@ApiOperation("课程计划排序")
@PostMapping("/teachplan/{moveType}/{teachplanId}")
public void orderByTeachplan(@PathVariable String moveType, @PathVariable Long teachplanId) {
teachplanService.orderByTeachplan(moveType, teachplanId);
}

9.2.4 接口测试

可以随意打乱顺序,且移动到最上或最下也会给出提示

9.3 师资管理

9.3.1 需求分析

这部分需要完成师资信息的查询、修改、新增、删除功能

注意:只允许向机构自己的课程中添加老师、删除老师,机构id统一使用1232141425L

不过这里机构校验在前面的新增课程已经完成了,不允许修改本机构外的课程

9.3.2 接口定义

接口示例

  • 查询教师
  • 添加教师
  • 修改教师
  • 删除教师
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
get /courseTeacher/list/75
75为课程id,请求参数为课程id

响应结果
[
{
"id": 23,
"courseId": 75,
"teacherName": "张老师",
"position": "讲师 ",
"introduction": "张老师教师简介张老师教师简介张老师教师简介张老师教师简介",
"photograph ":null,
"createDate ":null
}
]
  • 从接口示例中可以看到,新增和删除用的是同一个接口,判断请求是新增还是删除,是根据请求参数中是否传递了id来决定的

    • 请求参数中没有id,则为新增教师
    • 请求参数中有id,则为修改教师
  • 定义的接口如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @Slf4j
    @RestController
    @Api(value = "教师信息相关接口", tags = "教师信息相关接口")
    public class CourseTeacherController {
    @Autowired
    private CourseTeacherService courseTeacherService;

    @ApiOperation("查询教师信息接口")
    @GetMapping("/courseTeacher/list/{courseId}")
    public List<CourseTeacher> getCourseTeacherList(@PathVariable Long courseId) {
    return null;
    }

    @ApiOperation("添加/修改教师信息接口")
    @PostMapping("/courseTeacher")
    public CourseTeacher saveCourseTeacher(@RequestBody CourseTeacher courseTeacher) {
    return null;
    }

    @ApiOperation("删除教师信息接口")
    @DeleteMapping("/courseTeacher/course/{courseId}/{teacherId}")
    public void deleteCourseTeacher(@PathVariable Long courseId, @PathVariable Long teacherId) {

    }
    }

9.3.3 接口开发

定义Service接口

1
2
3
4
5
6
7
public interface CourseTeacherService {
List<CourseTeacher> getCourseTeacherList(Long courseId);

CourseTeacher saveCourseTeacher(CourseTeacher courseTeacher);

void deleteCourseTeacher(Long courseId, Long teacherId);
}

编写对应的实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Slf4j
@Service
public class CourseTeacherServiceImpl implements CourseTeacherService {
@Autowired
private CourseTeacherMapper courseTeacherMapper;

@Override
public List<CourseTeacher> getCourseTeacherList(Long courseId) {
// SELECT * FROM course_teacher WHERE course_id = 117
LambdaQueryWrapper<CourseTeacher> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(CourseTeacher::getCourseId, courseId);
List<CourseTeacher> courseTeachers = courseTeacherMapper.selectList(queryWrapper);
return courseTeachers;
}

@Transactional
@Override
public CourseTeacher saveCourseTeacher(CourseTeacher courseTeacher) {
Long id = courseTeacher.getId();
if (id == null) {
// id为null,新增教师
CourseTeacher teacher = new CourseTeacher();
BeanUtils.copyProperties(courseTeacher, teacher);
teacher.setCreateDate(LocalDateTime.now());
int flag = courseTeacherMapper.insert(teacher);
if (flag <= 0)
XueChengPlusException.cast("新增失败");
return getCourseTeacher(teacher);
} else {
// id不为null,修改教师
CourseTeacher teacher = courseTeacherMapper.selectById(id);
BeanUtils.copyProperties(courseTeacher, teacher);
int flag = courseTeacherMapper.updateById(teacher);
if (flag <= 0)
XueChengPlusException.cast("修改失败");
return getCourseTeacher(teacher);
}
}

@Override
public void deleteCourseTeacher(Long courseId, Long teacherId) {
LambdaQueryWrapper<CourseTeacher> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(CourseTeacher::getId, teacherId);
queryWrapper.eq(CourseTeacher::getCourseId, courseId);
int flag = courseTeacherMapper.delete(queryWrapper);
if (flag < 0)
XueChengPlusException.cast("删除失败");
}

public CourseTeacher getCourseTeacher(CourseTeacher courseTeacher) {
return courseTeacherMapper.selectById(courseTeacher.getId());
}
}

9.3.4 接口测试

进入教师设置页页面,对教师信息进行增删改查

9.4 删除课程

9.4.1 需求分析

  • 课程的审核状态为未提交时方可删除。
  • 删除课程需要删除课程相关的基本信息、营销信息、课程计划、课程教师信息。

9.4.2 接口定义

删除课程接口示例如下

1
2
3
4
5
6
delete /course/87
87为课程id

请求参数:课程id

响应:状态码200,不返回信息

定义的接口如下,这里需要指定companyId,只能删除本机构的课程

1
2
3
4
5
6
@ApiOperation("删除课程")
@DeleteMapping("/course/{courseId}")
public void deleteCourse(@PathVariable Long courseId) {
Long companyId = 1232141425L;
courseBaseInfoService.delectCourse(companyId,courseId);
}

9.4.3 接口开发

定义Service接口

1
void delectCourse(Long companyId, Long courseId);

编写接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
@Override
public void delectCourse(Long companyId, Long courseId) {
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if (!companyId.equals(courseBase.getCompanyId()))
XueChengPlusException.cast("只允许删除本机构的课程");
// 删除课程教师信息
LambdaQueryWrapper<CourseTeacher> teacherLambdaQueryWrapper = new LambdaQueryWrapper<>();
teacherLambdaQueryWrapper.eq(CourseTeacher::getCourseId, courseId);
courseTeacherMapper.delete(teacherLambdaQueryWrapper);
// 删除课程计划
LambdaQueryWrapper<Teachplan> teachplanLambdaQueryWrapper = new LambdaQueryWrapper<>();
teachplanLambdaQueryWrapper.eq(Teachplan::getCourseId, courseId);
teachplanMapper.delete(teachplanLambdaQueryWrapper);
// 删除营销信息
courseMarketMapper.deleteById(courseId);
// 删除课程基本信息
courseBaseMapper.deleteById(courseId);
}

9.4.4 接口测试

当我们尝试删除非本机构的课程时,会给出错误提示信息

当我们删除本机构课程时,会将课程对应的教师信息、课程计划、营销信息、课程基本信息均删除