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.
This commit is contained in:
+161
-37
@@ -10,12 +10,16 @@ REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 dom
|
||||
platform-web/
|
||||
ApiResult<T> # { code, message, data, traceId } 统一响应包装
|
||||
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
|
||||
ErrorCode # 错误码常量/枚举
|
||||
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` + 全局异常处理示例
|
||||
@@ -30,10 +34,10 @@ data class ApiResult<T>(
|
||||
) {
|
||||
companion object {
|
||||
fun <T> ok(data: T): ApiResult<T> =
|
||||
ApiResult(ErrorCode.OK, "success", data, TraceIdHolder.current())
|
||||
ApiResult(ErrorCode.OK, "success", data, currentTraceId())
|
||||
|
||||
fun error(code: Int, message: String): ApiResult<Nothing> =
|
||||
ApiResult(code, message, null, TraceIdHolder.current())
|
||||
ApiResult(code, message, null, currentTraceId())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,15 +49,40 @@ object ErrorCode {
|
||||
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 库存 / 3xxxx F6·Mini 透传类,各 domain 在自己的段内分配
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -67,14 +96,22 @@ class GlobalExceptionHandler {
|
||||
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
|
||||
@@ -85,39 +122,34 @@ class StoreController(
|
||||
private val storeAppService: StoreAppService,
|
||||
) {
|
||||
@Operation(summary = "查询当前用户可访问的门店列表")
|
||||
@GetMapping
|
||||
fun listStores(): ApiResult<List<StoreResponse>> =
|
||||
ApiResult.ok(storeAppService.listStores())
|
||||
@GetMapping("/accessible")
|
||||
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
|
||||
ApiResult.ok(storeAppService.listAccessibleStores())
|
||||
|
||||
@Operation(summary = "切换当前门店")
|
||||
@PostMapping("/switch")
|
||||
fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult<Unit> {
|
||||
storeAppService.switchStore(request.storeId)
|
||||
return ApiResult.ok(Unit)
|
||||
}
|
||||
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
|
||||
@PostMapping("/{storeId}/switch")
|
||||
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
|
||||
ApiResult.ok(storeAppService.switchStore(storeId))
|
||||
}
|
||||
|
||||
// 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,
|
||||
val code: String,
|
||||
)
|
||||
```
|
||||
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段(比如某个只有 `infrastructure` 层需要的标记位)被不小心带出去。
|
||||
路径和响应体是照着客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md)、[../05-networking.md](../05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
|
||||
## DTO 与 entity 的转换:用 MapStruct
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
|
||||
|
||||
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写。字段名一致的直接映射,不一致的用 `@Mapping` 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
|
||||
## DTO 转换:MapStruct,放在 application 层
|
||||
|
||||
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
|
||||
|
||||
```groovy
|
||||
// build.gradle(Kotlin 项目用 kapt 做注解处理)
|
||||
// build.gradle(Kotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP)
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.kapt'
|
||||
}
|
||||
@@ -129,29 +161,121 @@ dependencies {
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// api/mapper/StoreMapper.kt
|
||||
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
|
||||
@Mapper(componentModel = "spring")
|
||||
interface StoreMapper {
|
||||
fun toResponse(entity: StoreEntity): StoreResponse
|
||||
fun toResponse(view: StoreView): StoreResponse
|
||||
|
||||
@Mapping(target = "displayName", source = "name")
|
||||
fun toSummary(entity: StoreEntity): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
|
||||
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
|
||||
|
||||
fun toResponseList(entities: List<StoreEntity>): List<StoreResponse>
|
||||
fun toResponseList(views: List<StoreView>): List<StoreResponse>
|
||||
}
|
||||
```
|
||||
|
||||
`componentModel = "spring"` 让 MapStruct 生成的实现类自动注册成 Spring bean,直接在 `application` 层注入 `StoreMapper` 使用,不用手动 `new`。
|
||||
**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 域集成 |
|
||||
|
||||
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
|
||||
- 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)。
|
||||
- **错误码是数字,`0` 表示成功**,按 domain 分段(`10xxx` 平台通用 / `11xxx` 认证与门店 / `20xxx` 采购 / `21xxx` 库存 / `3xxxx` F6·Mini 透传类)。分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。
|
||||
- 客户端侧的对应契约见 [../12-error-and-api-contract.md](../12-error-and-api-contract.md),两边的分段方案必须保持一致。
|
||||
- **`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)。
|
||||
|
||||
## 附录:为什么要统一响应包装,而不是直接返回业务对象
|
||||
|
||||
@@ -161,12 +285,12 @@ interface StoreMapper {
|
||||
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
|
||||
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
|
||||
|
||||
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。
|
||||
代价是:这不是纯粹的 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))保持同步。
|
||||
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
|
||||
Reference in New Issue
Block a user