1.内容管理

tim-qtp...大约 12 分钟学成在线项目

1. 课程查询

界面

1. 查询条件:

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

课程名称:可以模糊搜索

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

课程发布状态:未发布、已发布、已下线(审核不通过的,都没有发布的资格)

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

2. 查询结果:

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

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

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

类型:录播、直播。

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

3. 课程course-base表:

FieldTypeCOMMENT
idbigint主键,自增,步长为 1
company_idbigint机构ID
company_namevarchar(255)机构名称
namevarchar(100)课程名称
usersvarchar(500)适用人群
tagsvarchar(50)课程标签
mtvarchar(20)大分类
mt_namevarchar(255)课程大类名称
stvarchar(20)小分类
st_namevarchar(255)课程小类名称
gradevarchar(32)课程等级
teachmodevarchar(32)教育模式(common普通,record 录播,live直播等)
descriptiontext课程介绍
picvarchar(500)课程图片
create_datedatetime创建时间
change_datedatetime修改时间
create_peoplevarchar(50)创建人
change_peoplevarchar(50)更新人
audit_statusvarchar(10)审核状态
audit_mindvarchar(200)审核意见
audit_numsint审核次数
audit_datedatetime审核时间
audit_peoplevarchar(50)审核人
statusint1为未发布,0为删除,2已发布
course_pub_idbigint课程发布标识
course_pub_datedatetime课程发布时间

4. 什么时候用VO?

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

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

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

5. Swaager的常用注解

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

@ApiImplicitParam属性如下:

属性取值作用
paramType查询参数类型
path以地址的形式提交数据
query直接跟参数完成自动映射赋值
body以流的形式提交仅支持POST
header参数在request headers里边提交
form以form表单的形式提交仅支持POST
dataType参数的数据类型只作为标志说明,并没有实际验证
Long
String
name接收参数名
value接收参数的意义描述
required参数是否必填
TRUE必填
FALSE非必填
defaultValue默认值

6.SpringBoot 接口开发的常用注解有哪些?

  1. @Controller 标记此类是一个控制器,可以返回视图解析器指定的 HTML 页面。通过 @ResponseBody 可以将结果返回 JSON、XML 数据。
  2. @RestController 相当于 @ResponseBody@Controller,实现 REST 接口开发,返回 JSON 数据,不能返回 HTML 页面(只能用Controller)。
  3. @RequestMapping 定义接口地址,可以标记在类上也可以标记在方法上,支持 HTTP 的 POST、PUT、GET 等方法。
  4. @PostMapping 定义 POST 接口,只能标记在方法上,用于添加记录或复杂条件的查询接口。
  5. @GetMapping 定义 GET 接口,只能标记在方法上,用于查询接口的定义。
  6. @PutMapping 定义 PUT 接口,只能标记在方法上,用于修改接口的定义。
  7. @DeleteMapping 定义 DELETE 接口,只能标记在方法上,用于删除接口的定义。
  8. @RequestBody 定义在方法上,用于将 JSON 串转换成 Java 对象。
  9. @PathVariable 接收请求路径中占位符的值。
  10. @ApiOperation Swagger 注解,对接口方法进行说明。
  11. @Api Swagger 注解,对接口类进行说明。
  12. @Autowired 基于类型注入。
  13. @Resource 基于名称注入,如果基于名称注入失败,则转为基于类型注入。

7. 创建数据字典表

如图:

下边是课程审核状态的定义:

[
    {"code":"202001","desc":"审核未通过"},
    {"code":"202002","desc":"未审核"},
    {"code":"202003","desc":"审核通过"}
]

每一项都由代码和名称组成。

DictionaryController

@GetMapping("/dictionary/code/{code}")
public Dictionary getByCode(@PathVariable String code) {
    return dictionaryService.getByCode(code);
}

ServiceImpl

@Override
public Dictionary getByCode(String code) {

    LambdaQueryWrapper<Dictionary> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Dictionary::getCode, code);

    Dictionary dictionary = this.getOne(queryWrapper);

    return dictionary;
}

8. Httpclient

使用Httpclient简单测试

POST http://localhost:63040/course/list?pageNo=2&pageSize=5
Content-Type: application/json

{
"auditStatus": "202002",
"courseName": "",
"publishStatus":""
}

9.查询代码:

controller:

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

serviceImpl:

