跳到内容

Web MVC Starter

受众:开发者 摘要:统一 HTTP API 基础:Result<T> + 全局异常 + TraceId + Validation + CORS + 安全响应头 + 统一响应封装。

何时使用

场景建议
普通 JSON APIResponseEntity.ok(Result.success(data))
文件下载 / 二进制Resource / byte[] + @IgnoreResponseWrapper
SSE / 流式SseEmitter / StreamingResponseBody + @IgnoreResponseWrapper
页面 / HTML@Controller + produces=text/html(见 view.md
接口元数据 / Swagger默认跳过封装,看 api/openapi.md
Validation 消息{messages.xxx} + i18n key
业务异常继承 FrameworkException,如 BusinessException
链路追踪TraceIdContext.getOrCreate(),自动写 X-Trace-Id

HTTP 只表达传输层是否成功;业务成功失败由 Result.code 判断。即使业务异常、参数校验失败、资源不存在,Web MVC starter 仍返回 HTTP 200 OK

快速开始

xml
<dependency>
    <groupId>com.springopen</groupId>
    <artifactId>spring-open-starter-webmvc</artifactId>
</dependency>

Controller:

java
@RestController
@RequestMapping("/api/v1/admin/iam/users")
public class UserController extends BaseController {

    @GetMapping("/{id}")
    public ResponseEntity<?> show(@PathVariable Long id) {
        preHandle(ControllerAction.SHOW, "iam:user:view");
        return ok(UserVO.from(userService.getRequired(id)));
    }
}

响应:

json
{
  "code": "200",
  "message": "操作成功",
  "data": { },
  "traceId": "5f9f1c7c3a344c8c8d5b1e3d8d2d5c7a",
  "timestamp": 1790000000000
}

API 版本

REST API 路径必须显式带版本号:

text
/api/v{major}/{domain}/{resources}

推荐把版本号固定放在 /api 后面,例如 /api/v1/iam/users/api/v1/mall/orders/api/v1/ai/gateway。业务域放在主版本后面,便于网关规则、OpenAPI 分组、前端 baseURL 和日志检索保持一致;后端应通过公共路由常量派生域前缀,减少未来调整主前缀时的改动面。

后台管理 API 对外统一使用 /api/v1/admin/{domain}/**,例如 /api/v1/admin/iam/users/api/v1/admin/mall/orders。开发期不保留旧后台路径兼容转发,后端 Controller 应直接挂载真实 owning domain。

规则说明
v1第一版稳定契约
同版本内只做兼容扩展(新增可选字段、可选参数、新接口)
v2 / v3破坏性语义、响应字段语义变化、鉴权模型变化、请求结构不兼容
新模块新增无版本 REST API
OpenAPI / Swagger / Actuator / 页面 / 静态资源 / WebSocket 握手不属于 REST API 版本规则

不推荐通过 Header 或 query 参数传版本号,因为它们对浏览器调试、Nginx / 网关路由、OpenAPI 展示、日志排查和第三方接入都不如路径版本直观。

AI 网关这类兼容外部协议的接口允许出现两层版本,例如 /api/v1/ai/gateway/claude/v1/messages/api/v1 是框架 API 主版本,claude/v1 是兼容 Claude / Anthropic Messages 协议的版本,两者语义不同,可以共存。

Facade API

Result<T>

字段说明
code业务状态码(字符串)
message用户可见消息,支持 i18n key
data业务数据
traceId链路追踪 ID
timestamp响应时间戳
java
Result.success();
Result.success(data);
Result.success(data, message);
Result.fail(resultCode);
Result.fail(code, message);
Result.fail(code, message, data);
Result.of(code, message, data);

result.isSuccess();
result.isFailure();

BaseController

方法说明
preHandle(action, permission)显式前置(权限校验等)
ok()ResponseEntity<Result<Void>>
ok(data)Result<T> JSON 响应体

BaseController 是通用 CRUD Controller,推导 URL 权限,替代显式 DTO / VO / Service。

后续可评估 @ControllerResourceHandlerInterceptor 自动前置,但当前版本继续保持显式 preHandle(action, permission)。原因是权限码和资源动作必须清晰可审计,不能为了省几行代码让 URL 推导、默认权限或自动 CRUD 放大安全风险。

GlobalResultCode

code含义
200成功
400请求错误
401未认证
403无权限
404资源不存在
405方法不允许
409冲突
422参数校验失败
429请求过多
500系统内部错误
503服务不可用
40001通用业务异常
50001配置异常

TraceIdContext

java
String traceId = TraceIdContext.getOrCreate();

异步任务、队列、调度任务继承或生成新 traceId 并写入执行日志。

配置项

yaml
spring:
  open:
    web:
      enabled: true
      response-wrapper-enabled: true
      exception-handler-enabled: true
      trace-id-enabled: true
      trace-header-name: X-Trace-Id
      trace-header-aliases:
        - X-Request-Id
        - X-Request-ID
      response-wrapper-exclude-paths:
        - /actuator
        - /v3/api-docs
        - /swagger-ui
        - /swagger-ui.html
      client-context:
        enabled: true
        filter-enabled: true
        default-locale: zh-CN
        default-country: CN
        default-currency: CNY
        default-time-zone: Asia/Shanghai
        infer-country-from-locale: true
        geo-ip:
          enabled: true
          default-provider: noop
          fallback-providers:
            - noop
          providers:
            mica-ip2region:
              enabled: true
              # 默认读取依赖内置离线库;也可改为 file:/home/app/data/geoip/ip2region_v4.xdb
              ipv4-location: classpath:ip2region/ip2region_v4.xdb
              ipv6-location: classpath:ip2region/ip2region_v6.xdb
            maxmind:
              enabled: true
              # 需要部署方自行提供 GeoLite2 / GeoIP2 离线库
              location: ""
              locales:
                - zh-CN
                - en
      cors:
        enabled: true
        allow-all-origins: true
        allowed-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
        allowed-headers: "*"
        exposed-headers: X-Trace-Id
        allow-credentials: false
        max-age: 3600
      security:
        headers:
          enabled: true
          content-type-options: nosniff
          frame-options: SAMEORIGIN
          referrer-policy: strict-origin-when-cross-origin
          xss-protection: 0
          content-security-policy: ""
          permissions-policy: ""
      config:
        enabled: true
配置默认值说明
enabledtrue启用开关
response-wrapper-enabledtrue统一响应封装
exception-handler-enabledtrue全局异常处理
trace-id-enabledtrueTraceId 过滤器
trace-header-nameX-Trace-IdTraceId 请求 / 响应头
trace-header-aliasesX-Request-Id,X-Request-IDTraceId 兼容请求头别名;响应仍使用 trace-header-name
client-context.enabledtrue请求级客户端上下文
client-context.filter-enabledtrue自动为每个请求写入 ClientContextHolder
client-context.default-localezh-CN默认语言区域
client-context.default-countryCN默认国家或地区
client-context.default-currencyCNY默认展示法币
client-context.default-time-zoneAsia/Shanghai默认时区
client-context.geo-ip.default-providernoop默认 GeoIP Provider
client-context.geo-ip.fallback-providersnoopGeoIP 后备 Provider 链
client-context.geo-ip.providers.<name>.enabledtrue单个 GeoIP Provider 启用开关
client-context.geo-ip.providers.<name>.location通用离线库路径,支持绝对路径、file:classpath:
client-context.geo-ip.providers.mica-ip2region.ipv4-locationclasspath:ip2region/ip2region_v4.xdbmica-ip2region IPv4 离线库
client-context.geo-ip.providers.mica-ip2region.ipv6-locationclasspath:ip2region/ip2region_v6.xdbmica-ip2region IPv6 离线库
client-context.geo-ip.providers.maxmind.localeszh-CN,enMaxMind 城市名称语言偏好
cors.enabledtrueCORS
cors.allow-all-originstrue默认开发友好;生产改 false
security.headers.xss-protection0现代浏览器建议 0
config.enabledtrue允许数据库配置覆盖 Web 配置

客户端上下文

ClientContextManager 会按请求生成 ClientContext,供金额展示、语言、国家、默认法币、时区和后续国际化能力复用。

解析优先级:

  1. 前端明确传入:Accept-LanguageX-CountryX-CurrencyX-Time-Zone
  2. 登录用户偏好:语言、国家 / 地区、默认展示法币和时区
  3. GeoIP Provider 或可信网关头
  4. Accept-Language 的地区部分
  5. Web 配置默认值:zh-CN / CN / CNY / Asia/Shanghai

country 表示当前请求最终用于展示的国家或地区;detectedCountry 表示服务端通过 GeoIP 或可信网关识别到的国家或地区。业务可以用 country 做展示默认值,但不能把它直接当作 KYC、税务、支付准入或风控事实。

默认内置 Provider:

Provider说明
noop默认占位,不做 IP 推断,保证本地和 Docker 开箱即用
header读取可信网关头,例如 CF-IPCountryCloudFront-Viewer-CountryX-Geo-Country
mica-ip2region国内离线库,默认读取依赖内置 ip2region v4/v6 数据,也支持项目私有库路径
maxmind国际通用离线库,读取 GeoLite2 / GeoIP2 .mmdb,需要部署方自行提供库文件

离线 GeoIP Provider 统一支持两类库文件位置:外部文件 / Docker 挂载,以及 classpath 资源。项目方可以把私有 IP 库放到 src/main/resources/geoip/ 随应用打包,例如 classpath:geoip/ip2region.xdbclasspath:geoip/GeoLite2-City.mmdb;默认仓库不内置真实 IP 数据库文件,mica-ip2region 依赖自身携带的基础离线库除外。

阿里云 / 腾讯云在线 IP 查询接口当前不作为内置 Provider,且已取消接入计划。需要云厂商在线能力时,建议先由 CDN / 网关注入可信国家码,再使用 header Provider。

推荐链路:

场景默认 Provider回退链
开箱默认noopnoop
生产有 CDN / 网关国家码headermica-ip2region -> maxmind -> noop
国内私有化 / 国内服务器mica-ip2regionmaxmind -> noop
海外 / 国际化部署maxmindmica-ip2region -> noop

开箱默认不做 IP 推断,保证本地开发、Docker 和私有部署不会因为 GeoIP 配置影响主请求。生产环境优先信任 CDN / 网关注入的国家码,其次再用离线库。

国内私有化示例:

yaml
spring:
  open:
    web:
      client-context:
        geo-ip:
          default-provider: mica-ip2region
          fallback-providers:
            - maxmind
            - noop

生产有 CDN / 网关时:

yaml
spring:
  open:
    web:
      client-context:
        geo-ip:
          default-provider: header
          fallback-providers:
            - mica-ip2region
            - maxmind
            - noop

海外 / 国际化部署:

yaml
spring:
  open:
    web:
      client-context:
        geo-ip:
          default-provider: maxmind
          fallback-providers:
            - mica-ip2region
            - noop

X-Country / X-Currency 只代表展示偏好,不能作为 KYC、税务、支付准入或风控判断依据。虚拟币 / Web3 资产不从语言或 IP 推断,继续使用 assetCode、链、合约和资产精度体系。

Provider

Web 本身只提供请求级 GeoIP Provider;业务能力 Provider 见 provider.md。其他扩展能力:

  • 自定义 MessageResolver 改异常消息解析
  • 自定义 ResultCode 实现追加业务返回码
  • 自定义 ResponseBodyAdvice 进一步包装响应(推荐覆盖默认 wrapper)
  • 自定义 WebSecurityHeadersCustomizer 调整安全响应头

Controller 返回类型

Controller 统一 ResponseEntity<?>,目的不是替代 Result<T>,而是同时支持 JSON / 文件 / SSE / 二进制 / 重定向 / 特殊 Header。

java
@GetMapping
public ResponseEntity<?> index(@Valid UserPageRequest request) {
    PageResult<UserVO> page = userService.pageUsers(request.toQuery()).map(UserVO::from);
    return ResponseEntity.ok(Result.success(page));
}

@DeleteMapping("/{id}")
public ResponseEntity<?> destroy(@PathVariable Long id) {
    userService.deleteUser(id);
    return ResponseEntity.ok(Result.success());
}

跳过响应封装

自动跳过:

类型场景
Resource文件下载
byte[]二进制响应
ResponseBodyEmitter / SseEmitter / StreamingResponseBody流式 / SSE
ProblemDetailSpring 原生问题详情
text/html 字符串页面 / 安装向导 / 静态 HTML

默认跳过路径:/actuator / /v3/api-docs / /swagger-ui / /swagger-ui.html

显式跳过:

java
@IgnoreResponseWrapper
@GetMapping("/download")
public ResponseEntity<Resource> download() {
    return ResponseEntity.ok(resource);
}

类上标注时整个 Controller 跳过封装。

页面 Controller 显式 produces = MediaType.TEXT_HTML_VALUE;客户端用 Accept: application/json 请求只提供 HTML 的入口时,框架返回 Result.code=406

文件下载

java
@IgnoreResponseWrapper
@GetMapping("/files/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
    Resource resource = fileService.loadResource(id);
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"sample.txt\"")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
}

SSE

java
@IgnoreResponseWrapper
@GetMapping("/events")
public ResponseEntity<SseEmitter> events() {
    return ResponseEntity.ok(new SseEmitter());
}

异常处理

可预期异常继承 FrameworkException

异常默认 code
BusinessException40001
ValidationException422
UnauthorizedException401
ForbiddenException403
ResourceNotFoundException404
ConfigurationException50001
java
throw new ResourceNotFoundException(MessageKeys.DATA_RECORD_NOT_FOUND);
throw new BusinessException("{messages.user.username.exists}", username);

全局异常处理器解析消息 key,返回结构化 Result。未处理异常记录错误日志,返回 GlobalResultCode.INTERNAL_ERROR

Validation

java
public class UserSaveRequest {
    @NotBlank(message = "{messages.user.username.not-blank}")
    @Size(max = 64, message = "{messages.user.username.size}")
    private String username;
}

@PostMapping
public ResponseEntity<?> store(@Valid @RequestBody UserSaveRequest request) {
    return ResponseEntity.ok(Result.success(userService.createUser(request.toCommand())));
}

校验失败返回:

json
{
  "code": "422",
  "message": "参数校验失败",
  "data": [
    {"field": "username", "message": "用户名不能为空", "rejectedValue": ""}
  ],
  "traceId": "...",
  "timestamp": 1790000000000
}

文案必须用 {messages.xxx}${}

FormRequest(规范化 + 授权)

@Valid DTO 已覆盖字段校验和统一错误响应。如果还需要「校验前规范化」和「请求级授权」与 DTO 同处(对标 Laravel FormRequest),让请求体实现 FormRequest

java
public class CreateOrderRequest implements FormRequest {

    @NotBlank(message = "{messages.order.sku.not-blank}")
    private String sku;

    @Min(1)
    private int quantity;

    @Override
    public void prepareForValidation() {
        sku = sku == null ? null : sku.trim();   // 校验前规范化
    }

    @Override
    public boolean authorize() {
        return Auth.hasPermission("order:create"); // 校验前授权,false 抛 ForbiddenException(403)
    }
}

Controller 写法不变(@Valid @RequestBody CreateOrderRequest request)。FormRequestBodyAdvice 在反序列化之后、Bean Validation 之前依次调用 prepareForValidation()authorize(),顺序与 Laravel「规范化 → 授权 → 校验」一致;授权失败走统一 403、校验失败走上面的错误袋。底层仍是 Bean Validation,不另造校验引擎。请求级授权只承载与本请求强绑定的判断;跨请求通用权限仍走 Sa-Token / Gate

TraceId

请求 / 响应头默认 X-Trace-Id

http
X-Trace-Id: trace-sample

没传时 TraceIdFilter 自动生成,同时写入 SLF4J MDC(traceId)。入口会兼容读取 X-Request-Id / X-Request-ID,但响应仍回写标准 X-Trace-Id。外部传入的 TraceId 只接受短文本中的字母、数字、 -_.:,超长或包含空白 / 换行 / 路径分隔符时会被丢弃并重新生成。

应用上下文中的 RestTemplate 会自动追加当前线程 TraceId 到出站请求;如果请求已显式设置 X-Trace-Id,框架不会覆盖。队列消息带 traceId 时,Queue dispatcher 会在 handler 执行期间写入 TraceIdContext 和 MDC,执行结束后恢复进入 dispatcher 前的上下文。

Locale

Spring 根据 Accept-Language 解析。Web MVC starter 只消费 MessageResolver维护资源文件。多语言看 i18n.md

响应编码

默认 UTF-8:

yaml
server:
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true

统一 JSON 响应显式 Content-Type: application/json;charset=UTF-8,避免代理按 GBK 误解码。

条件包含(API Resource)

VO + @ApiResult 已覆盖 model→JSON。要做「按请求展开关系」(对标 Laravel API Resource 的 whenLoaded / JSON:API 的 ?include=),用 ResourceIncludes:Controller 直接声明形参,框架从 ?include= 解析注入。

java
@GetMapping("/{id}")
@ApiResult(ArticleVO.class)
public ResponseEntity<?> show(@PathVariable Long id, ResourceIncludes includes) {
    Article article = articleService.getRequired(id);
    ArticleVO vo = ArticleVO.of(article);
    if (includes.has("author")) {
        vo.setAuthor(AuthorVO.of(userService.getRequired(article.getAuthorId())));
    }
    if (includes.has("comments")) {
        vo.setComments(commentService.listByArticle(id, includes.nested("comments")));
    }
    return ResponseEntity.ok(Result.success(vo));
}

请求 GET /articles/1?include=author,comments.userhas("author") / has("comments") 为真,has("comments.user") 为真,nested("comments") 得到子包含 user 传给评论 VO。

约定要点,保持类型安全与稳定 OpenAPI schema(不引入动态 map 响应):

  • 可选关系字段声明为可空字段并加 @JsonInclude(JsonInclude.Include.NON_NULL),未请求即不填、序列化自动省略。
  • 关系名是稳定 API 契约,include 值应做白名单校验,不透传成数据库列名或任意关联查询。
  • 只做「条件包含」;字段级稀疏(?fields=)不内建,避免破坏 schema 与脱敏约定。

批量删除

ID 不放 URL,用请求体:

http
DELETE /api/v1/admin/iam/users
Content-Type: application/json

{"ids": [1, 2, 3]}

BatchDeleteRequest.idsList<Object>,兼容 Long / String 不同主键。

CORS

默认全开放方便前后端分离开发。生产收紧:

yaml
spring:
  open:
    web:
      cors:
        allow-all-origins: false
        allowed-origins:
          - https://admin.example.com
          - https://www.example.com

可写入数据库配置,key 一致。变更后新请求按最新配置处理。

安全响应头

默认:

http
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 0

X-XSS-Protection=0 是现代浏览器推荐值,真正 XSS 防护依赖:

  • 前端模板默认转义
  • 富文本白名单清洗
  • 后端输出编码
  • 关键页面按需配置 CSP
yaml
spring:
  open:
    web:
      security:
        headers:
          content-security-policy: "default-src 'self'; img-src 'self' data: https:; script-src 'self'"

全局改写请求 body / 参数,避免破坏 JSON / 富文本 / 开放平台签名 / Webhook 原始报文。富文本清洗在对应字段进业务前做白名单。

中间件

借鉴 Laravel route middleware,在路由命中后、Controller 执行前按「中间件组」对一组路由施加横切逻辑。它建立在 Spring HandlerInterceptor 之上,是 HTTP 横切的「应用 / 路由层」,与底层 servlet 过滤器(CORS / 安全响应头 / TraceId / 客户端上下文)和 Sa-Token 注解鉴权互补,不替代也不重复。

写一个中间件

实现 Middleware,声明为 Spring Bean 即可被按别名引用:

java
@Component
public class AdminGuardMiddleware implements Middleware {

    @Override
    public boolean handle(HttpServletRequest request, HttpServletResponse response, List<String> parameters)
            throws Exception {
        if (!authorized(request, parameters)) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return false; // 短路:不再执行后续中间件与 Controller
        }
        return true;
    }

    // 可选:响应完成后收尾(terminable middleware)
    @Override
    public void terminate(HttpServletRequest request, HttpServletResponse response) { }
}
  • 返回 false 即短路(等价 Laravel 中不调用 $next),短路时需自行写出响应。
  • 别名默认取类名去 Middleware 后缀转 kebab-case(AdminGuardMiddlewareadmin-guard),重写 alias() 可固定别名。
  • 中间件参数:引用写成 alias:p1,p2(如 throttle:120,1),参数以 List<String> 传入 handle

把中间件绑定到路由

配置式声明中间件组(组按 path 模式施加,组内按序执行):

yaml
spring:
  open:
    web:
      middleware:
        enabled: true
        groups:
          admin:
            order: 0
            path-patterns: ["/api/v1/admin/**"]
            exclude-patterns: ["/api/v1/admin/auth/**"]
            middleware: ["admin-guard", "throttle:120,1"]

或编程式声明(模块侧补充,等价 Laravel HTTP Kernel):

java
@Bean
MiddlewareRegistrar adminMiddleware() {
    return registry -> registry.group("admin")
            .order(0)
            .pathPatterns("/api/v1/admin/**")
            .excludePatterns("/api/v1/admin/auth/**")
            .middleware("admin-guard");
}

多个组命中同一路径时按组 order 升序执行;同名中间件别名视为装配歧义并启动失败。默认无任何中间件组时不产生额外开销。

按处理方法施加(注解式)

中间件组适合「一类路由的统一策略」;针对具体接口可直接用 @RouteMiddleware 标注 Controller 类或方法,等价 Laravel 路由 / 控制器 middleware:

java
@RestController
@RouteMiddleware("admin-guard")            // 类级:对该 Controller 全部方法生效
public class OrderAdminController {

    @RouteMiddleware("throttle:120,1")     // 方法级:仅此方法生效
    @GetMapping("/api/v1/admin/orders")
    public ResponseEntity<?> page() { ... }
}
  • 引用同样按别名解析,支持带参 alias:p1,p2
  • 同一方法上类级中间件先于方法级执行(控制器范围 → 方法范围)。
  • 注解中间件默认在中间件组之后执行(global → group → route),可由 spring.open.web.middleware.annotation-order 调整顺序。
  • 注解命名为 @RouteMiddleware(而非 @Middleware)以区别于中间件 SPI 接口 Middleware。引用未知别名启动后首次命中即报错。

JSON ObjectMapper

Spring Boot 4 Web JSON 默认 Jackson 3。

部分基础模块依赖 Jackson 2 稳定生态处理持久化载荷和第三方回调 JSON。存在 Jackson 2 时 Web MVC starter 提供独立 Jackson 2 ObjectMapper Bean,参与 MVC 响应序列化。业务 Controller 无需关心这个兼容 Bean。

路由内省(Actuator)

/actuator/routes 把全部 HTTP 路由聚合为「方法 + 路径 + 处理器 + operationId + 权限码 + 路由中间件」的只读清单,对标 php artisan route:list,对开发者和 AI Agent 一览接口面很有用。

jsonc
[
  {
    "methods": ["PUT"],
    "path": "/api/v1/admin/iam/users/{id}",
    "handler": "UserAdminController#update",
    "operationId": "iam_user__update",
    "permissions": ["iam:user:update"],
    "middleware": ["admin-guard"]
  }
]
  • 仅当应用引入 actuator 时注册(webmvc 以 optional 依赖 actuator,不强制 Web 底座依赖)。默认开启,可用 spring.open.web.routes-endpoint-enabled=false 关闭;暴露走标准 management.endpoints.web.exposure.include
  • 路由基础信息与 @RouteMiddleware 原生读取;operationId(OpenAPI)与权限码(Sa-Token)按类名反射读取,相应 starter 不在类路径时该列留空——即 Web 底座不硬依赖 openapi / auth。
  • 只读内省,不承载鉴权或运行时改动;生产环境按需通过 actuator 暴露与鉴权策略控制访问。

开发约定

  • Controller 写业务逻辑,只做鉴权、参数接收、调 Service、组装响应
  • 入参用 DTO + Validation
  • 出参用 VO,直接返回数据库 Entity
  • Service 承载业务逻辑,写操作加事务
  • 普通 JSON 返 ResponseEntity.ok(Result.success(...))
  • 文件下载 / SSE / 二进制 / 流式保持原始响应类型 + @IgnoreResponseWrapper
  • 用户可见消息走 i18n key
  • 用 HTTP status 判断业务成功失败,统一看 Result.code

验证命令

bash
./mvnw -pl spring-open-starters/spring-open-starter-webmvc -am test -DskipITs
./mvnw -pl spring-open-application -am test -DskipITs -DskipFrontend

相关

Released under the Apache License 2.0.