# spring-plus-apiserver **Repository Path**: amonest/spring-plus-apiserver ## Basic Information - **Project Name**: spring-plus-apiserver - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-07-24 - **Last Updated**: 2024-08-15 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README spring-plus-apiserver ======================= ### 主要特性 - 集成spring boot 常用开发组件集、公共配置、AOP日志等 - 单体架构,更快更简单 - 提供PC管理端与APP端代码接口 - 集成mybatis plus快速dao操作 - 快速生成后台代码: entity/dto/query/vo/controller/service/mapper/xml - 集成Swagger/Knife4j,可自动生成api文档 - 集成Redis缓存 - 集成HikariCP连接池,JDBC性能和慢查询检测 ### 项目环境 名称 | 版本 | 备注 -|--------|---------- JDK | 21+ | JDK21及以上 | MySQL | 8.3.0+ | 8.3.0及以上 | ### MyBatis-Plus 增强框架 [MyBatis-Plus](https://baomidou.com/) 是一个 [MyBatis](https://www.mybatis.org/mybatis-3/) 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。 - 官方网站:https://baomidou.com/ - 快速开始:https://baomidou.com/introduce/ - 代码生成器:https://baomidou.com/guides/new-code-generator/ - [MybatisX](https://github.com/baomidou/MybatisX) - 一款全免费且强大的 IDEA 插件,支持跳转,自动补全生成 SQL,代码生成。 - [Kisso](https://github.com/baomidou/kisso) - 基于 Cookie 的单点登录组件。 - [FlowLong](https://gitee.com/aizuda/flowlong) - 真正的国产工作流引擎,为中国特色审批匠心打造。 - [SnailJob](https://gitee.com/aizuda/snail-job) - 分布式任务重试和调度平台。 添加依赖。 ~~~xml com.baomidou mybatis-plus-spring-boot3-starter 3.5.7 ~~~ 在 application.yml 配置文件中添加 H2 数据库的相关配置: ~~~yaml # DataSource Config spring: datasource: driver-class-name: org.h2.Driver username: root password: test sql: init: schema-locations: classpath:db/schema-h2.sql data-locations: classpath:db/data-h2.sql ~~~ 上面的配置是任何一个 Spring Boot 工程都会配置的数据库链接信息,如果您使用的是其他数据库,如 MySQL,则需要修改相应的配置信息。 在 Spring Boot 启动类中添加 @MapperScan 注解,扫描 Mapper 文件夹: ~~~java @SpringBootApplication @MapperScan("com.baomidou.mybatisplus.samples.quickstart.mapper") public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } ~~~ 编写实体类 User.java: ~~~java @Data public class User { private Long id; private String name; private Integer age; private String email; } ~~~ 编写 Mapper 接口类 UserMapper.java: ~~~java public interface UserMapper extends BaseMapper { } ~~~ 添加测试类,进行功能测试: ~~~java @SpringBootTest public class SampleTest { @Autowired private UserMapper userMapper; @Test public void testSelect() { System.out.println(("----- selectAll method test ------")); List userList = userMapper.selectList(null); Assert.isTrue(5 == userList.size(), ""); userList.forEach(System.out::println); } } ~~~ ### knife4j 文档生成工具 Knife4j 是一个集 Swagger2 和 OpenAPI3 为一体的增强解决方案。 - 官方网站:https://doc.xiaominfo.com/ - 快速开始:https://doc.xiaominfo.com/docs/quick-start - Swagger: http://localhost:8080/swagger-ui.html - 文档地址:http://localhost:8080/doc.html - 秒懂SpringBoot之如何集成SpringDoc:https://zhuanlan.zhihu.com/p/638887405 首先,引用 Knife4j 的 starter。 ~~~xml com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter 4.5.0 ~~~ 引入之后,其余的配置,开发者即可完全参考 [springdoc-openapi](https://springdoc.org/) 的项目说明,Knife4j只提供了增强部分,如果要启用Knife4j的增强功能,可以在配置文件中进行开启。 部分配置如下: ~~~yaml # springdoc-openapi项目配置 # https://springdoc.org/#properties # https://springdoc.org/#swagger-ui-properties springdoc: swagger-ui: path: /swagger-ui.html tags-sorter: alpha operations-sorter: alpha api-docs: path: /v3/api-docs group-configs: - group: 'default' paths-to-match: '/**' packages-to-scan: com.xiaominfo.knife4j.demo.web # knife4j的增强配置,不需要增强可以不配 # https://doc.xiaominfo.com/docs/features/enhance knife4j: enable: true setting: language: zh_cn ~~~ Knife4j更多增强配置明细,请移步 [文档](https://doc.xiaominfo.com/docs/features/enhance) 进行查看 最后,使用OpenAPI3的规范注解,注释各个Spring的REST接口,示例代码如下: ~~~java @RestController @RequestMapping("body") @Tag(name = "body参数") public class BodyController { @Operation(summary = "普通body请求") @PostMapping("/body") public ResponseEntity body(@RequestBody FileResp fileResp){ return ResponseEntity.ok(fileResp); } @Operation(summary = "普通body请求+Param+Header+Path") @Parameters({ @Parameter(name = "id",description = "文件id",in = ParameterIn.PATH), @Parameter(name = "token",description = "请求token",required = true,in = ParameterIn.HEADER), @Parameter(name = "name",description = "文件名称",required = true,in=ParameterIn.QUERY) }) @PostMapping("/bodyParamHeaderPath/{id}") public ResponseEntity bodyParamHeaderPath(@PathVariable("id") String id,@RequestHeader("token") String token, @RequestParam("name")String name,@RequestBody FileResp fileResp){ fileResp.setName(fileResp.getName()+",receiveName:"+name+",token:"+token+",pathID:"+id); return ResponseEntity.ok(fileResp); } } ~~~ 最后,访问Knife4j的文档地址:http://localhost:8080/doc.html 即可查看文档 ### 统一返回结果 定义一个泛型类 ApiResponse,表示API接口返回的响应数据。 ~~~java @Data @Accessors(chain = true) @Builder @AllArgsConstructor @Schema(description = "响应结果") public class ApiResult implements Serializable { private static final long serialVersionUID = 7594052194764993562L; @Schema(description = "响应编码 200:成功,500:失败") private int code; @Schema(description = "响应结果 true:成功,false:失败") private boolean success; @Schema(description = "响应消息") private String message; @Schema(description = "响应数据") private T data; @Schema(description = "响应时间") @JSONField(format = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date time; @Schema(description = "日志链路ID") private String traceId; public static ApiResult success() { return success(null); } public static ApiResult success(Object data) { return result(ResultCode.SUCCESS, data); } public static ApiResult failed() { return failed(ResultCode.FAILED); } public static ApiResult failed(String message) { return failed(ResultCode.FAILED, message); } public static ApiResult failed(ResultCode resultCode) { return failed(resultCode, null); } public static ApiResult failed(ResultCode resultCode, String message) { if (ResultCode.SUCCESS == resultCode) { throw new RuntimeException("失败结果状态码不能为" + ResultCode.SUCCESS.getCode()); } return result(resultCode, message, null); } public static ApiResult result(boolean flag) { if (flag) { return success(); } return failed(); } public static ApiResult result(ResultCode resultCode) { return result(resultCode, null); } public static ApiResult result(ResultCode resultCode, Object data) { return result(resultCode, null, data); } public static ApiResult result(ResultCode resultCode, String message, Object data) { if (resultCode == null) { throw new RuntimeException("结果状态码不能为空"); } boolean success = false; int code = resultCode.getCode(); if (ResultCode.SUCCESS.getCode() == code) { success = true; } String outMessage; if (StringUtils.isBlank(message)) { outMessage = resultCode.getMessage(); } else { outMessage = message; } String traceId = MDC.get(Constants.TRACE_ID); return ApiResult.builder() .code(code) .message(outMessage) .data(data) .success(success) .time(new Date()) .traceId(traceId) .build(); } } ~~~ 定义一个 Java 枚举类型 ResponseCode。 ~~~java public enum ResultCode { /** * 操作成功 **/ SUCCESS(200, "操作成功"), /** * 操作失败 **/ FAILED(500, "操作失败"), /** * token已过期 */ TOKEN_EXCEPTION(5001, "Token为空或已过期,请重新登录"); private final int code; private final String message; ResultCode(final int code, final String message) { this.code = code; this.message = message; } public static ResultCode getApiCode(int code) { ResultCode[] resultCodes = ResultCode.values(); for (ResultCode resultCode : resultCodes) { if (resultCode.getCode() == code) { return resultCode; } } return FAILED; } public int getCode() { return code; } public String getMessage() { return message; } } ~~~ 实际使用场景中,并不是所有接口都能完美的统一格式,使用注解可以按需控制接口返回格式。 ~~~java @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ApiResult { String value() default ""; int successCode() default 0; String successMsg() default "success"; Class extends IResult> resultClass() default Result.class; } ~~~ 编写ControllerAdvice。 ~~~java @Order(0) @ControllerAdvice public class MyResponseBodyAdvice implements ResponseBodyAdvice { protected boolean isStringConverter(Class converterType) { return converterType.equals(StringHttpMessageConverter.class); } protected boolean isApiResult(MethodParameter returnType) { return returnType.hasMethodAnnotation(ApiResult.class); } @Override public boolean supports(MethodParameter returnType, Class converterType) { return !isStringConverter(converterType) && isApiResult(returnType); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { return Result.success(body); } } ~~~ 这里有一点要注意,这个advice中supports方法中判断返回结果类型必须为非String类型。如果返回结果为String类型,那么result要转为json字符串后再返回,需要再写一个advice。 ### 统一异常处理 创建全局异常处理类GlobalExceptionHandler。 ~~~java @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(value = Exception.class) @ResponseStatus(HttpStatus.OK) public ApiResult exceptionHandle(Exception exception) { log.error("exception:", exception); return handle(exception); } private static ApiResult handle(Throwable exception) { if (exception instanceof LoginException) { return ApiResult.failed(ResultCode.TOKEN_EXCEPTION, exception.getMessage()); } else if (exception instanceof AuthException) { return ApiResult.failed(exception.getMessage()); } else if (exception instanceof BusinessException) { return ApiResult.failed(exception.getMessage()); } else if (exception instanceof MethodArgumentNotValidException) { MethodArgumentNotValidException ex = (MethodArgumentNotValidException) exception; BindingResult bindingResult = ex.getBindingResult(); List fieldErrors = bindingResult.getFieldErrors(); FieldError fieldError = fieldErrors.get(0); String errorMessage = fieldError.getDefaultMessage(); log.error("参数校验错误" + ":" + errorMessage); return ApiResult.failed(errorMessage); } else if (exception instanceof HttpMessageNotReadableException) { return ApiResult.failed("请求参数解析异常"); } else if (exception instanceof MethodArgumentTypeMismatchException) { return ApiResult.failed("请求参数数据类型错误"); } else if (exception instanceof DuplicateKeyException) { return ApiResult.failed("数据违反唯一约束"); } else if (exception instanceof DataIntegrityViolationException) { return ApiResult.failed("数据完整性异常"); } else if (exception instanceof HttpRequestMethodNotSupportedException) { return ApiResult.failed(exception.getMessage()); } return ApiResult.failed(); } } ~~~ 创建业务异常类BusinessException。 ~~~java public class BusinessException extends RuntimeException { private Integer code; public BusinessException(String message) { super(message); } public BusinessException(String message, Integer code) { this(message); this.code = code; } public BusinessException(String message, Throwable e) { super(message, e); } public BusinessException(ExceptionEnum exceptionEnum) { this(exceptionEnum.getMessage(), exceptionEnum.getCode()); } public Integer getCode() { return code; } } ~~~ ### Slf4j 日志门面 SLF4J,即简单日志门面(Simple Logging Facade for Java),不是具体的日志解决方案,它只服务于各种各样的日志系统。 SLF4J是为各种loging APIs提供一个简单统一的接口,从而使得最终用户能够在部署的时候配置自己希望的loging APIs实现。 #### 使用 slf4j 创建静态日志对象。 ~~~java import org.slf4j.Logger; import org.slf4j.LoggerFactory; public abstract class TemplateEngine { private static final Logger LOGGER = LoggerFactory.getLogger(TemplateEngine.class); protected void merge(String templateName, Map objectMap, File outputFile, boolean fileOverride) { LOGGER.warn("跳过文件 [{}]", outputFile); } } ~~~ 或者使用 @Slf4j 注解。 ~~~java import response.cn.tqfeiyang.springplus.framework.ApiResult; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { /** * 全局异常处理 * * @param exception * @return */ @ExceptionHandler(value = Exception.class) @ResponseStatus(HttpStatus.OK) public ApiResult exceptionHandle(Exception exception) { log.error("exception:", exception); return handle(exception); } } ~~~ #### logback 日志实现 slf4j不提供日志的具体实现,只有slf4j是无法打印日志的。如果没有引入任何slf4j的实现类,控制台的输出为:  logback-spring.xml 常用配置。 ~~~xml ${CONSOLE_LOG_PATTERN} ${FILE_LOG_PATTERN} ${LOG_PATH}/${INFO_FILE_NAME}.log ${LOG_PATH}/${INFO_FILE_NAME}-%d{yyyy-MM-dd}.%i.log ${MAX_FILE_SIZE} ${MAX_HISTORY} ${FILE_LOG_PATTERN} ${LOG_PATH}/${ERROR_FILE_NAME}.log ${LOG_PATH}/${ERROR_FILE_NAME}-%d{yyyy-MM-dd}.%i.log ${MAX_FILE_SIZE} ${MAX_HISTORY} ERROR ACCEPT DENY 0 1024 0 1024 ~~~ #### 打印请求日志 Spring Boot 日志打印的 [三种方式](https://www.jb51.net/program/321487ipe.htm) : 过滤器、拦截器、AOP。 [How to Record Request and Response Bodies in Sping Boot Applications](https://www.springcloud.io/post/2022-03/record-request-and-response-bodies/) ~~~java @Slf4j @Component @Order(-999) @WebFilter(urlPatterns = "/*") public class AccessLoggingFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request); ContentCachingResponseWrapper resp = new ContentCachingResponseWrapper(response); // Execution request chain filterChain.doFilter(req, resp); // Get Cache byte[] requestBody = req.getContentAsByteArray(); byte[] responseBody = resp.getContentAsByteArray(); logRequest(request, requestBody); logResponse(response, responseBody); // Finally remember to respond to the client with the cached data. resp.copyBodyToResponse(); } private void logRequest(HttpServletRequest request, byte[] body) throws IOException { log.debug("===============================request begin========================================"); log.debug("Request URL : {}", request.getRequestURL()); log.debug("Request URI : {}", request.getRequestURI()); log.debug("IP Address : {}", NetworkUtils.getRequestIp(request)); log.debug("Query String : {}", request.getQueryString()); log.debug("HTTP Method : {}", request.getMethod()); log.debug("Headers : {}", JsonUtils.toJsonString(getHeadersMap(request))); log.debug("Cookies : {}", StringUtils.arrayToCommaDelimitedString(request.getCookies())); log.debug("Parameters : {}", JsonUtils.toJsonString(request.getParameterMap())); log.debug("Char Encoding : {}", request.getCharacterEncoding()); log.debug("Content Type : {}", request.getContentType()); log.debug("Request Body : {}", new String(body, StandardCharsets.UTF_8)); log.debug("================================request end========================================="); } private void logResponse(HttpServletResponse response, byte[] body) throws IOException { log.debug("===============================response begin======================================="); log.debug("Status Code : {}", response.getStatus()); log.debug("Headers : {}", JsonUtils.toJsonString(getHeadersMap(response))); log.debug("Char Encoding : {}", response.getCharacterEncoding()); log.debug("Content Type : {}", response.getContentType()); log.debug("Response Body : {}", new String(body, StandardCharsets.UTF_8)); log.debug("================================response end========================================"); } private Map getHeadersMap(HttpServletRequest request) { Map result = new HashMap<>(); Enumeration enumeration = request.getHeaderNames(); while (enumeration.hasMoreElements()) { String name = enumeration.nextElement(); result.put(name, request.getHeader(name)); } return result; } private Map getHeadersMap(HttpServletResponse response) { Map result = new HashMap<>(); response.getHeaderNames().forEach(name -> { result.put(name, response.getHeader(name)); }); return result; } } ~~~ #### 日志显示客户端IP 创建拦截器 ClientIpInterceptor,将客户端IP地址写入到 DC(Mapped Diagnostic Context)。 ~~~java /** * 客户端IP地址拦截器。 * 每次请求时,将客户端的IP地址注入到 MDC(Mapped Diagnostic Context) 中,这样可以在日志中显示。 * * @author tqfeiyang * @since 2024-07-19 */ @Component public class ClientIpInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String clientIp = NetworkUtils.getRequestIp(request); MDC.put("clientIp", clientIp); return true; } } ~~~ 在 WebMvcConfiguration 中注册拦截器。 ~~~java @Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Autowired private ClientIpInterceptor clientIpInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(clientIpInterceptor); } } ~~~ 修改 logback-spring.xml,在输出格式中添加 MDC 的值 clientIp。 ~~~xml ~~~ ### 安全框架 Spring Security #### [JSON Web Token(缩写 JWT)](https://zhuanlan.zhihu.com/p/651660344) JSON Web Token([JWT](https://jwt.io/))是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用 JSON 对象在各方之间安全地传输信息。此信息是经过数字签名的,因此可以验证和信任。 利用 Token 进行登录验证的步骤: 1. 用户输入账号密码点击登录 2. 后台收到账号密码,验证是否合法用户 3. 后台验证是合法用户,生成一个 Token返回给用户 4. 用户收到该 Token 并将其保存在每次请求的请求头中 5. 后台每次收到请求都去查询请求头中是否含有正确的 Token,只有 Token 验证通过才会返回请求的资源。  这种基于 Token 的认证方式相比较于基于传统的 cookie 和 session 方式更加节约资源,并且对移动端和分布式系统支持更加友好,其优点有: * 支持跨域访问:cookie 是不支持跨域的,而 Token 可以放在请求头中传输 * 无状态:Token 自身包含了用户登录的信息,无需在服务器端存储 session * 移动端支持更好:当客户端不是浏览器时,cookie 不被支持,采用 Token 无疑更好 * 无需考虑 CRSF:不使用 cookie,也就无需考虑 CRSF 的防御 JWT 由三部分组成,分别是 Header(头部)、Payload(有效载荷)、Signature(签名),用点(.)将三部分隔开便是 JWT 的结构,形如xxxxx.yyyyyy.zzzzz的字符串。  #### 使用 [JWT](https://jwt.io/introduction) 创建 JwtTokenSubject,保存需要在token中存放的数据。这里假设需要在token中保存用户名和uuid。 ~~~java @Data @NoArgsConstructor @AllArgsConstructor public class JwtTokenSubject { /** * 用户名 */ private String username; /** * UUID */ private String uuid; } ~~~ 创建 JwtTokenService,生成和解析token。 ~~~java @Component public class JwtTokenService { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expirationInMs}") private int jwtExpirationInMs; public String build(AuthenticationUserDetails userDetails) { return build(new JwtTokenSubject( userDetails.getUsername(), userDetails.getUuid() )); } public String build(JwtTokenSubject subject) { return Jwts.builder() .setSubject(JsonUtils.toJsonString(subject)) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationInMs)) .signWith(SignatureAlgorithm.HS512, jwtSecret) .compact(); } public JwtTokenSubject parse(String token) { Claims claims = Jwts.parser() .setSigningKey(jwtSecret) .parseClaimsJws(token) .getBody(); return parse(claims); } public JwtTokenSubject parse(Claims claims) { return JsonUtils.parseObject(claims.getSubject(), JwtTokenSubject.class); } } ~~~ #### 过滤器 AuthenticationFilter 验证控制器执行流程: 1. 当服务器收到客户请求后,检查是否存在token信息。 2. 如果存在token,就将它(字符串格式)解析成 JwtTokenSubject 结构。该结构包含了 username 和 uuid 等其它信息。 3. 根据 username 查找用户信息 AuthenticationUserDetails。目前有两种实现,一种是代码写死的 InMemoryAuthenticationUserLoader,另一种是从数据库读取 DbAuthenticationUserLoader。 4. 根据 AuthenticationUserDetails 创建 UsernamePasswordAuthenticationToken,这是 Security 上下文需要的结构。 5. 将 UsernamePasswordAuthenticationToken 注入到 SecurityContextHolder。这一步动作,表明当前用户已经是授权用户了。 6. 检查是否是 token刷新请求。如果是刷新请求,则将 JwtTokenSubject 保存到 request。目的是 控制器刷新处理时,可以根据 JwtTokenSubject 信息重新生成 token 发回到客户端。 ~~~java @Component public class AuthenticationFilter extends OncePerRequestFilter { @Resource private JwtTokenService jwtTokenService; @Resource private AuthenticationUserLoader authenticationUserLoader; @Resource @Qualifier("handlerExceptionResolver") private HandlerExceptionResolver handlerExceptionResolver; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { // JWT Token is in the form "Bearer token" String token = extractTokenFromRequest(request); if (StringUtils.hasText(token)) { JwtTokenSubject subject = jwtTokenService.parse(token); AuthenticationUserDetails userDetails = authenticationUserLoader.loadByUsername(subject.getUsername()); if (userDetails == null) { throw new JwtException("Username not found"); } Collection extends GrantedAuthority> authorities = userDetails.getAuthorities(); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, authorities); // After setting the Authentication in the context, we specify // that the current user is authenticated. So it passes the // Spring Security Configurations successfully. SecurityContextHolder.getContext().setAuthentication(authenticationToken); // Set the subject so that in controller we will be using it to create new JWT if (isRefreshTokenRequest(request)) { request.setAttribute(Constants.AUTH_SUBJECT_ATTR, subject); } } chain.doFilter(request, response); } catch (ExpiredJwtException ex) { if (isRefreshTokenRequest(request)) { // create a UsernamePasswordAuthenticationToken with null values. UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(null, null, null); // After setting the Authentication in the context, we specify // that the current user is authenticated. So it passes the // Spring Security Configurations successfully. SecurityContextHolder.getContext().setAuthentication(authenticationToken); // Set the subject so that in controller we will be using it to create new JWT request.setAttribute(Constants.AUTH_SUBJECT_ATTR, jwtTokenService.parse(ex.getClaims())); chain.doFilter(request, response); } else { handlerExceptionResolver.resolveException(request, response, null, ex); } } catch (Exception ex) { handlerExceptionResolver.resolveException(request, response, null, ex); } } private String extractTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader(Constants.AUTH_TOKEN_HEADER); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.AUTH_BEARER_PREFIX)) { return bearerToken.substring(7, bearerToken.length()); } return null; } private boolean isRefreshTokenRequest(HttpServletRequest request) { String requestURL = request.getRequestURL().toString(); return requestURL.contains(Constants.AUTH_REFRESH_URL); } } ~~~ [ModelMapper](http://modelmapper.org/) 是一个自动化对象映射库,可以自动将一个对象转换为另一个具有不同属性名称或类型的对象。例如,如果你有一个包含数据库记录的对象,并且需要将其转换为用于 API 响应的 DTO(数据传输对象),那么你可以使用 ModelMapper 来自动化这个过程,而无需手动编写转换代码。 * Github地址: https://github.com/modelmapper/modelmapper * 官网地址:http://modelmapper.org/ * 使用文档:http://modelmapper.org/user-manual/ ### 参数验证 @Validated [Springboot @Validated 注解详细说明](https://blog.csdn.net/qq_48721706/article/details/132132507) [优雅的参数验证@Validated](https://www.cnblogs.com/KL2016/p/17632649.html) @Valid 和 @ Validated 是 Spring Validation 框架提供的参数验证功能。 二者主要作用在于 都作为标准JSR-303规范,在检验Controller的入参是否符合规范时,使用@Validated或者@Valid在基本验证功能上没有太多区别。但是在分组、注解地方、嵌套验证等功能上两个有所不同。 所有参数注解含义  实体类 ~~~java @Data @Schema(description = "登录信息") public static class LoginDTO { @Schema(description = "用户名") @NotBlank(message = "用户名不能为空") private String username; @Schema(description = "密码") @NotBlank(message = "密码不能为空") private String password; } ~~~ 控制类 ~~~java @RestController @Tag(name = "授权管理") public class AuthenticationController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtTokenService jwtTokenService; /** * 用户登录 * * @return 登录结果 */ @PostMapping(value = Constants.AUTH_LOGIN_URL) @Operation(summary = "用户登录") public ApiResult login(@RequestBody @Validated LoginDTO loginDTO) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword())); AuthenticationUserDetails userDetails = (AuthenticationUserDetails) authentication.getPrincipal(); return ApiResult.success(new LoginVO(jwtTokenService.build(userDetails))); } } ~~~ ### 响应编码为 UTF-8 修改 application.properties。 ~~~properties # 响应编码为UTF-8 server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force-response=true ~~~ ### ModelMapper 自动化对象映射库 [ModelMapper](http://modelmapper.org/getting-started/) 是一个自动化对象映射库,可以自动将一个对象转换为另一个具有不同属性名称或类型的对象。例如,如果你有一个包含数据库记录的对象,并且需要将其转换为用于 API 响应的 DTO(数据传输对象),那么你可以使用 ModelMapper 来自动化这个过程,而无需手动编写转换代码。 ~~~java // 创建一个 ModelMapper 实例 ModelMapper modelMapper = new ModelMapper(); // 定义一个转换规则 modelMapper.createTypeMap(User.class, UserDto.class) .addMappings(mapper -> mapper.map(user -> user.getEmail(), UserDto::setEmail)); // 创建一个 User 对象 User user = new User(); user.setName("John Doe"); user.setEmail("john.doe@example.com"); // 将 User 对象转换为 UserDto 对象 UserDto userDto = modelMapper.map(user, UserDto.class); // 输出 UserDto 对象的内容 System.out.println(userDto.getName()); // 输出 "John Doe" System.out.println(userDto.getEmail()); // 输出 "john.doe@example.com" ~~~ ### 如何使用查询? 有两种查询方式: 1. 简单方式,例如 /warehouse/locations/list?id=1 2. 表达式方式,例如 /warehouse/locations/list?filterId=in(1,20) 所有的查询条件都要继承自 PageRequest 类,这个类包含了基本的查询有关参数,例如 页码、页大小、搜索关键字、排序等: ~~~java @Data public class PageRequest { private static final long serialVersionUID = 1569531322874458585L; @Schema(description = "页码,默认为 1", example = "1") private Integer pageNumber = Constants.PAGE_DEFAULT_INDEX; @Schema(description = "页大小,默认为 10", example = "10") private Integer pageSize = Constants.PAGE_DEFAULT_SIZE; @Schema(description = "搜索关键字", example = "") private String searchKeyword = ""; @Schema(description = "排序字段") private String orderByColumn = ""; @Schema(description = "排序方式 asc:升序,desc:降序") private OrderDirection orderDirection = null; } ~~~ 创建 LocationFTO 类,继承自 PageRequest 类。 LocationFTO 类只能接收客户端传过来的简单参数,处理不了表达式参数。 使用 LocationFTO 的好处是 可以确定使用哪些字段作为查询条件。 ~~~java @Data public class LocationFTO extends PageRequest { @FilterColumn(column = "location_id") @Schema(description = "位置ID") private Long locationId; @FilterColumn @Schema(description = "编码") private String code; @FilterColumn(column = "name") @Schema(description = "名称") private String name; @FilterColumn @Schema(description = "备注") private String remark; @FilterColumn @Schema(description = "创建时间") private LocalDateTime createTime; } ~~~ 控制器接收 LocationFTO 实例,然后传递给服务类。 ~~~java @RestController @RequestMapping("/warehouse/locations") public class LocationController { /** * 分页查询仓库位置 */ @GetMapping("/list") @Operation(summary = "分页查询仓库位置") @PreAuthorize("hasAnyAuthority('warehouse:locations:list')") public ApiResult> selectLocationList(LocationFTO locationSTO) { return ApiResult.success(locationService.selectLocationList(locationSTO)); } } ~~~ 服务类再传递给 PageHelper类 selectPage() 方法。 ~~~java @Service public class LocationServiceImpl extends ServiceImpl implements LocationService { /** * 分页查询仓库位置 * * @return 仓库位置列表 */ @Override public PagingData selectLocationList(LocationFTO locationSTO) { Assert.notNull(locationSTO, "locationFTO不能为空"); PageHelper pageHelper = new PageHelper<>(locationSTO); return pageHelper.selectPage(locationMapper, LocationVO.class); } } ~~~ PageHelper 类的 selectPage() 方法需要两个参数: ~~~java public class PageHelper { public PagingData selectPage(BaseMapper entityMapper, Function mapperFunction) { Page page = this.getPageObject(); Wrapper wrapper = this.getWrapper(); return PagingData.create(entityMapper.selectPage(page, wrapper), mapperFunction); } public PagingData selectPage(BaseMapper entityMapper, Class destinationType) { ConversionService conversionService = ContextUtils.getBean(ConversionService.class); return selectPage(entityMapper, item -> conversionService.map(item, destinationType)); } } ~~~ 第一个参数是 MybatisPlus 的 Mapper 接口,这个接口包含 一个 selectPage() 方法。 ~~~java public interface BaseMapper extends Mapper { /** * 根据 entity 条件,查询全部记录(并翻页) * * @param page 分页查询条件 * @param queryWrapper 实体对象封装操作类(可以为 null) */ default > P selectPage(P page, @Param(Constants.WRAPPER) Wrapper queryWrapper) { page.setRecords(selectList(page, queryWrapper)); return page; } } ~~~ 第二个参数是 VO 类型,例如 查询是实体类 LocationEntity,返回需要的是VO类 LocationVO,所以这里通过 ConversionService 服务实现转换。 ~~~java public class PageHelper { public PagingData selectPage(BaseMapper entityMapper, Function mapperFunction) { Page page = this.getPageObject(); Wrapper wrapper = this.getWrapper(); return PagingData.create(entityMapper.selectPage(page, wrapper), mapperFunction); } public PagingData selectPage(BaseMapper entityMapper, Class destinationType) { ConversionService conversionService = ContextUtils.getBean(ConversionService.class); return selectPage(entityMapper, item -> conversionService.map(item, destinationType)); } } ~~~ PageHelper 最关键的是 根据 PageRequest 和 HttpServletRequest 生成 QueryWrapper 对象。 ~~~java public class PageHelper { /** * 获取 MybatisPlus 的 Wrapper 对象,包含查询条件。 * * @return Wrapper 对象 */ @JsonIgnore public Wrapper getWrapper() { QueryWrapper wrapper = new QueryWrapper(); // 所有包含 @FilterColumn 注解的字段 List availableFields = getAvailableFields(); // 根据 PageRequest 对象增加查询条件 availableFields.forEach(fieldInfo -> { appendPageQuery(wrapper, fieldInfo, pageSearch); }); // 根据 HttpServletRequest 对象增加查询条件 HttpServletRequest httpRequest = ServletUtils.getRequest(); availableFields.forEach(fieldInfo -> { appendHttpQuery(wrapper, fieldInfo, httpRequest); }); return wrapper; } } ~~~ ### Apifox Helper插件 Apifox Helper 是 [Apifox](https://apifox.com/) 团队针对 IntelliJ IDEA 环境所推出的插件,可以在 IDEA 环境中识别本地 Java、Kotlin 后端项目的源代码,自动生成 API 文档并一键同步到 Apifox 的项目中。 * [插件快速上手](https://apifox.com/help/applications-and-plugins/idea/start) * [项目内配置](https://apifox.com/help/applications-and-plugins/idea/setting/use-config-in-project) * [可配置规则](https://apifox.com/help/applications-and-plugins/idea/setting/setting-rule) * []() ### 使用H2内存数据库 ~~~shell PATH: ~/.idea/default.mv.db URL: jdbc:h2:~/.idea/default ~~~ ### 参考网站 - spring-boot-plus: https://gitee.com/geekidea/spring-boot-plus
> P selectPage(P page, @Param(Constants.WRAPPER) Wrapper queryWrapper) { page.setRecords(selectList(page, queryWrapper)); return page; } } ~~~ 第二个参数是 VO 类型,例如 查询是实体类 LocationEntity,返回需要的是VO类 LocationVO,所以这里通过 ConversionService 服务实现转换。 ~~~java public class PageHelper { public PagingData selectPage(BaseMapper entityMapper, Function mapperFunction) { Page page = this.getPageObject(); Wrapper wrapper = this.getWrapper(); return PagingData.create(entityMapper.selectPage(page, wrapper), mapperFunction); } public PagingData selectPage(BaseMapper entityMapper, Class destinationType) { ConversionService conversionService = ContextUtils.getBean(ConversionService.class); return selectPage(entityMapper, item -> conversionService.map(item, destinationType)); } } ~~~ PageHelper 最关键的是 根据 PageRequest 和 HttpServletRequest 生成 QueryWrapper 对象。 ~~~java public class PageHelper { /** * 获取 MybatisPlus 的 Wrapper 对象,包含查询条件。 * * @return Wrapper 对象 */ @JsonIgnore public Wrapper getWrapper() { QueryWrapper wrapper = new QueryWrapper(); // 所有包含 @FilterColumn 注解的字段 List availableFields = getAvailableFields(); // 根据 PageRequest 对象增加查询条件 availableFields.forEach(fieldInfo -> { appendPageQuery(wrapper, fieldInfo, pageSearch); }); // 根据 HttpServletRequest 对象增加查询条件 HttpServletRequest httpRequest = ServletUtils.getRequest(); availableFields.forEach(fieldInfo -> { appendHttpQuery(wrapper, fieldInfo, httpRequest); }); return wrapper; } } ~~~ ### Apifox Helper插件 Apifox Helper 是 [Apifox](https://apifox.com/) 团队针对 IntelliJ IDEA 环境所推出的插件,可以在 IDEA 环境中识别本地 Java、Kotlin 后端项目的源代码,自动生成 API 文档并一键同步到 Apifox 的项目中。 * [插件快速上手](https://apifox.com/help/applications-and-plugins/idea/start) * [项目内配置](https://apifox.com/help/applications-and-plugins/idea/setting/use-config-in-project) * [可配置规则](https://apifox.com/help/applications-and-plugins/idea/setting/setting-rule) * []() ### 使用H2内存数据库 ~~~shell PATH: ~/.idea/default.mv.db URL: jdbc:h2:~/.idea/default ~~~ ### 参考网站 - spring-boot-plus: https://gitee.com/geekidea/spring-boot-plus