@Override
public PageResult<CourseBase> queryCourseBaseList(PageParams pageParams,
    QueryCourseParamsDto queryCourseParamsDto) {

    // 构建查询条件对象
    LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
    // 构建查询条件,根据课程名称查询
    queryWrapper.like(StringUtils.isNotEmpty(queryCourseParamsDto.getCourseName()), CourseBase::getName,
        queryCourseParamsDto.getCourseName());
    // 构建查询条件,根据课程审核状态查询
    queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParamsDto.getAuditStatus()), CourseBase::getAuditStatus,
        queryCourseParamsDto.getAuditStatus());
    // 构建查询条件,根据课程发布状态查询
    queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParamsDto.getPublishStatus()), CourseBase::getStatus,
        queryCourseParamsDto.getPublishStatus());

    // 分页对象
    Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
    // 查询数据内容获得结果
    Page<CourseBase> pageResult = courseBaseMapper.selectPage(page, queryWrapper);
    // 获取数据列表
    List<CourseBase> list = pageResult.getRecords();
    // 获取数据总数
    long total = pageResult.getTotal();
    // 构建结果集
    PageResult<CourseBase> courseBasePageResult =
        new PageResult<>(list, total, pageParams.getPageNo(), pageParams.getPageSize());
    return courseBasePageResult;
}

10. 最终成果:

11. 跨域需要注意的点:

为什么system服务必须要配置跨域,而content服务可以不配

因为system是硬编码进去的

// 列表
export async function dictionaryAll(params: any = undefined, body: any = undefined): Promise<ISystemDictionary[]> {
	const { data } = await createAPI('http://localhost:63110/system/dictionary/all', 'get', params, body)
	return data
}

而content是使用了代理

proxy: {
  '/api': { // 匹配所有以 /api 开头的请求
    target: process.env.VUE_APP_SERVER_API_URL, // 目标服务器地址
    changeOrigin: true, // 修改请求的 Origin 为目标地址
    ws: true, // 支持 WebSocket 代理
    pathRewrite: { // 重写请求路径
      '^/api': '' // 去掉 /api 前缀
    }
  }
}
VUE_APP_SERVER_API_URL=http://localhost:63040

代理服务器的核心思想是:让前端请求发送到同源的代理服务器,再由代理服务器转发到后端服务器。由于服务器之间的请求不受同源策略限制,因此可以绕过浏览器的跨域限制。

  • 前端请求http://localhost:8080/api/user(前端页面地址)
  • 代理服务器:将 /api 开头的请求转发到后端服务器。
  • 后端请求http://api.example.com/user(后端服务器地址)

2. 课程分类查询

有树型多级分类,这里两级就够了

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

{
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1",
"childrenTreeNodes" : [
               {
                  "childrenTreeNodes" : null,
                  "id" : "1-2-1",
                  "isLeaf" : null,
                  "isShow" : null,
                  "label" : "微信开发",
                  "name" : "微信开发",
                  "orderby" : 1,
                  "parentid" : "1-2"
               }
 }

mapper:

<!-- 课程分类树型结构查询映射结果 -->
<resultMap id="treeNodeResultMap" type="com.xuecheng.system.model.dto.CourseCategoryTreeDto">
    <!-- 一级数据映射 -->
    <id column="one_id" property="id"/>
    <result column="one_name" property="name"/>
    <result column="one_label" property="label"/>
    <result column="one_parentid" property="parentid"/>
    <result column="one_orderby" property="orderby"/>
    <!-- 一级中包含多个二级数据:二级的集合数据 -->
    <collection property="childrenTreeNodes" ofType="com.xuecheng.system.model.dto.CourseCategoryTreeDto">
        <!-- 二级数据映射 -->
        <id column="two_id" property="id"/>
        <result column="two_name" property="name"/>
        <result column="two_label" property="label"/>
        <result column="two_parentid" property="parentid"/>
        <result column="two_orderby" property="orderby"/>
    </collection>
</resultMap>
<!--课程分类树型结构查询-->
<select id="selectTreeNodes" resultMap="treeNodeResultMap">
    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
</select>

发现这里采用自连接,适用于分类比较固定,层数比较少(二级、三级)的时候

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

with recursive t1 as (
    select * from  course_category 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

1. WITH RECURSIVE t1 AS (...)

  • WITH RECURSIVE 是一个递归公用表表达式(Common Table Expression,简称 CTE)。
  • 递归 CTE 用于处理树形或分层数据,比如组织架构或分类结构。

2. 基础部分(Anchor Member)

select * from course_category where id = '1'
  • 这是递归查询的基础部分,它选出了 ID 为 1 的根节点(即树的起点)。

3. 递归部分(Recursive Member)

select t.* 
from course_category t 
inner join t1 on t1.id = t.parentid
  • 递归部分通过 INNER JOIN,将上一次递归找到的节点(t1)与 course_category 表中的数据进行匹配,找到所有子节点。
  • 条件是:t1.id = t.parentid,即当前节点的 id 和下一级节点的 parentid 匹配。
  • tcourse_category 表的别名。
  • UNION ALL将两个查询的结果集合合并在一起,包含所有的记录

适用场景

通常用于处理层级关系数据,例如:

  • 课程分类
  • 组织架构(从某部门开始,递归查找下属部门)。
  • 文件目录结构(从某目录开始,递归查找子目录和文件)。

基于递归的代码:

编写service接口实现

@Slf4j
@Service
public class CourseCategoryServiceImpl implements CourseCategoryService {

    @Autowired
    CourseCategoryMapper courseCategoryMapper;

    public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
       List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);
    //将list转map,以备使用,排除根节点
    Map<String, CourseCategoryTreeDto> mapTemp = courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).collect(Collectors.toMap(key -> key.getId(), value -> value, (key1, key2) -> key2));
    //最终返回的list
    List<CourseCategoryTreeDto> categoryTreeDtos = new ArrayList<>();
    //依次遍历每个元素,排除根节点
    courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).forEach(item->{
        if(item.getParentid().equals(id)){
            categoryTreeDtos.add(item);
        }
        //找到当前节点的父节点
        CourseCategoryTreeDto courseCategoryTreeDto = mapTemp.get(item.getParentid());
        if(courseCategoryTreeDto!=null){
            if(courseCategoryTreeDto.getChildrenTreeNodes() ==null){
                courseCategoryTreeDto.setChildrenTreeNodes(new ArrayList<CourseCategoryTreeDto>());
            }
            //下边开始往ChildrenTreeNodes属性中放子节点
            courseCategoryTreeDto.getChildrenTreeNodes().add(item);
        }
    });
    return categoryTreeDtos;
    }
}

