根据Spring-Security安全框架搭建问答论坛系统

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

什么是Spring安全框架

Spring安全:Spring-Security

是Spring提供的安全管理框架,功能是提供一个安全可靠的登录功能,并且支持权限管理功能,而且自带判断当前用户是否登录的过滤器,如果用户没有登录会跳转到登录页面

为什么需要Spring-Security

使用Spring-Security框架能够使新手程序也能写出企业级别安全的登录功能

Spring-Security包含了权限管理的功能,能够方便的保存一个用户的各种权限,使用简单地方式判断这个用户是否包含这些权限,决定是否允许访问

能够帮助程序员提升编写登录和权限管理功能的开发效率

启动Spring-Security

启动Spring-Security非常的简单 只需要添加依赖即可

<!-- Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

复制代码

加好这个依赖Spring-Security这个框架就会在项目中生效了

现在所有项目中的资源都会被Spring-Security保护

也就是说默认情况下,要想访问当前项目的任何资源,都需要先登录。

而登录方法是: 用户名:user 密码:启动服务时idea控制台出现的随机密码

image.png

访问控制器方法

打开创建好的UserController在其中添加一个方法

代码如下:

@RestController
//在类上编写@RequestMapping注解,表示当前控制器中的方法都需要以本注解
//添加的路径前缀来访问
@RequestMapping("/v1/users")
public class UserController {
    //编写控制器方法
    //结合类上面的注解,访问笨方法的最终路径是
    //localhost:8080/v1/users/get   
    @GetMapping("/get")
    public String get(){
        return "Hello html";
    }
}
复制代码

重启服务,访问localhost:8080/v1/users/get 也是需要登录的,因为控制器的响应也属于网站资源,受到Spring-Security保护

在这里插入图片描述

密码加密

上面我们登录只能使用user这个用户,而且密码每次都要到控制台复制,比较麻烦

Spring-Security允许我们自定义的用户名和密码配置到application.properties中配置

#配置Spring-Security的自定义用户名和密码
spring.security.user.name=admin
spring.security.user.password=123456
复制代码

但是这样配置的话,任何可以看到配置文件的人都可以登陆这个网站

所以我们需要学习密码加密,加密之后即使别人看到密码,也不能登录

我们可以使用市面上流星的安全加密算法:bcrypt

这个加密算法可以将任何数据进行加密保存,保证安全 在这里插入图片描述 在测试类中进行一个加密操作,代码如下:

@SpringBootTest
public class PasswordTest {
    //对Bcrypt加密对象实例化
    PasswordEncoder encoder = new BCryptPasswordEncoder();

    //执行加密测试
    @Test
    public void test(){
        //利用加密对象将str字符串加密为pwd
        String str = "123456";
        String pwd = encoder.encode(str);
        System.out.println(pwd);
    }

}
复制代码

运行输出了一个加密结果后发现每次结果都不同,因为每次加密秘结果相同的话安全性较低,bcrypt加密算法采用了"随机校验"技术,让每次生成结果都不同. 加密结果 加密完成下面进行验证的代码,bcrypt提供了验证的方法,可以判断一个字符串是否匹配一个加密结果

image.png

// 执行验证测试
@Test
public void match(){
// 下面的方法验证一个字符串是否匹配一个加密结果
// 返回boolean类型
boolean b=encoder.matches("123456",
"$2a$10$B5Ba4G77NuxAcRJ/iucipOaXjc/3uranz.lMW008IVxRdG
BATv8d2");
System.out.println("匹配结果:"+b);
}
复制代码

最终目的是将加密结果配置在配置文件中application.properties文件修改为

#配置Spring-Security的自定义用户名和密码
spring.security.user.name=admin
spring.security.user.password={bcrypt}$2a$10$.6XmtLGrTwxO/JWWJCoc4OjoBQ7RG6cZ1WEHtYNbbYQWzVaqjTj2i
复制代码

Spring-Security的权限管理功能

我们最终的登录是要支持现有数据库中所有user的数据

现在只能支持配置文件中的用户

如果要实现数据库登录,首先要有机会在java代码中设置用户名密码

创建一个security包,包中创建SecurityConfig,代码如下:

@Configuration
//启用spring-security提供的权限管理功能
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //当前类继承WebSecurityConfigurerAdapter
    //能够重写这个父类中的方法,这个父类中的方法都是用于设置权限管理的

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("tom")
                .password("{bcrpt}$2a$10$.6XmtLGrTwxO/JWWJCoc4OjoBQ7RG6cZ1WEHtYNbbYQWzVaqjTj2i")
                .authorities("test");
        //上面代码的含义是在Spring-Security框架中定义了一个用户
        //用户名是tom,密码是123456
        //具有"test"这个资格可以使当前用户具有访问test资格资源的访问权限
        //当我们设置这个用户之后,配置文件中设置的用户admin就失效了
    }
}
复制代码

控制器方法可以设定当前方法需要什么特殊权限才能访问,如果不设置默认情况下登录就可以访问

修改UserController代码如下:

@RestController
// 在类上编写下面注解,表示当前控制器中的方法都需要
// 以本注解添加的路径前缀来访问
@RequestMapping("/v1/users")
public class UserController {
// 编写控制器方法
// 结合类上面的注解,访问本方法的最终路径是
// localhost:8080/v1/users/get
@GetMapping("/get")
public String get(){
return "Hello html";
}
// 上面的方法没有设置特殊权限登录就可以访问
// 下面方法设置特殊权限,必须有匹配的资格才能访问
@GetMapping("/list")
// 当前这个方法必须是拥有test资格的用户才能访问
@PreAuthorize("hasAuthority('run')")
public String list(){
return "get list";
}
}


复制代码

实现数据库中的用户登录

通过之前的部分,现在知道在Java代码中,要想让用户登录至少要提供用户名,密码,和当前用户权限

数据库用户表中没有直接提供当前用户的权限,那么我们就要根据对当前用户的id查询当前用户的权限

image.png 这个查询可能涉及上面的5张表

因为用户对角色和角色对权限都是多对多

今后面试时如果问到权限数据的实现方式,需要回答上面的5张表

我们需要编写一个根据用户id查询所有权限的5表联查的sql语句

SELECT p.id , p.name
FROM user u
LEFT JOIN user_role ur ON u.id=ur.user_id
LEFT JOIN role r ON r.id=ur.role_id
LEFT JOIN role_permission rp ON r.id=rp.role_id
LEFT JOIN permission p ON p.id=rp.permission_id
WHERE u.id=11
复制代码

我们需要在数据访问层编写这个方法,在登录业务中需要时调用

打开UserMapper编写代码如下


@Repository
public interface UserMapper extends BaseMapper<User> {

    //根据用户id 查询用户所有权限的方法
    @Select("SELECT p.id , p.name\n" +
            "FROM user u\n" +
            "LEFT JOIN user_role ur ON u.id=ur.user_id\n" +
            "LEFT JOIN role r ON r.id=ur.role_id\n" +
            "LEFT JOIN role_permission rp ON r.id=rp.role_id\n" +
            "LEFT JOIN permission p ON p.id=rp.permission_id\n" +
            "WHERE u.id=#{id}")
    List<Permission> findUserPermissionsById(Integer id);

    //根据用户名查询用户对象
    @Select("select * from user where username=#{username}")
    User findUserByUsername(String username);
}

复制代码

有了用户名,密码和用户权限等信息

下面就可以按照Spring-Security规定方式进行登录代码的编写了

我们需要自己编写一个类,这个类实现Spring-Security提供的恶一个接口UserDetailsService,而这个接口中需要实现一个方法,这个方法的功能是根据用户输入在登录框中的用户名进行用户信息(用户名密码权限)的查询,返回值必须是UserDetails,我们需要将这个类型对象实例化后赋值最后返回以完成登录

在service.impl包中新建一个类UserDetailsServiceImpl

代码如下


@Component
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;
    //当前类需要保存到Spring容器@Component不能少
    //需要基于Spring-Security设计的方法进行登录,实现UserDetailsService接口
    //下面方法是接口提供的,我们来实现
    //方法的参数是用户在登录表单编写的用户名
    //方法的返回值是UserDetails类型对象包含登录需要的用户名密码权限等
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //1.根据用户名查询用户对象
        User user= userMapper.findUserByUsername(username);
        //2.判断是否能够查询到用户,没有该用户表示用户名不存在
        if (user==null){
            return null;
        }
        //3.根据用户id查询用户的所有权限
        List<Permission> permissions=
                userMapper.findUserPermissionsById(user.getId());
        //4.将权限的集合转换为String类型数组进行赋值
        String[] auth = new String[permissions.size()];
        int i =0;
        for (Permission p : permissions){
            auth[i]=p.getName();
            i++;
        }
        //5.构建UserDetails对象
        UserDetails details =
                org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .authorities(auth).accountLocked(user.getLocked()==1)//设置当前用户是否锁定
                .disabled(user.getEnabled()==0)//设置当前用户是否可用 false表示可用
                .build();
        //6.返回
        return details;
    }
}

复制代码

上面的代码是Spring-Security要求我们编写的完成登录功能的代码

我们要想登录成功,还要将这个类型对象和Spring-Security建立关系

回到security包中的SecurityConfig类

将我们之前编写的configure方法修改为

// 表示当前配置类是配置Spring框架的
@Configuration
// 启动Spring-Security提供的权限管理功能
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends
                        WebSecurityConfigurerAdapter {
    //当前类继承WebSecurityConfigurerAdapter
    // 能够重写这个父类中的方法,这个父类中的方法都是用于设置权限管理的

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);

    }
}
复制代码

设置放行页面

当今流行的网站都是有些页面允许不登录就能访问

而我们现在的Spring-Security下所有资源都需要登录才能访问

我们如果想放行一些页面需要配置下面代码

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests() // 设置网站的访问及放行规则
            // 下面的方法开始指定路径
            .antMatchers(
                    "/index_student.html",
                    "/css/*",
                    "/js/*",
                    "/img/**",
                    "/bower_components/**")
            .permitAll() // 上面的路径是全部允许的(不需要登录就能访问)
            .anyRequest() // 除上面之外的其他路径
            .authenticated() // 需要登录才能访问
            .and()  //上面的配置完成了,开始配置下面的
            .formLogin(); // 使用表单进行登录
}
复制代码

自定义登录页面

Spring-Security提供的默认登录页面不能体现当前网站的特征,也不能编写其他功能或连接,很受限制,我们希望能够使用自定义的login.html页面进行登录操作

也是需要进行对应的配置

