1.内容管理
1. 课程查询
界面
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image.png)
1. 查询条件:
包括:课程名称、课程审核状态、课程发布状态
课程名称:可以模糊搜索
课程审核状态:未提交、已提交、审核通过、审核未通过
课程发布状态:未发布、已发布、已下线(审核不通过的,都没有发布的资格)
因为是分页查询所以查询条件中还要包括当前页码、每页显示记录数。
2. 查询结果:
查询结果中包括:课程id、课程名称、任务数、创建时间、是否付费、审核状态、类型,操作
任务数:该课程所包含的课程计划数,即课程章节数。
是否付费:课程包括免费、收费两种。
类型:录播、直播。
因为是分页查询所以查询结果中还要包括总记录数、当前页、每页显示记录数。
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20220915175215298.png)
course-base
表:
3. 课程Field | Type | COMMENT |
---|---|---|
id | bigint | 主键,自增,步长为 1 |
company_id | bigint | 机构ID |
company_name | varchar(255) | 机构名称 |
name | varchar(100) | 课程名称 |
users | varchar(500) | 适用人群 |
tags | varchar(50) | 课程标签 |
mt | varchar(20) | 大分类 |
mt_name | varchar(255) | 课程大类名称 |
st | varchar(20) | 小分类 |
st_name | varchar(255) | 课程小类名称 |
grade | varchar(32) | 课程等级 |
teachmode | varchar(32) | 教育模式(common普通,record 录播,live直播等) |
description | text | 课程介绍 |
pic | varchar(500) | 课程图片 |
create_date | datetime | 创建时间 |
change_date | datetime | 修改时间 |
create_people | varchar(50) | 创建人 |
change_people | varchar(50) | 更新人 |
audit_status | varchar(10) | 审核状态 |
audit_mind | varchar(200) | 审核意见 |
audit_nums | int | 审核次数 |
audit_date | datetime | 审核时间 |
audit_people | varchar(50) | 审核人 |
status | int | 1为未发布,0为删除,2已发布 |
course_pub_id | bigint | 课程发布标识 |
course_pub_date | datetime | 课程发布时间 |
4. 什么时候用VO?
当前端有多个平台且接口存在差异时就需要设置VO对象用于前端和接口层传输数据。
此时,Service业务层尽量提供一个业务接口,即使两个前端接口需要的数据不一样,Service可以提供一个最全查询结果,由Controller进行数据整合。
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image (1).png)
如果前端的接口没有多样性且比较固定,此时可以取消VO,只用DTO即可。
5. Swaager的常用注解
@Api:修饰整个类,描述Controller的作用
@ApiOperation:描述一个类的一个方法,或者说一个接口
@ApiParam:单个参数描述
@ApiModel:用对象来接收参数
@ApiModelProperty:用对象接收参数时,描述对象的一个字段
@ApiResponse:HTTP响应其中1个描述
@ApiResponses:HTTP响应整体描述
@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 接口开发的常用注解有哪些?
- @Controller 标记此类是一个控制器,可以返回视图解析器指定的 HTML 页面。通过
@ResponseBody
可以将结果返回 JSON、XML 数据。 - @RestController 相当于
@ResponseBody
加@Controller
,实现 REST 接口开发,返回 JSON 数据,不能返回 HTML 页面(只能用Controller)。 - @RequestMapping 定义接口地址,可以标记在类上也可以标记在方法上,支持 HTTP 的 POST、PUT、GET 等方法。
- @PostMapping 定义 POST 接口,只能标记在方法上,用于添加记录或复杂条件的查询接口。
- @GetMapping 定义 GET 接口,只能标记在方法上,用于查询接口的定义。
- @PutMapping 定义 PUT 接口,只能标记在方法上,用于修改接口的定义。
- @DeleteMapping 定义 DELETE 接口,只能标记在方法上,用于删除接口的定义。
- @RequestBody 定义在方法上,用于将 JSON 串转换成 Java 对象。
- @PathVariable 接收请求路径中占位符的值。
- @ApiOperation Swagger 注解,对接口方法进行说明。
- @Api Swagger 注解,对接口类进行说明。
- @Autowired 基于类型注入。
- @Resource 基于名称注入,如果基于名称注入失败,则转为基于类型注入。
7. 创建数据字典表
如图:
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250110204623271.png)
下边是课程审核状态的定义:
[
{"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":""
}
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250110213238827.png)
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. 最终成果:
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250111110206606.png)
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. 课程分类查询
有树型多级分类,这里两级就够了
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250111114241429.png)
第二级的分类是第一级分类中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
WITH RECURSIVE t1 AS (...)
1. 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
匹配。 t
是course_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解析结构:
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250111161841784.png)
前端显示:
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250111165559617.png)
3. 新增课程
4.统一异常校验
首先在Base工程添加spring-boot-starter-validation的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
在javax.validation.constraints包下有很多这样的校验注解,直接使用注解定义校验规则即可。
规则如下:
![](https://qtp-1324720525.cos.ap-shanghai.myqcloud.com/blog/image-20250112132639828.png)
现在准备对内容管理模块添加课程接口进行参数校验,如下接口
@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,异常信息为:修改课程名称不能为空。