- Introduced integration layer design with Resilience4j for external vendor calls. - Established API design standards with unified response structures and global exception handling. - Defined configuration and service governance using Kubernetes native solutions. - Implemented observability practices including trace ID propagation and structured logging. - Outlined build and multi-environment deployment strategies using Gradle and GitLab CI/CD. - Specified testing strategies across different layers, utilizing JUnit, MockK, Testcontainers, and WireMock.
127 lines
5.6 KiB
Markdown
127 lines
5.6 KiB
Markdown
# 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` + 全局异常处理示例
|
||
|
||
```kotlin
|
||
// 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 示例
|
||
|
||
```kotlin
|
||
// 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` 层需要的标记位)被不小心带出去。
|
||
|
||
## 关键规则
|
||
|
||
- 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)。
|
||
|
||
## 附录:为什么要统一响应包装,而不是直接返回业务对象
|
||
|
||
不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后:
|
||
|
||
- 前端只需要判断 `code == "OK"` 就知道成功与否,不用对着 HTTP status code 猜。
|
||
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
|
||
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
|
||
|
||
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。
|
||
|
||
## 待补充
|
||
|
||
- DTO 与 entity 的转换方式(MapStruct 还是手写 mapper,模块变多之后再评估)。
|
||
- 分页/排序参数的统一约定。
|
||
- 错误码表(按 domain 分段还是全局统一编码)。
|
||
|
||
## 参考链接
|
||
|
||
- [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)
|