5.1 需求分析
5.1.1 业务流程
根据前边对内容管理模块的数据模型分析,课程相关的信息有:课程基本信息、课程营销信息、课程图片信息、课程计划、课程师资信息,所以新增一门课程需要完成这几部分信息的填写。
以下是业务流程:
1、进入课程查询列表
2、点击添加课程,选择课程形式为录播。
3、选择完毕,点击下一步,进入课程基本信息添加界面。
本界面分两部分信息,一部分是课程基本信息上,一部分是课程营销信息。
课程基本信息:
课程营销信息:
在这个界面中填写课程的基本信息、课程营销信息上。
填写完毕,保存并进行下一步。
4、在此界面填写课程计划信息
课程计划即课程的大纲目录。
课程计划分为两级,章节和小节。
每个小节需要上传课程视频,用户点击 小节的标题即开始播放视频。
如果是直播课程则会进入直播间。
5、课程 计划填写完毕进入课程师资的管理。
在课程师资界面维护该课程的授课老师。
至此,一门课程新增完成。
5.1.2 数据模型
通过业务流程可知,一门课程信息涉及:课程基本信息、课程营销信息、课程计划信息、课程师资信息。
本节开发新增课程按钮功能, 只向课程基本信息、课程营销信息添加记录。
这两部分信息分别在course_base、course_market两张表存储。当点击保存按钮时向这两张表插入数据。这两张表是一对一关联关系。
新建课程的初始审核状态为“未提交”、初始发布状态为“未发布”。
生成课程基本信息、课程营销信息的PO、Mapper文件
5.2 接口定义
根据业务流程,这里先定义提交课程基本信息的接口。
1、接口协议 :HTTP POST,Content-Type为application/json
2、请求及响应结果如下
3、接口请求示例如下
Java ### 创建课程 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 } |
3、定义请求参数类型和响应结构类型
根据接口定义内容,请求参数相比 CourseBase模型类不一致,需要在dto包下自定义,模型类从课程资料/工程目录获取。
4、定义接口如下
Java @ApiOperation("新增课程基础信息") @PostMapping("/course") public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){ return null; } |
5.3 接口开发
5.3.1 保存课程基本信息
根据需求分析,新增课程表单中包括了课程基本信息、课程营销信息,需要分别向课程基本信息表、课程营销表保证数据。
首先定义service接口,
Java /** * @description 添加课程基本信息 * @param companyId 教学机构id * @param addCourseDto 课程基本信息 * @return com.xuecheng.content.model.dto.CourseBaseInfoDto * @author Mr.M * @date 2022/9/7 17:51 */ CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto addCourseDto); |
编写service接口实现类,实现向课程基本信息表保存数据:
Java @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 = courseBaseMapper.insert(courseBaseNew); if(insert<=0){ throw new RuntimeException("新增课程基本信息失败"); } //todo:向课程营销表保存课程营销信息 //todo:查询课程基本信息及营销信息并返回 } |
5.3.2 保存营销信息
下边实现向课程营销表保存课程营销信息
Java 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 = courseBaseMapper.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 = courseBaseMapper.selectById(courseId); if(courseBase == null){ return null; } CourseMarket courseMarket = courseMarketMapper.selectById(courseId); CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto(); BeanUtils.copyProperties(courseBase,courseBaseInfoDto); if(courseMarket != null){ BeanUtils.copyProperties(courseMarket,courseBaseInfoDto); }
//查询分类名称 CourseCategory courseCategoryBySt = courseCategoryMapper.selectById(courseBase.getSt()); courseBaseInfoDto.setStName(courseCategoryBySt.getName()); CourseCategory courseCategoryByMt = courseCategoryMapper.selectById(courseBase.getMt()); courseBaseInfoDto.setMtName(courseCategoryByMt.getName());
return courseBaseInfoDto;
} |
5.4 接口测试
1、首先去完善controller方法:
Java @ApiOperation("新增课程基础信息") @PostMapping("/course") public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){ //机构id,由于认证系统没有上线暂时硬编码 Long companyId = 1232141425L; return courseBaseInfoService.createCourseBase(companyId,addCourseDto); } |
2、使用httpclient测试
在xc-content-api.http中定义:
Java ### 创建课程 POST {{content_host}}/content/course Content-Type: application/json
{ "charge": "201000", "price": 0, "originalPrice":0, "qq": "22333", "wechat": "223344", "phone": "13333333", "validDays": 365, "mt": "1-1", "st": "1-1-1", "name": "测试课程103", "pic": "", "teachmode": "200002", "users": "初级人员", "tags": "", "grade": "204001", "description": "" } |
3、前后端联调
打开新增课程页面,除了课程图片其它信息全部输入。
点击保存,观察浏览器请求接口参数及响应结果是否正常。
5.6 异常处理
5.6.1 异常问题分析
在service方法中有很多的参数合法性校验,当参数不合法则抛出异常,下边我们测试下异常处理。
请求创建课程基本信息,故意将必填项设置为空。
测试发现报500异常,如下:
Java http://localhost:63040/content/course
HTTP/1.1 500 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 07 Sep 2022 11:40:29 GMT Connection: close
{ "timestamp": "2022-09-07T11:40:29.677+00:00", "status": 500, "error": "Internal Server Error", "message": "", "path": "/content/course" } |
问题:并没有输出我们抛出异常时指定的异常信息。
所以,现在我们的需求是当正常操作时按接口要求返回数据,当非正常流程时要获取异常信息进行记录,并提示给用户。
异常处理除了输出在日志中,还需要提示给用户,前端和后端需要作一些约定:
1、错误提示信息统一以json格式返回给前端。
2、以HTTP状态码决定当前是否出错,非200为操作异常。
如何规范异常信息?
代码中统一抛出项目的自定义异常类型,这样可以统一去捕获这一类或几类的异常。
规范了异常类型就可以去获取异常信息。
如果捕获了非项目自定义的异常类型统一向用户提示“执行过程异常,请重试”的错误信息。
如何捕获异常?
代码统一用try/catch方式去捕获代码比较臃肿,可以通过SpringMVC提供的控制器增强类统一由一个类去完成异常的捕获。
如下图:
根据上边分析的方案,统一在base基础工程实现统一异常处理,各模块依赖了base基础工程都 可以使用。
首先在base基础工程添加需要依赖的包:
Java org.springframework spring-web
org.springframework.boot spring-boot-starter-log4j2 |
1、定义一些通用的异常信息
从课程资料/工程目录 拷贝CommonError 类到base工程com.xuecheng.base.execption下。
Java package com.xuecheng.base.execption;
/** * @description 通用错误信息 * @author Mr.M * @date 2022/9/6 11:29 * @version 1.0 */ public enum CommonError {
UNKOWN_ERROR("执行过程异常,请重试。"), PARAMS_ERROR("非法参数"), OBJECT_NULL("对象为空"), QUERY_NULL("查询结果为空"), REQUEST_NULL("请求参数为空");
private String errMessage;
public String getErrMessage() { return errMessage; }
private CommonError( String errMessage) { this.errMessage = errMessage; }
} |
2、自定义异常类型
在base工程com.xuecheng.base.execption下自定义异常类。
Java package com.xuecheng.base.execption;
/** * @description 学成在线项目异常类 * @author Mr.M * @date 2022/9/6 11:29 * @version 1.0 */ public class XueChengPlusException extends RuntimeException {
private String errMessage;
public XueChengPlusException() { super(); }
public XueChengPlusException(String errMessage) { super(errMessage); this.errMessage = errMessage; }
public String getErrMessage() { return errMessage; }
public static void cast(CommonError commonError){ throw new XueChengPlusException(commonError.getErrMessage()); } public static void cast(String errMessage){ throw new XueChengPlusException(errMessage); }
} |
3、响应用户的统一类型
Java package com.xuecheng.base.execption;
import java.io.Serializable;
/** * 错误响应参数包装 */ public class RestErrorResponse implements Serializable {
private String errMessage;
public RestErrorResponse(String errMessage){ this.errMessage= errMessage; }
public String getErrMessage() { return errMessage; }
public void setErrMessage(String errMessage) { this.errMessage = errMessage; } } |
4、全局异常处理器
从 Spring 3.0 - Spring 3.2 版本之间,对 Spring 架构和 SpringMVC 的Controller 的异常捕获提供了相应的异常处理。
- Spring3.0提供的标识在方法上或类上的注解,用来表明方法的处理异常类型。
- Spring3.2提供的新注解,从名字上可以看出大体意思是控制器增强, 在项目中来增强SpringMVC中的Controller。通常和@ExceptionHandler 结合使用,来处理SpringMVC的异常信息。
- Spring3.0提供的标识在方法上或类上的注解,用状态代码和应返回的原因标记方法或异常类。
调用处理程序方法时,状态代码将应用于HTTP响应。
通过上面的两个注解便可实现微服务端全局异常处理,具体代码如下:
Java package com.xuecheng.base.execption;
import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus;
/** * @description 全局异常处理器 * @author Mr.M * @date 2022/9/6 11:29 * @version 1.0 */ @Slf4j @ControllerAdvice public class GlobalExceptionHandler {
@ResponseBody @ExceptionHandler(XueChengPlusException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public RestErrorResponse customException(XueChengPlusException e) { log.error("【系统异常】{}",e.getErrMessage(),e); return new RestErrorResponse(e.getErrMessage());
}
@ResponseBody @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public RestErrorResponse exception(Exception e) {
log.error("【系统异常】{}",e.getMessage(),e);
return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());
} } |
5.6.3 异常处理测试
在内容管理的api工程添加base工程的依赖
Java com.xuecheng xuecheng-plus-base 0.0.1-SNAPSHOT |
在异常处理测试之前首先在代码中抛出自定义类型的异常,这里以新增课程的service方法为例进行代码修改。
Java @Override public CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto dto) { ... //合法性校验 if (StringUtils.isBlank(dto.getName())) { throw new XueChengPlusException("课程名称为空"); }
if (StringUtils.isBlank(dto.getMt())) { throw new XueChengPlusException("课程分类为空"); }
if (StringUtils.isBlank(dto.getSt())) { throw new XueChengPlusException("课程分类为空"); }
if (StringUtils.isBlank(dto.getGrade())) { throw new XueChengPlusException("课程等级为空"); }
if (StringUtils.isBlank(dto.getTeachmode())) { throw new XueChengPlusException("教育模式为空"); }
if (StringUtils.isBlank(dto.getUsers())) { throw new XueChengPlusException("适应人群"); }
if (StringUtils.isBlank(dto.getCharge())) { throw new XueChengPlusException("收费规则为空"); } 。。。 if ("201001".equals(charge)) { BigDecimal price = dto.getPrice(); if (ObjectUtils.isEmpty(price)) { throw new XueChengPlusException("收费课程价格不能为空"); } courseMarketNew.setPrice(dto.getPrice().floatValue()); } 。。。 |
1、首先使用httpclient测试
请求新增课程接口,故意将必填项课程名称设置为空。
测试结果与预期一致,可以捕获异常并响应异常信息,如下:
Java http://localhost:63040/content/course
HTTP/1.1 500 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 07 Sep 2022 13:17:14 GMT Connection: close
{ "errMessage": "课程名称为空。" } |
2、前后端调试
仍然测试新增课程接口,当课程收费的时候必须填写价格,这里设置课程为收费,价格设置为空。
通过测试发现,前端正常提示代码 中抛出的异常信息。
至此,项目异常处理的测试完毕,我们在开发中对于业务分支中错误的情况要抛出项目自定义的异常类型。
5.7 JSR303校验
5.7.1 统一校验的需求
前端请求后端接口传输参数,是在controller中校验还是在Service中校验?
答案是都需要校验,只是分工不同。
Contoller中校验请求参数的合法性,包括:必填项校验,数据格式校验,比如:是否是符合一定的日期格式,等。
Service中要校验的是业务规则相关的内容,比如:课程已经审核通过所以提交失败。
Service中根据业务规则去校验不方便写成通用代码,Controller中则可以将校验的代码写成通用代码。
早在JavaEE6规范中就定义了参数校验的规范,它就是JSR-303,它定义了Bean Validation,即对bean属性进行校验。
SpringBoot提供了JSR-303的支持,它就是spring-boot-starter-validation,它的底层使用Hibernate Validator,Hibernate Validator是Bean Validation 的参考实现。
所以,我们准备在Controller层使用spring-boot-starter-validation完成对请求参数的基本合法性进行校验。
5.7.2 统一校验实现
首先在Base工程添加spring-boot-starter-validation的依赖
Java org.springframework.boot spring-boot-starter-validation |
在javax.validation.constraints包下有很多这样的校验注解,直接使用注解定义校验规则即可。
规则如下:
现在准备对内容管理模块添加课程接口进行参数校验,如下接口
Java @ApiOperation("新增课程基础信息") @PostMapping("/course") public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){ //机构id,由于认证系统没有上线暂时硬编码 Long companyId = 1232141425L; return courseBaseInfoService.createCourseBase(companyId,addCourseDto); } |
此接口使用AddCourseDto模型对象接收参数,所以进入AddCourseDto类,在属性上添加校验规则。
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; 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注解,如下:
Bash @ApiOperation("新增课程基础信息") @PostMapping("/course") public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto){ //机构id,由于认证系统没有上线暂时硬编码 Long companyId = 1L; return courseBaseInfoService.createCourseBase(companyId,addCourseDto); } |
如果校验出错Spring会抛出MethodArgumentNotValidException异常,我们需要在统一异常处理器中捕获异常,解析出异常信息。
代码 如下:
Bash @ResponseBody @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public RestErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) { BindingResult bindingResult = e.getBindingResult(); List 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个字。
执行测试,接口响应结果如下:
Bash
{ "errMessage": "课程名称不能为空 课程分类不能为空 课程分类不能为空 适用人群内容过少 " } |
可以看到校验器生效。
5.7.3 分组校验
有时候在同一个属性上设置一个校验规则不能满足要求,比如:订单编号由系统生成,在添加订单时要求订单编号为空,在更新 订单时要求订单编写不能为空。此时就用到了分组校验,同一个属性定义多个校验规则属于不同的分组,比如:添加订单定义@NULL规则属于insert分组,更新订单定义@NotEmpty规则属于update分组,insert和update是分组的名称,是可以修改的。
下边举例说明
我们用class类型来表示不同的分组,所以我们定义不同的接口类型(空接口)表示不同的分组,由于校验分组是公用的,所以定义在 base工程中。如下:
Bash package com.xuecheng.base.execption; /** * @description 校验分组 * @author Mr.M * @date 2022/9/8 15:05 * @version 1.0 */ public class ValidationGroups {
public interface Inster{}; public interface Update{}; public interface Delete{};
} |
下边在定义校验规则时指定分组:
Bash @NotEmpty(groups = {ValidationGroups.Inster.class},message = "添加课程名称不能为空") @NotEmpty(groups = {ValidationGroups.Update.class},message = "修改课程名称不能为空") // @NotEmpty(message = "课程名称不能为空") @ApiModelProperty(value = "课程名称", required = true) private String name; |
在Controller方法中启动校验规则指定要使用的分组名:
Bash @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,异常信息为:修改课程名称不能为空。
5.7.4 校验规则不满足?
如果javax.validation.constraints包下的校验规则满足不了需求怎么办?
1、手写校验代码 。
2、自定义校验规则注解。
如何自定义校验规则注解,请自行查阅资料实现。