MeteorCat / SpringBoot脚手架技巧配置

Created Sun, 03 Dec 2023 18:35:22 +0800 Modified Wed, 29 Oct 2025 23:25:00 +0800
2478 Words

格式化JSON响应

带有 HTTP 状态 + JSON 内容响应结构体:

package com.app.fox.utils;

import lombok.Getter;

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

@Getter
public class JsonResponse {

    /**
     * 预定义空结构
     */
    public static Map<String, Object> EMPTY = new HashMap<>(0);

    private final String message;

    private final Object data;

    private final Integer time;


    private JsonResponse(String message, Object data) {
        this.message = message;
        this.data = data == null ? EMPTY : data;
        this.time = Math.toIntExact(System.currentTimeMillis() / 1000L);
    }


    public static ResponseEntity<JsonResponse> response(HttpStatus status, String message, Object data) {
        return new ResponseEntity<>(new JsonResponse(message, data), status);
    }


    public static ResponseEntity<JsonResponse> success(String message, Object data) {
        return response(HttpStatus.OK, message, data);
    }

    public static ResponseEntity<JsonResponse> success(Object data) {
        return success(HttpStatus.OK.getReasonPhrase(), data);
    }


    public static ResponseEntity<JsonResponse> fail(String message) {
        return response(HttpStatus.BAD_REQUEST, message, null);
    }

}

RestController配置

常用 API 接口通用映射方式 RestControllerAdvice :

package com.app.fox.config;

import com.app.fox.exception.UnAuthorizedException;
import com.app.fox.utils.JsonResponse;
import io.swagger.v3.oas.annotations.Parameter;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.HashMap;
import java.util.Objects;

@RestControllerAdvice
public class RestControllerAdviceConfig {


    /**
     * 参数验证器异常 spring-boot-starter-validation
     *
     * @param exception 转化异常
     * @return JSON
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConversionFailedException.class)
    public Object conversionException(RuntimeException exception) {
        return JsonResponse.fail(exception.getMessage());
    }


    /**
     * 同上
     *
     * @param exception 参数异常
     * @return JSON
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Object argumentException(MethodArgumentNotValidException exception) {
        String message = Objects.requireNonNull(exception.getBindingResult().getFieldError()).getDefaultMessage();
        return JsonResponse.fail(message);
    }


    /**
     * 提交类型异常
     *
     * @param exception 不支持类型
     * @return JSON
     */
    @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
    @ExceptionHandler(value = HttpMediaTypeNotSupportedException.class)
    public Object argumentException(HttpMediaTypeNotSupportedException exception) {
        return JsonResponse.response(HttpStatus.UNSUPPORTED_MEDIA_TYPE, exception.getMessage(), null);
    }

    /**
     * 404异常
     *
     * @param exception 未找到资源
     * @return JSON
     */
    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(value = NoResourceFoundException.class)
    public Object noResourceFoundException(NoResourceFoundException exception) {
        return JsonResponse.response(HttpStatus.NOT_FOUND, exception.getMessage(), null);
    }


    /**
     * 自定义的未授权异常
     *
     * @param exception 自定义异常
     * @return JSON
     */
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(value = UnAuthorizedException.class)
    public Object unAuthorizedException(UnAuthorizedException exception) {
        return JsonResponse.response(HttpStatus.UNAUTHORIZED, exception.getMessage(), null);
    }

}

参数结构体验证

需要追加依赖:


<dependencies>
    <!-- SpringBoot参数验证组件 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- 这里是需要内置的验证组件 @Valid -->
    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-validator</artifactId>
        <version>8.0.1.Final</version>
    </dependency>
</dependencies>

之后可以直接声明表单对象结构:

package com.app.fox.forms;


import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.Data;

@Data
@Schema(name = "登录信息")
public class LoginInfoForm {


