Files
conti-docs/backend/06-api-design.md
T

18 KiB
Raw Blame History

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.gradleKotlin 项目用 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 beanapplication 层直接注入使用。

统一请求头约定

客户端每个请求固定携带以下头(见 ../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-IdX-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"
  • IDLong,序列化成 JSON number。当前量级不会超过 JS 安全整数范围(2^53),如果将来引入雪花 ID 之类的大数字,必须改成字符串——这一条到时候是 breaking change,需要走版本升级。
  • null 策略不做全局的 null 字段剔除(不配 NON_NULL)。响应里保留 "field": null,让客户端能区分"这个字段服务端明确说了是空"和"服务端根本没返回这个字段"。集合类型永远返回 [] 而不是 null,客户端就不用到处判空。
  • 字段命名JSON 用小驼峰(storeIdcreatedAt),与 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.mdPageResult<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 采购 / 库存
    30xxx F6 集成
    31xxx Mini 域集成
    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 必须始终是给人看的,日志里 codemessage 一起打。

  • 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)。
  • 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。

参考链接