Files
conti-docs/backend/06-api-design.md
T
Guangfei.Zhao 74b02ed427 feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes.
- Added guidelines for parallel aggregation using a dedicated thread pool and context propagation.
- Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment.
- Included examples and best practices for implementing these concepts in the application.
2026-08-14 16:03:47 +08:00

301 lines
17 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 # 错误码常量
BusinessException # 业务异常基类,带错误码
domains/xxx/api/
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
request/ Xxx*Request # 请求 DTO
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
domains/xxx/application/
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct
```
## `ApiResult` + 全局异常处理示例
```kotlin
// 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, currentTraceId())
fun error(code: Int, message: String): ApiResult<Nothing> =
ApiResult(code, message, null, currentTraceId())
}
}
// 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 NOT_FOUND = 10404
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
const val INTERNAL_ERROR = 10500
// 11xxx 认证与门店
const val STORE_NOT_ACCESSIBLE = 11001
const val NO_STORE_PERMISSION = 11002
// 20xxx 采购 / 21xxx 库存,各 domain 在自己的段内分配
// 30xxx F6 集成,见 05-integration-layer.md
const val F6_UNAVAILABLE = 30001 // 熔断/超时/连不上
const val F6_BUSINESS_ERROR = 30002 // F6 明确拒绝了请求
// 31xxx Mini 域集成
const val MINI_UNAVAILABLE = 31001
}
// platform-web/.../GlobalExceptionHandler.kt
// platform-web/.../BusinessException.kt
// 所有可预期的业务失败都抛它(或它的子类,如 05-integration-layer.md 的 IntegrationException)。
// httpStatus 有默认值但可以覆盖:错误码是给客户端做分支的,HTTP 状态码是给中间层(网关、监控、
// 客户端拦截器)做粗粒度判断的,两者职责不同,不能只留一个。
open class BusinessException(
val code: Int,
override val message: String,
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) : RuntimeException(message)
// 用法示例:需要客户端走"无权限"分支时,必须显式给 403,
// 否则默认的 400 会让客户端把它当成参数错误
throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
@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(ObjectOptimisticLockingFailureException::class)
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md
log.error("未处理异常", ex)
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
}
}
```
**`GlobalExceptionHandler` 接不到 Spring Security 过滤器里抛的异常**(它们在 `DispatcherServlet` 之前),401/403 由 [04-security-auth.md](./04-security-auth.md) 里的 `AuthenticationEntryPoint`/`AccessDeniedHandler` 产出同样结构的 JSON。两处必须返回同一套结构,客户端才只需要一套解析逻辑。
## Controller + DTO 示例
```kotlin
// api/StoreController.kt
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
private val storeAppService: StoreAppService,
) {
@Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping("/accessible")
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listAccessibleStores())
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
// api/response/StoreResponse.kt
data class StoreResponse(
val id: Long,
val name: String,
val code: String,
)
```
路径和响应体是照着客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md)、[../05-networking.md](../05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
## DTO 转换:MapStruct,放在 application 层
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
```groovy
// build.gradleKotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP
plugins {
id 'org.jetbrains.kotlin.kapt'
}
dependencies {
implementation 'org.mapstruct:mapstruct:1.6.3'
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
}
```
```kotlin
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
@Mapper(componentModel = "spring")
interface StoreMapper {
fun toResponse(view: StoreView): StoreResponse
@Mapping(target = "displayName", source = "name")
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
fun toResponseList(views: List<StoreView>): List<StoreResponse>
}
```
**mapper 必须放在 `application` 层,不能放在 `api/mapper/`**:它的入参是 `Entity` 或投影(`infrastructure` 里的类型),放在 `api` 层就等于让 `api` 依赖 `infrastructure`,会被 [10-testing.md](./10-testing.md) 里的 ArchUnit 规则判红。对应 [02-layering.md](./02-layering.md) 的那句边界规则:
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
> **`Entity`/领域模型 → `Response` 的转换发生在 `application` 层。**
`componentModel = "spring"` 让生成的实现类自动注册成 Spring bean`application` 层直接注入使用。
## 统一请求头约定
客户端每个请求固定携带以下头(见 [../05-networking.md](../05-networking.md)),后端的处理规则:
| 请求头 | 必带 | 后端处理 |
| --- | --- | --- |
| `Authorization: Bearer <accessToken>` | 除免认证端点外 | 见 [04-security-auth.md](./04-security-auth.md) |
| `X-Trace-Id` | 是 | **优先复用**客户端传来的值作为本次请求的 traceId,格式非法时丢弃并自行生成,见 [08-observability.md](./08-observability.md) |
| `X-Store-Id` | 是 | **仅用于日志与排查**。数据范围一律以 token 里的 `storeId` 为准;不一致时记 warn,不拒绝请求 |
| `X-App-Version` | 是 | 用于版本兼容判断(见下)与埋点维度 |
| `X-Device-Id` | 是 | 用于日志关联和风控,不作为身份凭证 |
**关键规则:请求头里的任何值都不构成身份或权限依据。** `X-Store-Id``X-Device-Id` 都是客户端可以随手改的,只有 `Authorization` 里签过名的 claims 才算数。
## 数据格式约定
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../12-error-and-api-contract.md](../12-error-and-api-contract.md)。
- **时间**:一律 ISO-8601 UTC 字符串,带毫秒和 `Z` 后缀——`"2026-08-14T03:21:45.123Z"`。Kotlin 侧类型是 `Instant`。**不传时间戳数字**(数字看不出单位是秒还是毫秒,出过太多次事),**不传本地时间**(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 [03-persistence.md](./03-persistence.md)。
- **金额**Kotlin 侧 `BigDecimal`,序列化成**字符串**`"1234.56"`)而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,`0.1 + 0.2` 那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。
- **枚举**:序列化成大写下划线字符串(`"STORE_MANAGER"`),不传序号。**客户端遇到未知枚举值必须能容错**(降级成"未知"而不是崩溃),否则后端加一个枚举值就得等所有用户升级 APP。
- **布尔**:真正的 `true`/`false`,不用 `0`/`1`,不用 `"Y"`/`"N"`
- **ID**`Long`,序列化成 JSON number。当前量级不会超过 JS 安全整数范围(2^53),如果将来引入雪花 ID 之类的大数字,必须改成字符串——这一条到时候是 breaking change,需要走版本升级。
- **null 策略****不做全局的 null 字段剔除**(不配 `NON_NULL`)。响应里保留 `"field": null`,让客户端能区分"这个字段服务端明确说了是空"和"服务端根本没返回这个字段"。集合类型永远返回 `[]` 而不是 `null`,客户端就不用到处判空。
- **字段命名**:JSON 用小驼峰(`storeId``createdAt`),与 Kotlin 属性名一致,不做下划线转换。
## 分页与排序约定
(这条同时解决客户端 [../05-networking.md](../05-networking.md) 里挂着的"分页字段名待定"。)
**请求参数**
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `pageNum` | Int | 1 | **从 1 开始**。注意 Spring Data 的 `Pageable` 是从 0 开始的,转换在 Controller 层做完,不要把这个差异漏给客户端 |
| `pageSize` | Int | 20 | 上限 100,超过按 100 处理,防止被一次拉全表 |
| `sort` | String | 各接口自定 | `字段名,asc|desc`,如 `createdAt,desc`。**允许排序的字段必须是白名单**,不能把参数直接拼进 SQL/JPQL |
**响应结构**(对应 [03-persistence.md](./03-persistence.md) 的 `PageResult<T>`):
```json
{
"code": 0,
"message": "success",
"traceId": "...",
"data": {
"list": [],
"pageNum": 1,
"pageSize": 20,
"total": 134,
"hasMore": true
}
}
```
不直接把 Spring Data 的 `Page` 序列化出去——它的 JSON 结构由 Spring 版本决定(Boot 3.3 起还会为此打警告并推荐 `PagedModel`),升级框架就可能悄悄改掉 API 契约。
**游标分页**:数据量大或要求"下拉加载不重不漏"的列表,用 `lastId` + `pageSize`,响应里返回 `nextCursor`。理由和写法见 [03-persistence.md](./03-persistence.md) 的分页一节。哪些接口用哪种,在接口文档里写清楚。
## 错误码规则
- **错误码是数字,`0` 表示成功**,按 domain 分段:
| 段 | 归属 |
| --- | --- |
| `10xxx` | 平台通用(参数、认证、鉴权、系统错误) |
| `11xxx` | 认证与门店 |
| `20xxx` / `21xxx` | 采购 / 库存 |
| `30xxx` | F6 集成 |
| `31xxx` | Mini 域集成 |
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code``message` 一起打。
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
- 新增错误码时**同步更新客户端的 `ApiCode`**[../12-error-and-api-contract.md](../12-error-and-api-contract.md)),两边分段方案必须一致。
## 版本与兼容
- **路径版本化**`/api/v1/...`。**只有 breaking change 才升 `/v2`**,且 v1 必须保留到监控数据显示旧版本 APP 的活跃量足够低为止——APP 不像网页能强制刷新,用户手机上永远会有旧版本。
- **兼容性判定**(这是 API 改动 review 的检查表):
| 改动 | 兼容? |
| --- | --- |
| 响应里加字段 | ✅ 兼容 |
| 请求里加**可选**参数 | ✅ 兼容 |
| 放宽校验规则 | ✅ 兼容 |
| 删字段 / 改字段名 / 改字段类型 | ❌ 破坏性 |
| 加必填参数 / 收紧校验 | ❌ 破坏性 |
| 改字段语义(值域、单位、时区) | ❌ 破坏性,且**最危险**——编译不报错,测试可能也过,只有线上数据是错的 |
- **字段废弃流程**:① 新字段上线、旧字段继续双写,OpenAPI 上给旧字段标 `@Schema(deprecated = true)`;② 观察埋点,确认使用旧字段的 APP 版本占比降到可接受;③ 下一个版本移除。整个过程至少跨两个 APP 发版周期,不要图快跳步。
- **最低版本控制**:确实需要强制升级时,由后端根据 `X-App-Version` 返回一个专门的错误码,APP 弹强制升级引导。这个能力要提前留出来(哪怕暂时不用),否则真需要的时候一点办法都没有。
- 接口文档用 [springdoc-openapi](https://springdoc.org/) 自动生成,`implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'`Boot 4 对应 springdoc 3.x),Controller 上写清楚 `@Operation` 描述。**文档 UI 只在非生产环境开放**,见 [04-security-auth.md](./04-security-auth.md)。
- `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.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 语义"更重要。我们的折中是**两个都给对**:`code` 给客户端用,HTTP status code 给网关/监控/日志用。
## 待补充
- **完整错误码表**:分段方案已定(见上),但各 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/)