SecurityConfig继续配置

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() //禁用防跨域攻击功能
            .authorizeRequests() // 设置网站的访问及放行规则
            // 下面的方法开始指定路径
            .antMatchers(
                    "/index_student.html",
                    "/css/*",
                    "/js/*",
                    "/img/**",
                    "/bower_components/**",
                    "/login.html")
            .permitAll() // 上面的路径是全部允许的(不需要登录就能访问)
            .anyRequest() // 除上面之外的其他路径
            .authenticated() // 需要登录才能访问
            .and()  //上面的配置完成了,开始配置下面的
            .formLogin() // 使用表单进行登录
            .loginPage("/login.html") //配置登录时显示的页面
            .loginProcessingUrl("/login") //配置处理登录的路径
            .failureUrl("/login.html?error")// 登录失败跳转的页面
            .defaultSuccessUrl("/index_student.html")// 登录成功跳转的页面
            .and()
            .logout()
            .logoutUrl("/logout") // 配置登出的链接
            .logoutSuccessUrl("/login.html?logout");// 登出后跳转回登录页

}
复制代码

注册功能流程分析

注册业务流程

  1. 学生填写注册表单信息
  2. 提交注册信息到控制器
  3. 控制器接收到信息调佣业务逻辑层方法
  4. 业务逻辑层中判断邀请码,手机号并对密码加密后进行数据库新增
  5. mapper层执行新增方法,返回到业务逻辑层
  6. 业务逻辑层将注册结果返回给控制鞥
  7. 控制层将最终信息显示在页面上

注册业务准备

首设置注册页面和控制器路径的放行

打开SecurityConfig配置类,进行放行配置

http.csrf().disable() //禁用防跨域攻击功能
                .authorizeRequests() // 设置网站的访问及放行规则
                // 下面的方法开始指定路径
                .antMatchers(
                        "/index_student.html",
                        "/css/*",
                        "/js/*",
                        "/img/**",
                        "/bower_components/**",
                        "/login.html",
                        "/register.html",
                        "/register")
                .permitAll() // 上面的路径是全部允许的(不需要登录就能访问)
                .anyRequest() // 除上面之外的其他路径
                .authenticated() // 需要登录才能访问
                .and()  //上面的配置完成了,开始配置下面的
                .formLogin() // 使用表单进行登录
                .loginPage("/login.html") //配置登录时显示的页面
                .loginProcessingUrl("/login") //配置处理登录的路径
                .failureUrl("/login.html?error")// 登录失败跳转的页面
                .defaultSuccessUrl("/index_student.html")// 登录成功跳转的页面
                .and()
                .logout()
                .logoutUrl("/logout") // 配置登出的链接
                .logoutSuccessUrl("/login.html?logout");// 登出后跳转回登录页
复制代码

根据表单参数创建vo类

@Data
public class RegisterVo implements Serializable {
private String inviteCode; //邀请码
private String phone; //手机号\用户名
private String nickname; //昵称
private String password; //密码
private String confirm; //确认密码
}

复制代码

还需要自定义异常类

在我们编写的业务发生异常不能继续运行时,使用抛出异常的方式反馈 错误信息

我们定义一个自定义异常类,ServiceException

来表示业务逻辑运行过程中发生的各种不能继续运行程序的异常

例如:邀请码不正确\手机号已经被注册

新建一个包exception,新建类代码如下

public class ServiceException extends RuntimeException{
private int code = 500;
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);
}
public ServiceException(int code) {
this.code = code;
}
public ServiceException(String message, int code) {
super(message);
this.code = code;
}
public ServiceException(String message, Throwable
cause,
int code) {
super(message, cause);
this.code = code;
}
public ServiceException(Throwable cause, int code)
{
super(cause);
this.code = code;
}
public ServiceException(String message, Throwable
cause,
boolean enableSuppression,
boolean writableStackTrace, int code) {
super(message, cause, enableSuppression,
writableStackTrace);
this.code = code;
}
public int getCode() {
return code;
}
}

复制代码

还可以设置简单条件,适合偶尔一次查询数据库使用 我们在测试类中编写一个测试,按邀请码查询班级信息 代码如下

// 根据邀请码查询班级信息
// 如果写sql语句:
// select * from classroom where invite_code='JSD2001-
706246'
// 如果使用QueryWrapper进行查询 代码如下
@Autowired
ClassroomMapper classroomMapper;
@Test
public void query(){
// 我们实例化一个QueryWrapper的对象
// 这个对象其实就是代表查询的条件,泛型是实体类的类型
QueryWrapper<Classroom> query=new QueryWrapper<>();
// 设置查询条件 query.eq([列名],[值])
query.eq("invite_code","JSD2001-70624");
// 按QueryWrapper对象设置好的条件进行查询的操作
// selectOne方法只支持最多返回1行数据,否则报错,返回值是实
体类型
Classroom
classroom=classroomMapper.selectOne(query);
System.out.println(classroom);
}

复制代码

业务逻辑层概述

Vrd项目中完成一次请求响应流程一般会由两个部分组成 请求->控制器(controller)->数据访问层(mapper) 上面的执行流程只能处理相对简单的业务逻辑层 如果遇到企业中的相对复杂的业务逻辑就不能很好的处理了 每个类都应该有自己的职责 // 根据邀请码查询班级信息 // 如果写sql语句: // select * from classroom where invite_code='JSD2001- 706246' // 如果使用QueryWrapper进行查询 代码如下 @Autowired ClassroomMapper classroomMapper; @Test public void query(){ // 我们实例化一个QueryWrapper的对象 // 这个对象其实就是代表查询的条件,泛型是实体类的类型 QueryWrapper query=new QueryWrapper<>(); // 设置查询条件 query.eq([列名],[值]) query.eq("invite_code","JSD2001-70624"); // 按QueryWrapper对象设置好的条件进行查询的操作 // selectOne方法只支持最多返回1行数据,否则报错,返回值是实 体类型 Classroom classroom=classroomMapper.selectOne(query); System.out.println(classroom); } controller:职责就是接收前端页面的信息和将结果响应给页面,其他的事 情尽量不管 mapper:完成对数据库的增删改查操作,其他的操作也不管 如果出现了既不属于controller的也不属于mapper职责的工作,就需要 写在业务逻辑层中 service(业务逻辑层):职责就是将前端发送来的信息经过处理再调用数据 访问层的功能,例如我们接收了用户输入的邀请码但是需要判断是否正 确 企业标准中,service又由两个部分组成 service和service.impl service中保存业务逻辑层接口:一般命名为IXXXService(开头的I表示 Interface) service.impl中保存业务逻辑层实现类:一般命名为 XXXServiceImpl(Impl表示实现的缩写) 之所以采用接口配实现类的形式,是为了解耦 所以在需要业务逻辑层代码时,我们都声明接口类型 再今后我们开发程序的模型中,控制层,业务逻辑层,数据访问层这三层结 构如下

image.png

开发注册业务逻辑层代码

实际开发中,应该先完成数据访问层的编写,但是当前业务中,所有数据库 操作都是基本增删改查,已经由MybatisPlus提供了,所以Mapper层不需 要编写代码 先编写业务逻辑层接口 IUserService添加方法如下

public interface IUserService extends IService<User> {
// 在接口中如果想转到实现类快捷键Ctrl+Alt+B
void registerStudent(RegisterVo registerVo);
}

复制代码

UserServiceImpl实现类代码如下