    @NotBlank(message = "账户不能为空")
    @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "账号名称只允许字母数字")
    @Size(message = "账户必须在{min}-{max}字符当中", min = 5, max = 64)
    private String username;


    @NotBlank(message = "密码不能为空")
    @Size(message = "密码必须在{min}-{max}字符当中", min = 5, max = 64)
    private String password;

}

权限拦截验证

需要先声明 HandlerInterceptor 拦截器:

package com.app.fox.config;

import com.app.fox.exception.UnAuthorizedException;
import com.app.fox.repository.models.UserInfoModel;
import com.app.fox.service.UserInfoService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

public class LoginInterceptorConfig implements HandlerInterceptor {

    /**
     * 日志句柄
     */
    private final Logger logger = LoggerFactory.getLogger(getClass());

    private final UserInfoService userInfoService;

    public LoginInterceptorConfig(UserInfoService userInfoService) {
        this.userInfoService = userInfoService;
    }

    @Override
    public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
        UserInfoModel userInfoModel = userInfoService.getOnlineUser(request);
        logger.debug("启动鉴权:{}", userInfoModel);
        return true;
    }


    @Override
    public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) throws Exception {

    }
}

之后就是配置挂载服务 WebMvcConfigurer :

package com.app.fox.config;

import com.app.fox.service.UserInfoService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.ArrayList;

@Order(1)
@Configuration
public class WebMvcConfigurerConfig implements WebMvcConfigurer {


    private final UserInfoService userInfoService;

    public WebMvcConfigurerConfig(UserInfoService userInfoService) {
        this.userInfoService = userInfoService;
    }

    @Bean
    public LoginInterceptorConfig interceptorConfig() {
        return new LoginInterceptorConfig(userInfoService);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptorConfig())
                .addPathPatterns(new ArrayList<>() {{
                    // 监控链接
                    add("/admin/**");
                    add("/business/**");
                }})
                .excludePathPatterns(new ArrayList<>() {{
                    // 排除拦截
                    add("/admin/user/login");
                    add("/admin/user/logout");
                    add("/admin/public/**");
                    add("/business/user/login");
                    add("/business/user/logout");
                    add("/business/public/**");
                }});

    }
}

启用Swagger

用于接口开发调试, 注意正式服要关闭 swagger 服务, 首先追加配置类文件:

package com.app.fox.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.awt.*;

/**
 * Swagger配置
 */
@Configuration
public class SpringDocConfig {

    /**
     * 加载接口相关信息
     * @return OpenAPI
     */
    @Bean
    public OpenAPI openAPI() {
        Contact contact = new Contact();
        contact.setName("MeteorCat");
        contact.email("[email protected]");
        contact.url("https://www.meteorcat.net");

        Info info = new Info();
        info.title("FoxApi调试");
        info.description("FoxApi开发测试接口功能");
        info.version("V1.0");
        info.contact(contact);


        // 授权有些模块采用 bearer 做请求验权
        Components components = new Components();
        SecurityScheme securityScheme = new SecurityScheme();
        securityScheme.type(SecurityScheme.Type.HTTP);
        securityScheme.scheme("bearer");
        components.addSecuritySchemes("bearer-key", securityScheme);


        return new OpenAPI()
                .info(info)
                .components(components);
    }


    /**
     * 追加个 admin 路径的接口调试中心
     * @return GroupedOpenApi
     */
    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi
                .builder()
                .group("admin")
                .pathsToMatch("/admin/**")
                .build();
    }


    /**
     * 追加个 business 路径的接口调试中心
     * @return GroupedOpenApi
     */
    @Bean
    public GroupedOpenApi businessApi() {
        return GroupedOpenApi
                .builder()
                .group("business")
                .pathsToMatch("/business/**")
                .build();
    }

}

这里设置个复杂的文档配置:

package com.app.fox.controllers.admin;


import com.app.fox.forms.BusinessInfoForm;
import com.app.fox.service.UserInfoService;
import com.app.fox.utils.JsonResponse;
import com.app.fox.utils.ServerUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Tag(name = "admin.business", description = "后台商户用户")
@RequestMapping("/admin/business")
public class AdminBusiness {


