18 KiB
06. API 设计规范
决策
REST + JSON,统一响应包装,bff-orchestration 负责把内部多个 domain 的返回标准化成 APP 需要的形态。
结构约定
platform-web/
ApiResult<T> # { code, message, data, traceId } 统一响应包装
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
ErrorCode # 错误码常量
BusinessException # 业务异常基类,带错误码
domains/xxx/api/
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
request/ Xxx*Request # 请求 DTO
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
domains/xxx/application/
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct)
ApiResult + 全局异常处理示例
// platform-web/.../ApiResult.kt
data class ApiResult<T>(
val code: Int, // 0 = 成功;非 0 见下面的错误码分段
val message: String,
val data: T?,
val traceId: String,
) {
companion object {
fun <T> ok(data: T): ApiResult<T> =
ApiResult(ErrorCode.OK, "success", data, currentTraceId())
fun error(code: Int, message: String): ApiResult<Nothing> =
ApiResult(code, message, null, currentTraceId())
}
}
// platform-web/.../ErrorCode.kt
object ErrorCode {
const val OK = 0
// 10xxx 平台通用
const val INVALID_PARAM = 10001
const val UNAUTHORIZED = 10401
const val FORBIDDEN = 10403
const val NOT_FOUND = 10404
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
const val INTERNAL_ERROR = 10500
// 11xxx 认证与门店
const val STORE_NOT_ACCESSIBLE = 11001
const val NO_STORE_PERMISSION = 11002
// 20xxx 采购 / 21xxx 库存,各 domain 在自己的段内分配
// 30xxx F6 集成,见 05-integration-layer.md
const val F6_UNAVAILABLE = 30001 // 熔断/超时/连不上
const val F6_BUSINESS_ERROR = 30002 // F6 明确拒绝了请求
// 31xxx Mini 域集成
const val MINI_UNAVAILABLE = 31001
}
// platform-web/.../GlobalExceptionHandler.kt
// platform-web/.../BusinessException.kt
// 所有可预期的业务失败都抛它(或它的子类,如 05-integration-layer.md 的 IntegrationException)。
// httpStatus 有默认值但可以覆盖:错误码是给客户端做分支的,HTTP 状态码是给中间层(网关、监控、
// 客户端拦截器)做粗粒度判断的,两者职责不同,不能只留一个。
open class BusinessException(
val code: Int,
override val message: String,
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) : RuntimeException(message)
// 用法示例:需要客户端走"无权限"分支时,必须显式给 403,
// 否则默认的 400 会让客户端把它当成参数错误
throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResult<Nothing>> {
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
return ResponseEntity.badRequest().body(ApiResult.error(ErrorCode.INVALID_PARAM, message))
}
@ExceptionHandler(BusinessException::class)
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常"))
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md)
log.error("未处理异常", ex)
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
}
}
GlobalExceptionHandler 接不到 Spring Security 过滤器里抛的异常(它们在 DispatcherServlet 之前),401/403 由 04-security-auth.md 里的 AuthenticationEntryPoint/AccessDeniedHandler 产出同样结构的 JSON。两处必须返回同一套结构,客户端才只需要一套解析逻辑。
Controller + DTO 示例
// api/StoreController.kt
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
private val storeAppService: StoreAppService,
) {
@Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping("/accessible")
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listAccessibleStores())
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
// api/response/StoreResponse.kt
data class StoreResponse(
val id: Long,
val name: String,
val code: String,
)
路径和响应体是照着客户端 ../flutter-app/11-store-context-and-session.md、../flutter-app/05-networking.md 写的——这两个端点客户端已经实现了,后端对齐客户端,不是反过来。切店返回的是含新 accessToken 和菜单的完整上下文,不是空 body,理由见 04。
Controller 不直接返回 StoreEntity,而是转换成 StoreResponse——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
DTO 转换:MapStruct,放在 application 层
转换代码用 MapStruct 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
// build.gradle(Kotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP)
plugins {
id 'org.jetbrains.kotlin.kapt'
}
dependencies {
implementation 'org.mapstruct:mapstruct:1.6.3'
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
}
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
@Mapper(componentModel = "spring")
interface StoreMapper {
fun toResponse(view: StoreView): StoreResponse
@Mapping(target = "displayName", source = "name")
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
fun toResponseList(views: List<StoreView>): List<StoreResponse>
}
mapper 必须放在 application 层,不能放在 api/mapper/:它的入参是 Entity 或投影(infrastructure 里的类型),放在 api 层就等于让 api 依赖 infrastructure,会被 10-testing.md 里的 ArchUnit 规则判红。对应 02-layering.md 的那句边界规则:
XxxEntity不出现在api层的任何签名或 import 里,也不跨出所在模块的边界。Entity/领域模型 →Response的转换发生在application层。
componentModel = "spring" 让生成的实现类自动注册成 Spring bean,application 层直接注入使用。
统一请求头约定
客户端每个请求固定携带以下头(见 ../flutter-app/05-networking.md),后端的处理规则:
| 请求头 | 必带 | 后端处理 |
|---|---|---|
Authorization: Bearer <accessToken> |
除免认证端点外 | 见 04-security-auth.md |
X-Trace-Id |
是 | 优先复用客户端传来的值作为本次请求的 traceId,格式非法时丢弃并自行生成,见 08-observability.md |
X-Store-Id |
是 | 仅用于日志与排查。数据范围一律以 token 里的 storeId 为准;不一致时记 warn,不拒绝请求 |
X-App-Version |
是 | 用于版本兼容判断(见下)与埋点维度 |
X-Device-Id |
是 | 用于日志关联和风控,不作为身份凭证 |
关键规则:请求头里的任何值都不构成身份或权限依据。 X-Store-Id、X-Device-Id 都是客户端可以随手改的,只有 Authorization 里签过名的 claims 才算数。
数据格式约定
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 ../flutter-app/12-error-and-api-contract.md。
- 时间:一律 ISO-8601 UTC 字符串,带毫秒和
Z后缀——"2026-08-14T03:21:45.123Z"。Kotlin 侧类型是Instant。不传时间戳数字(数字看不出单位是秒还是毫秒,出过太多次事),不传本地时间(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 03-persistence.md。 - 金额:Kotlin 侧
BigDecimal,序列化成字符串("1234.56")而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,0.1 + 0.2那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。 - 枚举:序列化成大写下划线字符串(
"STORE_MANAGER"),不传序号。客户端遇到未知枚举值必须能容错(降级成"未知"而不是崩溃),否则后端加一个枚举值就得等所有用户升级 APP。 - 布尔:真正的
true/false,不用0/1,不用"Y"/"N"。 - ID:
Long,序列化成 JSON number。当前量级不会超过 JS 安全整数范围(2^53),如果将来引入雪花 ID 之类的大数字,必须改成字符串——这一条到时候是 breaking change,需要走版本升级。 - null 策略:不做全局的 null 字段剔除(不配
NON_NULL)。响应里保留"field": null,让客户端能区分"这个字段服务端明确说了是空"和"服务端根本没返回这个字段"。集合类型永远返回[]而不是null,客户端就不用到处判空。 - 字段命名:JSON 用小驼峰(
storeId、createdAt),与 Kotlin 属性名一致,不做下划线转换。
分页与排序约定
(这条同时解决客户端 ../flutter-app/05-networking.md 里挂着的"分页字段名待定"。)
请求参数:
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
pageNum |
Int | 1 | 从 1 开始。注意 Spring Data 的 Pageable 是从 0 开始的,转换在 Controller 层做完,不要把这个差异漏给客户端 |
pageSize |
Int | 20 | 上限 100,超过按 100 处理,防止被一次拉全表 |
sort |
String | 各接口自定 | `字段名,asc |
响应结构(对应 03-persistence.md 的 PageResult<T>):
{
"code": 0,
"message": "success",
"traceId": "...",
"data": {
"list": [],
"pageNum": 1,
"pageSize": 20,
"total": 134,
"hasMore": true
}
}
不直接把 Spring Data 的 Page 序列化出去——它的 JSON 结构由 Spring 版本决定(Boot 3.3 起还会为此打警告并推荐 PagedModel),升级框架就可能悄悄改掉 API 契约。
游标分页:数据量大或要求"下拉加载不重不漏"的列表,用 lastId + pageSize,响应里返回 nextCursor。理由和写法见 03-persistence.md 的分页一节。哪些接口用哪种,在接口文档里写清楚。
错误码规则
-
错误码是数字,
0表示成功,按 domain 分段:段 归属 10xxx平台通用(参数、认证、鉴权、系统错误) 11xxx认证与门店 20xxx/21xxx采购 / 库存 30xxxF6 集成 31xxxMini 域集成 32xxx阿里云 OCR 集成(车牌识别) 分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
-
分段和下面这组基础码现在定死,业务码在开发对应模块时随接口增补。不等一份"完整码表"齐了再开工——需求还在动的时候那份表不可能齐,等它等于卡住所有人。
object ErrorCode { const val OK = 0 // 10xxx 平台通用 const val INVALID_PARAM = 10001 const val UNAUTHORIZED = 10401 const val FORBIDDEN = 10403 const val NOT_FOUND = 10404 const val RATE_LIMITED = 10429 const val INTERNAL_ERROR = 10500 // 11xxx 认证与门店 const val STORE_NOT_ACCESSIBLE = 11001 const val NO_STORE_PERMISSION = 11002 // 3xxxx 集成 const val F6_UNAVAILABLE = 30001 const val MINI_UNAVAILABLE = 31001 const val OCR_UNAVAILABLE = 32001 // 车牌识别不可用 → APP 切手工输码 const val OCR_NO_PLATE_FOUND = 32002 // 图片里没找到车牌 → 提示重拍 }这组码客户端有对应分支逻辑(触发刷新、重拉门店上下文、展示 traceId 等),改动要两边一起改。段内新增的业务码不需要客户端配合——客户端默认直接展示
message。 -
码表和代码放在一起维护(
ErrorCode常量即码表),不落在某个人的 Excel 里。新增码值时在常量上写注释说明触发场景。 -
不允许在业务代码里写裸数字,一律走
ErrorCode常量。数字码在监控里聚合方便(可以直接group by code),代价是不自解释——所以message必须始终是给人看的,日志里code和message一起打。 -
message是给用户看的,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用traceId关联。 -
HTTP status code 仍然要用对:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看
code,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。 -
新增需要客户端特殊 UX 的错误码时,同步更新客户端的
ApiCode(../flutter-app/12-error-and-api-contract.md),两边分段方案必须一致。
版本与兼容
-
路径版本化:
/api/v1/...。只有 breaking change 才升/v2,且 v1 必须保留到监控数据显示旧版本 APP 的活跃量足够低为止——APP 不像网页能强制刷新,用户手机上永远会有旧版本。 -
兼容性判定(这是 API 改动 review 的检查表):
改动 兼容? 响应里加字段 ✅ 兼容 请求里加可选参数 ✅ 兼容 放宽校验规则 ✅ 兼容 删字段 / 改字段名 / 改字段类型 ❌ 破坏性 加必填参数 / 收紧校验 ❌ 破坏性 改字段语义(值域、单位、时区) ❌ 破坏性,且最危险——编译不报错,测试可能也过,只有线上数据是错的 -
字段废弃流程:① 新字段上线、旧字段继续双写,OpenAPI 上给旧字段标
@Schema(deprecated = true);② 观察埋点,确认使用旧字段的 APP 版本占比降到可接受;③ 下一个版本移除。整个过程至少跨两个 APP 发版周期,不要图快跳步。 -
最低版本控制:确实需要强制升级时,由后端根据
X-App-Version返回一个专门的错误码,APP 弹强制升级引导。这个能力要提前留出来(哪怕暂时不用),否则真需要的时候一点办法都没有。 -
接口文档用 springdoc-openapi 自动生成,
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'(Boot 4 对应 springdoc 3.x),Controller 上写清楚@Operation描述。文档 UI 只在非生产环境开放,见 04-security-auth.md。 -
traceId贯穿请求全链路(对应架构图Observability的要求),写入ApiResult和日志,详见 08-observability.md。
附录:为什么要统一响应包装,而不是直接返回业务对象
不统一包装的话,前端(APP)拿到的成功响应是 { id, name },失败响应是 Spring 默认的 { timestamp, status, error, path }——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 { code, message, data, traceId } 之后:
- 前端只需要判断
code == 0就知道成功与否,不用对着 HTTP status code 猜。 traceId无论成功失败都会带上,用户反馈问题时报个traceId,就能在日志里定位到具体这一次请求(见 08-observability.md),不需要靠时间戳模糊查找。- 新增一种失败场景时,只需要新增一个
code,不需要前端为每种 HTTP status code 单独写处理分支。
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。我们的折中是两个都给对:code 给客户端用,HTTP status code 给网关/监控/日志用。
待补充
- 各 domain 段内的业务码:分段方案和基础码已定死(见上),采购、库存、集成段内的具体码值由各 domain 负责人在开发对应模块时随接口增补,不集中排期。只有需要客户端特殊 UX 的码才要同步到客户端
ApiCode(../flutter-app/12-error-and-api-contract.md)。 - 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。