- Added object naming conventions (PO/DAO/BO/DTO/VO) in 02-layering.md to clarify terminology and usage within the team. - Updated 06-api-design.md to include MapStruct for DTO and entity conversion, providing examples and configuration details. - Expanded 07-config-governance.md with local development instructions and strategies for running without K8s, including two recommended approaches. - Included K8s probe configuration details in 08-observability.md for liveness and readiness checks. - Clarified CI/CD processes in 09-build-deploy.md, detailing environment distinctions and deployment strategies for local, Dev, UAT, and Prod. - Introduced ArchUnit for architectural testing in 10-testing.md, ensuring adherence to defined layering rules and coverage verification with Jacoco.
6.6 KiB
6.6 KiB
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: String,
val message: String,
val data: T?,
val traceId: String,
) {
companion object {
fun <T> ok(data: T): ApiResult<T> =
ApiResult("OK", "success", data, TraceIdHolder.current())
fun error(code: String, message: String): ApiResult<Nothing> =
ApiResult(code, message, null, TraceIdHolder.current())
}
}
// 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("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("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.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'
}
// 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*ResponseDTO,避免持久层字段变更影响 API 契约。 - 路径版本化:
/api/v1/...,未来 breaking change 走/api/v2/...,不在原路径上做不兼容修改。 - 用 springdoc-openapi 自动生成接口文档,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。
附录:为什么要统一响应包装,而不是直接返回业务对象
不统一包装的话,前端(APP)拿到的成功响应是 { id, name },失败响应是 Spring 默认的 { timestamp, status, error, path }——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 { code, message, data, traceId } 之后:
- 前端只需要判断
code == "OK"就知道成功与否,不用对着 HTTP status code 猜。 traceId无论成功失败都会带上,用户反馈问题时报个traceId,就能在日志里定位到具体这一次请求(见 08-observability.md),不需要靠时间戳模糊查找。- 新增一种失败场景时,只需要新增一个
code,不需要前端为每种 HTTP status code 单独写处理分支。
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。
待补充
- 分页/排序参数的统一约定。
- 错误码表(按 domain 分段还是全局统一编码)。