Files
conti-docs/backend/06-api-design.md
T
Guangfei.Zhao 8c0fcd84e8 feat: Enhance documentation on layering, object naming conventions, and API design
- 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.
2026-08-13 15:19:05 +08:00

158 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 层需要的标记位)被不小心带出去。
## DTO 与 entity 的转换:用 MapStruct
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写。字段名一致的直接映射,不一致的用 `@Mapping` 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
```groovy
// 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'
}
```
```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<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](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 语义"更重要。
## 待补充
- 分页/排序参数的统一约定。
- 错误码表(按 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)
- [MapStruct 官方文档](https://mapstruct.org/documentation/stable/reference/html/)