    /**
     * 日志句柄
     */
    final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 用户数据
     */
    final UserInfoService userInfoService;

    final HttpServletRequest httpServletRequest;

    public AdminBusiness(UserInfoService userInfoService, HttpServletRequest httpServletRequest) {
        this.userInfoService = userInfoService;
        this.httpServletRequest = httpServletRequest;
    }


    /**
     * 创建商业用户
     * 采用 Swagger , security 声明 Header带有 Authorized 授权才能访问
     *
     * @param businessInfoForm 账号结构体
     * @return JSON
     */
    @Operation(summary = "创建商业用户", description = "后台管理员创建商业用户", security = {@SecurityRequirement(name = "bearer-key")})
    @PostMapping(path = "/create", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "创建成功", content = {
                    @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = JsonResponse.class))
            }),
            @ApiResponse(responseCode = "400", description = "创建失败", content = {
                    @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = JsonResponse.class))
            })
    })
    public Object create(@Valid BusinessInfoForm businessInfoForm) {
        String username = businessInfoForm.getUsername();
        String nickname = businessInfoForm.getNickname();
        String ipAddress = ServerUtils.getIpAddress(httpServletRequest);
        nickname = nickname == null ? username : nickname;
        logger.debug("create: {}", businessInfoForm);

        // 检索出管理员
        if (userInfoService.hasAdminUser(username)) {
            return JsonResponse.fail("账号已存在");
        }

        // 检索出常规玩家
        if (userInfoService.hasBusinessUser(username)) {
            return JsonResponse.fail("账号已存在");
        }

        // 生成商户账号, 具体可以转发给
        Long uid = userInfoService.getOnlineUserId(httpServletRequest);
        userInfoService.createUser(
                username,
                businessInfoForm.getPassword(),
                1,
                nickname,
                uid,
                ipAddress
        );
        return JsonResponse.success(JsonResponse.EMPTY);
    }

}

最后在 application-development.properties 启用配置:

# swagger 配置
springdoc.swagger-ui.enabled=true
springdoc.swagger-ui.path=/swagger/api.html

之后访问地址 http://127.0.0.1:8080/swagger/api.html

MySQL/MariaDB的JSON转换

新版本 MySQL/MariaDB 集成了新的 json 类型用来保存序列 Json 数据, 如果使用 JPA 可以通过编写转化器处理解析过程:

package com.app.fox.utils;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import org.springframework.web.server.ServerErrorException;

import java.io.IOException;

/**
 * Json序列化转化器
 * @param <T> 未知序列化类型
 */
public class ConvertObjectAndJson<T> implements AttributeConverter<T, String> {

    private static final ObjectMapper mapper = new ObjectMapper();

    /**
     * 将 Object 对象转化为 Json String
     * @param t 传入对象
     * @return String
     */
    @Override
    public String convertToDatabaseColumn(T t) {
        try {
            return mapper.writeValueAsString(t);
        } catch (JsonProcessingException e) {
            throw new ServerErrorException(e.getMessage(), e.getCause());
        }
    }

    /**
     * 将 Json String 尝试序列化成实体对象
     * @param s Json String
     * @return 实体对象
     */
    @Override
    public T convertToEntityAttribute(String s) {
        if (s == null || s.isBlank()) {
            return null;
        }
        try {
            return mapper.readValue(s, new TypeReference<>() {
            });
        } catch (IOException e) {
            throw new ServerErrorException(e.getMessage(), e.getCause());
        }

    }
}

之后就是在 Jpa 当中声明 json 对象:

package com.app.fox.repository.models;


import com.app.fox.utils.ConvertObjectAndJson;
import jakarta.persistence.*;
import lombok.Data;
import lombok.ToString;

import java.io.Serializable;
import java.util.Map;

@Data
@ToString
@Entity
@Table(name = "tbl_ad_browse")
public class AdBrowseModel implements Serializable {

