Files
conti-docs/backend/06-api-design.md
T
Guangfei.Zhao 444db49818 feat: add engineering conventions and CI gates documentation
- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks.
- Updated README to include the new conventions document.
- Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability.
- Adjusted global exception handling to return numeric error codes.
- Updated tests to reflect changes in error code handling.
2026-08-13 19:28:36 +08:00

8.0 KiB
Raw Blame History

06. API 设计规范

决策

REST + JSON,统一响应包装,bff-orchestration 负责把内部多个 domain 的返回标准化成 APP 需要的形态。

结构约定

platform-web/
  ApiResult<T>                # { code, message, data, traceId } 统一响应包装
  GlobalExceptionHandler       # 统一异常 -> ApiResult 转换
  ErrorCode                    # 错误码常量/枚举

domains/xxx/api/
  XxxController                # 只做参数校验 + 调用 application 层,不写业务逻辑
  request/ Xxx*Request         # 请求 DTO
  response/ Xxx*Response        # 响应 DTO,不直接暴露 JPA entity

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, TraceIdHolder.current())

        fun error(code: Int, message: String): ApiResult<Nothing> =
            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<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(Exception::class)
    fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
        // 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md
        return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
    }
}

Controller + DTO 示例

// api/StoreController.kt
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
    private val storeAppService: StoreAppService,
) {
    @Operation(summary = "查询当前用户可访问的门店列表")
    @GetMapping
    fun listStores(): ApiResult<List<StoreResponse>> =
        ApiResult.ok(storeAppService.listStores())

    @Operation(summary = "切换当前门店")
    @PostMapping("/switch")
    fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult<Unit> {
        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 自动生成,不手写。字段名一致的直接映射,不一致的用 @Mapping 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。

// build.gradleKotlin 项目用 kapt 做注解处理)
plugins {
    id 'org.jetbrains.kotlin.kapt'
}

dependencies {
    implementation 'org.mapstruct:mapstruct:1.6.3'
    kapt 'org.mapstruct:mapstruct-processor:1.6.3'
}
// 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<StoreEntity>): List<StoreResponse>
}

componentModel = "spring" 让 MapStruct 生成的实现类自动注册成 Spring bean,直接在 application 层注入 StoreMapper 使用,不用手动 new

关键规则

  • Controller 不直接返回 entity,统一走 Xxx*Response DTO,避免持久层字段变更影响 API 契约。
  • 路径版本化:/api/v1/...,未来 breaking change 走 /api/v2/...,不在原路径上做不兼容修改。
  • springdoc-openapi 自动生成接口文档,Controller 上写清楚的 @Operation 描述;build.gradleimplementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' 即可在 /swagger-ui.html 看到文档。
  • traceId 贯穿请求全链路(对应架构图 Observability 的要求),从入口 filter 生成,写入 ApiResult 和日志,详见 08-observability.md
  • 错误码是数字,0 表示成功,按 domain 分段(10xxx 平台通用 / 11xxx 认证与门店 / 20xxx 采购 / 21xxx 库存 / 3xxxx F6·Mini 透传类)。分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
  • 不允许在业务代码里写裸数字,一律走 ErrorCode 常量。数字码在监控里聚合方便(可以直接 group by code),代价是不自解释——所以 message 必须始终是给人看的,日志里 codemessage 一起打。
  • 客户端侧的对应契约见 ../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),不需要靠时间戳模糊查找。
  • 新增一种失败场景时,只需要新增一个 code,不需要前端为每种 HTTP status code 单独写处理分支。

代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。

待补充

  • 分页/排序参数的统一约定。
  • 完整错误码表:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 ApiCode../12-error-and-api-contract.md)保持同步。

参考链接