56. 老师主页显示问题列表-持久层
(a) 规划需要执行的SQL语句
老师主页显示的问题列表应该显示出老师自己发表的问题,和学生指定该老师回答的问题。
这样的列表数据可以使用此前的QuestionVO
来表示每一个问题的数据,列表则使用List<QuestionVO>
来表示。
需要执行的SQL语句大致是:
select question.*
from question
left join user_question
on question.id=user_question.question_id
where question.user_id=? or user_question.user_id=? and is_delete=0
order by status, modified_time desc;
(b) 在接口中添加抽象方法
/**
* 查询老师的问题列表
*
* @param teacherId 老师的id
* @return 老师发表的问题和希望该老师回复的问题的列表
*/
List<QuestionVO> findTeacherQuestions(Integer teacherId);
© 配置SQL映射
<select id="findTeacherQuestions" resultMap="QuestionVOMap">
SELECT
question.*
FROM
question
LEFT JOIN
user_question
ON
question.id=user_question.question_id
WHERE
question.user_id=#{teacherId}
OR user_question.user_id=#{teacherId}
AND is_delete=0
ORDER BY
status, modified_time DESC
</select>
(d) 单元测试
@Test
void findTeacherQuestions() {
Integer teacherId = 3;
List<QuestionVO> questions = mapper.findTeacherQuestions(teacherId);
log.debug("question count={}", questions.size());
for (QuestionVO question : questions) {
log.debug(">>> {}", question);
}
}
57. 老师主页显示问题列表-业务层
(a)
(b) 接口与抽象方法
原本存在抽象方法:
PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer page);
改为:
PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer type, Integer page);
© 实现业务方法
为了便于阅读程序源代码,先在User
类中声明2个静态常量:
/**
* 账号类型:学生
*/
public static final Integer TYPE_STUDENT = 0;
/**
* 账号类型:老师
*/
public static final Integer TYPE_TEACHER = 1;
在原本存在的getQuestionsByUserId()
方法的参数列表中添加参数,与以上抽象方法保持一致,然后,在实现过程中:
@Override
public PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer type, Integer page) {
// 设置分页参数
PageHelper.startPage(page, pageSize);
// 根据账号类型,调用持久层不同的方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
List<QuestionVO> questions;
if (type == User.TYPE_STUDENT) {
questions = questionMapper.findStudentQuestions(userId);
} else {
questions = questionMapper.findTeacherQuestions(userId);
}
// 后续代码不变
}
(d) 单元测试
由于修改了业务方法的声明,当前控制器层的调用会因为参数不匹配而报错,将无法进行单元测试,所以,先处理完控制器层再测试。
58. 老师主页显示问题列表-控制器层
在原来的获取学生问题列表的方法中,调用业务方法时多添加type
值即可,该值来自UserInfo
参数:
@GetMapping("/my")
public R<PageInfo<QuestionVO>> getMyQuestions(Integer page,
@AuthenticationPrincipal UserInfo userInfo) {
if (page == null || page < 1) {
page = 1;
}
PageInfo<QuestionVO> questions = questionService.getQuestionsByUserId(userInfo.getId(), userInfo.getType(), page);
return R.ok(questions);
}
完成后,应该分别测试学生账号登录后显示列表和老师账号登录后显示列表。
59. 老师主页显示问题列表-前端页面
引用index.html
中的处理即可!也就是说:在index.html
中将列表区域设置为th:fragment
,然后在index_teacher.html
中通过th:replace
直接引用即可!
另外,关于点击问题的标题就可以跳转到“问题详情”页面,需要将跳转的<a>
标签的href
属性改为:
v-bind:href="'question/detail.html?' + question.id"
60. 显示问题详情-持久层
(a) 规划SQL语句
目前需要根据id显示问题的详情,在页面中需要显示的数据有:标题、正文、标签、收藏(暂未实现)、浏览次数、发布者、发布时间,目前,因为涉及问题的多个标签,只有QuestionVO
才可以包含以上所有信息,在查询时,也需要把以上相关信息都查出来,结合使用QuestionVO
封装结果,只需要查询question
这1张表的数据即可。需要执行的SQL语句大致是:
select * from question where id=?
注意:在设计SQL语句时,条件越简单越好,应该只添加最核心的、用于保证本意的条件,其它的条件尽量在业务层中完成!
(b) 接口中的抽象方法
在QuestionMapper
接口中添加:
/**
* 根据问题id查询问题详情
*
* @param id 问题的id
* @return 匹配的问题详情,如果没有匹配的数据,则返回null
*/
QuestionVO findById(Integer id);
© 配置SQL语句
在QuestionMapper.xml
中配置以上抽象方法映射的SQL语句:
<select id="findById" resultMap="QuestionVOMap">
SELECT
*
FROM
question
WHERE
id=#{id}
</select>
(d) 单元测试
在QuestionMapperTests
中编写并执行单元测试(测试结果中,tags
属性值目前为null
):
@Test
void findById() {
Integer id = 5;
QuestionVO questionVO = mapper.findById(id);
log.debug("question >>> {}", questionVO);
}
61. 显示问题详情-业务层
(a) 规划业务并创建所需的异常
本次需要执行的是“根据id获取问题的详情”,首先,可能存在“数据不存在”,这种情况下应该抛出对应的异常,所以,需要创建:
public class QuestionNotFoundException extends ServiceException {}
同时,还应该检查数据的其它管理属性,例如is_public
字段的值,或is_delete
字段的值,此处就不再反复演示。
小技巧:如果当前设计的是某种查询功能的业务,例如获取某1个数据,或者获取某种数据列表,可能需要:
- 检查数据是否存在;
- 检查数据的管理属性;
- 检查是否具有权限访问该数据(例如是不是自己的,或是否具有权限);
(b) 接口中的抽象方法
在IQuestionService
中添加:
/**
* 根据提问的id查找问题详情
*
* @param id 问题的id
* @return 匹配的问题的详情
*/
QuestionVO getQuestionById(Integer id);
© 实现业务方法
在QuestionServiceImpl
中实现以上方法:
/**
* 根据标签id获取标签(TagVO)数据的集合
*
* @param tagIdsStr 由若干个标签id组成的字符串,各id之间使用 , 分隔
* @return 签(TagVO)数据的集合
*/
private List<TagVO> getTagsByIds(String tagIdsStr) {
// 拆分
String[] tagIds = tagIdsStr.split(", ");
// 创建用于存放若干个标签的集合
List<TagVO> tags = new ArrayList<>();
// 遍历数组,从缓存中找出对应的TagVO
for (String tagId : tagIds) {
// 从缓存中取出对应的TagVO
Integer id = Integer.valueOf(tagId);
TagVO tag = tagService.getTagVOById(id);
// 将取出的TagVO添加到QuestionVO对象中
tags.add(tag);
}
// 返回
return tags;
}
@Override
public QuestionVO getQuestionById(Integer id) {
// 实现过程中,先通过持久层查询数据,并判断查询结果是否为null,如果为null,则抛出异常。
QuestionVO questionVO = questionMapper.findById(id);
if (questionVO == null) {
throw new QuestionNotFoundException("获取问题详情失败,尝试访问的数据不存在!");
}
// 根据查询结果中的tagIds确定tags的值。
questionVO.setTags(getTagsByIds(questionVO.getTagIds()));
// 返回查询结果
return questionVO;
}
(d) 单元测试
在QuestionServiceTests
中测试:
@Test
void getQuestionById() {
Integer id = 6;
QuestionVO questionVO = service.getQuestionById(id);
log.debug("question >>> {}", questionVO);
}
62. 显示问题详情-控制器层
(a) 处理异常
先在R.State
中创建新的异常对应的错误码。
然后在GlobalExceptionHandler
中处理新创建的QuestionNotFoundException
。
(b) 设计请求
请求路径:/api/v1/questions/{id}
请求参数:@PathVariable("id") Integer id
请求方式:GET
响应结果:R<QuestionVO>
© 处理请求
// http://localhost:8080/api/v1/questions/6
@GetMapping("/{id}")
public R<QuestionVO> getQuestionById(@PathVariable("id") Integer id) {
return R.ok(questionService.getQuestionById(id));
}
(d) 测试
在浏览器访问http://localhost:8080/api/v1/questions/6。
63. 显示问题详情-前端页面
前端页面需要使用的details.js
:
let questionInfoApp = new Vue({
el: '#questionInfoApp',
data: {
question: {
title: 'Vue中的v-text和v-html有什么区别?',
content: '感觉都是用来设置标签内部显示的内容的,区别在哪里呢?',
userNickName: '天下无敌',
createdTimeText: '58分钟前',
hits: 998,
tags: [
{ id: 5, name: 'Java SE' },
{ id: 7, name: 'Spring' },
{ id: 16, name: 'Mybatis' }
]
}
},
methods: {
loadQuestion: function () {
let id = location.search;
if (!id) {
alert("非法访问!参数不足!");
location.href = '/index.html';
return;
}
id = id.substring(1);
if (!id || isNaN(id)) { // is not a number
alert("非法访问!参数不足!");
location.href = '/index.html';
return;
}
$.ajax({
url: '/api/v1/questions/' + id,
success: function(json) {
if (json.state == 2000) {
questionInfoApp.question = json.data;
} else {
alert(json.message);
location.href = "/index.html";
}
}
});
}
},
created: function () {
this.loadQuestion();
}
});
64. 回答问题-持久层
直接使用MyBatis Plus提供的insert()
方法即可实现插入回复的数据。
65. 回答问题-业务层
(a) 规划业务流程、业务逻辑,创建必要的异常
此次的业务是向answer
表中插入数据,没有唯一的字段,也不与其它表存在关联,所以,在插入之前不需要执行检查,在数据完整的情况下,直接插入数据即可。
小技巧:通常,在以增、删、改为主的业务中,都伴随着查询操作,特别是删、改的业务,至少都应该检查数据是否存在,当前用户是否具备删、改数据的权限,如果是以增为主的业务,主要检查是否存在某些数据需要唯一 (例如在用户注册时,用户名或手机号等数据就可能要求唯一,则需要事先检查),如果增加时还涉及其它表的数据,也可以需要检查数据关联等问题。
(b) 接口中的抽象方法
在dto
包中创建AnswerDTO
类:
@Data
public class AnswerDTO {
private Integer questionId;
private String content;
}
在IAnswerService
中添加抽象方法:
/**
* 提交问题的回复
*
* @param answerDTO 客户端提交的回复对象
* @param userId 当前登录的用户id
* @param userNickName 当前登录的用户昵称
*/
void post(AnswerDTO answerDTO, Integer userId, String userNickName);
© 实现业务
在AnswerServiceImpl
中规划业务方法的具体步骤:
@Autowired
private AnswerMapper answerMapper;
public void post(AnswerDTO answerDTO, Integer userId, String userNickName) {
// 创建Answer对象
// 补全answer对象的属性值:content <<< 参数answerDTO中的content
// 补全answer对象的属性值:count_of_likes <<< 0
// 补全answer对象的属性值:user_id <<< 参数userId
// 补全answer对象的属性值:user_nick_name <<< 参数userNickName
// 补全answer对象的属性值:question_id <<< 参数answerDTO中的questionId
// 补全answer对象的属性值:created_time <<< 当前时间
// 补全answer对象的属性值:status_of_accept <<< 0
// 调用int answerMapper.insert(Answer answer)方法插入“回复”的数据,并获取返回结果
// 判断返回值是否不为1
// 是:抛出InsertException
}
具体实现以上业务:
@Service
public class AnswerServiceImpl extends ServiceImpl<AnswerMapper, Answer> implements IAnswerService {
@Autowired
private AnswerMapper answerMapper;
@Override
public void post(AnswerDTO answerDTO, Integer userId, String userNickName) {
// 创建Answer对象
Answer answer = new Answer();
// 补全answer对象的属性值:content <<< 参数answerDTO中的content
answer.setContent(answerDTO.getContent());
// 补全answer对象的属性值:count_of_likes <<< 0
answer.setCountOfLikes(0);
// 补全answer对象的属性值:user_id <<< 参数userId
answer.setUserId(userId);
// 补全answer对象的属性值:user_nick_name <<< 参数userNickName
answer.setUserNickName(userNickName);
// 补全answer对象的属性值:question_id <<< 参数answerDTO中的questionId
answer.setQuestionId(answerDTO.getQuestionId());
// 补全answer对象的属性值:created_time <<< 当前时间
answer.setCreatedTime(LocalDateTime.now());
// 补全answer对象的属性值:status_of_accept <<< 0
answer.setStatusOfAccept(0);
// 调用int answerMapper.insert(Answer answer)方法插入“回复”的数据,并获取返回结果
int rows = answerMapper.insert(answer);
// 判断返回值是否不为1
if (rows != 1) {
// 是:抛出InsertException
throw new InsertException("回复问题失败!服务器忙,请稍后再次尝试!");
}
}
}
(d) 单元测试
@SpringBootTest
@Slf4j
public class AnswerServiceTests {
@Autowired
IAnswerService service;
@Test
void post() {
try {
AnswerDTO answerDTO = new AnswerDTO()
.setQuestionId(1)
.setContent("HAHAHA!!!");
Integer userId = 2;
String userNickName = "天下第一";
service.post(answerDTO, userId, userNickName);
log.debug("OK");
} catch (ServiceException e) {
log.debug("failure >>> ", e);
}
}
}
66. 回答问题-控制器层
(a) 处理异常
本次业务层并没有抛出新的异常(从未处理过的异常),则无需处理!
(b) 设计请求
请求路径:/api/v1/answers/post
请求参数:Integer questionId
, String content
, @AuthenticationPriciple UserInfo userInfo
请求方式:POST
响应结果:R<Void>
© 处理请求
先在AnswerDTO
中为属性添加注解,用于验证请求参数的有效性:
@Data
@Accessors(chain = true)
public class AnswerDTO {
@NotNull(message="问题id不允许为空!")
private Integer questionId;
@NotBlank(message="必须填写回复的内容!")
private String content;
}
在AnswerController
中处理请求:
@RestController
@RequestMapping("/api/v1/answers")
public class AnswerController {
@Autowired
private IAnswerService answerService;
// http://localhost:8080/api/v1/answers/post?questionId=1&content=666
@RequestMapping("/post")
public R<Void> post(@Validated AnswerDTO answerDTO,
BindingResult bindingResult,
@AuthenticationPrincipal UserInfo userInfo) {
if (bindingResult.hasErrors()) {
String message = bindingResult.getFieldError().getDefaultMessage();
throw new ParameterValidationException(message);
}
answerService.post(answerDTO, userInfo.getId(), userInfo.getNickname());
return R.ok();
}
}
(d) 测试
http://localhost:8080/api/v1/answers/post?questionId=1&content=666
67. 回答问题-前端页面
关于postAnswer.js
代码:
let writeAnswerApp = new Vue({
el: '#writeAnswerApp',
data: {
},
methods: {
postAnswer: function () {
let questionId = location.search.substring(1);
let content = $('#summernote').val();
// 注意:以下data表示提交到服务器端的数据
// 属性名称必须与AnswerDTO的属性名称保持一致
let data = {
questionId: questionId,
content: content
}
$.ajax({
url: '/api/v1/answers/post',
data: data,
type: 'post',
success: function (json) {
if (json.state == 2000) {
alert('回复成功!');
// 应该将数据显示到列表
// 如果要上传图片,必须启动静态资源服务器
// $('#form-post-answer')[0].reset();
$('#summernote').summernote('reset');
} else {
alert(json.message);
}
}
});
}
}
});