Web MVC Starter
受众:开发者 摘要:统一 HTTP API 基础:
Result<T>+ 全局异常 + TraceId + Validation + CORS + 安全响应头 + 统一响应封装。
何时使用
| 场景 | 建议 |
|---|---|
| 普通 JSON API | ResponseEntity.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。
快速开始
<dependency>
<groupId>com.springopen</groupId>
<artifactId>spring-open-starter-webmvc</artifactId>
</dependency>Controller:
@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)));
}
}响应:
{
"code": "200",
"message": "操作成功",
"data": { },
"traceId": "5f9f1c7c3a344c8c8d5b1e3d8d2d5c7a",
"timestamp": 1790000000000
}API 版本
REST API 路径必须显式带版本号:
/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 | 响应时间戳 |
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。
后续可评估 @ControllerResource 或 HandlerInterceptor 自动前置,但当前版本继续保持显式 preHandle(action, permission)。原因是权限码和资源动作必须清晰可审计,不能为了省几行代码让 URL 推导、默认权限或自动 CRUD 放大安全风险。
GlobalResultCode
| code | 含义 |
|---|---|
200 | 成功 |
400 | 请求错误 |
401 | 未认证 |
403 | 无权限 |
404 | 资源不存在 |
405 | 方法不允许 |
409 | 冲突 |
422 | 参数校验失败 |
429 | 请求过多 |
500 | 系统内部错误 |
503 | 服务不可用 |
40001 | 通用业务异常 |
50001 | 配置异常 |
TraceIdContext
String traceId = TraceIdContext.getOrCreate();异步任务、队列、调度任务继承或生成新 traceId 并写入执行日志。
配置项
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| 配置 | 默认值 | 说明 |
|---|---|---|
enabled | true | 启用开关 |
response-wrapper-enabled | true | 统一响应封装 |
exception-handler-enabled | true | 全局异常处理 |
trace-id-enabled | true | TraceId 过滤器 |
trace-header-name | X-Trace-Id | TraceId 请求 / 响应头 |
trace-header-aliases | X-Request-Id,X-Request-ID | TraceId 兼容请求头别名;响应仍使用 trace-header-name |
client-context.enabled | true | 请求级客户端上下文 |
client-context.filter-enabled | true | 自动为每个请求写入 ClientContextHolder |
client-context.default-locale | zh-CN | 默认语言区域 |
client-context.default-country | CN | 默认国家或地区 |
client-context.default-currency | CNY | 默认展示法币 |
client-context.default-time-zone | Asia/Shanghai | 默认时区 |
client-context.geo-ip.default-provider | noop | 默认 GeoIP Provider |
client-context.geo-ip.fallback-providers | noop | GeoIP 后备 Provider 链 |
client-context.geo-ip.providers.<name>.enabled | true | 单个 GeoIP Provider 启用开关 |
client-context.geo-ip.providers.<name>.location | 空 | 通用离线库路径,支持绝对路径、file: 和 classpath: |
client-context.geo-ip.providers.mica-ip2region.ipv4-location | classpath:ip2region/ip2region_v4.xdb | mica-ip2region IPv4 离线库 |
client-context.geo-ip.providers.mica-ip2region.ipv6-location | classpath:ip2region/ip2region_v6.xdb | mica-ip2region IPv6 离线库 |
client-context.geo-ip.providers.maxmind.locales | zh-CN,en | MaxMind 城市名称语言偏好 |
cors.enabled | true | CORS |
cors.allow-all-origins | true | 默认开发友好;生产改 false |
security.headers.xss-protection | 0 | 现代浏览器建议 0 |
config.enabled | true | 允许数据库配置覆盖 Web 配置 |
客户端上下文
ClientContextManager 会按请求生成 ClientContext,供金额展示、语言、国家、默认法币、时区和后续国际化能力复用。
解析优先级:
- 前端明确传入:
Accept-Language、X-Country、X-Currency、X-Time-Zone - 登录用户偏好:语言、国家 / 地区、默认展示法币和时区
- GeoIP Provider 或可信网关头
Accept-Language的地区部分- Web 配置默认值:
zh-CN / CN / CNY / Asia/Shanghai
country 表示当前请求最终用于展示的国家或地区;detectedCountry 表示服务端通过 GeoIP 或可信网关识别到的国家或地区。业务可以用 country 做展示默认值,但不能把它直接当作 KYC、税务、支付准入或风控事实。
默认内置 Provider:
| Provider | 说明 |
|---|---|
noop | 默认占位,不做 IP 推断,保证本地和 Docker 开箱即用 |
header | 读取可信网关头,例如 CF-IPCountry、CloudFront-Viewer-Country、X-Geo-Country |
mica-ip2region | 国内离线库,默认读取依赖内置 ip2region v4/v6 数据,也支持项目私有库路径 |
maxmind | 国际通用离线库,读取 GeoLite2 / GeoIP2 .mmdb,需要部署方自行提供库文件 |
离线 GeoIP Provider 统一支持两类库文件位置:外部文件 / Docker 挂载,以及 classpath 资源。项目方可以把私有 IP 库放到 src/main/resources/geoip/ 随应用打包,例如 classpath:geoip/ip2region.xdb 或 classpath:geoip/GeoLite2-City.mmdb;默认仓库不内置真实 IP 数据库文件,mica-ip2region 依赖自身携带的基础离线库除外。
阿里云 / 腾讯云在线 IP 查询接口当前不作为内置 Provider,且已取消接入计划。需要云厂商在线能力时,建议先由 CDN / 网关注入可信国家码,再使用 header Provider。
推荐链路:
| 场景 | 默认 Provider | 回退链 |
|---|---|---|
| 开箱默认 | noop | noop |
| 生产有 CDN / 网关国家码 | header | mica-ip2region -> maxmind -> noop |
| 国内私有化 / 国内服务器 | mica-ip2region | maxmind -> noop |
| 海外 / 国际化部署 | maxmind | mica-ip2region -> noop |
开箱默认不做 IP 推断,保证本地开发、Docker 和私有部署不会因为 GeoIP 配置影响主请求。生产环境优先信任 CDN / 网关注入的国家码,其次再用离线库。
国内私有化示例:
spring:
open:
web:
client-context:
geo-ip:
default-provider: mica-ip2region
fallback-providers:
- maxmind
- noop生产有 CDN / 网关时:
spring:
open:
web:
client-context:
geo-ip:
default-provider: header
fallback-providers:
- mica-ip2region
- maxmind
- noop海外 / 国际化部署:
spring:
open:
web:
client-context:
geo-ip:
default-provider: maxmind
fallback-providers:
- mica-ip2region
- noopX-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。
@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 |
ProblemDetail | Spring 原生问题详情 |
text/html 字符串 | 页面 / 安装向导 / 静态 HTML |
默认跳过路径:/actuator / /v3/api-docs / /swagger-ui / /swagger-ui.html。
显式跳过:
@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。
文件下载
@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
@IgnoreResponseWrapper
@GetMapping("/events")
public ResponseEntity<SseEmitter> events() {
return ResponseEntity.ok(new SseEmitter());
}异常处理
可预期异常继承 FrameworkException:
| 异常 | 默认 code |
|---|---|
BusinessException | 40001 |
ValidationException | 422 |
UnauthorizedException | 401 |
ForbiddenException | 403 |
ResourceNotFoundException | 404 |
ConfigurationException | 50001 |
throw new ResourceNotFoundException(MessageKeys.DATA_RECORD_NOT_FOUND);
throw new BusinessException("{messages.user.username.exists}", username);全局异常处理器解析消息 key,返回结构化 Result。未处理异常记录错误日志,返回 GlobalResultCode.INTERNAL_ERROR。
Validation
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())));
}校验失败返回:
{
"code": "422",
"message": "参数校验失败",
"data": [
{"field": "username", "message": "用户名不能为空", "rejectedValue": ""}
],
"traceId": "...",
"timestamp": 1790000000000
}文案必须用 {messages.xxx},不用 ${}。
FormRequest(规范化 + 授权)
@Valid DTO 已覆盖字段校验和统一错误响应。如果还需要「校验前规范化」和「请求级授权」与 DTO 同处(对标 Laravel FormRequest),让请求体实现 FormRequest:
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:
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:
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= 解析注入。
@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.user:has("author") / has("comments") 为真,has("comments.user") 为真,nested("comments") 得到子包含 user 传给评论 VO。
约定要点,保持类型安全与稳定 OpenAPI schema(不引入动态 map 响应):
- 可选关系字段声明为可空字段并加
@JsonInclude(JsonInclude.Include.NON_NULL),未请求即不填、序列化自动省略。 - 关系名是稳定 API 契约,
include值应做白名单校验,不透传成数据库列名或任意关联查询。 - 只做「条件包含」;字段级稀疏(
?fields=)不内建,避免破坏 schema 与脱敏约定。
批量删除
ID 不放 URL,用请求体:
DELETE /api/v1/admin/iam/users
Content-Type: application/json
{"ids": [1, 2, 3]}BatchDeleteRequest.ids 用 List<Object>,兼容 Long / String 不同主键。
CORS
默认全开放方便前后端分离开发。生产收紧:
spring:
open:
web:
cors:
allow-all-origins: false
allowed-origins:
- https://admin.example.com
- https://www.example.com可写入数据库配置,key 一致。变更后新请求按最新配置处理。
安全响应头
默认:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 0X-XSS-Protection=0 是现代浏览器推荐值,真正 XSS 防护依赖:
- 前端模板默认转义
- 富文本白名单清洗
- 后端输出编码
- 关键页面按需配置 CSP
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 即可被按别名引用:
@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(AdminGuardMiddleware→admin-guard),重写alias()可固定别名。 - 中间件参数:引用写成
alias:p1,p2(如throttle:120,1),参数以List<String>传入handle。
把中间件绑定到路由
配置式声明中间件组(组按 path 模式施加,组内按序执行):
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):
@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:
@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 一览接口面很有用。
[
{
"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
验证命令
./mvnw -pl spring-open-starters/spring-open-starter-webmvc -am test -DskipITs
./mvnw -pl spring-open-application -am test -DskipITs -DskipFrontend相关
- 接口元数据 / Swagger:api/openapi.md
- 页面 / Thymeleaf:view.md
- 多语言:i18n.md
- 请求日志 / TraceId:logging.md
- Provider 扩展模型:provider.md