    @Id
    @Column(nullable = false, length = 32, unique = true)
    private String id;


    @Column(nullable = false, length = 32, unique = true)
    private String adId;


    @Column(nullable = false)
    private Integer createTime;


    @Column(nullable = false, length = 64)
    private String createIpAddress;


    /**
     * 尝试写入 JSON 对象并映射数据库, 这里就是关键点
     * 需要 Convert 注解
     */
    @Convert(converter = ConvertObjectAndJson.class)
    @Column(nullable = false, columnDefinition = "JSON")
    private Map<String, Object> data;
}

后续主要其他转化器都是可以 @Convert + AttributeConverter 做转化, 比如将数据库的 tinyint(1) - 1|0 转化位 true|false.

JPA 衍生 Convert 转化 Date

数据库常规用秒级时间戳保存int, 但是日常 Java 内部都是 date 形式, 这里可以自定义转化来处理:

package com.app.fox.utils;

import jakarta.persistence.AttributeConverter;

import java.util.Date;

/**
 * 日期转化器
 */
public class ConvertDateAndInteger implements AttributeConverter<Date, Integer> {


    /**
     * 将 Date 转为时间戳
     *
     * @param date 日期对象
     * @return Integer
     */
    @Override
    public Integer convertToDatabaseColumn(Date date) {
        return Math.toIntExact(date.getTime() / 1000L);
    }

    /**
     * 将时间戳转为date
     *
     * @param integer 秒级时间戳
     * @return Date
     */
    @Override
    public Date convertToEntityAttribute(Integer integer) {
        return new Date(integer * 1000L);
    }
}

之后在 JPA 实体对象追加注解即可:


@Data
@ToString
@Entity
@Table(name = "tbl_user_info")
public class UserInfoModel implements Serializable {

    /**
     * 自定义时间转化器
     */
    @Convert(converter = ConvertDateAndInteger.class)
    @Column(nullable = false, columnDefinition = "INT")
    private Date createTime;


    /**
     * 自定义时间转化器
     */
    @Convert(converter = ConvertDateAndInteger.class)
    @Column(nullable = false, columnDefinition = "INT")
    private Date updateTime = new Date(0L);

}

这也就能自定义关联在 Java 的 Date 对象处理.

JPA动态分页

JPA内部已经集成分页功能, 只需要在 Repository 接口中实现 JpaSpecificationExecutor<T>:

/**
 * 应用数据仓库工厂, 这里 JpaSpecificationExecutor 就是实现了分页功能
 */
public interface AppInfoRepository extends CrudRepository<AppInfoModel, Long>, JpaSpecificationExecutor<AppInfoModel> {
}

之后就是构建查询请求:


@RestController
@RequestMapping("/app")
public class App {

    @GetMapping(path = "/list", consumes = MediaType.ALL_VALUE)
    public Object list(@RequestParam Map<String, String> data) {
        // 检索条件, 采用GET获取请求参数
        String name = data.get("name");
        String id = data.get("id");

        // 复杂条件查询
        Specification<AppInfoModel> specification = (root, query, criteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();
            // 确定条件存在附加上条件
            if (name != null) {
                predicates.add(criteriaBuilder.equal(root.get("name"), name));
            }

            if (id != null) {
                predicates.add(criteriaBuilder.equal(root.get("id"), id));
            }

            Predicate[] arrays = new Predicate[predicates.size()];
            return criteriaBuilder.and(predicates.toArray(arrays));
        };


        // 分页生成, 并且排序处理
        int page = Integer.parseInt(data.getOrDefault("offset", "0"));
        int total = Integer.parseInt(data.getOrDefault("total", "20"));
        Pageable pageable = PageRequest.of(page, total, Sort.Direction.DESC,"id");

        // 数据仓库查询, 这就能查询到分页数据
        Page<AppInfoModel> result = repository.findAll(specification,pageable);
        return result;
    }
}