4. ​​z​

在项目中,每次处理用户提交的请求时,用户的请求数据的走向应该是:用户界面 --> 控制器层 --> 业务层 --> 持久层,以上各层的分工如下:


  • 用户界面:负责显示数据、提供用户操作入口,并提交请求,获取服务器响应的结果;
  • 控制器层:负责接收请求,并发出响应结果;
  • 业务层:负责业务流程和业务逻辑,以保障数据的安全性(数据必须按照业务所设定的规则而产生或发生变化)和完整性;
  • 持久层:负责数据访问,即增删改查。

在开发项目时,开发顺序应该是:持久层 --> 业务层 --> 控制器层 --> 用户界面。

5. 学生注册-持久层

用户注册的本质是向用户数据表中插入数据,然后,为了保证用户名或手机号或某字段唯一,还应该在插入数据之前通过查询进行检查。

由于使用了MyBatisPlus,常规的数据增删改查已经完成,可以不必自行开发所需要功能!


MyBatisPlus的使用存在一些争议,主要表现为:方法的调用可能比较麻烦,例如可能需要使用到QueryWrapper来封装WHERE子句的条件,另外,执行效率可能略低。


6. 学生注册-业务层

由于存在规则“学生注册时必须填写已知的邀请码(在数据表中有记录)才可以注册,将可以把学生根据邀请码分配到不同的班级”,所以,必须先保证“能够验证学生在注册时填写的邀请码是否正确”!

先从笔记服务器下载​​class_info​​​表的SQL脚本,登录MySQL控制台,使用​​straw​​数据库后,通过命令导入该SQL脚本:

source 脚本文件的路径

注意:脚本文件的路径,可以直接将脚本文件拖拽到控制台窗口自动得到,如果得到的路径被添加了引号框住,需要手动删除引号,​​source​​与路径之前必须存在空白,且该命令不要使用分号表示结束。

由于使用了新的数据库,则需要通过straw-generator来生成新数据表在项目中需要使用到的各个文件!直接执行​​CodeGenerator​​​,输入表名​​class_info​​,执行完成后,将straw-generator中生成的关于​​class_info​​表的相关文件(4个Java文件夹,1个配置SQL语句的XML文件)都复制到straw-portal子模块项目中,完成后,将straw-generator中刚刚生成的文件删除掉。

在执行“学生注册”时,可能出现异常的原因有:


  • 邀请码错误;
  • 班级已被禁用;
  • 手机号码已被占用;
  • 插入用户数据失败;

在项目中,当需要抛出异常时,推荐抛出​​RuntimeException​​的子孙类异常,通常,都会自定义异常来表示错误,关于如何自定义异常:


  • 自定义1个异常,在异常中声明某个属性,该属性的值不同时,就表示不同类型的错误;
  • 自定义若干个异常,每1种异常对应1种错误;

当前项目将始终使用以上第2种做法,首先,应该创建自定义异常的基类异常,便于表示“自定义业务异常”!则在​​cn.tedu.straw.portal.service.ex​​​包中创建​​ServiceException​​:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5IrR5NRW-1595509211297)(image-20200715144725092.png)]

该异常类需要继承自​​RuntimeException​​,并添加与父类相同的构造方法:

package cn.tedu.straw.portal.service.ex;

/**
* 业务异常的基类
*/
public class ServiceException extends RuntimeException {

public ServiceException() {
}

public ServiceException(String message) {
super(message);
}

public ServiceException(String message, Throwable cause) {
super(message, cause);
}

public ServiceException(Throwable cause) {
super(cause);
}

public ServiceException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}

接下来,还是在​​cn.tedu.straw.portal.service.ex​​​包中创建以上列举的4种错误对应的异常,这4个异常都必须继承自以上创建的​​ServiceException​​:

public class InviteCodeException extends ServiceException {
// 构造方法……
}
public class ClassDisabledException extends ServiceException {
// 构造方法……
}
public class PhoneDuplicateException extends ServiceException {
// 构造方法……
}
public class InsertException extends ServiceException {
// 构造方法……
}

当开发业务层时,应该先在​​IUserService​​接口中定义业务的抽象方法,然后,再在实现类实现该方法。

本次需要开发“注册”功能,声明的抽象方法应该是:

void registerStudent(User user, String inviteCode);


关于业务方法的返回值类型的设计:仅以操作成功为前提来设计返回值类型!不需要考虑操作失败的问题,当操作失败时,都会抛出某种异常的对象!


当抽象方法已经设计好了,就应该在实现类中实现以上抽象方法:

@Autowired
ClassInfoMapper classInfoMapper;
@Autowired
UserMapper userMapper;

