1、创建一个SpringBoot项目
打开 IDEA 来到,点击如下界面:
接着进行一些项目的基本信息填写:
填好之后,直接 next ,然后创建就行。
注意,这里我选择的 SpringBoot 模板是阿里提供的,而不是 spring 提供的。
因为 spring 提供的 SpringBoot 模板只允许 JDK 版本为 17 或者 21,而我们目前还是习惯 JDK 8 或者 11 来开发。所以 SpringBoot 模板我们就选择了阿里提供的,阿里的模板依然支持 8 和 11。
模板地址:start.aliyun.com
来到如下页面,项目就算创建成功了。
2、版本调整及打包
我们在创建 SpringBoot 项目时,并没有选择版本而是直接用了默认版本,但这并不是我们所惯用的版本,所以我们还是改为常用版本比较好。
SpringBoot 改为 2.5.0
下面我们来配置一下打包配置。
在上面位置,位置如下信息:
xml
复制代码
<build> <!-- 打包时 jar 名称 --> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.19.1</version> <configuration> <skipTests>true</skipTests> <!--默认关掉单元测试 --> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>8</source> <!-- depending on your project --> <target>8</target> <!-- depending on your project --> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.10</version> </path> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.4.2.Final</version> </path> <!-- other annotation processors --> </annotationProcessorPaths> </configuration> </plugin> </plugins> <resources> <!--编译配置文件--> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.*</include> </includes> </resource> </resources> </build>
下面我们根据下图再微调一下:
3、依赖坐标及启动
本项目中我们需要用到如下坐标:
xml
复制代码
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <exclusions> <exclusion> <artifactId>log4j-api</artifactId> <groupId>org.apache.logging.log4j</groupId> </exclusion> </exclusions> </dependency> <!--web 启动器--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--mysql--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.25</version> </dependency> <!--mybatis-plus--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.3.4</version> </dependency> <!-- Java集合增强 --> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.2</version> </dependency> <!-- 通用工具包 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.8.1</version> </dependency> <!--阿里连接池--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.22</version> </dependency> <!-- fastjson依赖 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.78</version> </dependency> <!--hutool工具包--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.2.0</version> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </dependency> <!--校验--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!--对象转换--> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>${org.mapstruct.version}</version> </dependency>
目前,咱们先配置这些,等后续有需求需要新的技术咱们再加。
在启动之前,我们还需要对本项目进行基本的配置。
1)在下图对应位置创建两个配置:
2)配置内容如下:
配置一
yaml
复制代码
server: #端口 port: 9292 spring: application: # 项目名称 name: school-book-sys profiles: # 项目激活的配置 active: mybatisplus-loc
配置二
yaml
复制代码
#datasource spring.datasource: type: com.alibaba.druid.pool.DruidDataSource # 数据源其他配置 druid: # driverClassName: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://地址+端口/school_book_sys?useUnicode=true&useSSL=false&characterEncoding=utf8&allowMultiQueries=true&serverTimezone=Asia/Shanghai username: 账号 password: 密码 # 配置初始化大小、最小、最大线程数 initialSize: 5 minIdle: 5 # CPU核数+1,也可以大些但不要超过20,数据库加锁时连接过多性能下降 maxActive: 20 # 最大等待时间,内网:800,外网:1200(三次握手1s) maxWait: 60000 timeBetweenEvictionRunsMillis: 60000 # 配置一个连接在池中最大空间时间,单位是毫秒 minEvictableIdleTimeMillis: 300000 validationQuery: SELECT 1 testWhileIdle: true # 设置从连接池获取连接时是否检查连接有效性,true检查,false不检查 testOnBorrow: true # 设置从连接池归还连接时是否检查连接有效性,true检查,false不检查 testOnReturn: true # 可以支持PSCache(提升写入、查询效率) poolPreparedStatements: true # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙 # filters: stat,wall,log4j filters: stat,wall # 保持长连接 keepAlive: true maxPoolPreparedStatementPerConnectionSize: 20 useGlobalDataSourceStat: true connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
注意:这个配置,需要你配置自己的数据库地址,我在文件中用中文写出来了,你们自己替换。
3)等如上配置弄好之后,就可以直接启动,出现下面字样就意味着你的项目基本配置就已经完成了,后面就可以开始填充业务功能了。
4、统一结果集
现在的项目用的都是比较主流的 SpringBoot + Vue 进行开发的,所以必定要进行前后端分离。也即一个前端,一个后端项目,那么这两个项目之间进行数据通信就至关重要了。而,目前比较主流的就是两个系统通过 JSON 格式进行数据通信,那 JSON 数据肯定是需要进行统一的格式约定的。
比如:
css
复制代码
{ "code":200, "message": "成功", "data":{} } { "code":500, "message": "失败", "data":{} }
code 表示后端返回给前端的状态码,可以表示成功或者失败。
message 表示消息说明,可以给前端展示结果失败或者成功的说明。
data 用于给前端返回结果对象,后续后端返回给前端的数据都是存在 data 中,前端也只需要通过这个对象获取对应的字段值。
那,我们应该如何实现,再实现这个功能之前,我建议你们下去看我下面的文章。之后你们再来看我下面文档的实现就好理解多了。
因为是返回统一的结果集,所以我们必须要定义一些对象,后续所有的结果都是通过这些固定的对象返回给前端,实现格式统一的数据。
4.1 统一格式对象
我们先定义一个基础的返回体结构对象:
kotlin
复制代码
@Getter public class ResultInfo implements Serializable { /** * 成功与失败 */ protected Boolean result; /** * 状态码 */ protected Integer code; /** * message = null , 不序列化出去 */ @JsonInclude(JsonInclude.Include.NON_NULL) protected String message; protected ResultInfo(Boolean result, Integer code, String message) { this.result = result; this.code = code; this.message = message; } }
这个对象中包含了如下三个属性:
- result:Boolean 类型,true 表示此次请求成功,反之表示失败
- code:后端返回给前端的状态码,方便区分错误类型
- message:一个字符串类型的消息说明
上面对象只是一个基础,下面我们为了更明确的区分成功结果集和失败结果集,所以我们要定义两个对象来分辨成功与失败结果集。
成功结果集对象
scala
复制代码
@Accessors(chain = true) @ToString @Getter public class SuccessInfo extends ResultInfo{ /** * 成功对象的默认值 */ protected static final Integer DEFAULT_CODE = 200; protected static final String DEFAULT_MESSAGE = "操作成功"; /** * 结果数据 */ @JsonInclude(JsonInclude.Include.NON_NULL) protected Object data; protected SuccessInfo(Object data) { super(true, DEFAULT_CODE, DEFAULT_MESSAGE); this.data = data; } }
失败结果集对象
scala
复制代码
@Accessors(chain = true) @ToString @Getter public class FailInfo extends ResultInfo{ /** * 失败对象的默认值 */ protected static final Integer DEFAULT_CODE = 500; protected static final String DEFAULT_MESSAGE = "操作失败"; /** * 失败信息 */ @JsonInclude(JsonInclude.Include.NON_NULL) private final String exception; /** * 构造函数 * * @param exception 失败信息 */ public FailInfo(String exception) { super(false, DEFAULT_CODE, DEFAULT_MESSAGE); this.exception = exception; } /** * 构造函数 * * @param code 失败code * @param exception 失败信息 */ public FailInfo(Integer code, String exception) { super(false, code, DEFAULT_MESSAGE); this.exception = exception; } }
4.2 统一成功结果集处理
看了上面我提到的文章,应该知道我们会将需要统一结果集处理的都用一个注解标识,这样的好处就是方便我们随意切换是否需要给前端返回统一的结果。
注解定义
less
复制代码
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) @Documented // @RestController // 组合 public @interface ResponseResult { // 是否忽略 boolean ignore() default false; }
后续,在需要返回统一结果集的 controller 上,标注这个注解,那么该类的所有方法返回值就会返回我们所定义的结构数据给到前端。
现在,我们还需要写一个对结果进行处理的处理器,他的目的就是将 controller 中方法的返回值填充到同一格式对象中,再返回到前端。
结果集处理逻辑
kotlin
复制代码
@Slf4j @ControllerAdvice @AllArgsConstructor public class ResponseResultHandler implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) { Method method = methodParameter.getMethod(); Class clazz = Objects.requireNonNull(method, "method is null").getDeclaringClass(); // 只处理 ResponseResult 标注的类或方法 ResponseResult annotation = (ResponseResult) clazz.getAnnotation(ResponseResult.class); if (Objects.isNull(annotation)) { annotation = method.getAnnotation(ResponseResult.class); } // 如果是FileSystemResource 则不拦截 if (method.getAnnotatedReturnType().getType().getTypeName() .equals(FileSystemResource.class.getTypeName())) { return false; } return annotation != null && !annotation.ignore(); } /** * 如果需要处理结果集,会来到这个方法处理结果集 * * @return */ @SneakyThrows @Override public Object beforeBodyWrite(Object data, MethodParameter mediaType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse serverHttpResponse) { SuccessInfo successInfo = new SuccessInfo(data); // 处理 String 类型情况 if ((data instanceof String) && MediaType.TEXT_PLAIN_VALUE.equals(selectedContentType.toString())) { ObjectMapper om = new ObjectMapper(); serverHttpResponse.getHeaders().set("Content-Type", "application/json"); return om.writeValueAsString(successInfo); } // 处理 空(null) 类型情况 if (Objects.isNull(data) && MediaType.TEXT_PLAIN_VALUE.equals(selectedContentType.toString())) { ObjectMapper om = new ObjectMapper(); serverHttpResponse.getHeaders().set("Content-Type", "application/json"); return om.writeValueAsString(successInfo); } return successInfo; } }
4.3 统一失败结果集处理
上面,我们只是对请求流程没有出现错误的情况下做的处理。但是如果后端报错了,那么返回给前端的数据结果将会是非格式化数据,这对前端的处理将是非常麻烦的,所以我们有必要在出现错误的情况下,也返回统一的格式出去。
其实,这个错误结果集的处理非常简单,因为我们背靠 SpringBoot ,所以只需要写个错误处理的切面类,SpringBoot 就会在业务流程出错的情况下,调用我们写的切面类进行相应的处理。
错误处理逻辑
java
复制代码
@Slf4j @RestControllerAdvice public class SysExceptionHandler { /** * 最大的兜底错误处理 * * @param ex * @return */ @ExceptionHandler(value = Exception.class) public FailInfo exception(Exception ex) { log.error("Exception_info:{}", ex.getMessage()); log.error("Exception_info:", ex); return new FailInfo(ex.getMessage()); } /** * 我们自己定义的错误类处理 SysException * @param ex * @return */ @ExceptionHandler(value = SysException.class) public FailInfo sysException(SysException ex) { log.error("Exception_info:{}", ex.getMessage()); log.error("Exception_info:", ex); return new FailInfo(ex.getMessage()); } // 如果还有其他错误需要处理,那么像上面那样写个对错误感兴趣的方法就行 }
SysException 类定义
typescript
复制代码
public class SysException extends RuntimeException { public SysException() { } public SysException(String message, Object... args) { super(String.format(message, args)); } public SysException(String message, Throwable cause, Object... args) { super(String.format(message, args), cause); } public SysException(Throwable cause) { super(cause); } }
4.4 测试
上述流程做完之后,你的代码结构应该如上图所示(包名不同没关系)。
我们现在写个测试 controller ,来看看是否达到我们需要的统一结果集效果。
代码:
typescript
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/test") public class TestController { /** * 测试返回 void * * @return */ @GetMapping("/voidTest") public void voidTest() { System.out.println("voidTest"); } /** * 测试返回 string * * @return */ @GetMapping("/stringTest") public String stringTest() { System.out.println("stringTest"); return "stringTest"; } /** * 测试返回 string * * @return */ @GetMapping("/stringNullTest") public String stringNullTest() { System.out.println("stringTest"); return null; } /** * 测试返回 UserVO * * @return */ @GetMapping("/userVOTest") public UserVO userVOTest() { System.out.println("userVOTest"); UserVO userVO = new UserVO(); userVO.setName("userVOTest"); return userVO; } /** * 测试返回 UserVO * * @return */ @GetMapping("/userVONullTest") public UserVO userVONullTest() { System.out.println("userVOTest"); UserVO userVO = new UserVO(); userVO.setName("userVOTest"); return null; } /** * 测试返回 异常 * * @return */ @GetMapping("/exceptionTest") public UserVO exceptionTest() { System.out.println("exceptionTest"); UserVO userVO = new UserVO(); userVO.setName("userVOTest"); throw new SysException("出错了!"); // return userVO; } }
结果:
5、代码自动创建
项目进度走到这里,我相信大家已经是建好了自己的数据库。那么这部分就是需要根据已经创建好的数据库,来生成相应的代码。
我们主要是通过 IDEA 的插件 MyBatisX
插件来生成对应的 entity、service、mapper 等类或 xml 文件。
具体步骤如下:
1)安装 MyBatisX
插件(我想大家都会)
2)项目连接 MySQL 数据库
开始连接你的数据库
3)使用插件,生成代码
只能一个表一个表的生成代码,步骤如图:
按照以上步骤走的话,你可以得到如下结构的代码:
后续,如果有其他表,我们也是按照这个逻辑。先生成这个表的基本代码,然后咱们才开始写后续的业务逻辑。
6、其他配置
在项目进行业务代码编写前,我们还需要对项目进行一些其他配置,具体如下。
6.1 MyBatisPlus 分页及自动填充配置
包:cn.j3code.booksys.config
less
复制代码
@Slf4j @Configuration @MapperScan("cn.j3code.*.mapper") @EnableScheduling public class ApplicationConfig { /** * mybatisplus 分页插件 * */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }
该类配置了一个分页插件和 mapper 接口的扫描地址。
后面,我们执行 MyBatisPlus 内置方法时,需要它自动帮我们填充一些值的需求,所以我们还需要做如下配置:
包:cn.j3code.booksys.handler
typescript
复制代码
@Slf4j @Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { // 起始版本 3.3.3(推荐) this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class); // 起始版本 3.3.3(推荐) this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); if (Objects.nonNull(SecurityUtil.getAuth())) { String name = ""; if (Boolean.FALSE.equals(RoleEnum.ROLE_USER.equals(SecurityUtil.getAuth().getRole()))) { name = SecurityUtil.getAuth().getAdmin().getNickName(); } else { name = SecurityUtil.getAuth().getUser().getName(); } String finalName = name; this.strictInsertFill(metaObject, "creator", () -> finalName, String.class); this.strictInsertFill(metaObject, "modifier", () -> finalName, String.class); } } @Override public void updateFill(MetaObject metaObject) { // 起始版本 3.3.3(推荐) this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); if (Objects.nonNull(SecurityUtil.getAuth())) { String name = ""; if (Boolean.FALSE.equals(RoleEnum.ROLE_USER.equals(SecurityUtil.getAuth().getRole()))) { name = SecurityUtil.getAuth().getAdmin().getNickName(); } else { name = SecurityUtil.getAuth().getUser().getName(); } String finalName = name; this.strictInsertFill(metaObject, "modifier", () -> finalName, String.class); } } }
可以看到,在调用 MyBatisPlus 内置的保存修改等方法时,会来回调这些方法,进而对创建时间,创建人,修改时间,修改人进行赋值,所以,我们在业务中就可以不需要自己手动设置这些固定的值了。
SecurityUtil 这个工具类不懂没关系,后面会提。
6.2 日期序列化配置
包:cn.j3code.booksys.config
typescript
复制代码
@Configuration public class LocalDateTimeSerializerConfig { private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; private static final String DATE_PATTERN = "yyyy-MM-dd"; /** * string转localdate */ @Bean public Converter<String, LocalDate> localDateConverter() { return new Converter<String, LocalDate>() { @Override public LocalDate convert(String source) { if (source.trim().length() == 0) { return null; } try { return LocalDate.parse(source); } catch (Exception e) { return LocalDate.parse(source, DateTimeFormatter.ofPattern(DATE_PATTERN)); } } }; } /** * string转localdatetime */ @Bean public Converter<String, LocalDateTime> localDateTimeConverter() { return new Converter<String, LocalDateTime>() { @Override public LocalDateTime convert(String source) { if (source.trim().length() == 0) { return null; } // 先尝试ISO格式: 2019-07-15T16:00:00 try { return LocalDateTime.parse(source); } catch (Exception e) { return LocalDateTime.parse(source, DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)); } } }; } /** * 统一配置 */ @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { JavaTimeModule module = new JavaTimeModule(); LocalDateTimeDeserializer localDateTimeDeserializer = new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)); module.addDeserializer(LocalDateTime.class, localDateTimeDeserializer); return builder -> { builder.simpleDateFormat(DATE_TIME_PATTERN); builder.serializers(new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_PATTERN))); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN))); builder.modules(module); }; } }
该类,主要是对前端传来的字符串日期进行转换为 yyyy-MM-dd HH:mm:ss 格式或者 yyyy-MM-dd 格式。还有一个是对后端的日期属性进行格式化,转换为 yyyy-MM-dd HH:mm:ss 格式或者 yyyy-MM-dd 格式。
7、登录流程分析与实现
在前面我们已经分析了能使用本系统的角色有三个,分别是:超级管理员、普通管理员、用户。那么这三个角色的登录流程应该如何划分或者如何实现。
这里,我先来说一下我实现的思路:
1、超级管理员账号是系统内置,当然登录进去之后,可改。
2、普通管理员的账号只能由超级管理员发放,也即超级管理员从系统中创建普通管理员账号信息给到他人使用。
3、用户则可以自行在系统的注册页面进行账号的注册与登录。
那么,从上面的几个点出发,我们可以得到下面的登录流程图:
ok,登录的大致思路现在应该是有了,那么我们下面就尝试来实现它。
7.1 controller 之 auth 方法创建
我们需要创建一个 controller 用来做我们的认证控制器,然后创建 auth 方法来实现我们的认证逻辑。
包:cn.j3code.booksys.api.controller
类和方法:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/auth") public class AuthController { private final AuthServer authServer; /** * 登录接口 * * @param form * @return */ @PostMapping("/login") public AuthVO auth(@Validated @RequestBody AuthForm form) { return authServer.auth(form); } }
接下来,开始写我们都业务方法。
7.2 server 之 auth 方法创建
包:cn.j3code.booksys.service
接口:
csharp
复制代码
public interface AuthServer { AuthVO auth(AuthForm form); }
实现类:
less
复制代码
@Slf4j @Service @AllArgsConstructor public class AuthServerImpl implements AuthServer { private final AdminService adminService; private final UserService userService; private final SysTokenService sysTokenService; @Override public AuthVO auth(AuthForm form) { // 这里我们需要根据不同角色,进行不同的认证 AuthVO authVO = new AuthVO(); authVO.setRole(form.getRole()); if (RoleEnum.ROLE_USER.equals(form.getRole())) { // 用户登录 authVO.setUser(userService.login(form)); } else { // 管理员登录(超级,普通) authVO.setAdmin(adminService.login(form)); } authVO.setToken(SysUtil.getToken()); // 将 token 保存到 MySQL 中 sysTokenService.saveToken(authVO); return authVO; } }
注意看,这里主要分为了三步:
- 用户登录
- 管理员登录
- 保存 token 方案
这三步正好对应上了刚刚我所花的流程,下面我们一一来看看这三个部分的实现。
7.2.1 用户登录
包:cn.j3code.booksys.service
接口:
csharp
复制代码
public interface UserService extends IService<User> { UserVO login(AuthForm form); }
实现类:
less
复制代码
@AllArgsConstructor @Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { private final UserConverter userConverter; @Override public UserVO login(AuthForm form) { User user = lambdaQuery() .eq(User::getStudentNumber, form.getUsername()) .eq(User::getPassword, PasswordUtil.encryptPassword(form.getPassword())) .one(); if (Objects.isNull(user)) { throw new RuntimeException("账号不存在或账号密码错误!"); } return userConverter.converter(user); } }
其实逻辑很简单,就是通过传入的账号,密码去数据库中找数据,找到则登录成功,反之则登录失败。
注意这里我们是需要将密码进行加密的,因为数据库不会存用户的明文密码,所以我们需要先加密,再去数据库中找。加密的流程你们在后续注册用户的时候,会看到。
还有一点就是你们会看到想这种带 converter 字样接口,你们肯定不懂。所以我推荐你们去看看我的这篇文章,看了你们就会惊叹,真方便!
7.2.2 管理员登录
其实这个流程和用户登录的流程很相似,你们往下看就知道了。
包:cn.j3code.booksys.service
接口:
csharp
复制代码
public interface AdminService extends IService<Admin> { AdminVO login(AuthForm form); }
实现类:
less
复制代码
@Slf4j @Service @AllArgsConstructor public class AdminServiceImpl extends ServiceImpl<AdminMapper, Admin> implements AdminService { private final UserConverter userConverter; @Override public AdminVO login(AuthForm form) { LambdaQueryChainWrapper<Admin> queryChainWrapper = lambdaQuery() .eq(Admin::getUsername, form.getUsername()) .eq(Admin::getPassword, PasswordUtil.encryptPassword(form.getPassword())); if (RoleEnum.ROLE_SUPER_ADMIN.equals(form.getRole())) { queryChainWrapper.eq(Admin::getRole, 0); } else { queryChainWrapper.ne(Admin::getRole, 0); } Admin admin = queryChainWrapper.one(); if (Objects.isNull(admin)) { throw new RuntimeException("账号不存在或账号密码错误!"); } return userConverter.converter(admin); } }
和用户登录很类似,只不过我们去找管理员数据的时候,要注意区分是超级管理员登录还是普通管理员登录。
7.2.3 token 存储方案
因为我们是一个前后端分离项目,所以前端系统向后端系统获取数据的时候肯定不是什么人都能获取的,必须要通过验证。那,登录时候后端返回给前端的 token 就是数据传输之间的认证凭证。
那么,前端每次获取数据的时候都携带 token ,而后端每次接收到请求的时候都过来验证这个 token 的有效性。如果有效就给数据,反之就拒之门外。现在的问题是后端如何来验证这个 token?
主要是后端的 token 存在什么地方?这个解决了,那么前端每次过来获取数据的时候后端将前端携带过来的 token 与后端存储 token 地方的数据进行对比,存在这个 token 那么就是有效的,反之就是无效的。
像业界一般都是将 token 存在 Redis ,而本次为了减轻系统技术的复杂性,我决定还是将 token 存在 MySQL 中。这样做当然只是适用于这种管理类的,用户群里不高的系统,如果是非管理系统的高并发、万级用户群里的系统,最好还是用性能高的 Redis 做存储。
通过上面的说明,我们知道了需要在 MySQL 中存入用户的 token 信息,所以我们需要创建一张表来存,表定义如下:
sql
复制代码
CREATE TABLE `sys_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `token` varchar(64) COLLATE utf8mb4_german2_ci NOT NULL COMMENT 'token字符串', `auth_info` varchar(1000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '认证信息,json 内容', `expired_time` datetime NOT NULL COMMENT 'token的过期时间', PRIMARY KEY (`id`), UNIQUE KEY `un` (`token`) ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci
然后使用插件,对这张表生成响应的代码。
最后,我们再来看看用户的 token ,后端是如何存储的。
包:cn.j3code.booksys.service
接口:
csharp
复制代码
public interface SysTokenService extends IService<SysToken> { void saveToken(AuthVO authVO); }
接口实现:
scala
复制代码
@Slf4j @Service public class SysTokenServiceImpl extends ServiceImpl<SysTokenMapper, SysToken> implements SysTokenService { @Override public void saveToken(AuthVO authVO) { SysToken sysToken = new SysToken() .setToken(authVO.getToken()) .setAuthInfo(JSON.toJSONString(authVO)) .setExpiredTime(SysUtil.getTokenExpiredTime()); try { save(sysToken); } catch (Exception e) { log.error("内部保存 token 失败! token:{}", JSON.toJSONString(sysToken), e); throw new SysException("登录失败!"); } } }
ok,至此我们的系统登录这一功能就实现完成了。
8、token 拦截认证
前面我们说了,前端请求后端获取数据的时候要进行 token 认证,那么肯定不是每个接口中都写一段这种认证逻辑,这也太不符合面向对象的开发模式了。
所以,Spring 给我们提供了一种方式,就是通过内置的拦截器接口来实现。我们只需要实现对应的连接器接口,这样 Spring 就会在每个请求到达业务之前,先调用一下我们实现的拦截器逻辑,如果在拦截器中认证时合法的,那么则放行到业务中,反之则拒绝访问。
那么,我们先来写拦截器逻辑。
包:cn.j3code.booksys.interceptor
java
复制代码
@Slf4j @Component @AllArgsConstructor public class SecurityInterceptor implements HandlerInterceptor { private final SysTokenService sysTokenService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { /** * 放开 OPTIONS 请求,只拦截固定的四种请求(不放开会有跨域错误) */ return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new SysException("请登录再访问!"); } AuthVO authVO = sysTokenService.authToken(token); if (Objects.isNull(authVO)) { throw new SysException("认证不通过,请登录再访问!"); } SecurityUtil.setAuthVO(authVO); return HandlerInterceptor.super.preHandle(request, response, handler); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { SecurityUtil.remove(); HandlerInterceptor.super.postHandle(request, response, handler, modelAndView); } }
SecurityInterceptor 类实现了接口的两个方法,一个是请求还未到业务时,进行认证处理,一个是业务执行完成之后,进行的资源释放处理。
8.1 token 验证逻辑
在 preHandle 方法中我们获取了 token 信息,那么这个 token 的有效性,就要通过 sysTokenService.authToken(token) 方法去验证了,具体逻辑如下。
包:cn.j3code.booksys.service
接口:
csharp
复制代码
public interface SysTokenService extends IService<SysToken> { AuthVO authToken(String token); }
实现类:
scala
复制代码
@Slf4j @Service public class SysTokenServiceImpl extends ServiceImpl<SysTokenMapper, SysToken> implements SysTokenService { @Override public AuthVO authToken(String token) { SysToken sysToken = lambdaQuery().eq(SysToken::getToken, token) .one(); if (Objects.isNull(sysToken)) { return null; } if (LocalDateTime.now().isAfter(sysToken.getExpiredTime())) { // token 过期,删除 removeByToken(token); return null; } // 续约 sysToken.setExpiredTime(SysUtil.getTokenExpiredTime()); updateById(sysToken); return JSON.parseObject(sysToken.getAuthInfo(), AuthVO.class); } }
逻辑很简单,就是将获取到的 token 与保存在 sys_token 表中的 token 进行对比,如果不存在或者失效了,那么这次请求肯定就是非法的,需要拒绝。
8.2 登录用户信息工具
这,其实就是一个工具类的封装。为啥要这么干呢,因为在业务中我们时长会有获取当前登录人信息的需求,所以在这里我们已经获取到了当前登录人的信息,所以有必要进行存储,为后续的业务流程提供数据支持。这也就是 SecurityUtil.setAuthVO(authVO) 方法的作用。
那么,来看看这个类的实现:
包:cn.j3code.booksys.utils
csharp
复制代码
public class SecurityUtil { private static ThreadLocal<AuthVO> authThreadLocal = new ThreadLocal<>(); public static ThreadLocal<AuthVO> getAuthThreadLocal() { return authThreadLocal; } public static void setAuthVO(AuthVO authVO) { authThreadLocal.set(authVO); } public static AuthVO getAuth() { return getAuthThreadLocal().get(); } public static UserVO getUserVO() { if (Objects.isNull(getAuth())) { return null; } return getAuth().getUser(); } public static Long getUserId() { UserVO userVO = Objects.requireNonNull(getUserVO(), "请登录再访问!"); return userVO.getId(); } public static AdminVO getAdminVO() { if (Objects.isNull(getAuth())) { return null; } return getAuth().getAdmin(); } public static void remove() { authThreadLocal.remove(); } }
8.3 配置拦截器
上面我们实现了拦截器,那么这一步是配置这个拦截器需要拦截那些请求。
包:cn.j3code.booksys.config
less
复制代码
@Slf4j @Configuration @AllArgsConstructor public class SysWebMvcConfig implements WebMvcConfigurer { private final SecurityInterceptor securityInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // 添加安全认证拦截器,拦截所有,排除特定url registry.addInterceptor(securityInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/static/**", "/**/open-api/**/", "/**/auth/login", "/**/user/register" ); } /** * 跨域处理 * @param registry */ @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**").allowedOriginPatterns("*") .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true).maxAge(3600); } }
该类不仅配置了拦截器需要拦截、放行的 url,还配置了跨域处理。
8.4 过期 token 清除
在前面定义 sys_token 表的时候,我们就给该表定义了一个过期字段。这是很有必要的,因为一个 token 产生后,如果用户就不再访问了,那么不及时清理这种僵尸 token,则会导致该表数据量逐渐增多,严重时可能会影响系统运行。
这里,我们通过定时任务来清理这种已到期的 token,逻辑如下:
包:cn.j3code.booksys.scheduled
less
复制代码
@Slf4j @Component @AllArgsConstructor public class TokenScheduled { private final SysTokenService sysTokenService; /** * 移除过期的 token */ @Scheduled(cron = "15 0/4 * * * ?") public void deleteToken() { try { log.info("删除过期token开始!"); sysTokenService.removeExpiredToken(LocalDateTime.now()); log.info("删除过期token完成!"); }catch (Exception e){ log.error("删除过期token失败", e); } } }
业务实现:
包:cn.j3code.booksys.service
csharp
复制代码
public interface SysTokenService extends IService<SysToken> { void removeExpiredToken(LocalDateTime now); }
方法实现:
scss
复制代码
public void removeExpiredToken(LocalDateTime now) { lambdaUpdate() .lt(SysToken::getExpiredTime, now) .remove(); }
9、注销
上面我们已经分析了登录和 token 认证的逻辑了,那么我们来实现注销的逻辑。
controller
csharp
复制代码
/** * 注销接口 */ @GetMapping("/logOut") public void logout() { authServer.logout(); }
server
csharp
复制代码
public void logout() { sysTokenService.removeByToken(SecurityUtil.getAuth().getToken()); }
removeByToken
scss
复制代码
public void removeByToken(String token) { lambdaUpdate() .eq(SysToken::getToken, token) .remove(); }
这里,我写的比较简单,主要的思路就是将 token 从 sys_token 表中移除就行。
10、系统内置超级管理员账号
上面我们光分析了登录注册,还没说内置的超级管理员如何实现呢!那,下面我们就来分析分析。
我的想法是这样的,项目编写的时候配置到超级管理员的账号密码,然后系统启动的时候就将配置的超级管理员账号,密码创建到数据库中。当然,如果数据库中存在了超级管理员账号,那么就不做处理。
10.1 配置文件配置超级管理员账号
10.2 配置类编写
上一步我们配置了账号信息,那么需要通过这个配置信息映射到对应的 Bean 中。
包:cn.j3code.booksys.config
less
复制代码
@Slf4j @Data @Configuration @ConfigurationProperties(prefix = "school-book-sys.config") public class SchoolBookSysConfig { /** * 超级管理员账号和密码 */ private SuperAdmin superAdmin; @Data static class SuperAdmin { private String username; private String password; } }
10.3 账号创建
写好了账号信息的配置之后,那么就需要存到数据库中,而且我们说了在启动启动的时候就需要处理。
还需要注意,存在超级管理员的时候,不做保存
在 SpringBoot 中,我们可以通过 ApplicationRunner 接口来实现在系统启动后,做一些后置处理,实现如下。
less
复制代码
@Order(100) @Slf4j @Component @AllArgsConstructor public class ApplicationInitConfig implements ApplicationRunner { private final AdminService adminService; private final SchoolBookSysConfig schoolBookSysConfig; /** * 系统启动后,对系统进行初始化 * 1、超级管理员账号初始化 * * @param args * @throws Exception */ @Override public void run(ApplicationArguments args) throws Exception { SchoolBookSysConfig.SuperAdmin superAdmin = schoolBookSysConfig.getSuperAdmin(); if (Objects.nonNull(superAdmin)) { if (StringUtils.isNoneBlank(superAdmin.getUsername(), superAdmin.getPassword())) { try { adminService.saveSuperAdmin(superAdmin.getUsername(), superAdmin.getPassword()); } catch (Exception e) { log.error("超级管理员账号初始化失败:", e); } } } } }
可以看到,保存逻辑是通过 saveSuperAdmin 方法实现的,那么来看看吧!
less
复制代码
public void saveSuperAdmin(String username, String password) { /** * 判断是否存在超级管理员账号,存在则不添加 */ if (lambdaQuery().eq(Admin::getRole, 0).count() > 0) { log.info("已经存在超级管理员,不执行保存操作"); return; } save(new Admin() .setNickName("超级管理员") .setUsername(username) .setPassword(PasswordUtil.encryptPassword(password)) .setRole(0)); log.info("超级管理员账号初始化成功!"); }
ok,代码应该写的很清楚,我就不做过多解释了。
上面带你们分析了一遍本系统的重要功能实现,我都是一步步的分析,自认为写的非常详细。而下面我不会再一个接口一个接口的去分别分析,因为大多数套路都一样。所以我打算按类(Controller)去分析,而不是每个接口进行分析。
11、超级管理员相关功能分析与实现
超级管理员登录之后的主要功能就是给系统增加普通管理员,所以他应该需要如下功能:
- 获取普通普通管理员账号列表
- 添加普通管理员
- 删除普通管理员
- 修改当前账户信息(这个是普通管理员和超级管理员都有的功能,所以可以共用)
ok,了解功能之后,来看看代码实现。
11.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/superAdmin") public class SuperAdminController { private final AdminService adminService; /** * 查询所有普通管理员账号列表 * 超级管理员,不查 * * @param query 查询对象 * @return */ @GetMapping("/page") public IPage<AdminVO> page(AdminQuery query) { return adminService.page(query); } /** * 保存或更新管理员账号 * * @param form 表单 * @return */ @PostMapping("/saveOrUpdate") public AdminVO saveOrUpdate(@Validated @RequestBody AdminSaveOrUpdateForm form) { return adminService.saveOrUpdate(form); } /** * 移除普通管理员账号 * * @param id 管理员id */ @GetMapping("/delete") public void delete(@RequestParam("id") Long id) { adminService.removeById(id); } }
11.2 server
这里我不贴接口的代码了,直接吧业务层面的代码放出来。
包:cn.j3code.booksys.service.impl
类:
less
复制代码
@Slf4j @Service @AllArgsConstructor public class AdminServiceImpl extends ServiceImpl<AdminMapper, Admin> implements AdminService { private final UserConverter userConverter; @Override public IPage<AdminVO> page(AdminQuery query) { Page<Admin> adminPage = lambdaQuery() .ne(Admin::getRole, 0) .like(StringUtils.isNotBlank(query.getNickName()), Admin::getNickName, query.getNickName()) .like(StringUtils.isNotBlank(query.getUsername()), Admin::getUsername, query.getUsername()) .page(query.getPage()); return adminPage.convert(userConverter::converter); } @Override public AdminVO saveOrUpdate(AdminSaveOrUpdateForm form) { Admin admin = userConverter.converter(form); if (lambdaQuery().eq(Admin::getUsername, admin.getUsername()) .ne(Objects.nonNull(admin.getId()), Admin::getId, admin.getId()).count() > 0) { throw new SysException("管理员账号已存在!"); } if (Objects.isNull(admin.getId())) { admin.setRole(1); // 密码加密 admin.setPassword(PasswordUtil.encryptPassword(admin.getPassword())); save(admin); } else { updateById(admin); } return userConverter.converter(getById(admin.getId())); } @Override public void saveSuperAdmin(String username, String password) { /** * 判断是否存在超级管理员账号,存在则不添加 */ if (lambdaQuery().eq(Admin::getRole, 0).count() > 0) { log.info("已经存在超级管理员,不执行保存操作"); return; } save(new Admin() .setNickName("超级管理员") .setUsername(username) .setPassword(PasswordUtil.encryptPassword(password)) .setRole(0)); log.info("超级管理员账号初始化成功!"); } @Override public AdminVO login(AuthForm form) { LambdaQueryChainWrapper<Admin> queryChainWrapper = lambdaQuery() .eq(Admin::getUsername, form.getUsername()) .eq(Admin::getPassword, PasswordUtil.encryptPassword(form.getPassword())); if (RoleEnum.ROLE_SUPER_ADMIN.equals(form.getRole())) { queryChainWrapper.eq(Admin::getRole, 0); } else { queryChainWrapper.ne(Admin::getRole, 0); } Admin admin = queryChainWrapper.one(); if (Objects.isNull(admin)) { throw new RuntimeException("账号不存在或账号密码错误!"); } return userConverter.converter(admin); } }
ok,到这里超级管理员的功能就写好了。
12、PageQuery 设计
大家看代码的时候会看到只要是关于查询的接口,参数对象都会继承这个类。他的作用很简单就是做分页处理的。
PageQuery 类中定义了分页所需要的基本属性:
- 当前页
- 页大小
- 排序字段
- 排序规则
除了字段,还提供了根据这些字段创建 MyBatisPlus 框架所需要的 Page 对象,来用于分页。这就相当于 PageQuery 是个中间对象,他接收前端的分页参数,并转化为 MyBatisPlus 所需要的 Page 对象。
实现代码:
arduino
复制代码
@Data public class PageQuery { private Long size = 10L; private Long current = 1L; /** * 需要进行排序的字段 */ private String column; /** * 自然排序(正序):由小到大,asc,“-” 号开头:true * 倒序:由大到小,desc,“+”号开头:false */ private boolean asc = true; /** * 设置排序字段及排序规则 * @param column +createTime 或 -createTime 等 */ public void setColumn(String column) { if (StringUtils.isBlank(column)) { return; } if (column.startsWith("+")) { this.asc = false; } // 驼峰转下划线 this.column = StrUtil.toUnderlineCase(column.substring(1)); } public void setSize(Long size) { if (size > 50) { throw new SysException("每页最大显示条目为 50!"); } this.size = size; } /** * 根据分页参数,生成 MyBatisPlus 分页对象 * @param <E> * @return */ public <E> Page<E> getPage() { Page<E> page = new Page<>(current, size); if (StringUtils.isNotBlank(column)) { // 排序 OrderItem orderItem = new OrderItem(); orderItem.setAsc(asc); orderItem.setColumn(column); page.setOrders(Collections.singletonList(orderItem)); } return page; } }
13、用户相关功能分析与实现
对于用户功能本系统主要提供如下几个:
- 管理员查询用户列表
- 管理员对用户数据进行删除
- 用户自己注册数据
- 用户自己修改数据
13.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/user") public class UserController { private final UserService userService; /** * 用户注册 * * @param form */ @PostMapping("/register") public void register(@Validated @RequestBody UserRegisterForm form) { userService.register(form); } /** * 用户修改 * * @param form */ @PostMapping("/update") public void update(@Validated @RequestBody UserRegisterForm form) { userService.update(form); } /** * 分页查询 * * @param query * @return */ @GetMapping("/page") public IPage<UserVO> page(UserQuery query) { return userService.page(query); } /** * 删除用户 * @param id */ @GetMapping("/delete") public void delete(@RequestParam("id") Long id) { userService.delete(id); } }
13.2 server
包:cn.j3code.booksys.service.impl
类:
scss
复制代码
@AllArgsConstructor @Slf4j @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { private final UserConverter userConverter; private final BookBorrowInfoService bookBorrowInfoService; private final UserReturnBookInfoService userReturnBookInfoService; @Override public UserVO login(AuthForm form) { User user = lambdaQuery() .eq(User::getStudentNumber, form.getUsername()) .eq(User::getPassword, PasswordUtil.encryptPassword(form.getPassword())) .one(); if (Objects.isNull(user)) { throw new RuntimeException("账号不存在或账号密码错误!"); } return userConverter.converter(user); } @Override public void register(UserRegisterForm form) { if (lambdaQuery().eq(User::getStudentNumber, form.getStudentNumber()).count() > 0) { throw new SysException("学号已存在!"); } if (StringUtils.isBlank(form.getPassword())) { throw new SysException("学号不能为空!"); } if (Boolean.FALSE.equals(SysUtil.checkPhone(form.getPhone()))) { throw new SysException("请输入正确电话号码格式!"); } User user = userConverter.converter(form); user.setPassword(PasswordUtil.encryptPassword(user.getPassword())); save(user); } @Override public IPage<UserVO> page(UserQuery query) { Page<User> userPage = lambdaQuery() .like(StringUtils.isNotBlank(query.getStudentNumber()), User::getStudentNumber, query.getStudentNumber()) .like(StringUtils.isNotBlank(query.getName()), User::getName, query.getName()) .like(StringUtils.isNotBlank(query.getCollege()), User::getCollege, query.getCollege()) .like(StringUtils.isNotBlank(query.getGrade()), User::getGrade, query.getGrade()) .like(StringUtils.isNotBlank(query.getPhone()), User::getPhone, query.getPhone()) .like(StringUtils.isNotBlank(query.getDormitory()), User::getDormitory, query.getDormitory()) .eq(Objects.nonNull(query.getSex()), User::getSex, query.getSex()) .page(query.getPage()); return userPage.convert(userConverter::converter); } @Override public void update(UserRegisterForm form) { if (Objects.isNull(form.getId())) { throw new SysException("用户ID不能为空!"); } if (lambdaQuery() .eq(User::getStudentNumber, form.getStudentNumber()) .ne(User::getId, form.getId()) .count() > 0) { throw new SysException("学号已存在!"); } if (Boolean.FALSE.equals(SysUtil.checkPhone(form.getPhone()))) { throw new SysException("请输入正确电话号码格式!"); } User user = userConverter.converter(form); user.setPassword(PasswordUtil.encryptPassword(user.getPassword())); updateById(user); } @Override public void delete(Long id) { // 删除用户借阅信息 bookBorrowInfoService.lambdaUpdate().eq(BookBorrowInfo::getUserId, id).remove(); // 删除用户归还信息 userReturnBookInfoService.lambdaUpdate().eq(UserReturnBookInfo::getUserId, id).remove(); // 删除用户信息 removeById(id); } }
14、图书类别相关功能分析与实现
在图书中,每本书都有对应的分类,所以这个图书类别就是为了区分图书用的。那么,对于图书分类我们需要实现下面几个功能:
- 管理员新增图书分类
- 查询分类列表(用户、管理员)
- 管理员修改分类
这里,我没有提供删除分类的功能,因为分类下面会绑定很多图书,如果分类一旦删除了则会导致很多图书没有分类信息,所以分类只许修改,不许删除。
14.1 controller
包:cn.j3code.booksys.api.controller
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/bookCategory") public class BookCategoryController { private final BookCategoryService bookCategoryService; /** * 分页查询 * * @param query 分页对象 * @param categoryName 分类名称 * @return */ @GetMapping("/page") public IPage<BookCategoryVO> page(PageQuery query, String categoryName) { return bookCategoryService.page(query, categoryName); } /** * 保存或修改类别 * * @param id 保存时候不传,修改时必传 * @param categoryName 类目名称 */ @GetMapping("/saveOrUpdate") public void saveOrUpdate(@RequestParam(name = "id", required = false) Long id, @RequestParam(name = "categoryName", required = true) String categoryName) { bookCategoryService.saveOrUpdate(id, categoryName); } }
14.2 server
包:cn.j3code.booksys.service.impl
less
复制代码
@Slf4j @AllArgsConstructor @Service public class BookCategoryServiceImpl extends ServiceImpl<BookCategoryMapper, BookCategory> implements BookCategoryService { private final BookConverter bookConverter; @Override public IPage<BookCategoryVO> page(PageQuery query, String categoryName) { Page<BookCategory> categoryPage = lambdaQuery() .like(StringUtils.isNotBlank(categoryName), BookCategory::getCategoryName, categoryName) .page(query.getPage()); return categoryPage.convert(bookConverter::converter); } @Override public void saveOrUpdate(Long id, String categoryName) { if (lambdaQuery().eq(BookCategory::getCategoryName, categoryName) .ne(Objects.nonNull(id), BookCategory::getId, id).count() > 0) { throw new RuntimeException("该分类已存在"); } BookCategory bookCategory = new BookCategory() .setId(id) .setCategoryName(categoryName); if (Objects.isNull(bookCategory.getId())) { save(bookCategory); } updateById(bookCategory); } }
15、图书相关功能分析与实现
本系统中,我们需要为图书提供如下几个功能:
- 获取图书列表(管理员,普通用户)
- 管理员添加和修改图书
- 用户对图书进行借阅
这里的功能都不复杂,但是我们会碰到一个问题,就是图片如何处理。我们在设计图书的时候是定义了图书封面图的,那么这个图片我们如何来存,这是个问题。
15.1 图片文件上传
为了不使系统的技术复杂度上升,所以我打算直接将图片上传到 SprignBoot 的 resources 目录下。
当然更好的做法是通过对象存储的形式存储文件类数据,但,我们这里的系统用户量不会很大,对性能要求不是很高,所以就直接简单高效的存在资源目录下就行了。
1)文件上传配置
yaml
复制代码
spring: # 文件上传大小限制 servlet: multipart: enabled: true max-file-size: 5MB max-request-size: 5MB
在 application.yml 配置文件中,配置这个上传文件大小的限制
2)SysWebMvcConfig 配置类中添加资源映射路径
typescript
复制代码
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { //获取文件的真实路径 String path = System.getProperty("user.dir") + "\src\main\resources\static\img\"; //static/img/**是对应resource下工程目录 registry.addResourceHandler("/static/img/**").addResourceLocations("file:" + path); }
这个方法的目的就是,让前端访问图片的 url 映射到我们项目本地的 /static/img/ 目录下。
用户在上传图片后,系统会返回一个 url 给前端
3)文件上传工具类实现
typescript
复制代码
@Slf4j public class ImgFileUtil { private static Integer PORT; private static Set<String> ALLOW_IMG_TYPE = new HashSet<>(); static { ALLOW_IMG_TYPE.add("jpg"); ALLOW_IMG_TYPE.add("png"); ALLOW_IMG_TYPE.add("jpeg"); } public static void setPORT(Integer port) { // 服务器启动的时候,会设置 PORT = port; } public static String uploadImg(MultipartFile imgFile) { // 校验文件格式 String uploadImgType = imgFile.getOriginalFilename().substring(imgFile.getOriginalFilename().lastIndexOf(".") + 1); if (StringUtils.isBlank(uploadImgType)) { throw new SysException("上传文件格式错误!"); } if (!ALLOW_IMG_TYPE.contains(uploadImgType.toLowerCase(Locale.ROOT))) { throw new SysException("图片只支持:jpg、png、jpeg 格式!"); } // 项目路径 String sysPath = System.getProperty("user.dir"); // 定义图片存储路径:资源目录下的 static\img 下 File imgFilePath = FileUtil.file(sysPath + "\src\main\resources\static\img"); if (Boolean.FALSE.equals(imgFilePath.exists())) { // 不存在,创建 imgFilePath.mkdirs(); } // 将图片重新放到如下的路径 String newImgFileName = RandomUtil.randomString(32) + "." + uploadImgType; // 创建文件 File newImgFile = new File(imgFilePath.getPath() + "\" + newImgFileName); try { // 将图片上传到新创建的文件中,完成图片上传 imgFile.transferTo(newImgFile); } catch (IOException e) { log.error("上传文件失败!", e); throw new SysException("上传文件失败!"); } // 图片资源路径生成 // 1、本地文件路径访问 // return newImgFile.getPath(); // 2、通过服务器访问静态资源 return "http://localhost:" + PORT + "/static/img/" + newImgFileName; } }
该类的目的就是调用 uploadImg 方法,将传入进来的图片文件存到项目的资源目录下的 /static/img/ 下。
uploadImg 方法会返回图片的访问地址,而这个地址则是我们自己拼接的,一般就是 ip + 端口 + 图片地址。
现在 ip 我们是本地开发所以直接写死:localhost 或者 127.0.0.1
端口可以启动的时候从系统中获取,方式如下:
至于最后的图片路径就是 /static/img/ + 随机字符串 + 图片后缀了。
4)图片上传 controller
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/fileUpload") public class FileUploadController { /** * 图片上传 * * @param file 图片文件 * @return 图片地址 */ @PostMapping("/imgUpload") public String imgUpload(MultipartFile file) { return ImgFileUtil.uploadImg(file); } }
这个没什么好说的了,就是定义一个 api 然后直接去调用图片上传工具类,最后返回图片 url。
15.2 controller
上面一节,完成了图片的上传功能,那么现在就可以开始编写图书控制层的相关 api 了。
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/book") public class BookController { private final BookService bookService; /** * 分页查询 * * @param query * @return */ @GetMapping("/page") public IPage<BookVO> page(BookQuery query) { return bookService.page(query); } /** * 根据id获取图书信息 * * @param id 图书id * @return */ @GetMapping("/one") public BookVO one(@RequestParam(name = "id", required = true) Long id) { return bookService.one(id); } /** * 保存或修改图书 * * @param form */ @PostMapping("/saveOrUpdate") public void saveOrUpdate(@Validated @RequestBody BookSaveOrUpdateForm form) { bookService.saveOrUpdate(form); } }
15.3 server
包:cn.j3code.booksys.service.impl
类:
scss
复制代码
@Slf4j @AllArgsConstructor @Service public class BookServiceImpl extends ServiceImpl<BookMapper, Book> implements BookService { private final BookConverter bookConverter; @Override public IPage<BookVO> page(BookQuery query) { return getBaseMapper().page(query.getPage(), query); } @Override public void saveOrUpdate(BookSaveOrUpdateForm form) { List<Book> bookList = getBaseMapper().bookListBy(form.getBookNumber(), form.getBookName()); long count = bookList.stream().filter(item -> Boolean.FALSE.equals(item.getId().equals(form.getId()))) .count(); if (count > 0) { throw new SysException("图书编号或名称已存在!"); } Book book = bookConverter.converter(form); if (Objects.isNull(book.getId())) { book.setExtantTotal(book.getTotal()); save(book); } else { Book loadBook = getById(form.getId()); // 计算可用库存 = 新库存 - 借出去的库存 book.setExtantTotal(form.getTotal() - (loadBook.getTotal() - loadBook.getExtantTotal())); if (book.getExtantTotal() < 0){ throw new SysException("已借出去的图书大于此库存数,请正确填写!"); } updateById(book); } } @Override public BookVO one(Long id) { BookVO vo = getBaseMapper().one(id); if (Objects.isNull(vo)) { throw new SysException("图书信息不存在!"); } return vo; } }
16、图书借阅相关功能分析与实现
当用户看到一本感兴趣的图书时,可以对图书进行借阅。所以本系统需要对借阅提供如下几个功能:
- 管理员或用户查看借阅列表
-
- 管理员查看所有借阅信息,用户只能查看自己的借阅信息
- 用户对图书进行借阅
就两个功能,不是很难,我们直接看代码实现
16.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/bookBorrowInfo") public class BookBorrowInfoController { private final BookBorrowInfoService bookBorrowInfoService; /** * 分页查询 * * @param query * @return */ @GetMapping("/page") public IPage<BookBorrowInfoVO> page(BookBorrowInfoQuery query) { if (SecurityUtil.getRole().equals(RoleEnum.ROLE_USER)) { // 当前访问角色是 用户 ,那么只能查询当前用户的归还信息 query.setUserId(SecurityUtil.getUserId()); } return bookBorrowInfoService.page(query); } /** * 借阅图书 * * @param bookId 图书id * @param borrowTotal 借阅数量 */ @GetMapping("/bookBorrow") public void bookBorrow( @RequestParam(name = "bookId", required = true) Long bookId, @RequestParam(name = "borrowTotal", required = true) Integer borrowTotal) { bookBorrowInfoService.bookBorrow(bookId, borrowTotal); } }
16.2 server
包:cn.j3code.booksys.service.impl
类:
scss
复制代码
@Slf4j @AllArgsConstructor @Service public class BookBorrowInfoServiceImpl extends ServiceImpl<BookBorrowInfoMapper, BookBorrowInfo> implements BookBorrowInfoService { private final BookService bookService; @Override public IPage<BookBorrowInfoVO> page(BookBorrowInfoQuery query) { return getBaseMapper().page(query.getPage(), query); } @Transactional(rollbackFor = {Exception.class}) @Override public void bookBorrow(Long bookId, Integer borrowTotal) { // 校验图书库存 Book book = Objects.requireNonNull(bookService.getById(bookId), "图书信息不存在"); if (book.getExtantTotal() < borrowTotal) { throw new SysException("图书库存不足"); } /* 如果借阅存在,合并 如果不存在,新增 */ BookBorrowInfo borrowInfo = lambdaQuery() .eq(BookBorrowInfo::getBookId, bookId) .eq(BookBorrowInfo::getUserId, SecurityUtil.getUserId()) .one(); if (Objects.isNull(borrowInfo)) { borrowInfo = new BookBorrowInfo(); borrowInfo.setUserId(SecurityUtil.getUserId()); borrowInfo.setBookId(bookId); borrowInfo.setBorrowCount(borrowTotal); save(borrowInfo); } else { borrowInfo.setBorrowCount(borrowTotal + borrowInfo.getBorrowCount()); updateById(borrowInfo); } book.setExtantTotal(book.getExtantTotal() - borrowTotal); bookService.updateById(book); } }
17、还书相关功能分析与实现
上面我们实现了图书的借阅,那么有借肯定就有还,所以本机我们需要实现如下几个功能:
- 管理员或者用户查看归还记录
-
- 管理员能查看所有归还记录,用户只能查看自己的归还记录
- 用户归还图书
是不是和借阅的功能很像,下面我们来看看代码实现。
17.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/userReturnBookInfo") public class UserReturnBookInfoController { private final UserReturnBookInfoService userReturnBookInfoService; /** * 分页查询用户归还图书信息 * * @param query * @return */ @GetMapping("/page") public IPage<UserReturnBookInfoVO> page(UserReturnBookQuery query) { if (SecurityUtil.getRole().equals(RoleEnum.ROLE_USER)) { // 当前访问角色是 用户 ,那么只能查询当前用户的归还信息 query.setUserId(SecurityUtil.getUserId()); } return userReturnBookInfoService.page(query); } /** * 用户归还图书 * * @param bookBorrowInfoId 图书借阅信息id * @param count 归还数量 */ @GetMapping("/userReturnBook") public void userReturnBook( @RequestParam(name = "bookBorrowInfoId", required = true) Long bookBorrowInfoId, @RequestParam(name = "count", required = true) Integer count) { userReturnBookInfoService.userReturnBook(bookBorrowInfoId, count); } }
17.2 server
包:cn.j3code.booksys.service.impl
less
复制代码
@Slf4j @AllArgsConstructor @Service public class UserReturnBookInfoServiceImpl extends ServiceImpl<UserReturnBookInfoMapper, UserReturnBookInfo> implements UserReturnBookInfoService { private final BookBorrowInfoConverter bookBorrowInfoConverter; private final BookBorrowInfoService bookBorrowInfoService; private final BookService bookService; @Override public IPage<UserReturnBookInfoVO> page(UserReturnBookQuery query) { return getBaseMapper().page(query.getPage(), query); } @Transactional(rollbackFor = {Exception.class}) @Override public void userReturnBook(Long bookBorrowInfoId, Integer count) { BookBorrowInfo bookBorrowInfo = Objects.requireNonNull( bookBorrowInfoService .lambdaQuery().eq(BookBorrowInfo::getId, bookBorrowInfoId) .eq(BookBorrowInfo::getUserId, SecurityUtil.getUserId()) .one(), "借阅信息不存在!"); if (bookBorrowInfo.getBorrowCount().equals(bookBorrowInfo.getReturnCount())) { throw new SysException("已全部归还,无需再归还图书"); } Integer returnCount = bookBorrowInfo.getReturnCount(); bookBorrowInfo.setReturnCount(returnCount + count); if (bookBorrowInfo.getReturnCount() > bookBorrowInfo.getBorrowCount()) { throw new SysException("图书归还数目不对,你只需归还:" + (bookBorrowInfo.getBorrowCount() - returnCount)); } Book book = Objects.requireNonNull(bookService.getById(bookBorrowInfo.getBookId()), "图书不存在!"); // 修改借阅信息的归还数量 bookBorrowInfoService.updateById(bookBorrowInfo); // 修改图书的剩余数量 book.setExtantTotal(book.getExtantTotal() + count); bookService.updateById(book); // 保存用户的归还信息 UserReturnBookInfo userReturnBookInfo = new UserReturnBookInfo(); userReturnBookInfo.setUserId(SecurityUtil.getUserId()); userReturnBookInfo.setBookId(bookBorrowInfo.getBookId()); userReturnBookInfo.setReturnCount(count); save(userReturnBookInfo); } }
18、公告相关功能分析与实现
公告是为了管理员方便通知使用本系统的用户,及时了解系统信息的一个重要功能。所以本系统需要提供如下几个功能:
- 管理员或用户查看公告列表
- 管理员添加或者删除公告
注意用户只有查看的功能,而没有添加或者删除的功能
18.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/announcement") public class AnnouncementController { private final AnnouncementService announcementService; /** * 公告列表分页查询 * * @param query 分页对象 * @param title 公告标题 * @return */ @GetMapping("/page") public IPage<AnnouncementVO> page(AnnouncementQuery query) { return announcementService.page(query); } /** * 公告添加 * * @param form */ @PostMapping("/save") public void save(@Validated @RequestBody AnnouncementSaveForm form) { announcementService.save(form); } /** * 公告删除 * * @param id 公告id */ @GetMapping("/delete") public void delete(@RequestParam(name = "id", required = true) Long id) { announcementService.removeById(id); } }
18.2 server
包:cn.j3code.booksys.service.impl
类:
less
复制代码
@Slf4j @AllArgsConstructor @Service public class AnnouncementServiceImpl extends ServiceImpl<AnnouncementMapper, Announcement> implements AnnouncementService { private final AnnouncementConverter announcementConverter; @Override public IPage<AnnouncementVO> page(AnnouncementQuery query) { Page<Announcement> announcementPage = lambdaQuery() .like(StringUtils.isNotBlank(query.getTitle()), Announcement::getTitle, query.getTitle()) .gt(Objects.nonNull(query.getStartTime()), Announcement::getCreateTime, query.getStartTime()) .lt(Objects.nonNull(query.getEndTime()), Announcement::getCreateTime, Objects.isNull(query.getEndTime()) ? null : query.getEndTime().plusDays(1)) .page(query.getPage()); return announcementPage.convert(announcementConverter::converter); } @Override public void save(AnnouncementSaveForm form) { save(announcementConverter.converter(form)); } }
19、轮播图相关功能分析与实现
轮播图主要是给进入用户的人,展示一些图片信息。具体有如下几个功能:
- 管理员查看轮播图列表
- 管理员添加,删除轮播图
代码实现:
19.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/carouselMap") public class CarouselMapController { private final CarouselMapService carouselMapService; /** * 分页查询 * * @param query 分页对象 * @return */ @GetMapping("/page") public IPage<CarouselMapVO> page(PageQuery query) { return carouselMapService.page(query); } /** * 轮播图添加 * * @param form */ @PostMapping("/save") public void save(@Validated @RequestBody CarouselMapSaveForm form) { carouselMapService.save(form); } /** * 轮播图删除 * * @param id 轮播图id */ @GetMapping("/delete") public void delete(@RequestParam(name = "id", required = true) Long id) { carouselMapService.removeById(id); } }
19.2 server
包:cn.j3code.booksys.service.impl
类:
less
复制代码
@AllArgsConstructor @Slf4j @Service public class CarouselMapServiceImpl extends ServiceImpl<CarouselMapMapper, CarouselMap> implements CarouselMapService { private final AnnouncementConverter announcementConverter; @Override public IPage<CarouselMapVO> page(PageQuery query) { Page<CarouselMap> carouselMapPage = lambdaQuery() .orderByAsc(CarouselMap::getWeight) .page(query.getPage()); return carouselMapPage.convert(announcementConverter::converter); } @Override public void save(CarouselMapSaveForm form) { CarouselMap carouselMap = new CarouselMap(); carouselMap.setImgUrl(form.getImgUrl()); carouselMap.setWeight(form.getWeight()); save(carouselMap); } }
20、首页相关功能分析与实现
当系统的用户,我们第一页给用户展示的数据就是首页的数据,那么目前我们需要给用户展示如下几个信息:
- 轮播图信息
- 公告信息,就展示一条,最新的那条
下面来看看代码实现:
20.1 controller
包:cn.j3code.booksys.api.controller
类:
less
复制代码
@Slf4j @ResponseResult @AllArgsConstructor @RestController @RequestMapping("/home") public class HomeController { private final HomeService homeService; /** * 获取首页的数据 * * @return */ @GetMapping("") public HomeVO home() { return homeService.home(); } }
20.2 server
包:cn.j3code.booksys.service.impl
类:
less
复制代码
@AllArgsConstructor @Slf4j @Service public class HomeServiceImpl implements HomeService { private final AnnouncementService announcementService; private final CarouselMapService carouselMapService; private final AnnouncementConverter announcementConverter; @Override public HomeVO home() { HomeVO homeVO = new HomeVO(); homeVO.setImgUrlList( carouselMapService.lambdaQuery().orderByAsc(CarouselMap::getWeight) .list().stream().map(CarouselMap::getImgUrl).collect(Collectors.toList()) ); Page<Announcement> page = announcementService.lambdaQuery() .orderByAsc(Announcement::getWeight) .orderByDesc(Announcement::getCreateTime) .page(new Page<>(1, 1)); if (page.getTotal() > 0) { homeVO.setAnnouncementVO(page.convert(announcementConverter::converter).getRecords().get(0)); } return homeVO; } }
21 接口权限控制
上面我们已经把所有的业务功能都实现完成了,那么现在就可以开始给我们的接口增加权限了。前面我们也说了,系统主要分为三个角色:
- 超级管理员:ROLE_SUPER_ADMIN
- 普通管理员:ROLE_ADMIN
- 用户:ROLE_USER
那么,这三个角色对应的权限肯定是不一样的,这如何实现呢!
其实也很简单,因为我们前面做过这个工具类 SecurityUtil ,他会在请求未到达业务逻辑之前将登录的用户角色保存起来,后面业务逻辑就可以从这里面获取当前登录人的角色信息。
现在就好办了,我们可以通过 AOP 来实现这一功能。
这里,你们要知道一个请求的优先级:过滤器先执行,然后是拦截器,最后才是 AOP 切面。
所以,我们在 AOP 切面中通过 SecurityUtil 获取登录人角色是一定可以获取到的。
21.1 定义注解
包:cn.j3code.booksys.common.apirule
less
复制代码
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface ApiRole { /** * api 运行访问的角色 * @return */ RoleEnum[] role(); }
这个注解只允许放在方法上,并且 role 设置的是可访问方法的角色。
当方法上标注了这个注解,那么里面的 role 值,就表示了这些角色可以访问这个方法,其他的一律提示,权限不足。
21.2 AOP 切面逻辑
包:cn.j3code.booksys.common.apirule
less
复制代码
@Aspect @Component public class ApiRuleAspect { @Pointcut("@annotation(cn.j3code.booksys.common.apirule.ApiRole)") private void apiRole(){} /** * 前置通知 */ @Before("apiRole()") public void before(JoinPoint joinPoint) { // controller 方法上 没有标注 @ApiRole 注解的,默认不做角色限制 ApiRole apiRole = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(ApiRole.class); if (Objects.isNull(apiRole)){ // 不做处理 return; } // 获取注解中指定的角色 Set<RoleEnum> rule = Arrays.stream(apiRole.role()).collect(Collectors.toSet()); // 对比当前登录人的角色和接口指定的角色 if (Boolean.FALSE.equals(rule.contains(SecurityUtil.getRole()))){ throw new SysException("权限不足!"); } } }
只要实现一个前置通知就行了,通过 JoinPoint 对象,获取标注在方法上的 ApiRole 注解。如果没有标注,表示不对方法做角色现在,也即都可以访问。反之,则进行角色对比,只允许指定角色访问。
21.3 使用
后续,自己对后端的所有 controller 方法都加上 ApiRole 注解。这样,我们就实现了后端接口的鉴权处理。
后端控制了,也别忘了前端页面也控制一下。
说明
以上所展示的代码,只是主流程代码,像一些请求对象,返回对象等我都没有一一贴出来,不然文档的体检就会过大。你们可以根据我的每一个小节去源代码中一一的对照理解就行。源码最全,代码注释也清晰。