2026-08-12 18:23:11 +08:00
|
|
|
|
# 06. API 设计规范
|
|
|
|
|
|
|
|
|
|
|
|
## 决策
|
|
|
|
|
|
|
|
|
|
|
|
REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 domain 的返回标准化成 APP 需要的形态。
|
|
|
|
|
|
|
|
|
|
|
|
## 结构约定
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
platform-web/
|
|
|
|
|
|
ApiResult<T> # { code, message, data, traceId } 统一响应包装
|
|
|
|
|
|
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
|
2026-08-14 16:03:47 +08:00
|
|
|
|
ErrorCode # 错误码常量
|
|
|
|
|
|
BusinessException # 业务异常基类,带错误码
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
|
|
|
|
|
domains/xxx/api/
|
|
|
|
|
|
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
|
|
|
|
|
|
request/ Xxx*Request # 请求 DTO
|
|
|
|
|
|
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
domains/xxx/application/
|
|
|
|
|
|
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct)
|
2026-08-12 18:23:11 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## `ApiResult` + 全局异常处理示例
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
// platform-web/.../ApiResult.kt
|
|
|
|
|
|
data class ApiResult<T>(
|
2026-08-13 19:28:36 +08:00
|
|
|
|
val code: Int, // 0 = 成功;非 0 见下面的错误码分段
|
2026-08-12 18:23:11 +08:00
|
|
|
|
val message: String,
|
|
|
|
|
|
val data: T?,
|
|
|
|
|
|
val traceId: String,
|
|
|
|
|
|
) {
|
|
|
|
|
|
companion object {
|
|
|
|
|
|
fun <T> ok(data: T): ApiResult<T> =
|
2026-08-14 16:03:47 +08:00
|
|
|
|
ApiResult(ErrorCode.OK, "success", data, currentTraceId())
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
2026-08-13 19:28:36 +08:00
|
|
|
|
fun error(code: Int, message: String): ApiResult<Nothing> =
|
2026-08-14 16:03:47 +08:00
|
|
|
|
ApiResult(code, message, null, currentTraceId())
|
2026-08-12 18:23:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 19:28:36 +08:00
|
|
|
|
// platform-web/.../ErrorCode.kt
|
|
|
|
|
|
object ErrorCode {
|
|
|
|
|
|
const val OK = 0
|
|
|
|
|
|
|
|
|
|
|
|
// 10xxx 平台通用
|
|
|
|
|
|
const val INVALID_PARAM = 10001
|
|
|
|
|
|
const val UNAUTHORIZED = 10401
|
|
|
|
|
|
const val FORBIDDEN = 10403
|
2026-08-14 16:03:47 +08:00
|
|
|
|
const val NOT_FOUND = 10404
|
|
|
|
|
|
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
|
2026-08-13 19:28:36 +08:00
|
|
|
|
const val INTERNAL_ERROR = 10500
|
|
|
|
|
|
|
|
|
|
|
|
// 11xxx 认证与门店
|
|
|
|
|
|
const val STORE_NOT_ACCESSIBLE = 11001
|
|
|
|
|
|
const val NO_STORE_PERMISSION = 11002
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-13 19:28:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 18:23:11 +08:00
|
|
|
|
// platform-web/.../GlobalExceptionHandler.kt
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-08-12 18:23:11 +08:00
|
|
|
|
@RestControllerAdvice
|
|
|
|
|
|
class GlobalExceptionHandler {
|
|
|
|
|
|
|
|
|
|
|
|
@ExceptionHandler(MethodArgumentNotValidException::class)
|
|
|
|
|
|
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResult<Nothing>> {
|
|
|
|
|
|
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
|
2026-08-13 19:28:36 +08:00
|
|
|
|
return ResponseEntity.badRequest().body(ApiResult.error(ErrorCode.INVALID_PARAM, message))
|
2026-08-12 18:23:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ExceptionHandler(BusinessException::class)
|
|
|
|
|
|
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
|
|
|
|
|
|
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常"))
|
|
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
|
|
|
|
|
|
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> =
|
|
|
|
|
|
ResponseEntity.status(HttpStatus.CONFLICT)
|
|
|
|
|
|
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
|
|
|
|
|
|
|
2026-08-12 18:23:11 +08:00
|
|
|
|
@ExceptionHandler(Exception::class)
|
|
|
|
|
|
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
|
|
|
|
|
|
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md)
|
2026-08-14 16:03:47 +08:00
|
|
|
|
log.error("未处理异常", ex)
|
2026-08-13 19:28:36 +08:00
|
|
|
|
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
|
2026-08-12 18:23:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
**`GlobalExceptionHandler` 接不到 Spring Security 过滤器里抛的异常**(它们在 `DispatcherServlet` 之前),401/403 由 [04-security-auth.md](./04-security-auth.md) 里的 `AuthenticationEntryPoint`/`AccessDeniedHandler` 产出同样结构的 JSON。两处必须返回同一套结构,客户端才只需要一套解析逻辑。
|
|
|
|
|
|
|
2026-08-12 18:23:11 +08:00
|
|
|
|
## Controller + DTO 示例
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
// api/StoreController.kt
|
|
|
|
|
|
@RestController
|
|
|
|
|
|
@RequestMapping("/api/v1/stores")
|
|
|
|
|
|
class StoreController(
|
|
|
|
|
|
private val storeAppService: StoreAppService,
|
|
|
|
|
|
) {
|
|
|
|
|
|
@Operation(summary = "查询当前用户可访问的门店列表")
|
2026-08-14 16:03:47 +08:00
|
|
|
|
@GetMapping("/accessible")
|
|
|
|
|
|
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
|
|
|
|
|
|
ApiResult.ok(storeAppService.listAccessibleStores())
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
|
|
|
|
|
|
@PostMapping("/{storeId}/switch")
|
|
|
|
|
|
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
|
|
|
|
|
|
ApiResult.ok(storeAppService.switchStore(storeId))
|
2026-08-12 18:23:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// api/response/StoreResponse.kt
|
|
|
|
|
|
data class StoreResponse(
|
|
|
|
|
|
val id: Long,
|
|
|
|
|
|
val name: String,
|
2026-08-14 16:03:47 +08:00
|
|
|
|
val code: String,
|
2026-08-12 18:23:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
路径和响应体是照着客户端 [../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md)、[../flutter-app/05-networking.md](../flutter-app/05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
|
2026-08-13 15:19:05 +08:00
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
## DTO 转换:MapStruct,放在 application 层
|
|
|
|
|
|
|
|
|
|
|
|
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
|
2026-08-13 15:19:05 +08:00
|
|
|
|
|
|
|
|
|
|
```groovy
|
2026-08-14 16:03:47 +08:00
|
|
|
|
// build.gradle(Kotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP)
|
2026-08-13 15:19:05 +08:00
|
|
|
|
plugins {
|
|
|
|
|
|
id 'org.jetbrains.kotlin.kapt'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dependencies {
|
|
|
|
|
|
implementation 'org.mapstruct:mapstruct:1.6.3'
|
|
|
|
|
|
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
2026-08-14 16:03:47 +08:00
|
|
|
|
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
|
2026-08-13 15:19:05 +08:00
|
|
|
|
@Mapper(componentModel = "spring")
|
|
|
|
|
|
interface StoreMapper {
|
2026-08-14 16:03:47 +08:00
|
|
|
|
fun toResponse(view: StoreView): StoreResponse
|
2026-08-13 15:19:05 +08:00
|
|
|
|
|
|
|
|
|
|
@Mapping(target = "displayName", source = "name")
|
2026-08-14 16:03:47 +08:00
|
|
|
|
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
|
2026-08-13 15:19:05 +08:00
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
fun toResponseList(views: List<StoreView>): List<StoreResponse>
|
2026-08-13 15:19:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
**mapper 必须放在 `application` 层,不能放在 `api/mapper/`**:它的入参是 `Entity` 或投影(`infrastructure` 里的类型),放在 `api` 层就等于让 `api` 依赖 `infrastructure`,会被 [10-testing.md](./10-testing.md) 里的 ArchUnit 规则判红。对应 [02-layering.md](./02-layering.md) 的那句边界规则:
|
2026-08-13 15:19:05 +08:00
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
|
|
|
|
|
|
> **`Entity`/领域模型 → `Response` 的转换发生在 `application` 层。**
|
|
|
|
|
|
|
|
|
|
|
|
`componentModel = "spring"` 让生成的实现类自动注册成 Spring bean,`application` 层直接注入使用。
|
|
|
|
|
|
|
|
|
|
|
|
## 统一请求头约定
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
客户端每个请求固定携带以下头(见 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)),后端的处理规则:
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
| 请求头 | 必带 | 后端处理 |
|
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
|
| `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 才算数。
|
|
|
|
|
|
|
|
|
|
|
|
## 数据格式约定
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)。
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
- **时间**:一律 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 属性名一致,不做下划线转换。
|
|
|
|
|
|
|
|
|
|
|
|
## 分页与排序约定
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
(这条同时解决客户端 [../flutter-app/05-networking.md](../flutter-app/05-networking.md) 里挂着的"分页字段名待定"。)
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
**请求参数**:
|
|
|
|
|
|
|
|
|
|
|
|
| 参数 | 类型 | 默认 | 说明 |
|
|
|
|
|
|
| --- | --- | --- | --- |
|
|
|
|
|
|
| `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 域集成 |
|
2026-08-21 13:46:56 +08:00
|
|
|
|
| `32xxx` | 阿里云 OCR 集成(车牌识别) |
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
- **分段和下面这组基础码现在定死,业务码在开发对应模块时随接口增补**。不等一份"完整码表"齐了再开工——需求还在动的时候那份表不可能齐,等它等于卡住所有人。
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
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 RATE_LIMITED = 10429
|
|
|
|
|
|
const val INTERNAL_ERROR = 10500
|
|
|
|
|
|
|
|
|
|
|
|
// 11xxx 认证与门店
|
|
|
|
|
|
const val STORE_NOT_ACCESSIBLE = 11001
|
|
|
|
|
|
const val NO_STORE_PERMISSION = 11002
|
|
|
|
|
|
|
|
|
|
|
|
// 3xxxx 集成
|
|
|
|
|
|
const val F6_UNAVAILABLE = 30001
|
|
|
|
|
|
const val MINI_UNAVAILABLE = 31001
|
|
|
|
|
|
const val OCR_UNAVAILABLE = 32001 // 车牌识别不可用 → APP 切手工输码
|
|
|
|
|
|
const val OCR_NO_PLATE_FOUND = 32002 // 图片里没找到车牌 → 提示重拍
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
这组码**客户端有对应分支逻辑**(触发刷新、重拉门店上下文、展示 traceId 等),改动要两边一起改。段内新增的业务码不需要客户端配合——客户端默认直接展示 `message`。
|
|
|
|
|
|
|
|
|
|
|
|
- **码表和代码放在一起维护**(`ErrorCode` 常量即码表),不落在某个人的 Excel 里。新增码值时在常量上写注释说明触发场景。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。
|
2026-08-14 16:03:47 +08:00
|
|
|
|
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
|
|
|
|
|
|
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
|
2026-08-21 13:46:56 +08:00
|
|
|
|
- 新增**需要客户端特殊 UX** 的错误码时,同步更新客户端的 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)),两边分段方案必须一致。
|
2026-08-14 16:03:47 +08:00
|
|
|
|
|
|
|
|
|
|
## 版本与兼容
|
|
|
|
|
|
|
|
|
|
|
|
- **路径版本化**:`/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)。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
|
|
|
|
|
## 附录:为什么要统一响应包装,而不是直接返回业务对象
|
|
|
|
|
|
|
|
|
|
|
|
不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后:
|
|
|
|
|
|
|
2026-08-13 19:28:36 +08:00
|
|
|
|
- 前端只需要判断 `code == 0` 就知道成功与否,不用对着 HTTP status code 猜。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
|
|
|
|
|
|
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
|
|
|
|
|
|
|
2026-08-14 16:03:47 +08:00
|
|
|
|
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。我们的折中是**两个都给对**:`code` 给客户端用,HTTP status code 给网关/监控/日志用。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
|
|
|
|
|
## 待补充
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
- **各 domain 段内的业务码**:分段方案和基础码已定死(见上),采购、库存、集成段内的具体码值由各 domain 负责人在开发对应模块时随接口增补,不集中排期。只有需要客户端特殊 UX 的码才要同步到客户端 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md))。
|
2026-08-14 16:03:47 +08:00
|
|
|
|
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
|
2026-08-12 18:23:11 +08:00
|
|
|
|
|
|
|
|
|
|
## 参考链接
|
|
|
|
|
|
|
|
|
|
|
|
- [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)
|
2026-08-13 15:19:05 +08:00
|
|
|
|
- [MapStruct 官方文档](https://mapstruct.org/documentation/stable/reference/html/)
|