public void registerStudent(User user, String inviteCode) {
// 【由于当前项目设计的规则是“学生账号通过手机号码注册、登录”,必须保证手机号码唯一】
// 调用ClassInfoMapper对象的selectOne()方法,根据参数inviteCode邀请码,查询class_info表
// 判断查询结果是否为空
// 是:表示没有找到有效的邀请码,不允许注册,抛出InviteCodeException

// 从以上查询到的班级信息中取出enabled,判断是否为0
// 是:表示该班级已禁用,不允许注册该班级的学生账号,抛出ClassDisabledException

// 从参数user中取出手机号码
// 调用UserMapper对象的selectOne()方法,根据手机号码查询学生账号信息
// 判断查询结果是否不为null
// 是:找到了学生信息,表示手机号码已经被占用,则不允许注册,抛出PhoneDuplicateException

// 没有找到学生信息,表示手机号码没有被占用,则允许注册……
// 确保参数user中的数据全部是有效的
// - 取出参数user中的密码,调用私有的encode()方法进行加密,并将加密后的密码封装回到user中
// - classId:此前验证邀请码时得到的结果
// - createdTime:当前时间,LocalDateTime.now()
// - enabled:1,允许使用
// - locked:0,不锁定
// - type:0,表示学生

// 调用UserMapper对象的insert()方法,根据参数user插入数据,获取返回值
// 判断返回值(受影响的行数)是否不为1
// 是:受影响的行数不是1,则插入用户数据失败,抛出InsertException
}

@Autowired
PasswordEncoder passwordEncoder;

// 将密码进行加密的方法
private String encode(String rawPassword) {
String encodePassword = passwordEncoder.encode(rawPassword);
return encodePassword;
}


在编写业务代码时,尽量根据“能够抛出异常”或其它能够使得方法运行结束为标准来写​​if​​​语句的判断条件,以避免出现太多的​​if​​嵌套!


具体实现代码为:

package cn.tedu.straw.portal.service.impl;

import cn.tedu.straw.portal.mapper.ClassInfoMapper;
import cn.tedu.straw.portal.mapper.UserMapper;
import cn.tedu.straw.portal.model.ClassInfo;
import cn.tedu.straw.portal.model.User;
import cn.tedu.straw.portal.service.IUserService;
import cn.tedu.straw.portal.service.ex.ClassDisabledException;
import cn.tedu.straw.portal.service.ex.InsertException;
import cn.tedu.straw.portal.service.ex.InviteCodeException;
import cn.tedu.straw.portal.service.ex.PhoneDuplicateException;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

/**
* <p>
* 服务实现类
* </p>
*
* @author tedu.cn
* @since 2020-07-14
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Autowired
private ClassInfoMapper classInfoMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;

@Override
public void registerStudent(User user, String inviteCode) {
// 【由于当前项目设计的规则是“学生账号通过手机号码注册、登录”,必须保证手机号码唯一】
// 调用ClassInfoMapper对象的selectOne()方法,根据参数inviteCode邀请码,查询class_info表
QueryWrapper<ClassInfo> classQueryWrapper = new QueryWrapper<>();
classQueryWrapper.eq("invite_code", inviteCode);
ClassInfo classInfo = classInfoMapper.selectOne(classQueryWrapper);
// 判断查询结果是否为空
if (classInfo == null) {
// 是:表示没有找到有效的邀请码,不允许注册,抛出InviteCodeException
throw new InviteCodeException();
}

// 从以上查询到的班级信息中取出enabled,判断是否为0
if (classInfo.getEnabled() == 0) {
// 是:表示该班级已禁用,不允许注册该班级的学生账号,抛出ClassDisabledException
throw new ClassDisabledException();
}

// 从参数user中取出手机号码
String phone = user.getPhone();
// 调用UserMapper对象的selectOne()方法,根据手机号码查询学生账号信息
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("phone", user.getPhone());
User result = userMapper.selectOne(userQueryWrapper);
// 判断查询结果是否不为null
if (result != null) {
// 是:找到了学生信息,表示手机号码已经被占用,则不允许注册,抛出PhoneDuplicateException
throw new PhoneDuplicateException();
}

// 没有找到学生信息,表示手机号码没有被占用,则允许注册……
// 确保参数user中的数据全部是有效的
// - 取出参数user中的密码,调用私有的encode()方法进行加密,并将加密后的密码封装回到user中
String rawPassword = user.getPassword();
String encodePassword = encode(rawPassword);
user.setPassword(encodePassword);
// - classId:此前验证邀请码时得到的结果
user.setClassId(classInfo.getId());
// - createdTime:当前时间,LocalDateTime.now()
user.setCreatedTime(LocalDateTime.now());
// - enabled:1,允许使用
user.setEnabled(1);
// - locked:0,不锁定
user.setLocked(0);
// - type:0,表示学生
user.setType(0);

// 调用UserMapper对象的insert()方法,根据参数user插入数据,获取返回值
int rows = userMapper.insert(user);
// 判断返回值(受影响的行数)是否不为1
if (rows != 1) {
// 是:受影响的行数不是1,则插入用户数据失败,抛出InsertException
throw new InsertException();
}
}

/**
* 执行密码加密
*
* @param rawPassword 原密码
* @return 根据原密码执行加密得到的密文
*/
private String encode(String rawPassword) {
String encodePassword = passwordEncoder.encode(rawPassword);
return encodePassword;
}

}