@Service
public class UserServiceImpl extends
ServiceImpl<UserMapper, User> implements IUserService {
//注入注册需要的各种依赖
@Autowired
private UserMapper userMapper;
@Autowired
private ClassroomMapper classroomMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Override
public void registerStudent(RegisterVo registerVo)
{
// 1.根据用户输入的邀请码获得班级信息
QueryWrapper<Classroom> query=new
QueryWrapper<>();
query.eq("invite_code",registerVo.getInviteCode());
Classroom
classroom=classroomMapper.selectOne(query);
// 2.判断班级信息是否存在,不存在直接抛异常
if(classroom==null){
throw new ServiceException("邀请码错误!");
}
// 3.根据用户输入的手机号,获得用户信息
User user=userMapper.findUserByUsername(
registerVo.getPhone());
// 4.如果能够获得用户信息,表示当前手机号已经被注册,抛出
异常
if(user!=null){
throw new ServiceException("手机号已经被注
册!");
}
// 5.对用户输入的密码进行加密
PasswordEncoder encoder=new
BCryptPasswordEncoder();
String pwd="
{bcrypt}"+encoder.encode(registerVo.getPassword());
// 6.实例化用户对象,为各个属性赋值,收集用户信息
User u=new User()
.setUsername(registerVo.getPhone())
.setNickname(registerVo.getNickname())
.setPassword(pwd)
.setClassroomId(classroom.getId())
.setCreatetime(LocalDateTime.now())
.setEnabled(1)
.setLocked(0)
.setType(0);
// 7.执行新增用户对象的操作
int num=userMapper.insert(u);
if(num!=1){
throw new ServiceException("数据库忙");
}
// 8.执行新增用户角色关系表的操作
UserRole userRole=new UserRole()
.setUserId(u.getId())
.setRoleId(2);
num=userRoleMapper.insert(userRole);
if(num!=1){
throw new ServiceException("数据库忙");
}
}
}




复制代码

推荐大家编写完比价复杂的业务逻辑代码时进行测试 代码如下

@Autowired
IUserService userService;
@Test
public void add(){
RegisterVo registerVo=new RegisterVo();
registerVo.setPhone("13033012345");
registerVo.setNickname("大龙");
registerVo.setInviteCode("JSD2001-706246");
registerVo.setPassword("123456");
userService.registerStudent(registerVo);
System.out.println("ok");
}
复制代码

开发控制层代码

我们先来编写控制层接收表单信息的代码

创建SystemController类

编写代码如下

@RestController
// lombok提供的一个记录日志用的注解
// 一旦在类上添加@Slf4j,这个类的方法中就可以使用log对象记录日志
@Slf4j
public class SystemController {
@Autowired
private IUserService userService;
@PostMapping("/register")
public String register(RegisterVo registerVo){
//利用日志对象,将接收到的信息输出到控制台
log.debug("接收到用户信息:{}",registerVo);
try {
userService.registerStudent(registerVo);
return "ok";
}catch (ServiceException e){
log.error("注册失败",e);
return e.getMessage();
}
}
}

复制代码

重启服务测试

注册成功表示代码正确,检查数据库user表和user_role表示的信息

修改为异步测试

gitee同步更新中 将一个同步的注册修改为异步注册需要如下修改

  1. 页面中需要的支持(vue,axios等)
<script
src="https://unpkg.com/axios/dist/axios.min.js">
</script>
复制代码
  1. 编写并引用js代码
</body>
<script src="js/utils.js"></script>
<script src="js/register.js"></script>
</html>
复制代码
  1. 页面的vue绑定(v-model...)

34行开始

<!--
@submit表示在表单提交时运行指定方法
.prevent阻止表单原有的提交动作
-->
<form action="/register" method="post"
@submit.prevent="register">
<div class="form-group has-icon">
<input type="text" name="inviteCode" class="formcontrol" placeholder="请输入邀请码"
required="required" v-model="inviteCode">
<span class="fa fa-barcode form-control-icon">
</span>
</div>
<div class="form-group has-icon">
<input type="tel" name="phone" class="form-control"
placeholder="请输入手机号"
pattern="^\d{11}$" required="required"
v-model="phone">
<span class=" fa fa-phone form-control-icon">
</span>
</div>
<div class="form-group has-icon">
<input type="text" name="nickname" class="formcontrol" placeholder="请设置昵称,字数为2-20之间"
pattern="^.{2,20}$" required="required"
v-model="nickname">
<span class="fa fa-user form-control-icon"></span>
</div>
<div class="form-group has-icon">
<input type="password" name="password" class="formcontrol" placeholder="设置密码6-20个字母、数字、下划线"
required="required" pattern="^\w{6,20}$"
v-model="password">
<span class="fa fa-lock form-control-icon"></span>
</div>
<div class="form-group has-icon">
<input type="password" name="confirm" class="formcontrol" placeholder="请再次输入密码"
required="required"
v-model="confirm">
<span class="fa fa-lock form-control-icon"></span>
</div>
<button type="submit" class="btn btn-primary btnblock btn-flat" >注册</button>
</form>
复制代码

我们需要修改一下注册页面显示错误信息的区域 30行附近

<!--
:class="" 是利用vue提供的逻辑判断决定是否向样式中添加d-block
hasError为true d-block生效,错误信息显示
hasError为false d-block无效,错误信息隐藏
-->
<div id="error" class="alert alert-danger"
style="display: none"
:class="{'d-block':hasError}">
<i class="fa fa-exclamation-triangle"></i>
<span v-text="message" >邀请码错误!</span>
</div>
复制代码

register.js代码 if判断修改一下

.then(function(r) {
console.log("|"+r.status+"|"+OK+"|");
if(r.data=="ok"){
console.log("注册成功");
console.log(r.data);
app.hasError = false;
location.href = '/login.html?register';
}else{
console.log(r.data);
app.hasError = true;
app.message = r.data;
}
});

复制代码

Spring验证框架

表单验证基本概念

我们页面中通过编写html5diamante可以实现表单验证的逻辑

一般情况下,非空正则表达式的验证都是被支持的

正常编写表单验证之后,可以降低服务器的压力,提高服务器的运行性能

表单眼中正式非常好的验证方案,但是也有缺点:它只能防止通过表单进行注册的用户提交错误信息

如果有人恶意绕开浏览器直接将请求信息发送给服务器。服务器就会处理错误信息,严重情况下服务器就会瘫痪,我们需要服务端进行验证

有了服务端验证,才能防止绕开浏览器的数据不经过验证就进入数据库

这样的验证我们自己写是比较繁琐的,我们需要框架帮助我们简化

什么是Spring验证框架

Spring验证框架:Spring Validation 它能实现简单方便的服务器端验证并且能够接收验证结果

Spring Validation的使用

要想使用先添加依赖

<!-- 验证框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-startervalidation</artifactId>
</dependency>

复制代码

添加完成依赖之后,刷新maven,到需要验证的类中边写正则表达式,也支持非空验证和正则表达式验证等其他验证方式

@Data
public class RegisterVo implements Serializable {
//message就是当本属性为空时,输出的错误信息
@NotBlank(message = "邀请码不能为空")
private String inviteCode; //邀请码
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1\\d{10}$",message = "手机号格式
不正确")
private String phone; //手机号\用户名
@NotBlank(message = "昵称不能为空")
@Pattern(regexp = "^.{2,20}$",message = "昵称是2~20
位字符")
private String nickname; //昵称
@NotBlank(message = "密码不能为空")
@Pattern(regexp = "^\\w{6,20}$",message = "密码是
6~20位字符")
private String password; //密码
@NotBlank(message = "确认密码不能为空")
private String confirm; //确认密码
}
复制代码

控制器启动验证

@PostMapping("/register")
public String register(
// RegisterVo参数前添加@Validated表示开启服务器端验
证功能
// 当控制器方法运行之前,SpringValidation框架会按
RegisterVo
// 中编写的规则进行验证
@Validated RegisterVo registerVo,
// 这个参数必须紧随RegisterVo之后
// result中包含RegisterVo的验证结果
BindingResult result){
//利用日志对象,将接收到的信息输出到控制台
log.debug("接收到用户信息:{}",registerVo);
if(result.hasErrors()){
String msg=result.getFieldError().
getDefaultMessage();
// 返回错误信息
return msg;
}
try {
userService.registerStudent(registerVo);
return "ok";
}catch (ServiceException e){
log.error("注册失败",e);
return e.getMessage();
}
}

复制代码

观察效果,我们还是使用浏览器来测试 所以可以暂时删除一些html5的表单验证

常用验证注解和功能

  • @NotBlank:只能使用在String类型的属性上,判断当前字符串不能为null,不能为“”,而且它底层会先对这个String对象调用trim()方法,再判断是不是“”(“”,也会被认定为错误信息
  • @Pattern:之鞥你使用在String类型的属性上,判断当前属性值是否匹配指定正则表达式
  • @NotNull:能够作用在任何引用类型上,判断当前属性值不能为null
  • @NotEmpty:使用在数组或集合属性上,判断当前数组或集合不能为null或长度不能为0

开发标签首页

我们完成了注册和登录功能 下面要完成首页

标签效果和流程

在这里插入图片描述

image.png

  1. 页面加载完成之后利用Vue的功能调用axios请求
  2. axios发送异步请求到TagController
  3. TagController中调用返回所有标签的业务逻辑层方法
  4. TagService调用数据层获得所有标签返回给控制层
  5. 控制层返回给axios,axios中复制为Vue对象,在页面上显示

编写业务逻辑层的方法

因为mybatisplus提供了全查所有标签的方法,所以我们直接使用即可,无序编写数据层代码 先在ITagService中添加查询所有标签的接口方法

image.png

public interface ITagService extends IService<Tag> {
    
![image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/60217b46a85f41418053d95876933e9c~tplv-k3u1fbpfcp-watermark.image?)
    // 查询所有标签对象的方法
    List<Tag> getTags();
    
}
复制代码

TagServiceImpl类实现代码如下

@Service
public class TagServiceImpl extends ServiceImpl<TagMapper, Tag> implements ITagService {

    @Autowired
    private TagMapper tagMapper;
    @Override
    public List<Tag> getTags() {
        // 查询所有标签
        List<Tag> tags=tagMapper.selectList(null);
        // 千万别忘了返回tags
        return tags;
    }
}


复制代码

编写控制层代码

TagController类编写调用业务逻辑层查询所有标签并返回的代码

@RestController
@RequestMapping("/v1/tags")
public class TagController {

    @Autowired
    private ITagService tagService;

    // @GetMapping("")的意思是get请求,路径是
    //   localhost:8080/v1/tags
    @GetMapping("")
    public List<Tag> tags(){
        List<Tag> tags=tagService.getTags();
        return tags;
    }
    

}

复制代码

一般情况下,如果是get请求的控制器方法,我们可以直接通过浏览器地址栏中手动输入url的方法进行测试,观察控制器方法是否能够返回我们想要的信息

这种测试方式我们称之为同步测试 在这里插入图片描述

Vue绑定和JS代码

下面我们要在index_student.html学生首页编写所有标签的vue绑定代码并添加相关的引用

步骤如下:

  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
复制代码

页面尾部添加引用

</body>
<script src="js/utils.js"></script>
<script src="js/tags_nav.js"></script>
</html>
复制代码

index_student.html163行附近

<!--引入标签的导航栏-->
<div class="container-fluid"  th:fragment="tags_nav" >
  <div class="nav font-weight-light" id="tagsApp">
    <a href="tag/tag_question.html" class="nav-item nav-link text-info"><small>全部</small></a>
    <a href="tag/tag_question.html"
       class="nav-item nav-link text-info"
       v-for="tag in tags">
      <small v-text="tag.name">Java基础</small>
    </a>
  </div>
</div>
复制代码

重启服务,建议大家访问login.html 在登录页面进行登录后会自动跳转到学生首页,这时显示所有标签即为正常

实现缓存列表的缓存

什么是缓存

所谓缓存一般指为了程序中能够快速获得信息而创建的内存空间,就是讲信息保存在内存中,来达到快速操作该数据的目的

使用缓存的好处

一旦将数据保存在缓存中,name内存中获得数据的速度会快于从硬盘中获得,能够大幅度提高运行效率

缓存的缺陷

  • 缓存是内存,容量会比硬盘小,不能什么都往缓存放
  • 缓存中的信息,不能长时间保存,如果断电或重启,内存中的信息就没了

缓存的使用场景

我们综合上面的优缺点给大家提炼了最终使用缓存的适合的场景,一般下面三条同时满足时,可以使用缓存保存这个数据

  • 数据量比较少
  • 被访问的频率越高,使用的缓存越值得
  • 缓存的数据在数据库中 不频繁修改,或对修改不敏感

改写业务逻辑层代码使用缓存保存标签 我们的标签列表符合使用缓存的场景,我们修改业务逻辑层来保存所有标签到缓存

@Service
public  class TagServiceImpl extends ServiceImpl<TagMapper,Tag> implements ITagService{
//缓存的使用:
//1.声明一个保存所有标签的属性,用于缓存所有的标签
private List<Tag> tags = new CopyOnWriteArraryList<>();
//2.缓存的使用逻辑:第一需要所有标签时,只能从数据库中获得,当第一次查询所有标签完毕后,将所有标签保存到tags中,第二次以后在需要所有标签,直接从缓存tags中获取
@Autowired
private TagMapper tagMapper;
@Override
public List<Tag> getTags(){
//判断tags是不是空
        //          3
        if(tags.isEmpty()) {
            //    1    2
            synchronized (tags) {
                if(tags.isEmpty()) {
                    // 查询所有标签
                    List<Tag> tags = tagMapper
                            .selectList(null);
                    //将查询到的所有标签保存到缓存中
                    this.tags.addAll(tags);
                }
            }
        }
        // 千万别忘了返回tags
        return tags;
    }
}
复制代码

重启服务,访问学生首页,第一次访问,标签时数据库查询出来的,之后每次访问都是从缓存中获得的(补充其服务的前提下)

开发显示学生问题列表

显示效果和开发流程

image.png

开发业务逻辑层

我们要实现学生的问题列表需要编写业务逻辑层代码,数据访问层是可以使用QueryWrapper来实现查询操作 具体代码如下:

IQuestionService

public interface IQuestionService extends IService<Question> {

    // 查询学生首页问题列表的方法
    List<Question> getMyQuestion(String username);

}
复制代码

QuestionServiceImpl实现类

@Service
public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> implements IQuestionService {

    @Autowired
    private QuestionMapper questionMapper;

    @Autowired
    private UserMapper userMapper;

    @Override
    public List<Question> getMyQuestion(String username) {
        // 1.根据控制器提供的当前登录的用户名,查询用户信息
        User user=userMapper.findUserByUsername(username);
        // 2.根据用户id查询所有标签,这里使用QueryWrapper
        QueryWrapper<Question> query=new QueryWrapper<>();
        query.eq("user_id",user.getId());
        query.eq("delete_status",0);
        query.orderByDesc("createtime");
        List<Question> list=questionMapper.selectList(query);
        // 3.别忘了返回所有问题列表
        return list;
    }
}
复制代码

开发控制层代码

QuestionController编写代码如下

@RestController
@RequestMapping("/v1/questions")
public class QuestionController {

    @Autowired
    private IQuestionService questionService;

    @GetMapping("/my")
    //@AuthenticationPrincipal注解能够将当前Spring-Security中
    // 保存的登录用户的信息取出保存到参数UserDetails中
    // Principal是当事人的意思
    public List<Question> my(
            @AuthenticationPrincipal UserDetails user){
        List<Question> questions= questionService
                        .getMyQuestion(user.getUsername());
        return questions;
    }

}
复制代码

重启服务,发送同步请求 localhost:8080/v1/questions/my来访问当前用户的问题列表(需要登陆后才能访问)

改变放行规则

我们发现学生首页的问题列表是根据当前用户决定的,所以学生首页需要登陆后才能访问

我们将SecurityConfig类中的放行规则修改一下将原设置为放行学生首页取消

.antMatchers(
    //  "/index_student.html",  这行删除就可以
        "/css/*",
        "/js/*",
        "/img/**",
        "/bower_components/**",
        "/login.html",
        "/register.html",
        "/register")
复制代码

Vue和Html绑定

页面尾部添加现实问题列表的js文件

</body>
<script src="js/utils.js"></script>
<script src="js/tags_nav.js"></script>
<!--  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  -->
<script src="js/index.js"></script>
</html>
复制代码

在180行附近开始编写html绑定代码

<div id="questionsApp">
<div class="row" style="display: none">
  <div class="alert alert-warning w-100" role="alert">
    抱歉您还没有提问内容, <a href="question/create.html" class="alert-link">您可以点击此处提问</a>,或者点击标签查看其它问答
  </div>
</div>
    		
<div class="media bg-white m-2 p-3"
    v-for="question in questions">
    <!--  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
  <div class="media-body w-50">
    <div class="row">
      <div class="col-md-12 col-lg-2">
        <span class="badge badge-pill badge-warning" style="display: none">未回复</span>
        <span class="badge badge-pill badge-info" style="display: none">已回复</span>
        <span class="badge badge-pill badge-success">已解决</span>
      </div>
      <div class="col-md-12 col-lg-10">
        <h5 class="mt-0 mb-1 text-truncate">
          <a class="text-dark"
             href="question/detail.html"
             v-text="question.title">
              <!--  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
            eclipse 如何导入项目?
          </a>
        </h5>
      </div>
    </div>

    <div class="font-weight-light text-truncate text-wrap text-justify mb-2" style="height: 70px;">
      <p v-html="question.content">
          <!--  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
        eclipse 如何导入项目?
      </p>
    </div>
    <div class="row">
      <div class="col-12 mt-1 text-info">
        <i class="fa fa-tags" aria-hidden="true"></i>
        <a class="text-info badge badge-pill bg-light" href="tag/tag_question.html"><small >Java基础 &nbsp;</small></a>
      </div>
    </div>
    <div class="row">
      <div class="col-12 text-right">
        <div class="list-inline mb-1 ">
          <small class="list-inline-item"
            v-text="question.userNickName">风继续吹</small>
            <!--  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
          <small class="list-inline-item">
            <span v-text="question.pageViews">12</span>
              <!--  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑  -->
            浏览</small>
          <small class="list-inline-item" >13分钟前</small>
        </div>
      </div>
    </div>

  </div>
  <!-- / class="media-body"-->
  <img src="img/tags/example0.jpg"  class="ml-3 border img-fluid rounded" alt="" width="208" height="116">
</div>
复制代码

重启服务,访问学生首页,登录st2用户,就能显示李四同学的所有问题了但是有些信息还是不完整

显示持续时间

我们希望时间方面显示问题体温距离当前时间的时间差,我们称之为持续时间

持续时间要分段,我们分为四段

  • 1分钟以内刚刚
  • 1小时以内显示xx分钟前
  • 一天以内显示xx小时前
  • 一天以上显示xx天前

上面的分段实现逻辑是先获取当前时间问题和问题发布时间的时间差,然后判断时间差的范围,按照不同的范围显示不同的分段结果

我们可以研究index.js的updateDuration方法实现,需要进行html绑定

<small class="list-inline-item"
  v-text="question.duration">13分钟前</small>
复制代码

显示当前问题的关联标签

每个问题都至少拥有一个标签,显示问题列表中的效果如下 在这里插入图片描述 标签和问题的关系是多对多的关系关系如下图

image.png 基本原理上,我们表示问题和标签的关系需要使用关系表question_tag但是一旦使用关系表,那么根据问题获得所有标签时就一定要进行多表联查

有些时候,程序员们为了避免复杂的连表查询,可以在question中建一个列 ,这个列中直接保存问题的标签关系,方便查询 优点: 省去了连表查询,提高开发效率和运行效率 缺点: 数据冗余,增加维护困难

修改Question实体类

因为我们要实现一个Question对象保存多个Tag对象,所以要在Question实体类中末尾添加List属性代码如下:

//当前数据库不包含下面的列
@TableField(exist = false)
// tags表示当前问题关联的所有标签集合
private List<Tag> tags;
复制代码

我们现在在不连表查询的前提下,可以获得所有标签名称的字符串"Java基础,Java SE,面试题",但是我们需要的不只是标签名称,还需要标签对象,并保存在上面的集合中,在根据标签名称获得标签对象的过程中,最好有一个Map包含所有标签

所以我们需要TagService提供除了所有标签的List之外还有包含所有标签的Map

ITagService添加方法

// 获得包含所有标签的Map对象的方法
Map<String,Tag> getTagMap();
复制代码

TagServiceImpl类修改代码如下

@Service
public class TagServiceImpl extends ServiceImpl<TagMapper, Tag> implements ITagService {

    // 缓存的使用:
    // 1.声明一个保存所有标签的属性,用于缓存所有标签
    private List<Tag> tags=new CopyOnWriteArrayList<>();
    // 2.缓存的使用逻辑:第一需要所有标签时,只能从数据库获得
    //                当第一次查询所有标签完毕后,将所有标签保存到缓存tags中
    //                第二次以后再需要所有标签,直接从缓存tags中获取

    // 声明包含所有标签的Map类型的缓存
    private Map<String,Tag> tagMap=new ConcurrentHashMap<>();



    @Autowired
    private TagMapper tagMapper;
    @Override
    public List<Tag> getTags() {
        //判断tags是不是空
        //          3
        if(tags.isEmpty()) {
            //    1    2
            synchronized (tags) {
                if(tags.isEmpty()) {
                    // 查询所有标签
                    List<Tag> tags = tagMapper
                            .selectList(null);
                    //将查询到的所有标签保存到缓存中
                    this.tags.addAll(tags);
                    //所有标签也保存在Map缓存中
                    for(Tag t:tags){
                        tagMap.put(t.getName(),t);
                    }
                }
            }
        }
        // 千万别忘了返回tags
        return tags;
    }

    @Override
    public Map<String, Tag> getTagMap() {
        // 如果当前map是空
        if(tagMap.isEmpty()){
            // 让上面的方法将map赋值
            getTags();
        }
        // 千万别忘了返回
        return tagMap;
    }
}
复制代码

显示当前问题所有标签

下面我们要编写一个方法,根据tagNames属性值获得一个标签集合

image.png QuestionServiceImpl类中添加\修改代码如下

@Override
public List<Question> getMyQuestion(String username) {
    // 1.根据控制器提供的当前登录的用户名,查询用户信息
    User user=userMapper.findUserByUsername(username);
    // 2.根据用户id查询所有标签,这里使用QueryWrapper
    QueryWrapper<Question> query=new QueryWrapper<>();
    query.eq("user_id",user.getId());
    query.eq("delete_status",0);
    query.orderByDesc("createtime");
    List<Question> list=questionMapper.selectList(query);
    // 循环将每个问题的标签都赋值
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    for (Question q : list){
        List<Tag> tags=tagNamesToTags(q.getTagNames());
        q.setTags(tags);
    }
    // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
    // 3.别忘了返回所有问题列表
    return list;
}

@Autowired
private ITagService tagService;

// 将tagNames字符串转换为List<Tag>的方法
private List<Tag> tagNamesToTags(String tagNames){
    // 将"Java基础,Java SE,面试题"这样的字符串
    // 转换为{"Java基础","Java SE","面试题"}字符串数组
    String[] names=tagNames.split(",");
    // 准备包含所有标签的Map
    Map<String,Tag> tagMap=tagService.getTagMap();
    // 新实例化一个集合,用于保存获取出来的标签并返回
    List<Tag> tags=new ArrayList<>();
    // 循环遍历names
    for(String name : names){
        //获得字符串对应的标签对象
        Tag t=tagMap.get(name);
        tags.add(t);
    }
    // 别忘了返回
    return tags;

}
复制代码

下面我们发一个同步测试观察现在返回的所有问题列表中是否包含标签的集合 localhost:8080/v1/questions/my 如果页面显示的结果中包含tags对应的信息,表示上面转换成功 下面进行显示vue绑定 在index_student.html215行附近

<a class="text-info badge badge-pill bg-light"
   href="tag/tag_question.html"
   v-for="tag in question.tags">
  <small v-text="tag.name" >Java基础 &nbsp;</small>
</a>
复制代码

重启服务,观察是否能够显示所有标签

显示当前问题配图

这个项目会自动为每个问题进行配图.配图原理比较简单,就是使用当前问题的第一个标签的id作为显示配图的一句,标签时1~ 20号,我们资源中有1~20号图片,对应标签,只需要将标签对象的id值获取出现,拼接到url图片,对应标签只需奥将标签对象的id值获取出现,拼接到url图片路径中即可 代码也是写好的 index.js文件中update tagImage方法 在238行附近进行修改

<!-- / class="media-body"-->
<img src="img/tags/example0.jpg"
     class="ml-3 border img-fluid rounded"
     alt="" width="208" height="116"
     :src="question.tagImage">
<!-- ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑   -->
复制代码

分页显示问题列表

什么是分页以及好处

所谓分也就是将查询出的数据按页显示,而不是一次性显示 分页的好处:

  • 相对于全查信息,查询一部分信息服务器压力肯定是小的
  • 客户端来讲 ,显示全部信息不但消耗更多流量,还需要更多时间加载界面
  • 真正有价值的信息通常情况下,就在全部信息开始的位置,之后的信息没有必要查询

PageHelper实现分页查询

mysql数据库,实现分页查询,通过limit关键字

SELECT * FROM question
WHERE user_id=11
LIMIT 0,8
复制代码

image.png 上面的内容使用mysql数据库分页查询命令 核心就是limit关键字,但是同时也发现,limit需要进行计算才能进行正常的分页,我们是用PageHelper能够更加方便的实现分页

添加分页依赖

父项目pom文件添加如下内容

<properties>          
<pagehelper.starter.version>1.3.0</pagehelper.starter.version>
</properties>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>${pagehelper.starter.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>
复制代码

portal项目的pom.xml文件添加

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
复制代码

PageHeleper实现分页查询非常简单 只需要在执行要分页的查询之前,执行PageHeleper.startPage方法即可进行分页,代码如下

//只要在执行查询前设置PageHelper分页,这次查询就会自动变为分页查询
//startPage([页码(1表示第一页)],[每页条数])
PageHelper.startPage(1,8);
List<Question> list=questionMapper.selectList(query);
复制代码

重构学生问题列表

我们要利用上面的分也知识,修改重构我们的学生问题列表,最终实现良好的分页功能,要想实现分页功能,上一页,下一页的连续就要可用,如果实现翻页跳转,就必须手机很多分业相关的信息,例如:当前第几页,有没有下一页/上一页,总共多少页,总共多少条,每页多少条等各种信息,如果这些信息也需要我们自己计算和收集的话就太麻烦了

PageHeleper提供了一个类型能够自动收集这些信息,方便我们开发,PageInfo就能实现上的信息收集,而且哈包括分页查询的结果IQuestionService接口修改查询学生问题列表的参数和返回值

public interface IQuestionService extends IService<Question> {

    // 查询学生首页问题列表的方法
    PageInfo<Question> getMyQuestion(String username,
                          Integer pageNum, Integer pageSize);

}
复制代码

QuestionServiceImpl实现类修改

@Override
//     ↓↓↓↓↓↓↓↓
public PageInfo<Question> getMyQuestion(String username,
                       Integer pageNum,Integer pageSize) {
    //					↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
    // 1.根据控制器提供的当前登录的用户名,查询用户信息
    User user=userMapper.findUserByUsername(username);
    // 2.根据用户id查询所有标签,这里使用QueryWrapper
    QueryWrapper<Question> query=new QueryWrapper<>();
    query.eq("user_id",user.getId());
    query.eq("delete_status",0);
    query.orderByDesc("createtime");
    //只要在执行查询前设置PageHelper分页,这次查询就会自动变为分页查询
    //startPage([页码(1表示第一页)],[每页条数])
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    PageHelper.startPage(pageNum,pageSize);
    List<Question> list=questionMapper.selectList(query);
    // 循环将每个问题的标签都赋值
    for (Question q : list){
        List<Tag> tags=tagNamesToTags(q.getTagNames());
        q.setTags(tags);
    }

    // 3.别忘了返回所有问题列表
    // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    return new PageInfo<>(list);
}
复制代码

控制器修改

QuestionController

@GetMapping("/my")
public PageInfo<Question> my(
        @AuthenticationPrincipal UserDetails user,
        Integer pageNum){
    Integer pageSize=8;
    if(pageNum==null){
        pageNum=1;
    }
    PageInfo<Question> pageInfo=questionService
            .getMyQuestion(user.getUsername()
                                ,pageNum,pageSize);
    return pageInfo;
}
复制代码

重启服务我们可以发送同步请求

localhost:8080/v1/questions/my来同步观察返回结果,如果能够返回系信息表示一切正常(返回的是PageInfo的json格式),下面还需要修改index.js文件才能保证页面也正常显示 index.js文件的25行附近

if(r.status == OK){
    questionsApp.questions = r.data.list;
    questionsApp.pageinfo = r.data;
    //为question对象添加持续时间属性
    questionsApp.updateDuration();
    questionsApp.updateTagImage();
}
复制代码

再重启服务页面也能正常显示分页查询的问题了

实现分页导航条

我们现在页面中只能显示第一页要想进行反转操作需要在分页导航条进行编码绑定才能实现 index_student.html的246行进行分页连接的设置

<div class="pagination">
  <a class="page-item page-link"
     href="#"
     @click.prevent="loadQuestions(pageinfo.prePage)"
  >上一页</a>

  <a class="page-item page-link " href="#"
    v-for="n in pageinfo.navigatepageNums"
    v-text="n"
    @click.prevent="loadQuestions(n)"
    :class="{'bg-secondary text-light':n==pageinfo.pageNum}" >
    1
  </a>

  <a class="page-item page-link"
     href="#"
     @click.prevent="loadQuestions(pageinfo.nextPage)"
  >下一页</a>
</div>
复制代码

重启服务就能实现页面导航连接翻页了

学生发布问题功能

发布问题流程图

image.png 问答系统流程概述:

  1. 学生登录后提问:刚提问的问题状态为未回复
  2. 讲师登录系统,对学生提问的问题进行解答,解答后状态变为已回复
  3. 学生可以对僵尸的恢复进行追问即讨论,讲师也可以讨论或追加回复
  4. 指导学生问题解决,学生可以采纳将使得回答,将问题标记为已解决状态

学生发布问题流程

image.png

复用页眉位置的标签列表

我们已经在学生首页完成了所有标签的开发,现在create.html也需要显示所有标签,我们不必要再开发一次,直接使用Vue模板就可以 复用已经开发好的功能需要三个步骤

  1. 定义模板
  2. 调用模板
  3. 引用模板

定义模板

在js文件夹中,创建一个tags_nav_temp.js代码如下

Vue.component("tags-app",{
    props:["tags"],
    template:`
    <div class="nav font-weight-light">
    <a href="tag/tag_question.html" class="nav-item nav-link text-info"><small>全部</small></a>
    <a href="tag/tag_question.html"
       class="nav-item nav-link text-info"
       v-for="tag in tags">
      <small v-text="tag.name">Java基础</small>
    </a>
    </div>
    `
})
复制代码

调用模板

我们现在是create.html需要这个模板

所以找到create.html页面中需要这个信息的位置

183行附近

<!--引入标签的导航栏-->
<div class="container-fluid" >
  <tags-app id="tagsApp" :tags="tags" ></tags-app>
</div>
复制代码

引用模板

在create.html页面开头和尾部,添加响应的引用资源

  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
复制代码

页面末尾添加各种js文件引用

</body>
<script src="../js/utils.js"></script>
<script src="../js/tags_nav_temp.js"></script>
<script src="../js/tags_nav.js"></script>

</html>
复制代码

重启服务,访问学生提问页面,就能看到复用的所有标签了

富文本编辑器

在我们的网站开发业务的过程中,可能需要用户添加比较复杂的内容,例如字体颜色的变化,格式表格等,甚至是图片和视频,都支持用户填写和输入,我们可以使用富文本编辑器 才用的富文本编辑器为summenote 官网:summenote官网

image.png 我们页面中已经帮大家编写好了启动富文本编辑器的代码,我们直接使用即可

后面使用到它的功能时,在讲解操作方式

多选列表

为了提高用户的使用体验我们采用多选下来列表框来实现标签和讲师的多选功能,我们使用的是Vue提供的v-select多选框插件

官网:vue-select官网

image.png 它的使用和关联方式如下

image.png

create.html195行

<div class="col-8" id="createQuestionApp">
复制代码

create.html204行

<div class="form-group">
  <label >请至少选择一个标签:</label>
  <v-select multiple required
      :options="tags"
      v-model="selectedTags"
      placeholder="请选择相关标签">
  </v-select>
</div>
<div class="form-group">
  <label >请选择老师:</label>
  <v-select multiple required
      :options="teachers"
      v-model="selectedTeachers"
      placeholder="请选择回答讲师">
  </v-select>
</div>
复制代码

页面尾部添加createQuestion.js文件的引用

</body>
<script src="../js/utils.js"></script>
<script src="../js/tags_nav_temp.js"></script>
<script src="../js/tags_nav.js"></script>
<!--  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓      -->
<script src="../js/createQuestion.js"></script>
</html>
复制代码

重启服务 我们可以在选择标签下拉框中选择所有标签,因为返回标签的控制器都是编写好的,我们需要先编写所有讲师的控制器才行

开发查询所有讲师的功能

先开发数据访问,查询所有讲师 UserMapper添加方法如下

// 查询所有讲师
@Select("select * from user where type=1")
List<User> findTeachers();
复制代码

下面是业务逻辑层

IUserService添加一个方法

// 查询所有讲师的方法
List<User> getTeachers();
复制代码

UserServiceImpl实现类方法

@Override
public List<User> getTeachers() {
    List<User> users=userMapper.findTeachers();
    return users;
}
复制代码

UserController控制层方法

@RestController
// 在类上编写下面注解,表示当前控制器中的方法都需要
// 以本注解添加的路径前缀来访问
@RequestMapping("/v1/users")
public class UserController {

    @Autowired
    private IUserService userService;

    // 返回所有讲师的控制器方法
    @GetMapping("/master")
    public List<User> teachers(){
        List<User> users=userService.getTeachers();
        return users;
    }

}
复制代码

重启服务,可以先发送同步

localhost:8080/v1/users/master

测试,如果测试能够查询出所有讲师,name直接翻跟学生提问页面,也应该可以选择所有讲师了!

创建QuestionVo

凡是提交表单的操作,我们都建议大家创建一个Vo类来保存提交表单中的信息,在使用时比较方便

我们编写提交表单的代码之前,先创建这个Vo类,同时将SpringValidation的验证注解也加进去

@Data
public class QuestionVo implements Serializable {

    @NotBlank(message = "问题标题不能为空")
    @Pattern(regexp="^.{3,50}$",message = "标题需要3~50个字符")
    private String title;

    @NotEmpty(message = "至少选择一个标签")
    private String[] tagNames={};

    @NotEmpty(message = "至少选择一个讲师")
    private String[] teacherNicknames={};

    @NotBlank(message = "问题内容不能为空")
    private String content;

}
复制代码

开发学生提问功能

完成表单提交的测试

因为表单提交时post请求,无法使用浏览器地址栏输入url测试,所以建议大家可以先编写控制层,测试是否能够正常接收表单信息QuestionController中编写一个方法

@PostMapping("")
public String createQuestion(
        @Validated QuestionVo questionVo,
        BindingResult result){
    log.debug("接收到表单信息:{}",questionVo);
    if(result.hasErrors()){
        String msg=result.getFieldError()
                                .getDefaultMessage();
        return msg;
    }
    //这里调用业务逻辑层方法
    // 返回响应字符串
    return "ok";
}
复制代码

create.html198行提交表单标签绑定vue方法

<form @submit.prevent="createQuestion" >
复制代码

然后重启服务提交提问表单,表单响应结果不用关注,只看Idea控制台输出的信息是否正确,如果正确,表示一切正常

编写讲师缓存

我们之前缓存了所有标签,现在也可以缓存所有讲师

IUserService添加一个返回包含所有讲师的Map,供后面业务使用

// 查询所有讲师的Map的方法
Map<String,User> getTeacherMap();
复制代码

UserServiceimpl实现类将实现缓存所有讲师的功能

代码如下

private List<User> users=new CopyOnWriteArrayList<>();
private Map<String,User> teacherMap=
                         new ConcurrentHashMap<>();

@Override
public List<User> getTeachers() {
    if(users.isEmpty()) {
        synchronized (users) {
            if(users.isEmpty()) {
                List<User> users = userMapper.findTeachers();
                this.users.addAll(users);
                for(User u:users){
                    teacherMap.put(u.getNickname(),u);
                }
            }
        }
    }
    return users;
}

@Override
public Map<String, User> getTeacherMap() {
    if(teacherMap.isEmpty()){
        getTeachers();
    }
    return teacherMap;
}
复制代码

实现思路直接参考缓存标签的实现即可

学生提问业务详解

我们已经编写实现了将表单信息提交到控制器的功能,下面就要开始实现学生提问的业务,这个业务关联的表比较多,如下图

image.png 举例示意

image.png 学生发布问题核心数据库操作总结如下

  1. 先按用户书的信息增question表
  2. 再新增question_tag表的信息,记录这个问题和标签的关联
  3. 最后增user_question标的信息,记录这个问题和讲师的关联

开始编写业务逻辑层

数据访问层不需要新增方法

直接业务逻辑层接口

IQuestionService添加方法

// 新增问题的方法(学生发布问题)
void saveQuestion(QuestionVo questionVo,String username);
复制代码

QuestionServiceImpl实现类代码

@Autowired
private QuestionTagMapper questionTagMapper;
@Autowired
private UserQuestionMapper userQuestionMapper;
@Autowired
private IUserService userService;

@Override
public void saveQuestion(QuestionVo questionVo, String username) {
    // 1.查询用户名对应的用户对象
    User user=userMapper.findUserByUsername(username);
    // 2.将用户选中的所有标签的数组拼接为tagNames字符串
    // {"java基础","javaSE","面试题"}->
    //                      "Java基础,javaSE,面试题"
    StringBuilder builder=new StringBuilder();
    for(String tagName:questionVo.getTagNames()){
        builder.append(tagName).append(",");
    }
    // 去掉最后一个","并赋值给String类型变量tagNames
    String tagNames=builder
            .deleteCharAt(builder.length()-1).toString();

    // 3.实例化Question对象,收集信息,为属性赋值
    Question question=new Question()
            .setTitle(questionVo.getTitle())
            .setContent(questionVo.getContent())
            .setUserNickName(user.getNickname())
            .setUserId(user.getId())
            .setCreatetime(LocalDateTime.now())
            .setStatus(0)
            .setPageViews(0)
            .setPublicStatus(0)
            .setDeleteStatus(0)
            .setTagNames(tagNames);
    // 4.执行新增Question对象到数据库
    int num=questionMapper.insert(question);
    if(num!=1){
        throw new ServiceException("数据库忙!");
    }
    // 5.新增问题和标签的关联关系
    // 因为用户选中的是标签名称,我们需要的是标签id,
    // 所以要先获得包含所有标签的map用于根据名称获得对象
    Map<String,Tag> tagMap=tagService.getTagMap();
    // 遍历用户选中的所有标签的数组,循环新增关系
    for(String tagName:questionVo.getTagNames()){
        // 根据标签名称获得标签对象
        Tag t=tagMap.get(tagName);
        QuestionTag questionTag=new QuestionTag()
                .setQuestionId(question.getId())
                .setTagId(t.getId());
        num=questionTagMapper.insert(questionTag);
        if(num!=1){
            throw new ServiceException("数据库忙");
        }
        log.debug("新增了问题和标签的关系:{}",questionTag);
    }
    // 6.新增问题和讲师的关联关系
    Map<String,User> teacherMap=userService.getTeacherMap();
    for(String nickname:questionVo.getTeacherNicknames()){
        User teacher=teacherMap.get(nickname);
        UserQuestion userQuestion=new UserQuestion()
                .setQuestionId(question.getId())
                .setUserId(teacher.getId())
                .setCreatetime(LocalDateTime.now());
        num=userQuestionMapper.insert(userQuestion);
        if(num!=1){
            throw new ServiceException("数据库忙");
        }
        log.debug("新增了问题和讲师的关系:{}",userQuestion);
    }
}
复制代码

编写控制层调用

之前我们编写了控制层代码,但是没有调用业务逻辑层

我们要转到QuestionController类中完成对业务逻辑层的调用

代码如下

@PostMapping("")
public String createQuestion(
        @Validated QuestionVo questionVo,
        BindingResult result,
    //  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
        @AuthenticationPrincipal UserDetails user){
    log.debug("接收到表单信息:{}",questionVo);
    if(result.hasErrors()){
        String msg=result.getFieldError()
                                .getDefaultMessage();
        return msg;
    }
    //  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    try {
        //这里调用业务逻辑层方法
        questionService.saveQuestion(questionVo,
                                user.getUsername());
        // 返回响应字符串
        return "ok";
    }catch (ServiceException e){
        return e.getMessage();
    }
}
复制代码

重启服务进行新增的测试

在表单中填写数据,提交后,数据库能显示新增的信息及关系表示一切正常

完善页面响应

现在我们的页面提交之后都是404 ,而且没有错误提示信息,我们需要修改一下createQuestion.js文件中createQuestion方法的axios响应进行修改

then(function(r){
    console.log(r.data);
    if(r.data=="ok"){
        // 如果发布问题成功,跳转到学生首页
        location.href="/index_student.html";
    }else{
        // 如果发布问题过程出错,输出错误信息
        console.log(r.data);
    }
})
复制代码

Spring声明式事务

什么是事务

程序中提到事务一般指数据库事务 **数据库事务(简:事务)**是数据库管理系统执行的过程中的一个逻辑单位,由一个有限的数据库操作序列构成 我们开发这个发布问题功能整个对数据库的操作就是一个事务,如果说我们的程序运行过程中,发生异常,我们现在便携的程序就会出现数据完整性缺失的情况(指的是发布的问题进入了数据库,但是因为异常,关系没有增到数据库)

这样的情况就违反了事务的原则,数据库中的数据就有了隐患了,我们要预防这样的情况

我们可以利用数据库自带的事务,放置上面的数据完整性缺失的情况,如果食物运行过程中发生异常,name事务中已经执行的sql语句也会自动撤销(回滚)

常见面试题
复制代码

数据库事务的四大特性(ACID特性)

  • 原子性(Atomicity):事务是一个整体,是操作数据库的最小单位,这个整体中包含的所有sql语句要么都执行要么都不执行
  • 一致性(Consistency):运行事务前后,数据库状态时一致的
  • 隔离性(Isolation):多个事务并发时,事务之间互不影响
  • 持久性(Durability):有资料译作"永久性",意思是事务的操作对数据库的影响是持久的,意思是数据不会随意的变回之前的状态

SpringBoot下使用事务

我们之前便携的所有对数据库的增删改操作都是没有开启事务的,每个操作都是单独的事务,单独生效

我们现在需要组合多个增删改查操作,使他们称为一个事务,要么都执行,要么都不执行,比如我们发布的问题的业务,新增完问题还要新增关系

SpringBoot下提供了非常方便的解决方案,在一个业务逻辑方法上添加注解,这个业务逻辑层中所有操作数据库的指令就会自动合成一个事务,最终代码如下:

@Override
// @Transactional(事务)
// 下面方法中的所有增删改操作,要么都执行要么都不执行
// 如果方法运行过程中发生异常,那么已经执行的sql操作会自动撤销(回滚)
@Transactional
public void saveQuestion(QuestionVo questionVo, String username) {

	//....代码略
}
复制代码

今后我们的业务逻辑层方法中如果有两次或者两次以上的增删改操作执行时,方法上必须添加事务注解,世界上注册业务逻辑层也要添加事务注解

统一异常处理

我们可以观察我们的控制层代码,注册功能和发布问题功能都变谢了try-catch结构

try {
    userService.registerStudent(registerVo);
    return "ok";
}catch (ServiceException e){
    log.error("注册失败",e);
    return e.getMessage();
}
复制代码

其中catch部分内容几乎都是一样的,这就造成了代码冗余,而且try-catch结构本身也是冗余的,我们可以李荣SpringMvc框架提供的"统一异常处理"的功能削弱上面的冗余 因为我们处理异常是针对控制器的,所以我们讲这个异常处理类新建在controller包中,代码如下

// 这个注解表示控制器有特殊情况时,可以运行这个类中的方法
// 所谓特殊情况可以多种定义,我们这里指的就是发生异常的情况
@RestControllerAdvice
@Slf4j
public class ExceptionControllerAdvice {

    // 处理ServiceException异常的方法
    // Exception:异常  Handler:处理者
    // 表示下面方法是用来处理控制器中异常的
    @ExceptionHandler
    public String handlerServiceException(ServiceException e){
        log.error("业务异常",e);
        return e.getMessage();
    }

    @ExceptionHandler
    public String handlerException(Exception e){
        log.error("其他异常",e);
        return e.getMessage();
    }
}
复制代码

image.png 下面可以将注册和发布问题的控制器try-catch删除掉了

实现文件上传

提供给大家一个上传页面使用

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>文件上载演示</title>
</head>
<body>
    <form id="demoForm" method="post" 
          enctype="multipart/form-data" 
          action="/upload/file" >
        <div>
            <label>上传文件
                <input id="imageFile" type="file" name="imageFile">
            </label>
        </div>
        <button type="submit">上传文件</button>
    </form>
    <img id="image" src=""  alt="">
</body>
</html>
复制代码

完成同步上传功能页面是完全写好的,我们可以编写SystemController类中代码来实现上传

@PostMapping("/upload/file")
//MultipartFile是SpringMvc框架提供的类型,专门用于接收上传的文件
// imageFile表单中文件域name一致
public String uploadFile(MultipartFile imageFile) throws IOException {

    // 根据日期获得path路径
    String path= DateTimeFormatter.ofPattern("yyyy/MM/dd")
                    .format(LocalDate.now());
    // path:  2021/11/03
    // 确定上传路径
    File folder=new File("F:/upload/"+path);
    // 创建文件夹
    folder.mkdirs();//mkdirssssss

    // 随机文件名
    String filename=imageFile.getOriginalFilename();//原始文件名
    //  xx.xx.jpg
    //  012345678
    String ext=filename.substring(filename
                                    .lastIndexOf("."));
    // ext:  .jpg
    String name= UUID.randomUUID().toString()+ext;
    // name:  [UUID].jpg   ajsdhfjkahsdkf.jpg
    // 确定要上传的路径
    File file=new File(folder,name);
    log.debug("上传的路径为:{}",file.getAbsolutePath());

    // 执行上传
    imageFile.transferTo(file);

    return "upload success";

}
复制代码

重启服务,测试上传功能

异步上传实现

将上面的同步上传改写为一步的,只需要修改页面代码即可

<form id="demoForm" method="post"
      enctype="multipart/form-data"
      action="/upload/file" >
    <div>
        <label>上传文件
            <input id="imageFile" type="file" name="imageFile">
        </label>
    </div>
    <button type="submit">上传文件</button>
</form>
<img id="image" src=""  alt="">
</body>
<script>
$("#demoForm").submit(function(){

    // 获得用户选中上传的文件
    let files=document.getElementById("imageFile").files;
    // 判断用户是否选中了文件
    if(files.length>0){
        // 将用户选中的文件执行上传
        uploadFile(files[0]);
    }else{
        alert("请选择要上传的文件");
    }
    // 阻止表单提交
    return false;
})
// 上传文件的方法
function uploadFile(file) {
    // 创建表单
    let form=new FormData();
    form.append("imageFile",file);
    axios({
        url:"/upload/file",
        method:"post",
        data:form
    }).then(function(response){
        console.log(response.data);
    })
}

</script>
复制代码

创建静态资源服务器

企业开发过程中,经常将静态资源(例如图片,视频等资源文件)保存在一个专门的服务器中,单独管理,和处理业务的服务器分离,互不干扰

下面就创建一个项目,专门管理静态资源 在这里插入图片描述

父子相认

<module>knows-resource</module>
复制代码

子项目pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>knows</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>knows-resource</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>knows-resource</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

</project>
复制代码

删除knows-resource项目的test文件夹

在这里插入图片描述

# 配置当前项目的端口号,默认8080,为了不和其他项目冲突需要指定
server.port=8899

# 指定静态资源服务的资源路径
# 一个web项目默认情况下资源路径是static目录
# 如果输入localhost:xxxx/login.html 访问的就是static目录下的login.html

# 但是如果配置了下面的信息,localhost:xxxx/login.html的含义就变为了
#  在F:/upload/路径下访问login.html
spring.resources.static-locations=file:F:/upload
复制代码

利用静态资源服务器进行上传图片的回显

我们希望在页面上看到刚刚上传的文件,实现起来步骤如下,portal项目中application.properties文件需要进行配置

# 配置上传文件及回显图片需要的路径
knows.resource.path=file:F:/upload
knows.resource.host=http://localhost:8899
复制代码

SystemController代码修改如下

// 获取application.properties配置的信息
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
@Value("${knows.resource.path}")
private File resourcePath;
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
@Value("${knows.resource.host}")
private String resourceHost;


@PostMapping("/upload/file")
//MultipartFile是SpringMvc框架提供的类型,专门用于接收上传的文件
// imageFile表单中文件域name一致
public String uploadFile(MultipartFile imageFile) throws IOException {

    // 根据日期获得path路径
    String path= DateTimeFormatter.ofPattern("yyyy/MM/dd")
                    .format(LocalDate.now());
    // path:  2021/11/03
    // 确定上传路径
    				// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    File folder=new File(resourcePath,path);
    // 创建文件夹
    folder.mkdirs();//mkdirssssss

    // 随机文件名
    String filename=imageFile.getOriginalFilename();//原始文件名
    //  xx.xx.jpg
    //  012345678
    String ext=filename.substring(filename
                                    .lastIndexOf("."));
    // ext:  .jpg
    String name= UUID.randomUUID().toString()+ext;
    // name:  [UUID].jpg   ajsdhfjkahsdkf.jpg
    // 确定要上传的路径
    File file=new File(folder,name);
    log.debug("上传的路径为:{}",file.getAbsolutePath());

    // 执行上传
    imageFile.transferTo(file);
	// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    // 回显的关键,是上传控制返回通过静态资源服务器访问资源的路径
    //http://localhost:8899/2021/11/03/6dd-ecf91c532c4c.jpg
    //  resourceHost          path           name
    String url=resourceHost+"/"+path+"/"+name;
    log.debug("生成的Url:{}",url);
    // 返回url,以实现页面回显上传的图片
    //     ↓↓↓
    return url;

}
复制代码

还要修改一下upload.html页面的axios响应代码

.then(function(response){
    console.log(response.data);
    // 将上传的图片路径赋值给img标签的src属性
    $("#image").attr("src",response.data);
})
复制代码

重启服务(启动knows-resource,重启knows-portal)

在upload.html页面中进行文件的上传操作,图片能够显示在当前页面表示一切正常

富文本编辑器上传图片功能

create.html页面最后,编写的ks代码修改添加上传功能,代码如下

<script>
  $(document).ready(function() {
    $('#summernote').summernote({
      height: 300,
      tabsize: 2,
      lang: 'zh-CN',
      placeholder: '请输入问题的详细描述...',
      callbacks:{  //回调
        // 在用户选中图片之后,自动运行的方法
        onImageUpload: function(files){
          // 方法参数files就是用户选中的图片
          // 获取用户选中的第一张图片
          let file=files[0];
          // 创建表单对象,并将用户选中的图片保存到其中
          let  form=new FormData();
          form.append("imageFile",file);
          axios({
            url:"/upload/file",
            method:"post",
            data:form
          }).then(function(response){
            // 创建一个Img标签对象,并为它的src属性赋值
            let img=new Image();
            img.src=response.data;
            // 利用summernote提供的api将img标签对象保存到富文本编辑器中
            $("#summernote").summernote("insertNode",img);

          })

        }
      }
    });
    $('select').select2({placeholder:'请选择...'});
  });
</script>
复制代码

重启服务,测试在富文本编辑器选中图片,显示在富文本编辑器中,表示一切正常

现实问题状态信息

我们把页面转回到index_student.html 191行附近添加v-show实现显示隐藏状态

<!--
   v-show="[boolean表达式]"
   当=后结果为true时,当前元素一定会显示
   当=后结果为false时,当前元素一定会隐藏
 -->
<span class="badge badge-pill badge-warning"
      style="display: none"
      v-show="question.status==0">未回复</span>
<span class="badge badge-pill badge-info"
      style="display: none"
      v-show="question.status==1">已回复</span>
<span class="badge badge-pill badge-success"
      v-show="question.status==2">已解决</span>
复制代码

重启服务测试观察结果

显示登录用户信息面板

image.png

很多页面都有当前登录用户信息面板的展示很多学生登录包含提问次数和收藏数,其他的不需要管

准备Vo类

我们先要创建一个包含用户信息面板各种属性的Vo类UserVo用户查询和数据的承载

@Data
//支持链式set赋值
@Accessors(chain = true)
public class UserVo implements Serializable {

    private Integer id;
    private String username;
    private String nickname;

    // 问题数
    private int questions;
    // 收藏数
    private int collections;
    
}
复制代码

确定sql语句

既然是查询一个用户的问题数和收藏数

那么我们就需要确定我们查询的sql

-- 查询用户id为11的问题数
SELECT COUNT(*) FROM question 
WHERE user_id=11

-- 查询用户id为11的收藏数
SELECT COUNT(*) FROM user_collect
WHERE user_id=11
复制代码

数据访问层

在QuestionMapper中编写方法查询问题数

收藏数查询方法作为作业(建议也写在QuestionMapper中)

@Repository
public interface QuestionMapper extends BaseMapper<Question> {

    // 根据用户id查询问题数
    @Select("select count(*) from question where user_id=#{id}")
    int countQuestionsByUserId(Integer userId);
}
复制代码

业务逻辑层

IUserService接口中添加查询用户信息面板的方法

// 按用户名查询用户信息面板的方法
UserVo getUserVo(String username);
复制代码

UserServiceImpl实现类代码如下

@Override
public UserVo getUserVo(String username) {
    // 查用户信息
    User user=userMapper.findUserByUsername(username);
    // 查用户提问数
    int count=questionMapper
                .countQuestionsByUserId(user.getId());
    // (作业)查询收藏数
    UserVo userVo=new UserVo()
            .setId(user.getId())
            .setUsername(user.getUsername())
            .setNickname(user.getNickname())
            .setQuestions(count);
    // 别忘了返回!!!
    return userVo;
}
复制代码

编写显示用户信息面板的控制层代码

上面完成了数据访问层和业务逻辑层

下面要在控制层中调用业务逻辑层代码

// 根据登录用户查询用户信息面板的方法
@GetMapping("/me")
public UserVo me(
        @AuthenticationPrincipal UserDetails user){
    UserVo userVo=userService.getUserVo(user.getUsername());
    return userVo;
}
复制代码

重启服务

建议同步测试:localhost:8080/v1/users/me

编写vue和html绑定

index_student.html

285行附近,修改html绑定

<!--个人信息-->
<div class="container-fluid font-weight-light"
  id="userApp">
<!-- ↑↑↑↑↑↑-->
  <div class="card">
    <h5 class="card-header"
      v-text="user.nickname">陈某</h5>
      <!-- ↑↑↑↑↑↑-->
    <div class="card-body">
      <div class="list-inline mb-1 ">
          <a class="list-inline-item mx-3 my-1 text-center">
            <div><strong>10</strong></div>
            <div>回答</div>
          </a>
          <a class="list-inline-item mx-3 my-1 text-center" href="personal/myQuestion.html">
            <div>
              <strong v-text="user.questions">10</strong>
                		<!-- ↑↑↑↑↑↑-->
            </div>
            <div>提问</div>
          </a>
          <a class="list-inline-item mx-3 my-1 text-center" href="personal/collect.html">
            <div><strong>10</strong></div>
            <div>收藏</div>
          </a>
          <a class="list-inline-item mx-3 my-1 text-center" href="personal/task.html">
            <div><strong>10</strong></div>
            <div>任务</div>
          </a>
      </div>
    </div>
  </div>
</div>
复制代码

可以先添加js文件的引用

在页面末尾添加引用如下

</body>
<script src="js/utils.js"></script>
<script src="js/tags_nav.js"></script>
<script src="js/index.js"></script>
<script src="js/user_info.js"></script>
			<!-- ↑↑↑↑↑↑-->
</html>
复制代码

创建一个js文件

user_info.js

代码如下

let userApp = new Vue({
    el:"#userApp",
    data:{
        user:{}
    },
    methods:{
        loadUserVo:function(){
            axios({
                url:"/v1/users/me",
                method:"get"
            }).then(function(response){
                userApp.user=response.data;
            })
        }
    },
    created:function(){
        // 页面加载完毕时运行的方法
        this.loadUserVo();
    }
})
复制代码

问题发布页复用用户信息

create.html页面右侧也有用户信息面板,我们可以使用Vue模板复用实现

js文件夹中创建模板文件

user_info_temp.js

代码如下

Vue.component("user-app",{
    props:["user"],
    template:`
    <div class="container-fluid font-weight-light">
        <div class="card">
          <h5 class="card-header"
            v-text="user.nickname">陈某</h5>
          <div class="card-body">
            <div class="list-inline mb-1 ">
                <a class="list-inline-item mx-3 my-1 text-center">
                  <div><strong>10</strong></div>
                  <div>回答</div>
                </a>
                <a class="list-inline-item mx-3 my-1 text-center" href="personal/myQuestion.html">
                  <div>
                    <strong v-text="user.questions">10</strong>
                  </div>
                  <div>提问</div>
                </a>
                <a class="list-inline-item mx-3 my-1 text-center" href="personal/collect.html">
                  <div><strong>10</strong></div>
                  <div>收藏</div>
                </a>
                <a class="list-inline-item mx-3 my-1 text-center" href="personal/task.html">
                  <div><strong>10</strong></div>
                  <div>任务</div>
                </a>
            </div>
          </div>
        </div>
      </div>
    `
})
复制代码

调用模板

create.html

231行附近将原有的显示用户信息面板的区域代码删除

修改如下

<!--个人信息-->
<user-app id="userApp" :user="user"></user-app>
复制代码

引用模板

最后还要将需要的各种js文件引用到当前页面

create.html页面末尾

<script src="../js/user_info_temp.js"></script>
<script src="../js/user_info.js"></script>
复制代码

重启服务访问学生发布问题的页面,观察用户信息面板

显示讲师首页

复习问答流程

image.png 学生发布问题已经完成了

下面要开始完成讲师回复

实现讲师回复之前,要先开发讲师首页

并且实现不同身份登录跳转不同首页的效果

讲师任务列表显示的内容

讲师登录之后,显示讲师首页的话,需要显示讲师的任务列表

所谓任务列表就是哪些问题问了当前讲师和讲师自己的提问

显示这样任务列表的sql语句如下

SELECT q.* FROM
question q 
LEFT join user_question uq ON q.id=uq.question_id
WHERE uq.user_id=3 OR q.user_id=3
ORDER BY q.createtime desc
复制代码

这样的Sql语句要编写到数据访问层

开发数据访问层

QuestionMapper中编写查询讲师任务列表的方法

// 查询讲师问题列表的方法
@Select("SELECT q.* FROM\n" +
        "question q \n" +
        "LEFT join user_question uq ON q.id=uq.question_id\n" +
        "WHERE uq.user_id=#{id} OR q.user_id=#{id}\n" +
        "ORDER BY q.createtime desc")
List<Question> findTeacherQuestions(Integer userId);
复制代码

这个sql比较复杂建议测试

测试代码如下

@Autowired
QuestionMapper questionMapper;
@Test
public void teacherList(){
    List<Question> list=questionMapper
                    .findTeacherQuestions(5);
    for (Question q: list){
        System.out.println(q);
    }

}
复制代码

开发业务逻辑层

编写IQuestionService添加方法

注意分页的支持

// 查询讲师首页任务列表的方法
PageInfo<Question> getTeacherQuestions(
         String username,Integer pageNum,Integer pageSize);   
复制代码

QuestionServiceImpl实现类代码如下

@Override
public PageInfo<Question> getTeacherQuestions(
        String username,Integer pageNum,Integer pageSize){
    User user=userMapper.findUserByUsername(username);
    // 设置分页信息
    PageHelper.startPage(pageNum,pageSize);
    List<Question> list=questionMapper
            .findTeacherQuestions(user.getId());
    // 遍历所有问题将问题的tags属性赋值
    for(Question q:list){
        List<Tag> tags=tagNamesToTags(q.getTagNames());
        q.setTags(tags);
    }
    // 返回PageInfo类型对象
    return new PageInfo<>(list);
}
复制代码

开发控制层

QuestionController添加讲师任务列表的查询功能

代码如下

// 查询讲师任务列表的控制器方法
@GetMapping("/teacher")
// 当前登录用户在Spring-Security登录时
// 必须拥有/question/answer权限才能访问下面的方法
@PreAuthorize("hasAuthority('/question/answer')")
public PageInfo<Question> teacher(
        @AuthenticationPrincipal UserDetails user,
        Integer pageNum){
    Integer pageSize=8;
    if(pageNum==null)
        pageNum=1;
    // 调用业务逻辑层方法
    PageInfo<Question> pageInfo=questionService
            .getTeacherQuestions(user.getUsername(),
                    pageNum,pageSize);
    return pageInfo;

}
复制代码

讲师首页复用模板

转到讲师首页

我们需要先将所有标签和用户信息面板显示复用

这里的复用不用再定义模板了,直接从调用模板即可

index_teacher.html 的173行修改为

<!--引入标签的导航栏-->
<div class="container-fluid">
  <tags-app id="tagsApp" :tags="tags"></tags-app>
</div>
复制代码

index_teacher.html 的249行修改为

<!--个人信息-->
<user-app id="userApp" :user="user"></user-app>
复制代码

最后引用模板

当前页面从未运行过axios 先加axios的引用

  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
复制代码

然后再到页面末尾将所有js文件引用添加

</body>
<script src="js/utils.js"></script>
<script src="js/tags_nav_temp.js"></script>
<script src="js/user_info_temp.js"></script>
<script src="js/tags_nav.js"></script>
<script src="js/user_info.js"></script>
</html>
复制代码

重启服务,登录后访问讲师首页,能够成功的显示所有标签和当前登录用户信息面板表示一切正常

复用问题列表区域

这里我们采用直接复制学生首页代码的方式进行复用

js代码和学生的也几乎一样,复制一份进行微调即可

js文件夹中赋值index.js,就地粘贴命名为index_teacher.js

修改index_teacher.js文件axios请求的路径为

axios({
    //                  ↓↓↓↓↓↓
    url: '/v1/questions/teacher',
    method: "GET",
    params:{
        pageNum:pageNum
    }
})
复制代码

然后将学生首页questionsApp的代码整体复制到讲师首页同样的位置

讲师首页末尾添加引用

<script src="js/index_teacher.js"></script>
复制代码

根据身份登录不同页面

学生首页和讲师首页都开发完毕了

但是登录后,我们还需要手动输入地址决定是显示讲师\学生首页

这明显不是正常业务流程

我们要实现登录不同身份跳转对应身份的首页

实现思路如下

1.我们要编写一个查询操作,根据用户id查询这个用户的所有角色(role)信息

2.当前Spring-Security登录代码中我们只保存了用户的权限(permission),这次我们还要将用户的所有角色(role)也保存到Spring-Security中

3.编写一个控制器(Controller),控制器中编写判断当前登录用户角色的代码,根据判读结果决定跳转不同页面

编写身份查询sql

下面是根据用户id查询所有角色的方法

SELECT r.id, r.name 
FROM user u
LEFT JOIN user_role ur ON u.id=ur.user_id
LEFT JOIN role r       ON r.id=ur.role_id
WHERE u.id=3
复制代码

和查询用户所有的权限一样,也是在UserMapper中添加这个方法

// 根据用户id查询用户角色
@Select("SELECT r.id, r.name \n" +
        "FROM user u\n" +
        "LEFT JOIN user_role ur ON u.id=ur.user_id\n" +
        "LEFT JOIN role r       ON r.id=ur.role_id\n" +
        "WHERE u.id=#{id}")
List<Role> findUserRolesById(Integer id);
复制代码

将用户角色保存到Spring-Security

我们之前完成了Spring-Security登录

我们编写了UserDetailsServiceImpl这个类

这个类中我们将用户的所有权限查询并保存了,现在还要保存用户的角色

修改代码如下

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // 1.根据用户名查询用户对象
    User user=userMapper.findUserByUsername(username);
    // 2.判断是否能够查询到用户,没有该用户表示用户名不存在
    if(user == null){
        return null;
    }
    // 3.根据用户id查询用户的所有权限
    List<Permission> permissions=userMapper
                    .findUserPermissionsById(user.getId());
    // 4.将权限的集合转换为String类型数组,并赋值
    String[] auth=new String[permissions.size()];
    int i=0;
    for(Permission p:permissions){
        auth[i]=p.getName();
        i++;
    }
    //  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    // 查询当前用户所有角色
    List<Role> roles=userMapper.findUserRolesById(
                                            user.getId());
    // 数组扩容
    auth= Arrays.copyOf(auth,
                  auth.length+roles.size());
    //  {"/getid","/add","/update","/upload",null}
    for(Role role:roles){
        auth[i++]=role.getName();
    }
	//  ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑

    // 5.构建UserDetails对象
    UserDetails details= org.springframework.security
            .core.userdetails.User.builder()
            .username(user.getUsername())
            .password(user.getPassword())
            .authorities(auth)
            //设置当前账户是否锁定 false表示不锁定
            .accountLocked(user.getLocked()==1)
            //设置当前账户是否可用 false表示可用
            .disabled(user.getEnabled()==0)
            .build();
    // 6.返回UserDetails对象
    //  千万别返回null
    return details;
}
复制代码

随笔

QueryWrapper方法含义:

eq(): equals-等于 gt(): grate than 大于 lt(): less than 小于 ge(): grate equals 大于等于 le(): 小于等于 ne(): not equals 不等于

问题标签转换思路

tagNames="Java基础,Java SE,面试题"

现将这样的字符串通过split方法转换为String[]

String[] names= tagNames.split(",")

//names={"Java基础","Java SE","面试题"}

names[0]="Java基础"

tagMap是包含所有标签的map,我们通过标签名称获得标签对象

Tag t=tagMap.get(names[0])

最后还要将获得的对应的标签对象t增到一个list中

这个list会赋值到question对象的tags属性中

PageInfo属性解释

//当前页
private int pageNum;
//每页的数量
private int pageSize;
//当前页的行数量
private int size;
//当前页面第一个元素在数据库中的行号
private int startRow;
//当前页面最后一个元素在数据库中的行号
private int endRow;
//总页数
private int pages;
//前一页页号
private int prePage;
//下一页页号
private int nextPage;
//是否为第一页
private boolean isFirstPage;
//是否为最后一页
private boolean isLastPage;
//是否有前一页
private boolean hasPreviousPage;
//是否有下一页
private boolean hasNextPage;
//导航条中页码个数
private int navigatePages;
//所有导航条中显示的页号
private int[] navigatepageNums;
//导航条上的第一页页号
private int navigateFirstPage;
//导航条上的最后一页号
private int navigateLastPage;
复制代码

猜你喜欢

转载自juejin.im/post/7080809464223252510
今日推荐