在线json解析结构:

前端显示:

3. 新增课程

4.统一异常校验

首先在Base工程添加spring-boot-starter-validation的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

在javax.validation.constraints包下有很多这样的校验注解,直接使用注解定义校验规则即可。

规则如下:

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

@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){
    //机构id,由于认证系统没有上线暂时硬编码
    Long companyId = 1232141425L;
  return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}

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

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;
import java.math.BigDecimal;

/**
 * @description 添加课程dto
 * @author Mr.M
 * @date 2022/9/7 17:40
 * @version 1.0
 */
@Data
@ApiModel(value="AddCourseDto", description="新增课程基本信息")
public class AddCourseDto {

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

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

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

 @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 BigDecimal price;

}

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

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

@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto){
    //机构id,由于认证系统没有上线暂时硬编码
    Long companyId = 1L;
  return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}

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

代码 如下:

@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
    BindingResult bindingResult = e.getBindingResult();
    List<String> msgList = new ArrayList<>();
    //将错误信息放在msgList
    bindingResult.getFieldErrors().stream().forEach(item->msgList.add(item.getDefaultMessage()));
    //拼接错误信息
    String msg = StringUtils.join(msgList, ",");
    log.error("【系统异常】{}",msg);
    return new RestErrorResponse(msg);
}

重启内容管理服务。

使用httpclient进行测试,将必填项设置为空,“适用人群” 属性的内容设置1个字。

执行测试,接口响应结果如下:

{
  "errMessage": "课程名称不能为空,课程分类不能为空,适用人群内容过少"
}

可以看到校验器生效。

5. 分组校验

有时候在同一个属性上设置一个校验规则不能满足要求,比如:订单编号由系统生成,在添加订单时要求订单编号为空,在更新 订单时要求订单编写不能为空。此时就用到了分组校验,同一个属性定义多个校验规则属于不同的分组,比如:添加订单定义@NULL规则属于insert分组,更新订单定义@NotEmpty规则属于update分组,insert和update是分组的名称,是可以修改的。

下边举例说明

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

public class ValidationGroups {
     public interface Inster{};
     public interface Update{};
     public interface Delete{};
}

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

@NotEmpty(groups = {ValidationGroups.Inster.class},message = "添加课程名称不能为空")
@NotEmpty(groups = {ValidationGroups.Update.class},message = "修改课程名称不能为空")
// @NotEmpty(message = "课程名称不能为空")
 @ApiModelProperty(value = "课程名称", required = true)
 private String name;

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

@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated({ValidationGroups.Inster.class}) AddCourseDto addCourseDto){
    //机构id,由于认证系统没有上线暂时硬编码
    Long companyId = 1L;
  return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}

再次测试,由于这里指定了Insert分组,所以抛出 异常信息:添加课程名称不能为空。

如果修改分组为ValidationGroups.Update.class,异常信息为:修改课程名称不能为空。