完成后,在src/test/java的​​cn.tedu.straw.portal​​​包中创建​​service​​​子包,并在这个包中创建​​UserServiceTests​​测试类,以测试以上方法:

package cn.tedu.straw.portal.service;

import cn.tedu.straw.portal.model.User;
import cn.tedu.straw.portal.service.ex.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@Slf4j
public class UserServiceTests {

@Autowired
IUserService userService;

@Test
void registerStudent() {
try {
User user = new User();
user.setPhone("13800138002");
user.setNickname("小王同学");
user.setPassword("1234");
String inviteCode = "JSD2003-005803";
userService.registerStudent(user, inviteCode);
log.debug("register student > OK.");
} catch (ServiceException e) {
log.debug("register student > failure.");
log.debug("cause : {}", e.getClass());
}
}

}

7. 学生注册-控制器层

由于当前项目已经集成了Spring Security框架,默认情况下,当前站点的所有请求都是要求登录后才可以访问的(具体如何设置某些请求可以免登录后面会讲),可以在application.properties中添加临时使用的用户名和密码:

=root
spring.security.user.password=$2a$10$tsM03ULkiifEpSCWtQ5Mq.yrLZIPKVr5vHwU1FGjtT9B1vPlswa.C

以上密码值是将​​1234​​​作为原密码通过Bcrypt算法进行加密的,当前项目中已经自动装配了​​BcryptPasswordEncoding​​​密码加密器,Spring Security框架会自动将用户在页面中输入的密码通过该密码加密器进行加密后再对比,所以,在以上配置文件中配置的是​​1234​​​对应的密文。如果需要临时使用其它密码,可以先通过单元测试生成密文,再将密文配置到以上​​spring.security.user.password​​属性中。

然后,在​​UserController​​中添加简单的处理请求的方法,并测试是否可以访问该URL:

// http://localhost:8080/portal/user/student/register?inviteCode=JSD2003-111111&nickname=Hello&phone=13800138002
@RequestMapping("/student/register")
public String studentRegister() {
return "studentRegister";
}

在实际处理请求时,最终响应给客户端的应该是JSON数据,为了保证可以响应JSON数据到客户端,先在​​cn.tedu.straw.portal.vo​​​包中创建​​R​​​类(表示​​Result​​):

package cn.tedu.straw.portal.vo;

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain=true)
public class R {

private Integer state;
private String message;

}

然后,调整控制器中处理请求的方法:

package cn.tedu.straw.portal.controller;


import cn.tedu.straw.portal.model.User;
import cn.tedu.straw.portal.service.IUserService;
import cn.tedu.straw.portal.service.ex.ClassDisabledException;
import cn.tedu.straw.portal.service.ex.InsertException;
import cn.tedu.straw.portal.service.ex.InviteCodeException;
import cn.tedu.straw.portal.service.ex.PhoneDuplicateException;
import cn.tedu.straw.portal.vo.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

/**
* <p>
* 前端控制器
* </p>
*
* @author tedu.cn
* @since 2020-07-14
*/
@RestController
@RequestMapping("/portal/user")
public class UserController {

@Autowired
private IUserService userService;

// http://localhost:8080/portal/user/student/register?inviteCode=JSD2003-111111&nickname=Hello&phone=13800138002&password=1234
@RequestMapping("/student/register")
public R studentRegister(User user, String inviteCode) {
try {
userService.registerStudent(user, inviteCode);
return new R().setState(1).setMessage("注册成功!");
} catch (InviteCodeException e) {
return new R().setState(2).setMessage("注册失败!邀请码错误!");
} catch (ClassDisabledException e) {
return new R().setState(3).setMessage("注册失败!班级已经被禁用!");
} catch (PhoneDuplicateException e) {
return new R().setState(4).setMessage("注册失败! 手机号码已经被注册!");
} catch (InsertException e) {
return new R().setState(5).setMessage("注册失败!服务器忙,请稍后再次尝试!");
}
}

}

完成后,重启项目,打开浏览器,登录后,通过以上URL进行测试。