# 06. API 设计规范 ## 决策 REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 domain 的返回标准化成 APP 需要的形态。 ## 结构约定 ``` platform-web/ ApiResult # { code, message, data, traceId } 统一响应包装 GlobalExceptionHandler # 统一异常 -> ApiResult 转换 ErrorCode # 错误码常量/枚举 domains/xxx/api/ XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑 request/ Xxx*Request # 请求 DTO response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity ``` ## `ApiResult` + 全局异常处理示例 ```kotlin // platform-web/.../ApiResult.kt data class ApiResult( val code: Int, // 0 = 成功;非 0 见下面的错误码分段 val message: String, val data: T?, val traceId: String, ) { companion object { fun ok(data: T): ApiResult = ApiResult(ErrorCode.OK, "success", data, TraceIdHolder.current()) fun error(code: Int, message: String): ApiResult = ApiResult(code, message, null, TraceIdHolder.current()) } } // 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 INTERNAL_ERROR = 10500 // 11xxx 认证与门店 const val STORE_NOT_ACCESSIBLE = 11001 const val NO_STORE_PERMISSION = 11002 // 20xxx 采购 / 21xxx 库存 / 3xxxx F6·Mini 透传类,各 domain 在自己的段内分配 } // platform-web/.../GlobalExceptionHandler.kt @RestControllerAdvice class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity> { 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> = ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常")) @ExceptionHandler(Exception::class) fun handleUnexpected(ex: Exception): ResponseEntity> { // 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md) return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试")) } } ``` ## Controller + DTO 示例 ```kotlin // api/StoreController.kt @RestController @RequestMapping("/api/v1/stores") class StoreController( private val storeAppService: StoreAppService, ) { @Operation(summary = "查询当前用户可访问的门店列表") @GetMapping fun listStores(): ApiResult> = ApiResult.ok(storeAppService.listStores()) @Operation(summary = "切换当前门店") @PostMapping("/switch") fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult { storeAppService.switchStore(request.storeId) return ApiResult.ok(Unit) } } // api/request/SwitchStoreRequest.kt data class SwitchStoreRequest( @field:NotNull(message = "storeId 不能为空") val storeId: Long?, ) // api/response/StoreResponse.kt data class StoreResponse( val id: Long, val name: String, ) ``` Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段(比如某个只有 `infrastructure` 层需要的标记位)被不小心带出去。 ## DTO 与 entity 的转换:用 MapStruct 转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写。字段名一致的直接映射,不一致的用 `@Mapping` 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。 ```groovy // build.gradle(Kotlin 项目用 kapt 做注解处理) plugins { id 'org.jetbrains.kotlin.kapt' } dependencies { implementation 'org.mapstruct:mapstruct:1.6.3' kapt 'org.mapstruct:mapstruct-processor:1.6.3' } ``` ```kotlin // api/mapper/StoreMapper.kt @Mapper(componentModel = "spring") interface StoreMapper { fun toResponse(entity: StoreEntity): StoreResponse @Mapping(target = "displayName", source = "name") fun toSummary(entity: StoreEntity): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定 fun toResponseList(entities: List): List } ``` `componentModel = "spring"` 让 MapStruct 生成的实现类自动注册成 Spring bean,直接在 `application` 层注入 `StoreMapper` 使用,不用手动 `new`。 ## 关键规则 - Controller 不直接返回 entity,统一走 `Xxx*Response` DTO,避免持久层字段变更影响 API 契约。 - 路径版本化:`/api/v1/...`,未来 breaking change 走 `/api/v2/...`,不在原路径上做不兼容修改。 - 用 [springdoc-openapi](https://springdoc.org/) 自动生成接口文档,Controller 上写清楚的 `@Operation` 描述;`build.gradle` 加 `implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'` 即可在 `/swagger-ui.html` 看到文档。 - `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),从入口 filter 生成,写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。 - **错误码是数字,`0` 表示成功**,按 domain 分段(`10xxx` 平台通用 / `11xxx` 认证与门店 / `20xxx` 采购 / `21xxx` 库存 / `3xxxx` F6·Mini 透传类)。分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。 - **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。 - 客户端侧的对应契约见 [../12-error-and-api-contract.md](../12-error-and-api-contract.md),两边的分段方案必须保持一致。 ## 附录:为什么要统一响应包装,而不是直接返回业务对象 不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后: - 前端只需要判断 `code == 0` 就知道成功与否,不用对着 HTTP status code 猜。 - `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。 - 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。 代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。 ## 待补充 - 分页/排序参数的统一约定。 - **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`([../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。 ## 参考链接 - [springdoc-openapi](https://springdoc.org/) - [Spring 官方 Bean Validation 指南](https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html) - [Microsoft REST API 设计指南](https://github.com/microsoft/api-guidelines) - [MapStruct 官方文档](https://mapstruct.org/documentation/stable/reference/html/)