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:
Guangfei.Zhao
2026-08-14 16:03:47 +08:00
parent 444db49818
commit 74b02ed427
13 changed files with 2688 additions and 474 deletions
+24 -11
View File
@@ -45,20 +45,22 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
### backend/ ### backend/
后端(Kotlin + Spring Boot + Gradle)架构决策文档,按序号阅读: 后端(Kotlin 2.4 + Spring Boot 4.1 + Java 21 + Gradle 多模块)架构决策文档,按序号阅读:
| 文档 | 内容 | | 文档 | 内容 |
| --- | --- | | --- | --- |
| [backend/01-project-structure.md](./backend/01-project-structure.md) | 工程结构 / 模块划分(Gradle 多模块,模块化单体) | | [backend/01-project-structure.md](./backend/01-project-structure.md) | 工程结构 / 模块划分(模块化单体`-contract` 契约模块,版本基线 |
| [backend/02-layering.md](./backend/02-layering.md) | 分层规范(api/application/domain/infrastructure | | [backend/02-layering.md](./backend/02-layering.md) | 分层规范(api/application/domain/infrastructureEntity 边界 |
| [backend/03-persistence.md](./backend/03-persistence.md) | 持久层方案(Spring Data JPA + HibernateFlyway | | [backend/03-persistence.md](./backend/03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate + MySQLFlyway 多实例 |
| [backend/04-security-auth.md](./backend/04-security-auth.md) | 安全与认证方案Spring Security + JWT门店上下文 | | [backend/04-security-auth.md](./backend/04-security-auth.md) | 安全与认证(Spring Security + JWTrefresh 轮换,门店上下文与越权隔离 |
| [backend/05-integration-layer.md](./backend/05-integration-layer.md) | 集成层设计(F6 Adapter / Mini 客户端) | | [backend/05-integration-layer.md](./backend/05-integration-layer.md) | 集成层设计(同步 RestClient + Resilience4jF6 Adapter / Mini 客户端) |
| [backend/06-api-design.md](./backend/06-api-design.md) | API 设计规范(统一响应、DTO、版本化 | | [backend/06-api-design.md](./backend/06-api-design.md) | API 设计规范(统一响应、错误码、请求头、分页与序列化约定 |
| [backend/07-config-governance.md](./backend/07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret | | [backend/07-config-governance.md](./backend/07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret、Key Vault、启动期校验 |
| [backend/08-observability.md](./backend/08-observability.md) | 可观测性(Trace ID、日志、审计) | | [backend/08-observability.md](./backend/08-observability.md) | 可观测性(Micrometer Tracing、结构化日志与脱敏、指标与告警、审计) |
| [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Gradle、Docker、GitLab CI/CD | | [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Docker、GitLab CI/CD、优雅停机、迁移与回滚协同 |
| [backend/10-testing.md](./backend/10-testing.md) | 测试策略 | | [backend/10-testing.md](./backend/10-testing.md) | 测试策略Testcontainers、WireMock、ArchUnit、覆盖率聚合) |
| [backend/11-cross-domain-collaboration.md](./backend/11-cross-domain-collaboration.md) | 跨域协作与聚合(契约模块、领域事件、并行 fan-out 与局部降级) |
| [backend/12-concurrency-and-scheduling.md](./backend/12-concurrency-and-scheduling.md) | 并发、事务与定时任务(事务边界、幂等、乐观锁、ShedLock、本地缓存) |
## 已知文档间差异(PRD 待修订) ## 已知文档间差异(PRD 待修订)
@@ -89,6 +91,17 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
| **内测分发渠道未定** | [08](./08-build-flavors.md) | Firebase App Distribution 国内可达性存疑,需选替代方案 | | **内测分发渠道未定** | [08](./08-build-flavors.md) | Firebase App Distribution 国内可达性存疑,需选替代方案 |
| **车牌识别技术路径未验证** | [07](./07-native-integration.md) | 通用扫码库只能解条码/二维码,VIN 印刷字符和车牌需要 OCR,车牌可能需要商用 SDK | | **车牌识别技术路径未验证** | [07](./07-native-integration.md) | 通用扫码库只能解条码/二维码,VIN 印刷字符和车牌需要 OCR,车牌可能需要商用 SDK |
### 已解决(2026-08 后端文档评审)
| 原待确认项 | 结论 | 出处 |
| --- | --- | --- |
| 数据库选型未定 | **MySQL 8.4**UAT/Prod 用 Azure Database for MySQL Flexible Server + Private Endpoint | [backend/03](./backend/03-persistence.md)、[backend/09](./backend/09-build-deploy.md) |
| `X-Trace-Id` 请求头契约未定 | 客户端传 32 位小写 hex;服务端校验通过则复用,否则忽略并自行生成,最终值在响应头和 `ApiResult.traceId` 里回写 | [backend/08](./backend/08-observability.md)、[05](./05-networking.md) |
| 分页参数约定未定 | `pageNum`1 起)/ `pageSize`(≤100/ `sort`(白名单),响应含 `hasMore` | [backend/06](./backend/06-api-design.md) |
| 集成层同步还是响应式 | **同步 `RestClient`**,不引入 WebClient/Mono | [backend/05](./backend/05-integration-layer.md) |
后端错误码表仍未定(见上表阻塞项):分段规则已在 [backend/06](./backend/06-api-design.md) 定好,缺的是各业务域把自己的码填进去。
## 语言约定 ## 语言约定
文档以中文为主。 文档以中文为主。
+141 -47
View File
@@ -4,6 +4,29 @@
Kotlin + Spring Boot + GradleGroovy DSL`build.gradle`)。 Kotlin + Spring Boot + GradleGroovy DSL`build.gradle`)。
## 版本基线
| 组件 | 版本 | 说明 |
| --- | --- | --- |
| Spring Boot | `4.1.0` | OSS 支持至 2027-07。3.x 全系列 OSS 支持已结束(3.5 于 2026-06-30 结束),新项目不应再从 3.x 起步 |
| Spring Cloud | `2025.1.2`Oakwood | Boot 4.1 需要 2025.1.2 及以上;提供 `spring-cloud-kubernetes` 5.0.2,见 [07-config-governance.md](./07-config-governance.md) |
| Kotlin | `2.4.10` | |
| Java | `21`LTS,用 Gradle toolchain 锁定) | |
Spring Boot 4 连带升级了 Spring Framework 7 / Spring Security 7 / **Jackson 3**,第三方库必须选对应版本线,各篇文档里出现的坐标已按此对齐:
| 库 | 版本 | 注意点 |
| --- | --- | --- |
| Resilience4j | `2.4.0` | artifact 是 **`resilience4j-spring-boot4`**,不是 `-spring-boot3`2.4.0 才加的 Boot 4 支持 |
| springdoc-openapi | `3.0.3` | Boot 4 对应 springdoc **3.x**Boot 3 才是 2.x |
| logstash-logback-encoder | `9.0` | 9.0 起迁到 Jackson 3,正好匹配 Boot 48.x 及以前是 Jackson 2 |
| MapStruct | `1.6.3` + kapt | MapStruct 至今没有正式的 KSP 支持,Kotlin 项目仍走 kapt |
| springmockk | `5.0.1` | Boot 4 删除了 `@MockBean`/`@SpyBean`springmockk 5.x 对应 Framework 7`@SpykBean` 已改名 `@MockkSpyBean` |
| ArchUnit | `1.4.2` | |
| Testcontainers | 跟随 Spring Boot BOM | 2.x 做了模块化拆分,坐标和包名都变了(`org.testcontainers:testcontainers-mysql` / `org.testcontainers.mysql.MySQLContainer`),升级时以 `./gradlew dependencies` 的实际解析结果为准,见 [10-testing.md](./10-testing.md) |
升级 Boot 版本时,这张表要整体复核一遍,不要只改 Boot 版本号。
## 决策 ## 决策
单一 Gradle 多模块工程,落地为**模块化单体(Modular Monolith)**:只有一个可执行部署单元,内部按 [architecture-diagram](../Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。 单一 Gradle 多模块工程,落地为**模块化单体(Modular Monolith)**:只有一个可执行部署单元,内部按 [architecture-diagram](../Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。
@@ -12,71 +35,122 @@ Kotlin + Spring Boot + GradleGroovy DSL`build.gradle`)。
- 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。 - 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。
- App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。 - App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-integration`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。 - 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-adapter`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
## 模块结构总览 ## 模块结构总览
``` ```
conti-backend/ conti-backend/
settings.gradle settings.gradle
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot BOM build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot / Spring Cloud BOM
bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像 bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像
build.gradle # 依赖所有 platform-* / domains/* 模块 + @SpringBootApplication 启动类 build.gradle # 依赖所有 platform-* / domains/* / integration/* + @SpringBootApplication 启动类
platform/ platform/
platform-web/ # 统一异常处理、Result包装、参数校验、GlobalExceptionHandler platform-web/ # 统一异常处理、ApiResult 包装、参数校验、GlobalExceptionHandler
platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入 platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 配置 platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 多实例配置
platform-observability/ # 日志格式、Trace ID 透传、Micrometer配置 platform-observability/ # 日志格式、Trace 透传、Micrometer 配置
platform-integration/ # WebClient/Resilience4j 基础封装(超时/重试/熔断通用能力) platform-integration/ # RestClient/Resilience4j 基础封装(超时/重试/熔断/舱壁通用能力)
domains/ domains/
identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限 identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限
bff-orchestration/ # BFF Orchestration:面向APP的统一接口聚合与协议标准化 identity-store-contract/ # ↑ 对外契约:接口 + 传输模型 + 领域事件,其他 domain 只能依赖这个
bff-orchestration/ # BFF Orchestration:面向 APP 的统一接口聚合与协议标准化
workbench/ # Workbench Aggregation:首页聚合、局部降级 workbench/ # Workbench Aggregation:首页聚合、局部降级
webview-ticket/ # WebView Ticket CenterF6 WebView 换票、会话绑定 webview-ticket/ # WebView Ticket CenterF6 WebView 换票、会话绑定
f6-integration/ # Integration LayerF6 Adapter,对应架构图里的适配层 integration/
f6-adapter/ # F6 Adapter:对应架构图里的适配层,换票、供应商访问上下文准备
mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装 mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装
architecture-test/ # ArchUnit 架构规则测试,见 10-testing.md
``` ```
(模块名称先按架构图职责命名,实际开发中如果和团队习惯冲突可以再改,不影响这套结构本身。) (模块名称先按架构图职责命名,实际开发中如果和团队习惯冲突可以再改,不影响这套结构本身。)
### 为什么把集成层从 `domains/` 里拿出来
`f6-adapter``mini-clients` 不是业务域,是**对外部系统的适配层**——架构图里 Integration Layer 本来也是单独一层。放在 `domains/` 下会直接和"domains 之间不允许互相依赖"这条规则冲突:`workbench` 要拿 O2O 的数据,就必须依赖 `mini-clients`,于是要么破规则,要么给规则打补丁。单独一组 `integration/` 之后,规则变成干净的一句"所有 domain 都可以依赖 integration",不需要例外。
## 依赖规则(编译期强制边界,是这套结构的核心价值) ## 依赖规则(编译期强制边界,是这套结构的核心价值)
- `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*``platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。 - `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*``integration/*``platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
- `domains/*` 之间**不允许**互相依赖`bff-orchestration` 是唯一例外——它是编排层,天然需要依赖其他所有 domain 才能做聚合。 - **`domains/x` 不可以依赖 `domains/y`。**
- `platform-*` 不含业务逻辑,各 `domains/*` 可依赖`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。 - **`domains/x`依赖 `domains/y-contract`**(契约模块,见下一节)。
- domain 的数据/事件传递,只能走 `bff-orchestration` 编排,或者在某个 `platform-*` 定义抽象接口、各 domain 各自实现(依赖倒置),不允许 `identity-store` 直接 import `workbench` 的类 - **`domains/*` 都可以依赖 `integration/*` `platform-*`**
- `platform-*` 不含业务逻辑;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
些规则由 Gradle 的模块依赖机制**物理强制**`domains/identity-store``build.gradle` 里根本不会声明对 `domains/workbench` 的依赖,编译时 import 不到,不是靠 code review 口头约束 三条规则的价值在于**可以被 ArchUnit 精确表达**,不是靠 code review 口头约束——测试写法见 [10-testing.md](./10-testing.md)。同时它们也由 Gradle 物理强制:`domains/workbench``build.gradle` 里根本不会声明对 `domains/identity-store` 的依赖,编译时 import 不到。
### 模块目录名与包名的对应关系
目录名带连字符,包名不能带——这个映射必须写死,因为 [10-testing.md](./10-testing.md) 的 ArchUnit 规则是按包名匹配的,改一个就要改另一个:
| 模块目录 | 基础包名 |
| --- | --- |
| `domains/identity-store``domains/identity-store-contract` | `com.continental.retailapp.identitystore`(契约在 `.identitystore.contract` |
| `domains/bff-orchestration` | `com.continental.retailapp.bff` |
| `domains/workbench` | `com.continental.retailapp.workbench` |
| `domains/webview-ticket` | `com.continental.retailapp.webviewticket` |
| `integration/f6-adapter` | `com.continental.retailapp.integration.f6` |
| `integration/mini-clients` | `com.continental.retailapp.integration.mini` |
| `platform/platform-*` | `com.continental.retailapp.platform.*` |
规则是**去掉连字符直接拼接**`identity-store``identitystore`),唯一的例外是 `bff-orchestration``bff``bfforchestration` 读不出来)。新增模块时按这个规则取名,并同步更新 `10-testing.md` 里 ArchUnit 的 `domains` 列表——那个列表漏了谁,谁的边界就没人管。
`integration/*``platform/*` 不按 api/application/domain/infrastructure 分四层(它们本来就不是业务域),内部按自己的职责组织包即可,见 [02-layering.md](./02-layering.md)。
### 契约模块(`-contract`):跨 domain 协作的唯一通道
`workbench` 聚合首页时需要门店名称,`webview-ticket` 需要知道"用户切了门店"——这类跨域需求是真实存在的,不可能全部塞进 `bff-orchestration`(那会让 BFF 变成什么都知道的上帝模块)。做法是让被依赖方**显式发布一个最小契约**:
```
domains/identity-store-contract/
src/main/kotlin/com/continental/retailapp/identitystore/contract/
StoreQueryService.kt # 接口:fun listStoresByUserId(userId: Long): List<StoreInfo>
StoreInfo.kt # 传输模型:只含对外承诺的字段
StoreSwitchedEvent.kt # 领域事件,见 11-cross-domain-collaboration.md
```
约束:
- **契约模块里只放接口、传输模型和事件类型**,不放实现、不依赖 Spring Web/JPA,不依赖任何其他 domain。
- **实现方(`identity-store`)依赖自己的契约模块并实现它**;调用方(`workbench`)只依赖契约模块,拿不到 `identity-store` 内部的任何类型(包括 Entity)。
- **按需创建**,不预先给每个 domain 都建一个空的 contract 模块——没有跨域调用就不需要它。
- 命名用 `-contract` 而不是 `-api`,避免和模块内部的 `api/` 包(Controller 层,见 [02-layering.md](./02-layering.md))混淆。
跨 domain 的写操作和状态联动优先走**领域事件**而不是直接调接口,规则见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。
### 根 `build.gradle` 示例 ### 根 `build.gradle` 示例
```groovy ```groovy
plugins { plugins {
id 'org.jetbrains.kotlin.jvm' version '2.0.20' apply false id 'org.jetbrains.kotlin.jvm' version '2.4.10' apply false
id 'org.jetbrains.kotlin.plugin.spring' version '2.0.20' apply false id 'org.jetbrains.kotlin.plugin.spring' version '2.4.10' apply false
id 'org.springframework.boot' version '3.3.4' apply false id 'org.jetbrains.kotlin.kapt' version '2.4.10' apply false
id 'io.spring.dependency-management' version '1.1.6' apply false id 'org.springframework.boot' version '4.1.0' apply false
} }
subprojects { subprojects {
apply plugin: 'org.jetbrains.kotlin.jvm' apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.jetbrains.kotlin.plugin.spring' apply plugin: 'org.jetbrains.kotlin.plugin.spring'
apply plugin: 'io.spring.dependency-management'
group = 'com.continental.retailapp' group = 'com.continental.retailapp'
version = '0.1.0-SNAPSHOT' version = '0.1.0-SNAPSHOT'
// 用 toolchain 统一 Java 版本:Kotlin 插件会自动把 jvmTarget 对齐到同一个版本。
// 只写 sourceCompatibility 是不够的——Kotlin 的 jvmTarget 默认值和它无关,
// 两边不一致时 Gradle 会直接报 "Inconsistent JVM-target compatibility" 构建失败。
java { java {
sourceCompatibility = JavaVersion.VERSION_21 toolchain {
} languageVersion = JavaLanguageVersion.of(21)
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.4"
} }
} }
dependencies { dependencies {
// 用 Gradle 原生的 platform() 做版本对齐,不再引入 io.spring.dependency-management 插件:
// 少一个需要跟着 Boot 一起升级的插件,行为也更符合 Gradle 自身的依赖解析语义。
// testImplementation 继承自 implementation,所以测试依赖同样受这两个 BOM 约束。
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.0')
implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.2')
implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.jetbrains.kotlin:kotlin-reflect'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
} }
@@ -99,43 +173,56 @@ subprojects {
rootProject.name = 'conti-backend' rootProject.name = 'conti-backend'
include 'bootstrap' include 'bootstrap'
include 'architecture-test'
include 'platform:platform-web' include 'platform:platform-web'
include 'platform:platform-security' include 'platform:platform-security'
include 'platform:platform-persistence' include 'platform:platform-persistence'
include 'platform:platform-observability' include 'platform:platform-observability'
include 'platform:platform-integration' include 'platform:platform-integration'
include 'domains:identity-store' include 'domains:identity-store'
include 'domains:identity-store-contract'
include 'domains:bff-orchestration' include 'domains:bff-orchestration'
include 'domains:workbench' include 'domains:workbench'
include 'domains:webview-ticket' include 'domains:webview-ticket'
include 'domains:f6-integration'
include 'domains:mini-clients' include 'integration:f6-adapter'
include 'integration:mini-clients'
``` ```
### `domains/workbench/build.gradle` 示例(体现依赖规则) ### `domains/workbench/build.gradle` 示例(体现依赖规则)
```groovy ```groovy
apply plugin: 'org.springframework.boot' // 只用来获得 bootJar 之外的 starter 依赖管理,不产出可执行 jar // 注意:库模块不 apply 'org.springframework.boot' 插件。
// 版本对齐已经由根工程的 platform() BOM 统一处理,库模块 apply Boot 插件唯一的作用
bootJar { // 就是产出一个我们并不需要的 bootJar,然后再手动把它关掉——多余的一步。
enabled = false // bootstrap 模块不产出可执行 jar // 只有 bootstrap 需要 Boot 插件。
}
jar {
enabled = true
}
dependencies { dependencies {
implementation project(':platform:platform-web') implementation project(':platform:platform-web')
implementation project(':platform:platform-persistence') implementation project(':platform:platform-persistence')
implementation project(':platform:platform-integration') implementation project(':platform:platform-integration')
implementation project(':domains:mini-clients') // 允许:workbench 依赖只读客户端封装
// 不允许出现 implementation project(':domains:identity-store') 这种同级 domain 依赖 implementation project(':integration:mini-clients') // 允许:domain 可以依赖 integration
implementation project(':domains:identity-store-contract') // 允许:只依赖契约模块
// 不允许:implementation project(':domains:identity-store') // 同级 domain 的实现模块
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
} }
``` ```
### `domains/identity-store-contract/build.gradle` 示例
```groovy
dependencies {
// 契约模块保持极简:不依赖 Spring Web / JPA / 其他 domain
// 这样任何 domain 依赖它都不会顺带把实现细节拉进来。
}
```
### `bootstrap/build.gradle` 示例 ### `bootstrap/build.gradle` 示例
```groovy ```groovy
@@ -147,12 +234,15 @@ dependencies {
implementation project(':platform:platform-persistence') implementation project(':platform:platform-persistence')
implementation project(':platform:platform-observability') implementation project(':platform:platform-observability')
implementation project(':platform:platform-integration') implementation project(':platform:platform-integration')
implementation project(':domains:identity-store') implementation project(':domains:identity-store')
implementation project(':domains:identity-store-contract')
implementation project(':domains:bff-orchestration') implementation project(':domains:bff-orchestration')
implementation project(':domains:workbench') implementation project(':domains:workbench')
implementation project(':domains:webview-ticket') implementation project(':domains:webview-ticket')
implementation project(':domains:f6-integration')
implementation project(':domains:mini-clients') implementation project(':integration:f6-adapter')
implementation project(':integration:mini-clients')
} }
``` ```
@@ -170,18 +260,18 @@ fun main(args: Array<String>) {
``` ```
domains/xxx/ domains/xxx/
build.gradle # 依赖需要的 platform-* 模块,bootJar 禁用 build.gradle # 依赖需要的 platform-* / integration-* / 其他 domain 的 -contract
src/main/kotlin/com/continental/retailapp/xxx/ src/main/kotlin/com/continental/retailapp/xxx/ # xxx = 去掉连字符的模块名,见上方对应表
api/ # Controller、请求/响应 DTO api/ # Controller、请求/响应 DTO
application/ # Service,编排用例 application/ # Service、定时任务;Entity → Response 的 mapper 也在这层
domain/ # 可选,见 02-layering.md domain/ # 可选,见 02-layering.md
infrastructure/ # repository 实现、外部 client 实现 infrastructure/ # repository 实现、Entity、外部 client 实现、模块内的 @Configuration
src/main/resources/db/migration/xxx/ src/main/resources/db/migration/xxx/
V1__init.sql V1__init.sql
src/test/kotlin/... src/test/kotlin/...
``` ```
新增模块后记得在 `settings.gradle``include`,并在 `bootstrap/build.gradle` 里加依赖——这两步是唯一需要"手动接线"的地方,其余边界规则都由各模块自己的 `build.gradle` 保证。 新增模块后需要手动接线的地方只有三处:`settings.gradle``include``bootstrap/build.gradle` 里加依赖、[03-persistence.md](./03-persistence.md) 里给这个 domain 注册一个 Flyway 实例(因为每个 domain 有自己独立的 database 和迁移版本序列)。其余边界规则都由各模块自己的 `build.gradle` 保证。
## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层 ## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层
@@ -191,7 +281,9 @@ domains/xxx/
如果整个后端只是一个 Gradle 模块、内部靠 `com.xxx.identity` / `com.xxx.workbench` 这样的包名分层——短期能跑,但 Java/Kotlin 的包(package**不是编译期边界**`workbench` 包里的类可以随手 `import com.xxx.identity.SomeInternalClass`,IDE 不会报错,只能靠人自觉或者 ArchUnit 这类静态检查工具事后检测。等项目大到几十人协作时,"事后检测"经常检测不过来,边界会慢慢被绕开。 如果整个后端只是一个 Gradle 模块、内部靠 `com.xxx.identity` / `com.xxx.workbench` 这样的包名分层——短期能跑,但 Java/Kotlin 的包(package**不是编译期边界**`workbench` 包里的类可以随手 `import com.xxx.identity.SomeInternalClass`,IDE 不会报错,只能靠人自觉或者 ArchUnit 这类静态检查工具事后检测。等项目大到几十人协作时,"事后检测"经常检测不过来,边界会慢慢被绕开。
**Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/identity-store` 这个模块的 `build.gradle` 里没有声明对 `domains/workbench` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。 **Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/workbench` 这个模块的 `build.gradle` 里没有声明对 `domains/identity-store` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。
契约模块是这个思路的延伸:不是"要么全开放、要么全封闭",而是让被依赖方自己决定**对外承诺哪些东西**,其余一律看不见。这跟微服务里"只有 HTTP API 是公开契约、数据库表是私有实现"是同一个道理,只是这里的强制手段从网络协议换成了 Gradle 依赖图。
### 跟微服务的关系 ### 跟微服务的关系
@@ -200,11 +292,13 @@ Gradle 多模块和微服务解决的是同一类问题(业务边界隔离)
- 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。 - 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。
- 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。 - 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。
我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。 我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx-contract')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。契约模块在这里额外多给了一层好处:**要拆的那个接口清单已经现成写在 contract 模块里了**,不需要先花时间考古"到底谁在用我的什么"。
## 参考链接 ## 参考链接
- [Gradle Multi-Project Builds](https://docs.gradle.org/current/userguide/multi_project_builds.html) - [Gradle Multi-Project Builds](https://docs.gradle.org/current/userguide/multi_project_builds.html)
- [Gradle: Platforms / BOM 支持](https://docs.gradle.org/current/userguide/platforms.html)
- [Spring Boot Gradle Plugin](https://docs.spring.io/spring-boot/gradle-plugin/index.html) - [Spring Boot Gradle Plugin](https://docs.spring.io/spring-boot/gradle-plugin/index.html)
- [Spring Boot 4.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide)
- [Modular Monolith: A Primer (Kamil Grzybek)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer) - [Modular Monolith: A Primer (Kamil Grzybek)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer)
- [ArchUnit(可选的架构规则静态检查工具)](https://www.archunit.org/) - [ArchUnit](https://www.archunit.org/)
+71 -14
View File
@@ -8,17 +8,19 @@
domains/xxx/ domains/xxx/
src/main/kotlin/com/continental/retailapp/xxx/ src/main/kotlin/com/continental/retailapp/xxx/
api/ # Controller、请求/响应 DTO api/ # Controller、请求/响应 DTO
application/ # Service,编排用例、跨 repository 协调 application/ # Service,编排用例、跨 repository 协调Entity/领域模型 → Response 的转换
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则 domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
infrastructure/ # JPA repository 实现、外部 client 实现 infrastructure/ # JPA repository 实现、外部 client 实现
``` ```
`integration/*` 模块(`f6-adapter``mini-clients`,见 [01-project-structure.md](./01-project-structure.md))不套这四层——它们本身就是"别人的 infrastructure",内部只有 client 实现 + 对外暴露的接口和传输模型,规则见 [05-integration-layer.md](./05-integration-layer.md)。
## 各层职责 ## 各层职责
- **api**`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,不直接依赖 `infrastructure` 的具体实现类 - **api**`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,**不 import `infrastructure` 包下的任何类型**(包括 Entity)
- **application**`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。 - **application**`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。**`Entity`/领域模型 → `Response` 的转换在这一层完成**`application/mapper/`,见 [06-api-design.md](./06-api-design.md))。
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。 - **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
- **infrastructure**`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、`WebClient`/Feign 外部调用实现。 - **infrastructure**`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、基于 `RestClient` 外部调用实现。
## 对象命名约定(PO / DAO / BO / DTO / VO ## 对象命名约定(PO / DAO / BO / DTO / VO
@@ -35,14 +37,31 @@ Java 生态里这几个缩写来源不一、经常被混用,这里把我们实
落地规则: 落地规则:
- **类名统一用 `Request`/`Response` 后缀**,不额外起 `XxxDTO`/`XxxVO` 这样的名字——`Request`/`Response` 已经把方向(输入/输出)表达清楚了,`DTO`/`VO` 只是这两者的统称,没必要在类名上重复。 - **类名统一用 `Request`/`Response` 后缀**,不额外起 `XxxDTO`/`XxxVO` 这样的名字——`Request`/`Response` 已经把方向(输入/输出)表达清楚了,`DTO`/`VO` 只是这两者的统称,没必要在类名上重复。
- **`domain` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`PO直接`application`/`api` 层转换成 `Response`,不会凭空多出一个 BO。 - **`domain` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`PO)由 `application` 层转换成 `Response`,不会凭空多出一个 BO。
- **`Entity`PO)永远不跨出 `infrastructure` 层**`api`/`application` 看到的最多是 `domain` 层模型或 `Response`,见 [06-api-design.md](./06-api-design.md) 里 `Entity → Response` 的 MapStruct 转换约定。 - **Entity 边界规则**(这一条在 [06-api-design.md](./06-api-design.md) 和 [10-testing.md](./10-testing.md) 的 ArchUnit 规则里用的是同一句话,三处必须一致):
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
> **`Entity`/`Domain` 模型 → `Response` 的转换发生在 `application` 层。**
`domain` 层时更严一档:Entity 到 `infrastructure` 的 repository 实现为止,`application` 拿到的已经是领域模型。
这句话对应 ArchUnit 的 `noClasses().that().resideInAPackage("..api..").should().dependOnClassesThat().resideInAPackage("..infrastructure..")`,能被自动检查,不靠人盯。
### 为什么规则不是"Entity 永不跨出 infrastructure"
因为跳过 domain 层的简单 CRUD 场景下,那条更严的规则会强迫我们为每个查询凭空造一个和 Entity 字段一模一样的中间模型,只为了把它从 `infrastructure` 搬到 `application`——纯粹的样板代码,没有换来任何隔离收益(`application` 转手就把它变成 `Response` 了)。
真正需要防的是**两件事**:Entity 泄漏到 API 契约上(数据库字段一改,APP 就崩),以及 Entity 泄漏到别的模块(别的模块从此依赖上你的表结构)。上面那条规则精确地只挡这两件事,所以它既能自动检查,也不会逼出无意义的中间类。
## 何时可以跳过 domain 层 ## 何时可以跳过 domain 层
先明确一件事:**"跳过 domain 层"跳过的是领域模型和领域服务,不是跳过接口抽象**。`application` 依赖的仍然是一个接口(只不过接口定义挪到了 `infrastructure` 包内),而不是直接 `@Autowired` 一个 `JpaRepository``EntityManager` 到处用。
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。 - **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain``infrastructure` 反向实现。 - **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain``infrastructure` 反向实现。
判断不确定时按"先跳过、需要时再补"处理:从"无 domain 层"补出一个 domain 层是局部重构(把规则从 `application` 提到 `domain`,加一层模型转换),成本可控;反过来为了对称给所有简单查询都套上 domain 层,则是持续付出的样板成本。
## 依赖方向 ## 依赖方向
``` ```
@@ -53,6 +72,8 @@ infrastructure → 依赖 domain 的接口(若有),依赖 platform-persist
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。 `domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
模块之间的依赖方向(domain 之间不互相依赖、跨域只走 `-contract` 契约模块)见 [01-project-structure.md](./01-project-structure.md),两套规则一个管模块内、一个管模块间,都由 [10-testing.md](./10-testing.md) 里的 ArchUnit 测试检查。
## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验) ## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验)
```kotlin ```kotlin
@@ -103,6 +124,7 @@ class WebviewTicketAppService(
@Transactional @Transactional
fun issueTicket(userId: Long, storeId: Long): WebviewTicketResponse { fun issueTicket(userId: Long, storeId: Long): WebviewTicketResponse {
val ticket = issueWebviewTicketService.issue(userId, storeId) val ticket = issueWebviewTicketService.issue(userId, storeId)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt) return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
} }
} }
@@ -112,6 +134,7 @@ class WebviewTicketAppService(
class WebviewTicketRepositoryImpl( class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository, private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository { ) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? = override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain() jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
@@ -123,38 +146,72 @@ class WebviewTicketRepositoryImpl(
`IssueWebviewTicketService` 的复用/过期判断规则可以直接用一个假的 `WebviewTicketRepository` 实现做单元测试,不需要起 Spring 容器或真实数据库,也不需要 mock HTTP。 `IssueWebviewTicketService` 的复用/过期判断规则可以直接用一个假的 `WebviewTicketRepository` 实现做单元测试,不需要起 Spring 容器或真实数据库,也不需要 mock HTTP。
注意 `IssueWebviewTicketService` 上**没有 `@Service` 注解**——domain 层不依赖 Spring[10-testing.md](./10-testing.md) 有一条 ArchUnit 规则盯着这件事)。它成为 bean 的方式是在 `infrastructure/config/` 里显式声明:
```kotlin
// infrastructure/config/DomainServiceConfig.kt
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
```
多写这几行换来的是:领域规则这一层可以脱离 Spring 单独编译和测试。domain 层类不多,这个成本是可控的。
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询) ## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
```kotlin ```kotlin
// infrastructure/persistence/StoreView.kt
// Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
// 用它而不是直接返回 StoreEntity,是为了让 application 拿到的东西不带 Entity 的
// 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
interface StoreView {
val id: Long
val name: String
val code: String
}
// infrastructure/persistence/StoreRepository.kt // infrastructure/persistence/StoreRepository.kt
interface StoreRepository { interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreEntity> fun findStoresByUserId(userId: Long): List<StoreView>
} }
@Repository @Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository { interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query("select s from StoreEntity s join UserStoreEntity us on us.storeId = s.id where us.userId = :userId") @Query(
override fun findStoresByUserId(userId: Long): List<StoreEntity> """
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId
""",
)
override fun findStoresByUserId(userId: Long): List<StoreView>
} }
// application/StoreAppService.kt // application/StoreAppService.kt
@Service @Service
class StoreAppService( class StoreAppService(
private val storeRepository: StoreRepository, private val storeRepository: StoreRepository,
private val storeMapper: StoreMapper, // application/mapper/,见 06-api-design.md
) { ) {
fun listStores(userId: Long): List<StoreResponse> = fun listAccessibleStores(userId: Long): List<StoreResponse> =
storeRepository.findStoresByUserId(userId).map { StoreResponse(it.id, it.name) } storeMapper.toResponseList(storeRepository.findStoresByUserId(userId))
} }
``` ```
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure``application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。 没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure``application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
写操作或者确实需要整个实体的场景,`StoreRepository` 也可以返回 `StoreEntity`——那时候 Entity 进到 `application` 是允许的(见上面的边界规则),只要它不出现在 `api` 层、也不跨出这个模块就行。投影是查询场景下的优选,不是硬性要求。
## 附录:为什么要分层,以及依赖倒置在这里怎么体现 ## 附录:为什么要分层,以及依赖倒置在这里怎么体现
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `new WebClient.create(...)` 调用 F6——短期能跑,但会导致: 如果 `Controller` 里直接写 `EntityManager` 查询、直接 `RestClient.create(...)` 调用 F6——短期能跑,但会导致:
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。 1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `WebClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。 2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `RestClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
分层的关键不是"分了几层",而是**依赖方向单向流动**,`domain` 只定义接口("我需要一个能查到 `WebviewTicket` 的东西"),不关心 `infrastructure` 具体怎么实现——这是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。 分层的关键不是"分了几层",而是**依赖方向单向流动**,`domain` 只定义接口("我需要一个能查到 `WebviewTicket` 的东西"),不关心 `infrastructure` 具体怎么实现——这是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。
@@ -162,4 +219,4 @@ class StoreAppService(
- [依赖倒置原则(Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) - [依赖倒置原则(Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
- [The Clean ArchitectureUncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - [The Clean ArchitectureUncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Spring 官方分层架构指南](https://docs.spring.io/spring-framework/reference/core/beans/introduction.html) - [Spring Data JPA: Projections](https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html)
+234 -32
View File
@@ -2,7 +2,7 @@
## 决策 ## 决策
Spring Data JPA + Hibernate 作为默认 ORMFlyway 做 schema 迁移。 **MySQL 8.4 LTS**(生产用 Azure Database for MySQL Flexible Server,见 [09-build-deploy.md](./09-build-deploy.md)+ Spring Data JPA / Hibernate + Flyway 做 schema 迁移。
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑: 选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
@@ -10,23 +10,82 @@ Spring Data JPA + Hibernate 作为默认 ORMFlyway 做 schema 迁移。
- 大部分 domain 模块(`identity-store``webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。 - 大部分 domain 模块(`identity-store``webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。 - 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
MySQL 一侧需要注意的是:**MySQL 里 schema 和 database 是同一个东西**`CREATE SCHEMA` 就是 `CREATE DATABASE` 的别名)。下文说"每个 domain 一个 schema"时,物理上就是"同一个 MySQL 实例里的一个 database"。这跟 PostgreSQL 的 "一个 database 里多个 schema" 不是一回事,很多网上的多 schema 方案不能直接照搬,包括权限模型(见后面的跨 domain 规则)。
## 结构约定 ## 结构约定
``` ```
platform-persistence/ platform-persistence/
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承 BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
VersionedEntity # BaseEntity + @Version 乐观锁,有并发更新的表继承它
PageResult<T> # 统一分页返回封装 PageResult<T> # 统一分页返回封装
JpaAuditingConfig # 开启 Spring Data JPA Auditing JpaAuditingConfig # 开启 Spring Data JPA Auditing
DomainFlywayConfig # 按 domain database 分别建 Flyway 实例
domains/xxx/ domains/xxx/
src/main/kotlin/.../xxx/infrastructure/persistence/ src/main/kotlin/.../xxx/infrastructure/persistence/
XxxEntity # JPA entity XxxEntity # JPA entity
XxxJpaRepository # : JpaRepository<XxxEntity, Long> XxxJpaRepository # : JpaRepository<XxxEntity, Long>
src/main/resources/db/migration/xxx/ src/main/resources/db/migration/xxx/
V1__init.sql # Flyway migration按 domain 分子目录 V1__init.sql # Flyway migration目录名 = database 名(下划线形式)
``` ```
## `BaseEntity` 示例 迁移目录名统一用**下划线形式、与 database 名一致**`identity_store``webview_ticket`),不用模块名的中划线形式(`identity-store`)——因为 `DomainFlywayConfig` 里是拿 database 名直接拼 `classpath:db/migration/$schema`,两边不一致会静默地一条迁移都不执行。
## 全局 JPA / 数据源配置
```yaml
spring:
datasource:
url: >-
jdbc:mysql://${DB_HOST}:3306/?sslMode=REQUIRED
&connectionTimeZone=UTC&preserveInstants=true
&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD} # 来自 K8s Secret,见 07-config-governance.md
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 拿不到连接就快速失败,不要让请求线程堆在这里
max-lifetime: 570000 # 略小于 MySQL 的 wait_timeout,避免用到已被服务端关闭的连接
transaction-isolation: TRANSACTION_READ_COMMITTED
jpa:
open-in-view: false # 必须显式关掉,Boot 默认是 true
hibernate:
ddl-auto: validate # 表结构只由 Flyway 改,Hibernate 只做校验
properties:
hibernate:
jdbc:
time_zone: UTC
batch_size: 50
order_inserts: true
order_updates: true
query:
fail_on_pagination_over_collection_fetch: true
flyway:
enabled: false # 关掉 Boot 的单实例自动配置,改由 DomainFlywayConfig 接管
```
几条不显眼但会实际出事的配置:
- **`open-in-view: false`**Boot 默认 `true`,意思是数据库连接会一直持有到视图渲染完(对我们来说是到 JSON 序列化完)。后果是连接被无谓占用、懒加载在 Controller 层还能"碰巧成功"从而掩盖 N+1 问题。关掉之后,`application` 层事务外访问懒加载字段会直接抛 `LazyInitializationException`——这是好事,问题会在开发期暴露而不是压测时暴露。
- **`ddl-auto: validate`**:绝不能是 `update``update` 会在应用启动时按 Entity 反推 DDL 去改生产库,且它的改法不可预测、无法评审、无法回滚。表结构的唯一事实来源是 Flyway 脚本。
- **`transaction-isolation: TRANSACTION_READ_COMMITTED`**MySQL 默认是 REPEATABLE READ,我们显式降到 READ COMMITTED。理由:RR 下的一致性读快照在整个事务期间不变,长一点的事务会读到过时数据;RR 还会用更多的 gap lock,并发插入时更容易死锁。绝大多数 Web 业务不需要 RR 的可重复读语义,需要防并发覆盖的地方我们用乐观锁(下一节)显式处理,比依赖隔离级别更清楚。
- **`rewriteBatchedStatements=true`**MySQL 驱动默认**不会**把 JDBC batch 真的合成一条多值 `INSERT`,只配 `hibernate.jdbc.batch_size` 是没用的,必须在 JDBC URL 上开这个开关。
### 时区:全链路 UTC
数据库里只存 UTC,时区转换只在客户端做。三处配置必须一起生效,缺一处就会出现"写进去和读出来差几个小时":
1. 时间列一律用 `datetime(6)`Kotlin 侧一律用 `Instant`(不用 `LocalDateTime`,它不带时区信息,语义上表达不了"某个时刻")。
2. `spring.jpa.properties.hibernate.jdbc.time_zone=UTC`——Hibernate 写库时按 UTC 转换。
3. JDBC URL 上 `connectionTimeZone=UTC&preserveInstants=true`——驱动层按 UTC 解释。
不用 MySQL 的 `timestamp` 类型:它会按会话时区自动转换(结果依赖服务器/连接的时区设置,是上面这类 bug 的常见来源),而且有 2038 年上限。API 层的时间格式约定见 [06-api-design.md](./06-api-design.md)。
## `BaseEntity` / `VersionedEntity` 示例
```kotlin ```kotlin
// platform-persistence/src/main/kotlin/.../BaseEntity.kt // platform-persistence/src/main/kotlin/.../BaseEntity.kt
@@ -50,19 +109,41 @@ abstract class BaseEntity {
var updatedBy: String? = null var updatedBy: String? = null
} }
// platform-persistence/src/main/kotlin/.../VersionedEntity.kt
@MappedSuperclass
abstract class VersionedEntity : BaseEntity() {
@Version
@Column(nullable = false)
var version: Long = 0
}
// platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt // platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt
@Configuration @Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware") @EnableJpaAuditing(auditorAwareRef = "auditorAware")
class JpaAuditingConfig { class JpaAuditingConfig(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) {
@Bean @Bean
fun auditorAware(): AuditorAware<String> = AuditorAware { fun auditorAware(): AuditorAware<String> = AuditorAware {
Optional.ofNullable(StoreContextHolder.currentUserIdOrNull()?.toString()) // StoreContextHolder 是 @RequestScope bean,定时任务/启动流程里没有请求上下文,
// 这里必须容忍拿不到的情况,否则后台任务写库会直接抛 BeanCreationException。
runCatching { storeContextHolder.`object`.userId?.toString() }
.getOrNull()
.let { Optional.ofNullable(it) }
} }
} }
``` ```
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。 `auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
**继承 `BaseEntity` 的表,建表脚本必须带全 `created_at` / `updated_at` / `created_by` / `updated_by` 四列**,且前两列 `not null`——漏一列,第一次插入就会直接失败。这是最容易在新表上重复踩的坑,建表脚本 review 时优先看这一条。
### 乐观锁(`@Version`
只要一条记录可能被两个请求同时改(门店信息编辑、票据状态流转、库存类数据),Entity 就继承 `VersionedEntity`,表上加一列 `version bigint not null default 0`。Hibernate 在 `update` 时自动带上 `where version = ?``version + 1`,更新影响行数为 0 时抛 `ObjectOptimisticLockingFailureException`
`application` 层要显式处理这个异常,转成业务错误码返回给 APP("数据已被他人修改,请刷新后重试"),而不是让它落到 `GlobalExceptionHandler` 变成 500。并发冲突的重试策略见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## Entity + Repository + Migration 示例(`identity-store` 里的门店表) ## Entity + Repository + Migration 示例(`identity-store` 里的门店表)
```kotlin ```kotlin
@@ -76,13 +157,13 @@ class StoreEntity(
@Column(nullable = false, length = 128) @Column(nullable = false, length = 128)
var name: String, var name: String,
@Column(name = "code", nullable = false, unique = true, length = 32) @Column(name = "code", nullable = false, length = 32)
var code: String, var code: String,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING) // 存字符串,不存序号,见下面的说明
@Column(nullable = false, length = 16) @Column(nullable = false, length = 16)
var status: StoreStatus, var status: StoreStatus,
) : BaseEntity() ) : VersionedEntity()
@Repository @Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> { interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
@@ -93,57 +174,175 @@ interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
```sql ```sql
-- src/main/resources/db/migration/identity_store/V1__init.sql -- src/main/resources/db/migration/identity_store/V1__init.sql
create schema if not exists identity_store; -- 注意:不要在迁移脚本里写 create database / usedatabase 由 Flyway 实例的
-- defaultSchema 指定(见 DomainFlywayConfig),脚本里一律用不带库名的表名。
create table identity_store.store ( create table store (
id bigint generated always as identity primary key, id bigint not null auto_increment,
name varchar(128) not null, name varchar(128) not null,
code varchar(32) not null unique, code varchar(32) not null,
status varchar(16) not null, status varchar(16) not null,
created_at timestamp not null, version bigint not null default 0,
updated_at timestamp not null, created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64), created_by varchar(64),
updated_by varchar(64) updated_by varchar(64),
); primary key (id),
unique key uk_store_code (code),
key idx_store_status (status)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
``` ```
Flyway 版本号(`V1``V2`…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。 约定说明:
- **字符集固定 `utf8mb4` + `utf8mb4_0900_ai_ci`**。MySQL 的 `utf8` 是三字节的历史遗留别名,存不了 emoji 和部分生僻汉字,一律不用。
- **索引命名**:唯一索引 `uk_<表名>_<列名>`,普通索引 `idx_<表名>_<列名>`,多列用下划线连接(`idx_store_status_created_at`)。唯一约束写成 `unique key uk_xxx (...)` 而不是列上的 `unique`,是为了让它有个能在日志和慢查询里认出来的名字。
- **InnoDB 单个索引键最长 3072 字节**,`utf8mb4` 下一个字符最多 4 字节,所以 `varchar(768)` 是能整列建索引的上限。要给长文本建索引时用前缀索引(`key idx_x_url (url(255))`)。
- **枚举用 `@Enumerated(EnumType.STRING)`**,绝不用默认的 `ORDINAL`——`ORDINAL` 存的是枚举常量的下标,以后在枚举中间插入一个值,历史数据的含义会整体错位,且这种错位没有任何报错。
- **金额用 `decimal(18, 4)`**Kotlin 侧 `BigDecimal`,永远不用 `double`/`float`
- **布尔用 `tinyint(1)`**Hibernate 对 Kotlin `Boolean` 的默认映射)。
- **外键**:同一个 database 内部可以用物理外键;**跨 database 一律只做逻辑关联**(存 ID,不建 `foreign key` 约束),否则模块边界在数据库层就被焊死了,将来任何一个域想单独拆库都要先拆约束。
- **软删除**:不做全局的 `@SQLDelete` + `@Where` 软删除(它会污染所有查询、和唯一索引冲突、还容易被忘记)。确实需要保留历史的表,显式加 `status``deleted_at` 列并在每个查询里显式过滤。
## Flyway:每个 domain database 一个独立实例
Boot 自动配置的 Flyway 只有一个实例、一张 `flyway_schema_history`。如果各 domain 目录各自从 `V1__init.sql` 开始编号,这个单实例扫到两个 `V1` 会直接报 `Found more than one migration with version 1`,启动失败。所以关掉自动配置,按 database 各建一个 Flyway 实例,各自维护自己那张历史表、各自的版本序列:
```kotlin
// platform-persistence/.../DomainFlywayConfig.kt
@Configuration
class DomainFlywayConfig {
companion object {
// 新增一个 domain 时,这里加一行 —— 见 01-project-structure.md 的脚手架说明
val DOMAIN_SCHEMAS = listOf(
"identity_store",
"workbench",
"webview_ticket",
)
}
@Bean
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
DOMAIN_SCHEMAS.forEach { schema ->
Flyway.configure()
.dataSource(dataSource)
.schemas(schema) // MySQL 下即 database;不存在时会自动创建
.defaultSchema(schema) // 历史表和脚本里的裸表名都落在这个 database
.table("flyway_schema_history")
.locations("classpath:db/migration/$schema")
.load()
.migrate()
}
return DomainFlywayMigrations
}
// 必须让 EntityManagerFactory 等迁移跑完再初始化,
// 否则 ddl-auto=validate 会在建表之前校验,启动直接失败。
// Boot 自带的这个依赖关系只认名为 flyway/flywayInitializer 的 bean,自定义实例要自己挂。
@Bean
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
}
object DomainFlywayMigrations // 只是一个用来表达依赖顺序的标记 bean
```
规则:
- 各 domain 目录内版本号独立递增,`identity_store/V2__xxx.sql``workbench/V2__yyy.sql` 互不冲突。
- **迁移脚本一旦合入主干就不可修改**(Flyway 会校验 checksum,改了会导致其他环境启动失败)。写错了就再加一个 `V(n+1)` 修正。
- 迁移脚本必须**前向兼容**:滚动更新期间新旧两个版本的应用会同时连着同一个库,所以不能有"旧代码见到就会崩"的改动。加列不删列、分两个版本走 expand-contract,规则和发布流程的配合见 [09-build-deploy.md](./09-build-deploy.md)。
- **运行账号和迁移账号分开**:Flyway 用的账号需要 DDL 权限,应用运行时只需要 DML。生产上把迁移放在部署流程里用单独的账号执行(见 09),运行时账号不给 `CREATE`/`DROP`/`ALTER`——这样即使应用被注入了 DDL,也执行不了。
## 跨 domain 数据访问规则 ## 跨 domain 数据访问规则
**每个 domain 独立 schema**:即使同一个数据库实例,各 `domains/*` 的表也归属各自 schema,不允许跨 domain 直接 `join` 表——需要数据时通过对方模块暴露的 `application` 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分) **每个 domain 一个独立的 database**`identity_store` / `workbench` / `webview_ticket` 各自建库,不允许跨 domain 直接 join 表。需要别的域的数据时,走对方 `-contract` 模块暴露的接口或领域事件(见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),不查对方的表
```kotlin ```kotlin
// 错误示范workbench 直接 join identity_store 的 // 正确做法workbench 通过 identity-store 的契约模块获取门店信息
@Query("""
select w from WorkbenchTileEntity w
join StoreEntity s on s.id = w.storeId -- 跨 schema 直接 join,禁止
""")
fun findTilesWithStoreInfo(): List<WorkbenchTileEntity>
// 正确做法:workbench 通过 identity-store 暴露的接口获取门店信息
@Service @Service
class WorkbenchAppService( class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // identity-store 模块对外暴露的接口 private val storeQueryService: StoreQueryService, // 来自 domains/identity-store-contract
private val tileRepository: WorkbenchTileRepository, private val tileRepository: WorkbenchTileRepository,
) { ) {
fun listTiles(userId: Long): List<TileResponse> { fun listTiles(userId: Long): List<TileResponse> {
val stores = storeQueryService.listStoresByUserId(userId) // 走 application 层调用,不查表 val stores = storeQueryService.listStoresByUserId(userId) // 走契约接口,不查表
val tiles = tileRepository.findByUserId(userId) val tiles = tileRepository.findByUserId(userId)
return buildTiles(tiles, stores) return buildTiles(tiles, stores)
} }
} }
``` ```
## 附录:为什么坚持"每个 domain 独立 schema" ### 这条规则的强制力:哪些是硬约束、哪些不是
模块化单体最容易被破坏的地方就是数据库——代码层面 Gradle 依赖规则挡住了跨 domain import 类,但如果两个 domain 的表都在同一个 schema 下,写 SQL 的时候很容易"顺手 join 一下",这条规则完全不受编译器约束,只能靠约定。所以我们把 schema 拆开:`workbench``DataSource` 配置的默认 schema 是 `workbench`,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。 这里要说清楚,避免高估它的保护力度:
代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界 1. **JPQL 层面是硬约束**。JPQL 引用的是 Entity 类,`workbench` 想写 `join StoreEntity s` 就得 `import ...identitystore...StoreEntity`——而 [01-project-structure.md](./01-project-structure.md) 的 Gradle 依赖规则让 `workbench` 模块根本编译不到这个类。这一条编译期就挡死了,不需要靠自觉
2. **原生 SQL 是软约束**`@Query(nativeQuery = true)` 里的表名是字符串,写 `join identity_store.store s` 完全能编译通过。而且——这一点必须讲清楚——**MySQL 里跨 database join 是完全合法的**,只要连接用的账号对两个库都有权限就能执行成功。我们是模块化单体,整个进程共用一个 `DataSource` 和一个数据库账号,这个账号必然对所有 domain 的库都有权限。所以在 MySQL 下,"跨库 join 会报错"这个说法**不成立**,别指望数据库替我们拦住它。
> 顺带说明:网上很多"多 schema 隔离"的方案是按 PostgreSQL 写的。PG 里 schema 是 database 内部的命名空间,可以按 schema 单独 `REVOKE` 权限从而做成硬约束;MySQL 里 schema 就是 database,我们这种单账号单数据源的结构做不到同样的效果。
兜底手段只能是流程性的:**原生 SQL 一律在 code review 里重点看**,并且加一条测试扫描所有 `@Query(nativeQuery = true)` 的字符串里有没有出现其他 domain 的 database 名(写法见 [10-testing.md](./10-testing.md))。这道防线不完美,但配合第 1 条已经覆盖了绝大多数实际会发生的情况——正常人不会为了 join 一张表专门去写原生 SQL 绕过编译错误。
3. **如果将来需要把它升级成硬约束**:做法是每个 domain 一个 `DataSource` + 一个只 GRANT 本库的数据库账号,那时跨库 join 会因为权限不足而真的失败。代价是多套 `EntityManagerFactory`/`TransactionManager`、多个连接池(连接数要重新算),并且跨 domain 的本地事务彻底不可能——最后一条其实是好事,但整体复杂度明显上升。现阶段不做,等到真的出现跨域乱查的实际问题、或者某个域准备独立拆库时再上。
代价那一面也要认:确实需要跨 domain 做一次性数据修复或报表查询时,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
## 查询规范
### N+1 与抓取策略
- **所有 `@ManyToOne` / `@OneToOne` 显式写 `fetch = FetchType.LAZY`**。JPA 规范里这两种关联默认是 `EAGER`,意味着查一个 Entity 会顺带把关联对象也查出来,列表查询时就是典型的 N+1。
- 确实需要一次带出关联数据时,用 `@EntityGraph` 或 JPQL 的 `join fetch` 显式声明,不要靠懒加载在循环里触发。
- **分页 + `join fetch` 集合属性会导致 Hibernate 把全表拉进内存再分页**。上面配置里的 `fail_on_pagination_over_collection_fetch: true` 让这种写法直接抛异常而不是悄悄变慢。
- 开发环境开 `spring.jpa.properties.hibernate.generate_statistics=true` 或用 [datasource-proxy](https://github.com/jdbc-observations/datasource-proxy) 观察每个请求实际发了多少条 SQL;关键列表接口在测试里断言 SQL 条数,比事后压测发现要早得多。
### 分页
统一用 Spring Data 的 `Pageable` + 我们自己的 `PageResult<T>` 返回(字段名和 APP 侧约定见 [06-api-design.md](./06-api-design.md)),不直接把 Spring 的 `Page` 序列化给前端——`Page` 的 JSON 结构由 Spring 版本决定,升级 Boot 时会变,属于把框架内部结构写进 API 契约。
```kotlin
data class PageResult<T>(
val list: List<T>,
val pageNum: Int,
val pageSize: Int,
val total: Long,
val hasMore: Boolean,
)
```
翻很深的页(`offset` 很大)在 MySQL 上会越来越慢,因为它必须先扫过前面所有行再丢弃。App 上的列表基本都是"下拉加载更多",这种场景优先用**游标分页**(按 `id``created_at``lastId``where id < :lastId order by id desc limit :size`),不用 `offset`
### 批量写
`hibernate.jdbc.batch_size` + JDBC URL 上的 `rewriteBatchedStatements=true` 两个都配上,批量 update/delete 才会真的合并。
但**批量 insert 有个 MySQL 特有的坑**`@GeneratedValue(strategy = IDENTITY)` 下 Hibernate 必须逐条插入才能拿回自增主键,JDBC batch 会被直接禁用,配了也没用。所以:常规写入保持 `IDENTITY` 不变(简单、够用);真正的大批量导入场景(几千行以上)绕开 JPA,直接用 `JdbcTemplate.batchUpdate` 或一条多值 `INSERT`。不要为了让 JPA 能批量插入就把主键策略换成 `TABLE` 生成器——那会引入一张全局竞争的序列表,得不偿失。
## 连接池容量怎么算
`maximum-pool-size` 不是越大越好,要和数据库实例的连接上限对齐:
```
所有 Pod 的连接总数 = maximum-pool-size × Pod 副本数(含滚动更新期间的临时多余副本)
必须 ≤ MySQL 实例的 max_connections 预留(运维/迁移/监控账号,留 20 左右)
```
Azure Database for MySQL Flexible Server 的 `max_connections` 由 SKU 规格决定(跟内存挂钩),扩副本前要先确认这个数字。按当前 3 副本、滚动更新时最多 4 副本估算,`maximum-pool-size: 15` 对应峰值 60 个连接。
池子大小本身的经验值是"略大于并发执行 SQL 的线程数",不是"等于 Tomcat 线程数"——大部分请求线程在等下游 HTTP(见 [05-integration-layer.md](./05-integration-layer.md))而不是等数据库。池子配得过大反而会让数据库承受更多并发、整体延迟变差。
## 事务
- `@Transactional` 只加在 `application` 层(见 [02-layering.md](./02-layering.md)),不加在 Controller 或 repository 上。
- **事务里不要调外部 HTTP**。一次 F6 调用可能耗时几秒,事务开着就意味着数据库连接和行锁被占几秒。把外部调用挪到事务外,或者用 `@TransactionalEventListener(AFTER_COMMIT)`
- 只读查询加 `@Transactional(readOnly = true)`:Hibernate 会跳过脏检查,省掉一次快照比对。
- 详细的事务边界、传播行为、幂等与并发冲突处理见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## 待补充 ## 待补充
- 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
- 复杂查询是否引入 QueryDSL/jOOQ`Specification` 不够用时再决定)。 - 复杂查询是否引入 QueryDSL/jOOQ`Specification` 不够用时再决定)。
- 各 domain 的实际表结构,等开发到对应模块时再补。 - 各 domain 的实际表结构,等开发到对应模块时再补。
@@ -152,3 +351,6 @@ class WorkbenchAppService(
- [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/) - [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/)
- [Flyway 官方文档](https://documentation.red-gate.com/fd) - [Flyway 官方文档](https://documentation.red-gate.com/fd)
- [Spring Data JPA Auditing](https://docs.spring.io/spring-data/jpa/reference/auditing.html) - [Spring Data JPA Auditing](https://docs.spring.io/spring-data/jpa/reference/auditing.html)
- [MySQL 8.4 参考手册:字符集与排序规则](https://dev.mysql.com/doc/refman/8.4/en/charset.html)
- [MySQL Connector/J:时区处理](https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html)
- [HikariCP: About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
+378 -106
View File
@@ -2,79 +2,178 @@
## 决策 ## 决策
Spring Security + JWT,由 `identity-store` 模块统一签发校验,对应架构图里 `Auth and Token Center`;门店/角色上下文通过统一 filter 解析后注入 `RequestScope`,供各 domain 读取,对应架构图 `Store Context` 的职责。 Spring Security + JWT,由 `identity-store` 模块统一签发,由 `platform-security` 统一校验,对应架构图里 `Auth and Token Center`;门店/角色上下文在认证之后注入 `@RequestScope` bean,供各 domain 读取,对应架构图 `Store Context` 的职责。
JWT 的签发与校验**用 Spring Security 内置的 `NimbusJwtEncoder`/`NimbusJwtDecoder`,不引入 jjwt**,理由见文末附录。
## 结构约定 ## 结构约定
``` ```
platform-security/ platform-security/
JwtTokenProvider # 签发/解析/刷新 token JwtProperties # security.jwt.* 配置绑定 + 启动期校验
JwtAuthenticationFilter # 统一 filter:解析 JWT,写入 SecurityContext + StoreContextHolder JwtEncoderConfig # NimbusJwtEncoder / NimbusJwtDecoderHS256
StoreContextHolder # RequestScope bean,持有当前 用户+门店+角色 AccessTokenIssuer # 签发 access tokenclaims 结构见下)
SecurityConfigSupport # 各 domain 复用的 Spring Security 通用配置片段 StoreContextFilter # 认证之后执行:把 Jwt claims 写进 StoreContextHolder
StoreContextHolder # @RequestScope bean,持有当前 用户+门店+角色
ApiResultAuthenticationEntryPoint # 401 → ApiResult JSON
ApiResultAccessDeniedHandler # 403 → ApiResult JSON
SecurityConfig # 统一 SecurityFilterChain
domains/identity-store/ domains/identity-store/
负责登录、token 签发/刷新/失效、门店列表、菜单权限 负责登录、refresh token 签发/轮换/撤销、门店列表与切换、菜单权限
``` ```
## `JwtTokenProvider` 示例 ## 端点契约(与客户端已实现的行为对齐)
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../05-networking.md)、[../11-store-context-and-session.md](../11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `POST /api/v1/auth/login` | 免认证 | `{ username, password }``{ accessToken, refreshToken, user }` |
| `POST /api/v1/auth/refresh` | 免认证 | `{ refreshToken }``{ accessToken, refreshToken }`。**必须免认证**:调用它的时候 access token 恰好已经过期 |
| `POST /api/v1/auth/logout` | 需认证 | `{ refreshToken }``ApiResult<Unit>`。撤销该 refresh token;客户端 3 秒超时、失败不阻断本地登出 |
| `GET /api/v1/auth/me` | 需认证 | → `{ user, currentStoreId }`。客户端冷启动时用本地 token 调它恢复会话(客户端 `11-store-context-and-session.md` 的启动流程)。**必须是轻量查询**,不做任何写操作——它在每次冷启动都会被调用 |
| `POST /api/v1/stores/{id}/switch` | 需认证 | → `StoreContext`(含新的 `accessToken` + 菜单),见下面"切换门店" |
客户端冷启动的完整顺序是 `GET /auth/me``GET /api/v1/stores/accessible`(后者见 [06-api-design.md](./06-api-design.md)):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 `ApiResult` JSON(见下面 `ApiResultAuthenticationEntryPoint`),不能是 500。
其余端点的路径与响应包装规则见 [06-api-design.md](./06-api-design.md)。
## Token 配置与校验
```kotlin ```kotlin
// platform-security/.../JwtTokenProvider.kt // platform-security/.../JwtProperties.kt
@Component @ConfigurationProperties(prefix = "security.jwt")
class JwtTokenProvider( @Validated
@Value("\${security.jwt.secret}") secret: String, data class JwtProperties(
@Value("\${security.jwt.access-token-ttl-minutes:30}") private val accessTokenTtlMinutes: Long, /** 当前签发用的密钥 ID,写进 JWT header 的 kid,用于密钥轮换,见"密钥轮换"一节。 */
@field:NotBlank val activeKeyId: String,
/** kid -> base64 密钥,允许同时持有多个用于校验,只有 activeKeyId 用于签发。 */
val keys: Map<String, String> = emptyMap(),
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
@field:Min(1) val refreshTokenTtlDays: Long = 30,
) { ) {
private val key = Keys.hmacShaKeyFor(secret.toByteArray()) @PostConstruct
fun validate() {
fun issueAccessToken(userId: Long, storeId: Long, roles: List<String>): String = val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
Jwts.builder() require(Base64.getDecoder().decode(key).size >= 32) {
.subject(userId.toString()) "HS256 密钥长度必须 ≥ 32 字节,当前配置不满足" // 启动即失败,不允许带着弱密钥跑起来
.claim("storeId", storeId) }
.claim("roles", roles) }
.issuedAt(Date())
.expiration(Date.from(Instant.now().plus(accessTokenTtlMinutes, ChronoUnit.MINUTES)))
.signWith(key)
.compact()
fun parse(token: String): Jws<Claims> =
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
} }
``` ```
## `JwtAuthenticationFilter` + `StoreContextHolder` 示例 ```kotlin
// platform-security/.../JwtEncoderConfig.kt
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtEncoderConfig(private val props: JwtProperties) {
private fun secretKey(keyId: String): SecretKey =
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
@Bean
fun jwtEncoder(): JwtEncoder {
val jwkSet = JWKSet(
props.keys.keys.map { kid ->
OctetSequenceKey.Builder(secretKey(kid)).keyID(kid).algorithm(JWSAlgorithm.HS256).build()
},
)
return NimbusJwtEncoder(ImmutableJWKSet(jwkSet))
}
@Bean
fun jwtDecoder(): JwtDecoder =
// 校验时按 header 里的 kid 选密钥,所以轮换期间新旧 token 都能验过
NimbusJwtDecoder.withSecretKey(secretKey(props.activeKeyId))
.macAlgorithm(MacAlgorithm.HS256)
.build()
}
```
```kotlin
// platform-security/.../AccessTokenIssuer.kt
@Component
class AccessTokenIssuer(
private val jwtEncoder: JwtEncoder,
private val props: JwtProperties,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
val now = clock.instant()
val claims = JwtClaimsSet.builder()
.subject(userId.toString())
.claim("storeId", storeId)
.claim("roles", roles)
.id(UUID.randomUUID().toString()) // jti
.issuedAt(now)
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
.build()
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}
}
```
### Access Token 的 claims 结构
```json
{
"sub": "1024",
"storeId": 7,
"roles": ["STORE_MANAGER"],
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
"iat": 1735600000,
"exp": 1735601800
}
```
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。
## `StoreContextHolder` 与上下文注入
```kotlin ```kotlin
// platform-security/.../StoreContextHolder.kt // platform-security/.../StoreContextHolder.kt
@Component @Component
@RequestScope @RequestScope // 默认 proxyMode = TARGET_CLASS,可以直接注入到单例 bean 里
class StoreContextHolder { class StoreContextHolder {
var userId: Long? = null var userId: Long? = null
var storeId: Long? = null var storeId: Long? = null
var roles: List<String> = emptyList() var roles: List<String> = emptyList()
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里") fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
} }
```
// platform-security/.../JwtAuthenticationFilter.kt Kotlin 里这个类必须是 `open` 的(CGLIB 代理要求),`kotlin-spring` 插件会因为 `@Component` 自动放开,不需要手写 `open`——但如果哪天把 `@Component` 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider, ```kotlin
private val storeContextHolder: StoreContextHolder, // platform-security/.../StoreContextFilter.kt
// 注意:这个 filter 只负责"把已经验过的 claims 搬进 StoreContextHolder"
// 不做任何解析或验签——验签由 Spring Security 的 BearerTokenAuthenticationFilter 做完了。
// 这一点很关键:token 无效时的 401 响应由标准的 AuthenticationEntryPoint 产出,
// 不会出现"自己写的 filter 里抛异常 → @RestControllerAdvice 接不住 → 返回 500"的情况。
class StoreContextFilter(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) : OncePerRequestFilter() { ) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val token = request.getHeader("Authorization")?.removePrefix("Bearer ") val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
if (token != null) { if (jwt != null) {
val claims = jwtTokenProvider.parse(token).payload val ctx = storeContextHolder.`object`
storeContextHolder.userId = claims.subject.toLong() ctx.userId = jwt.subject.toLong()
storeContextHolder.storeId = (claims["storeId"] as Number).toLong() ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
@Suppress("UNCHECKED_CAST") ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
storeContextHolder.roles = claims["roles"] as List<String>
val authorities = storeContextHolder.roles.map { SimpleGrantedAuthority("ROLE_$it") } // 客户端会带 X-Store-Id(见 06-api-design.md 的统一请求头约定)。
SecurityContextHolder.getContext().authentication = // 服务端一律以 token 里的 storeId 为准;不一致时记一条 warn 用于排查
UsernamePasswordAuthenticationToken(storeContextHolder.userId, null, authorities) // (切店瞬间客户端可能还在用旧 header,直接拒绝会误伤正常请求)。
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
if (headerStoreId != ctx.storeId) {
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
}
}
} }
chain.doFilter(request, response) chain.doFilter(request, response)
} }
@@ -91,92 +190,139 @@ class WorkbenchAppService(
) { ) {
fun listTiles(): List<TileResponse> { fun listTiles(): List<TileResponse> {
val userId = storeContextHolder.currentUserId() val userId = storeContextHolder.currentUserId()
val storeId = storeContextHolder.currentStoreId()
// ... // ...
} }
} }
``` ```
## `SecurityConfigSupport` 示例(各 domain/bootstrap 复用) ## `SecurityConfig`
```kotlin ```kotlin
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity // 开启 @PreAuthorize
class SecurityConfig( class SecurityConfig(
private val jwtTokenProvider: JwtTokenProvider, private val storeContextHolder: ObjectFactory<StoreContextHolder>,
private val storeContextHolder: StoreContextHolder, private val entryPoint: ApiResultAuthenticationEntryPoint,
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
private val environment: Environment,
) { ) {
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
http http
.csrf { it.disable() } // 无状态 API,不需要 CSRF token .csrf { it.disable() } // 无状态 API + Bearer token,不存在 CSRF 的前提
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { .authorizeHttpRequests { auth ->
it.requestMatchers("/actuator/health", "/api/v1/auth/login").permitAll() auth.requestMatchers(
it.anyRequest().authenticated() "/actuator/health/**", // 存活/就绪探针,见 08-observability.md
"/api/v1/auth/login",
"/api/v1/auth/refresh", // 调它的时候 access token 已过期,必须免认证
).permitAll()
if (!isProd) {
// 接口文档只在非生产环境开放;生产环境走内网文档站或 CI 产物
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
} }
.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider, storeContextHolder), auth.anyRequest().authenticated()
UsernamePasswordAuthenticationFilter::class.java, }
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
oauth2.authenticationEntryPoint(entryPoint) // token 缺失/过期/签名错 → 统一 401 JSON
}
.exceptionHandling {
it.authenticationEntryPoint(entryPoint)
it.accessDeniedHandler(accessDeniedHandler) // 认证过但权限不足 → 统一 403 JSON
}
.addFilterAfter(
StoreContextFilter(storeContextHolder),
BearerTokenAuthenticationFilter::class.java, // 必须在认证之后
) )
return http.build() return http.build()
} }
private fun rolesConverter(): Converter<Jwt, AbstractAuthenticationToken> {
val authorities = JwtGrantedAuthoritiesConverter().apply {
setAuthoritiesClaimName("roles")
setAuthorityPrefix("ROLE_")
}
return JwtAuthenticationConverter().apply { setJwtGrantedAuthoritiesConverter(authorities) }
}
} }
``` ```
## 关键规则 ```kotlin
// platform-security/.../ApiResultAuthenticationEntryPoint.kt
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。 @Component
- 门店/角色上下文在统一 filter 里解析 JWT 后写入 `StoreContextHolder`,各 domain 通过它读取当前上下文,不各自解析 token——避免"每个 domain 有一份自己的 token 解析逻辑"这种重复和不一致。 class ApiResultAuthenticationEntryPoint(
- 切换门店会使当前上下文失效,`webview-ticket` 相关会话需要联动失效(对应架构图 Flow 2 的规则):`identity-store` 切换门店成功后,需要通知 `webview-ticket` 使当前 ticket 状态置为 `INVALIDATED`(走 `bff-orchestration` 编排或事件通知,不是 `identity-store` 直接改 `webview-ticket` 的表)。 private val objectMapper: ObjectMapper,
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。 ) : AuthenticationEntryPoint {
override fun commence(req: HttpServletRequest, resp: HttpServletResponse, ex: AuthenticationException) {
## Access Token 的 claims 结构 resp.status = HttpStatus.UNAUTHORIZED.value() // 必须是 401:客户端只在 401 时触发刷新
resp.contentType = MediaType.APPLICATION_JSON_VALUE
在现有 `JwtTokenProvider.issueAccessToken` 基础上补充 `jti`(每次签发的唯一 ID),完整 claims: resp.characterEncoding = Charsets.UTF_8.name()
objectMapper.writeValue(
```json resp.outputStream,
{ ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
"sub": "1024", )
"storeId": 7, }
"roles": ["STORE_MANAGER"],
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
"iat": 1735600000,
"exp": 1735601800
} }
``` ```
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token **为什么这两个 handler 必须存在**Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出
## Refresh Token:不用 Redis,直接用现有 Postgres ## Refresh Token:不用 Redis,直接用现有 MySQL
Access token TTL 短(现有配置 30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。 Access token TTL 短(30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。
```kotlin ```kotlin
// domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt // domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt
@Entity @Entity
@Table(name = "refresh_tokens") @Table(name = "refresh_token", schema = "identity_store")
class RefreshTokenEntity( class RefreshTokenEntity(
@Id @GeneratedValue val id: Long? = null, @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val userId: Long, val userId: Long,
@Column(name = "token_hash", nullable = false, length = 64)
val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文 val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文
@Column(nullable = false)
val expiresAt: Instant, val expiresAt: Instant,
@Column
var revokedAt: Instant? = null, var revokedAt: Instant? = null,
@Column
var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放" var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放"
) : BaseEntity() ) : BaseEntity()
``` ```
```sql ```sql
-- domains/identity-store/src/main/resources/db/migration/identity-store/V2__refresh_tokens.sql -- domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql
CREATE TABLE refresh_tokens ( create table refresh_token (
id BIGSERIAL PRIMARY KEY, id bigint not null auto_increment,
user_id BIGINT NOT NULL REFERENCES users(id), user_id bigint not null,
token_hash CHAR(64) NOT NULL UNIQUE, token_hash char(64) not null,
expires_at TIMESTAMPTZ NOT NULL, expires_at datetime(6) not null,
revoked_at TIMESTAMPTZ, revoked_at datetime(6),
replaced_by_token_id BIGINT, replaced_by_token_id bigint,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
); created_at datetime(6) not null,
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_refresh_token_hash (token_hash),
key idx_refresh_token_user_id (user_id),
key idx_refresh_token_expires_at (expires_at),
constraint fk_refresh_token_user foreign key (user_id) references user (id)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
``` ```
```kotlin ```kotlin
@@ -184,6 +330,7 @@ CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
@Service @Service
class RefreshTokenService( class RefreshTokenService(
private val repository: RefreshTokenJpaRepository, private val repository: RefreshTokenJpaRepository,
private val props: JwtProperties,
private val clock: Clock, private val clock: Clock,
) { ) {
fun issue(userId: Long): String { fun issue(userId: Long): String {
@@ -192,64 +339,189 @@ class RefreshTokenService(
RefreshTokenEntity( RefreshTokenEntity(
userId = userId, userId = userId,
tokenHash = sha256Hex(rawToken), tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS), expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
), ),
) )
return rawToken return rawToken // 返回明文给客户端,库里只有 hash
} }
@Transactional @Transactional
fun rotate(rawToken: String): String { fun rotate(rawToken: String): RotatedTokens {
val existing = repository.findByTokenHash(sha256Hex(rawToken)) val existing = repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null && it.expiresAt.isAfter(clock.instant()) } ?: throw InvalidRefreshTokenException() // 从来不存在的 token
?: throw InvalidRefreshTokenException() // 已过期/已撤销/被重放,一律要求重新登录
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
if (existing.revokedAt != null) {
repository.revokeAllByUserId(existing.userId, clock.instant())
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
throw InvalidRefreshTokenException()
}
if (existing.expiresAt.isBefore(clock.instant())) {
throw InvalidRefreshTokenException()
}
val newRawToken = generateOpaqueToken()
val rotated = repository.save( val rotated = repository.save(
RefreshTokenEntity( RefreshTokenEntity(
userId = existing.userId, userId = existing.userId,
tokenHash = sha256Hex(generateOpaqueToken()), tokenHash = sha256Hex(newRawToken), // 存 hash
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS), expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
), ),
) )
existing.revokedAt = clock.instant() existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id existing.replacedByTokenId = rotated.id
return rotated.tokenHash // 实际返回给客户端的是加密前的 rawToken,此处示意轮换关系
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken) // 返回明文
} }
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 登出/强制下线 @Transactional
fun revoke(rawToken: String) {
repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null }
?.let { it.revokedAt = clock.instant() }
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
}
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 强制下线
} }
data class RotatedTokens(val userId: Long, val refreshToken: String)
``` ```
- **轮换(rotation**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`——如果旧 token 之后又被人拿来用一次,说明它可能已经泄漏被盗用,能立刻识别出这次"重放",可以顺带撤销该用户名下所有 refresh token,强制重新登录 - **轮换(rotation**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)
- **过期清理**不依赖 Redis 的自动 TTL,加一个简单的定时任务(`@Scheduled`)定期删掉 `expires_at < now()` 的行即可——单个用户同时存在的有效 refresh token 数量本来就很小,数据量级不构成问题 - **过期清理**加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)
### 为什么现阶段不引入 Redis ### 为什么现阶段不引入 Redis
Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束: Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束:
- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。 - 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,Postgres 加个唯一索引足够支撑。 - 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,MySQL 上一个唯一索引足够支撑。
- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。 - 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
结论:refresh token 存现有 Postgres 就够,不引入 Redis;如果以后出现 Redis 能解决、Postgres 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。 结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
## 附录:为什么用 `RequestScope` bean 而不是 `ThreadLocal` ## 切换门店
```kotlin
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(private val storeAppService: StoreAppService) {
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
```
```kotlin
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val accessTokenIssuer: AccessTokenIssuer,
private val storeContextHolder: StoreContextHolder,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun switchStore(targetStoreId: Long): StoreContextResponse {
val userId = storeContextHolder.currentUserId()
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
val roles = storeRepository.findRoles(userId, targetStoreId)
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
// 只改客户端本地状态不换 token,等于门店没真的切过去。
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
// 3. 切店会使旧门店下的 WebView 票据失效(架构图 Flow 2)。
// 走领域事件,不由 identity-store 直接改 webview-ticket 的表,见 11-cross-domain-collaboration.md
eventPublisher.publishEvent(StoreSwitchedEvent(userId, from = storeContextHolder.storeId, to = targetStoreId))
return StoreContextResponse(accessToken, store.toInfo(), roles, menuOf(roles))
}
}
```
refresh token **不随切店轮换**:它绑定的是用户身份而不是门店,切店只换 access token。
## 越权与数据隔离规则
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
1. **所有涉及门店数据的查询,必须以 `storeContextHolder.currentStoreId()` 作为过滤条件**,而不是用请求参数里传来的 `storeId`。请求参数可以被任意篡改,token 里的不行。
2. **确实需要按路径/参数指定 `storeId` 的接口(如切店),必须先校验该用户对目标门店的可访问性**,校验不过返回 `STORE_NOT_ACCESSIBLE`。上面的 `findAccessibleStore` 就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。
3. **按 ID 查单条记录时,`where id = ? and store_id = ?`**,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。
4. 这三条在 code review 里是必看项,新增查询方法时优先检查。
### 接口级权限 vs 菜单权限
- **菜单权限**(切店时返回的 `menu`)只决定 APP 上显示什么,是 UI 层的便利,**不构成安全边界**。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。
- **接口级权限**用 `@PreAuthorize``application` 层(或 Controller)上显式声明,这才是真正的边界:
```kotlin
@PreAuthorize("hasRole('STORE_MANAGER')")
fun approveProcurement(id: Long) { ... }
```
- 两者的数据来源应该是同一份角色/权限配置,但**必须两边都做**。只做菜单不做接口校验,等于没做。
## 密码存储与登录保护
- **密码用 BCrypt 存储**`BCryptPasswordEncoder`,强度 12),存 hash,绝不存明文或可逆加密。用 `DelegatingPasswordEncoder`Spring Security 默认)让 hash 带 `{bcrypt}` 前缀,将来换算法时新旧可以共存。
- **登录失败限流**`user` 表上加 `failed_attempts` / `locked_until` 两列,同一账号连续失败 5 次锁定 15 分钟。放在数据库而不是内存里,是因为多副本下内存计数各算各的,等于没限。
- **IP 维度的限流放在 API Gateway 做**,后端不重复实现——后端看到的往往是网关的 IP,自己做也做不准。
- **登录失败的响应不区分"用户不存在"和"密码错误"**,统一返回同一个错误码和文案,避免被用来枚举账号。
- 密码、token、`Authorization` 头等敏感字段**不允许进日志**,脱敏规则见 [08-observability.md](./08-observability.md)。
## 密钥轮换
JWT 签名密钥放在 K8s Secret 里(见 [07-config-governance.md](./07-config-governance.md)),轮换按 `kid` 分三步走,全程不需要让用户重新登录:
1. **加新密钥**:在 `security.jwt.keys` 里加一个新 `kid`,但 `activeKeyId` 保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。
2. **切签发**:把 `activeKeyId` 改成新 `kid`。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。
3. **删旧密钥**:等超过一个 access token TTL30 分钟)后,从 `keys` 里移除旧 `kid`。
三步之间必须各自完成一次滚动更新,不能合并成一次改动——合并了会出现"某些实例已经用新密钥签发,另一些实例还不认识新密钥"的窗口,表现为随机 401。
## 关键规则
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
- 门店/角色上下文在统一 filter 里从已认证的 `Jwt` 写入 `StoreContextHolder`,各 domain 通过它读取,不各自解析 token。
- 切换门店必须重新签发 access token,并联动使 `webview-ticket` 的当前票据失效(架构图 Flow 2)——走领域事件,不跨模块直接改表。
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
## 附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / [01-project-structure.md](./01-project-structure.md))下它有个具体问题:jjwt 的 JSON 序列化模块 `jjwt-jackson` 依赖 **Jackson 2**`com.fasterxml.jackson`),而 Boot 4 默认用的是 **Jackson 3**`tools.jackson`)。两者包名不同、不二进制兼容,用 jjwt 就意味着要在同一个应用里同时带两套 Jackson——能跑,但多一份依赖、多一处升级时要照顾的地方,还容易让人误以为 `ObjectMapper` 只有一个。
Spring Security 本身就自带基于 Nimbus JOSE + JWT 的 `JwtEncoder`/`JwtDecoder``spring-boot-starter-oauth2-resource-server`),版本由 Boot BOM 管,不引入第二套 JSON 库。更重要的是,用它就能顺带用上 `oauth2ResourceServer` 这套标准链路:**token 的解析和验签由框架的 filter 完成,失败时走标准的 `AuthenticationEntryPoint` 返回 401**——而自己写 filter 解析 token 时,最常见的 bug 恰恰是异常没接住导致返回 500(详见上面 handler 那一节)。选它是同时解决依赖和正确性两个问题。
代价:Nimbus 的 API 比 jjwt 的链式 builder 略啰嗦一点(多一个 `JwtClaimsSet`/`JwsHeader` 的概念),以及如果团队里有人熟悉 jjwt 需要适应一下。如果后续确实要换回 jjwt,注意版本要选和 Jackson 3 兼容的,或者自己实现 jjwt 的 `Serializer`/`Deserializer` 接口接到 Jackson 3 上。
## 附录二:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑: 传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。 1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
2. **响应式/协程场景失效**:一旦引入 `WebClient` 的异步回调或 Kotlin 协程切换线程,`ThreadLocal` 绑定的线程和实际处理请求的线程可能不是同一个。 2. **线程切换场景失效**:一旦把工作交给另一个线程池(比如 `workbench` 首页的并行聚合,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),`ThreadLocal` 绑定的线程和实际干活的线程不是同一个
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。当前阶段 domain 内部都是同步 Servlet 栈(Spring MVC),`RequestScope` 完全够用;如果未来某个模块换成 WebFlux(响应式),需要改用 Reactor Context 传递上下文,不能直接照搬 `RequestScope` Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
需要注意的是 `@RequestScope` 也**不会自动跨线程传播**:并行聚合时子线程里拿不到它。那种场景要么把需要的值(`userId`/`storeId`)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 `TaskDecorator` 显式搬运上下文——两种做法的取舍见 11 篇。
## 待补充 ## 待补充
- 权限模型细节(菜单权限 vs 接口级权限,是否需要单独的权限表) - 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色
- 与 K8s ConfigMap/Secret 配合的密钥轮换方式,见 [07-config-governance.md](./07-config-governance.md)。
## 参考链接 ## 参考链接
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html) - [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
- [jjwtJWT 库)](https://github.com/jwtk/jjwt) - [Spring Security: JWT 支持(JwtEncoder / JwtDecoder](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) - [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
- [OWASP 认证 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) - [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)
+243 -59
View File
@@ -2,130 +2,314 @@
## 决策 ## 决策
供应商(F6)和历史 Mini 域的调用统一收口在 `f6-integration` / `mini-clients` 模块,业务 domain 不直接持有 `WebClient` HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断。 供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离
HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`
### 为什么是同步 `RestClient` 而不是 `WebClient`
`WebClient` 是 Spring 官方推荐的现代 HTTP 客户端,但它的返回值是 `Mono`/`Flux`,把响应式编程模型带进了整个调用链。在我们这套以 Spring MVC(同步 Servlet 栈)为主的应用里,这会造成几个实际问题:
- **上下文会丢**。`StoreContextHolder``@RequestScope` bean、日志的 `traceId` 在 MDC 里,这两样都绑在请求线程上。一旦回调跑在 Reactor 的 event-loop 线程上,两者都拿不到——日志断链、上下文取不到值,而且这类 bug 只在特定时序下出现,很难查。
- **收益用不上**。响应式的价值是用少量线程扛住大量并发连接,前提是**整条链路都是非阻塞的**。我们的链路里有 JPA(阻塞 JDBC),只要还在用 JPA,中间夹一段响应式并不会减少线程占用,只是把复杂度带进来。
- **心智成本**。`Mono` 一旦进入业务代码,测试、异常处理、事务边界都要按另一套规则写,团队要同时掌握两套模型。
`RestClient`Spring 6.1+)提供的是和 `WebClient` 一样的流式 API,但返回值是普通对象、执行是同步的。等到将来整条链路真的要转非阻塞(比如换掉 JPA),再统一切换到 `WebClient` 不迟——那时是一次有明确收益的整体迁移,而不是现在这样局部引入。
## 结构约定 ## 结构约定
``` ```
platform-integration/ platform-integration/
WebClientConfig # 统一封装 WebClient(连接池、超时基线配置 RestClientConfig # 按下游系统构建 RestClient(连接池、超时、拦截器
Resilience4jDefaults # 超时/重试/熔断的公共默认配置 IntegrationClientProperties # integration.clients.* 配置绑定
TracePropagationInterceptor # 统一给出站请求加 X-Trace-Id
IntegrationException # 集成层异常基类(继承 platform-web 的 BusinessException
domains/f6-integration/ integration/f6-adapter/
负责:换票、供应商访问上下文准备、超时/重试/熔断策略、异常转换为内部标准错误码 负责:换票、供应商访问上下文准备、超时/重试/熔断/舱壁策略、异常转换为内部标准错误码
domains/mini-clients/ integration/mini-clients/
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
``` ```
## `WebClient` + Resilience4j 配置示例 `platform-integration` 依赖 `platform-web`(为了复用 `BusinessException``ErrorCode`)是本次允许的少数几个 platform 间依赖之一。
## `RestClient` 配置
```groovy
// platform-integration/build.gradle
dependencies {
implementation project(':platform:platform-web')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.apache.httpcomponents.client5:httpclient5' // 版本由 Boot BOM 管
implementation 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0'
}
```
```kotlin
// platform-integration/.../RestClientConfig.kt
@Configuration
@EnableConfigurationProperties(IntegrationClientProperties::class)
class RestClientConfig(private val props: IntegrationClientProperties) {
@Bean
fun f6RestClient(builder: RestClient.Builder): RestClient = build(builder, props.f6) { req, resp ->
// 5xx / 连接类问题 → 可重试;4xx → 请求本身有问题,重试没有意义
if (resp.statusCode.is5xxServerError) {
throw F6ServerException("F6 返回 ${resp.statusCode}")
}
throw F6ClientException("F6 拒绝了请求: ${resp.statusCode}")
}
@Bean
fun miniRestClient(builder: RestClient.Builder): RestClient = build(builder, props.mini) { _, resp ->
throw MiniIntegrationException("Mini 域返回 ${resp.statusCode}")
}
private fun build(
builder: RestClient.Builder, // 注入 Boot 提供的 Builder,而不是 RestClient.create()
config: ClientConfig, // 这样 Micrometer 的 observation/W3C traceparent 会自动挂上(见 08
statusHandler: RestClient.ResponseSpec.ErrorHandler,
): RestClient = builder
.baseUrl(config.baseUrl)
.requestFactory(requestFactory(config))
.requestInterceptor(TracePropagationInterceptor())
.defaultStatusHandler(HttpStatusCode::isError, statusHandler)
.build()
private fun requestFactory(config: ClientConfig): ClientHttpRequestFactory {
val connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(config.maxConnections)
.setMaxConnPerRoute(config.maxConnectionsPerRoute) // 必须 ≥ 对应的舱壁并发上限,理由见下
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(config.connectTimeoutMs))
.setValidateAfterInactivity(TimeValue.ofSeconds(5)) // 复用前探活,避开对端已断的空闲连接
.build(),
)
.build()
val httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(
RequestConfig.custom()
// 从连接池拿连接的等待上限。漏配它是最隐蔽的一个坑:
// 池子被占满时线程会无限期地等在这里,前面所有超时配置全部失效。
.setConnectionRequestTimeout(Timeout.ofMilliseconds(config.connectionRequestTimeoutMs))
.setResponseTimeout(Timeout.ofMilliseconds(config.readTimeoutMs))
.build(),
)
.evictIdleConnections(TimeValue.ofSeconds(30))
.build()
return HttpComponentsClientHttpRequestFactory(httpClient)
}
}
```
```yaml
integration:
clients:
f6:
base-url: ${F6_BASE_URL}
connect-timeout-ms: 1000
read-timeout-ms: 2000
connection-request-timeout-ms: 500
max-connections: 60
max-connections-per-route: 30
mini:
base-url: ${MINI_BASE_URL}
connect-timeout-ms: 500
read-timeout-ms: 1000
connection-request-timeout-ms: 300
max-connections: 60
max-connections-per-route: 30
```
**超时由 HTTP 客户端承担,不用 `@TimeLimiter`。** Resilience4j 的 `@TimeLimiter` 只能作用于 `CompletableFuture` 或响应式返回值——同步方法上加了不会报错,但**完全不生效**,是个非常容易误配的注解。同步栈下真正管用的是上面三个:`connectTimeout`(建连)、`responseTimeout`(等响应)、`connectionRequestTimeout`(等连接池)。
## Resilience4j 配置
```yaml ```yaml
# application.yml
resilience4j: resilience4j:
timelimiter: # 叠加顺序由这几个 *-aspect-order 属性决定,跟注解写在方法上的先后顺序无关(详见文末附录)。
instances: # 这里显式写死,避免依赖框架默认值——默认值会随版本变,而顺序变了语义就变了。
f6-api:
timeout-duration: 2s
mini-o2o:
timeout-duration: 1s
retry: retry:
retry-aspect-order: 3 # 最外层
instances: instances:
f6-api: f6-api:
max-attempts: 2 max-attempts: 2
wait-duration: 200ms wait-duration: 200ms
exponential-backoff-multiplier: 2
retry-exceptions: retry-exceptions:
- java.net.SocketTimeoutException # 同步栈下真实会抛出来的类型:
- org.springframework.web.reactive.function.client.WebClientRequestException # - ResourceAccessException 包住了 SocketTimeoutException/ConnectException 等所有 IO 异常,
# 业务代码永远不会直接见到 SocketTimeoutException,写它是无效配置
# - F6ServerException 是我们在 statusHandler 里对 5xx 的转换
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
ignore-exceptions:
# 熔断已打开时抛的异常,重试它毫无意义,只会白白多等一轮 wait-duration
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- com.continental.retailapp.integration.f6.F6ClientException
circuitbreaker: circuitbreaker:
circuit-breaker-aspect-order: 2
instances: instances:
f6-api: f6-api:
sliding-window-type: COUNT_BASED
sliding-window-size: 20 sliding-window-size: 20
minimum-number-of-calls: 10 # 样本太少时不做判断,避免启动后头几个请求就把熔断打开
failure-rate-threshold: 50 failure-rate-threshold: 50
slow-call-duration-threshold: 1500ms
slow-call-rate-threshold: 80 # 慢调用也算故障:只统计失败率的话,"每次都卡满 2 秒但最终成功"永远不会熔断
wait-duration-in-open-state: 10s wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5 permitted-number-of-calls-in-half-open-state: 5
record-exceptions:
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
bulkhead:
bulkhead-aspect-order: 1 # 最内层,贴着真实调用
instances:
f6-api:
max-concurrent-calls: 20
max-wait-duration: 0 # 拿不到名额立刻失败走降级,不排队 —— 排队等于把阻塞换个地方而已
mini-o2o:
max-concurrent-calls: 20
max-wait-duration: 0
``` ```
## F6 客户端示例
```kotlin ```kotlin
// domains/f6-integration/.../F6ApiClient.kt // integration/f6-adapter/.../F6ApiClient.kt
@Component @Component
class F6ApiClient( class F6ApiClient(
private val webClient: WebClient, // 来自 platform-integration 的统一封装 private val f6RestClient: RestClient, // 来自 platform-integration 的统一封装
) { ) {
@Bulkhead(name = "f6-api") // 默认 SEMAPHORE 类型,不额外起线程
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList") @CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
@Retry(name = "f6-api") @Retry(name = "f6-api")
@TimeLimiter(name = "f6-api") fun fetchProcurementList(storeId: Long): ProcurementListResponse =
fun fetchProcurementList(storeId: Long): Mono<ProcurementListResponse> = f6RestClient.get()
webClient.get()
.uri("/f6/procurement/list?storeId={storeId}", storeId) .uri("/f6/procurement/list?storeId={storeId}", storeId)
.retrieve() .retrieve()
.onStatus({ it.isError }) { resp -> .body(ProcurementListResponse::class.java)!!
resp.bodyToMono(String::class.java)
.map { body -> F6IntegrationException("F6 采购列表调用失败: ${resp.statusCode()} $body") }
}
.bodyToMono(ProcurementListResponse::class.java)
// Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型一致 // Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型与原方法一致(同步下就是 T
fun fallbackProcurementList(storeId: Long, ex: Throwable): Mono<ProcurementListResponse> = fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementListResponse {
Mono.just(ProcurementListResponse.degraded()) log.warn("F6 采购列表降级返回,storeId={}, cause={}", storeId, ex.toString())
return ProcurementListResponse.degraded()
}
} }
``` ```
```kotlin ```kotlin
// 统一异常转换:F6IntegrationException -> 内部标准错误码,业务层不感知供应商原始协议 // platform-integration/.../IntegrationException.kt
class F6IntegrationException(message: String) : RuntimeException(message) // 继承 BusinessException(见 06-api-design.md),复用 GlobalExceptionHandler
// 不再单独写一个 @RestControllerAdvice —— 少一个会和全局处理器抢优先级的地方。
open class IntegrationException(
code: Int,
message: String,
httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY,
) : BusinessException(code, message, httpStatus)
@RestControllerAdvice // integration/f6-adapter/.../F6Exceptions.kt
class F6ExceptionHandler { class F6ServerException(message: String) :
@ExceptionHandler(F6IntegrationException::class) IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试")
fun handle(ex: F6IntegrationException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(HttpStatus.BAD_GATEWAY) class F6ClientException(message: String) :
.body(ApiResult.error(code = "F6_UNAVAILABLE", message = "供应商服务暂不可用,请稍后重试")) IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝")
}
``` ```
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
## Mini 域客户端示例(内部系统,策略更宽松) ## Mini 域客户端示例(内部系统,策略更宽松)
```kotlin ```kotlin
// domains/mini-clients/.../O2OClient.kt // integration/mini-clients/.../O2OClient.kt
@Component @Component
class O2OClient(private val webClient: WebClient) { class O2OClient(private val miniRestClient: RestClient) {
@TimeLimiter(name = "mini-o2o") // 只兜底超时,不需要熔断(内部系统,稳定性相对可控) @Bulkhead(name = "mini-o2o") // 只做超时 + 并发隔离,不加熔断(内部系统,稳定性相对可控)
fun fetchOrderSummary(storeId: Long): Mono<OrderSummary> = fun fetchOrderSummary(storeId: Long): OrderSummary =
webClient.get() try {
miniRestClient.get()
.uri("/o2o/orders/summary?storeId={storeId}", storeId) .uri("/o2o/orders/summary?storeId={storeId}", storeId)
.retrieve() .retrieve()
.bodyToMono(OrderSummary::class.java) .body(OrderSummary::class.java)!!
.onErrorResume { Mono.just(OrderSummary.empty()) } // 局部降级,见 workbench 聚合规则 } catch (ex: Exception) {
log.warn("O2O 订单摘要降级返回,storeId={}", storeId, ex)
OrderSummary.empty() // 局部降级:首页某个 tile 空着,好过整个首页 500
}
} }
``` ```
## traceId 透传
```kotlin
// platform-integration/.../TracePropagationInterceptor.kt
class TracePropagationInterceptor : ClientHttpRequestInterceptor {
override fun intercept(
request: HttpRequest,
body: ByteArray,
execution: ClientHttpRequestExecution,
): ClientHttpResponse {
MDC.get("traceId")?.let { request.headers.set("X-Trace-Id", it) }
return execution.execute(request, body)
}
}
```
同步栈下拦截器和业务代码跑在同一个线程上,MDC 一定取得到,业务代码零感知。W3C 标准的 `traceparent` 头由 Micrometer Tracing 自动加(前提是用注入的 `RestClient.Builder` 构建,见上面的注释);这里额外发的 `X-Trace-Id` 是给 F6 这类只认自定义头的外部系统用的,两者并存,规则见 [08-observability.md](./08-observability.md)。
## 关键规则 ## 关键规则
- **F6 是外部供应商域**,稳定性不可控,必须配超时 + 重试 + 熔断,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。 - **F6 是外部供应商域**,稳定性不可控,必须配超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
- **异常统一转换**F6 / Mini 域返回的异常非标准错误,在 `f6-integration` / `mini-clients` 内部转换成内部标准错误码(如 `F6_UNAVAILABLE`,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。 - **异常统一转换**:F6 / Mini 域的异常非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
- **Mini 域调用相对可控**(内部系统),熔断策略可以比 F6 宽松(示例里只加超时兜底),但仍需要超时兜底,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"的要求 - **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。
- 业务 domain(如 `workbench`)只依赖 `mini-clients` / `f6-integration` 暴露的接口,不自己 `new WebClient` 发请求 - **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)
- **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。
- 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。
- `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。
## 附录:超时、重试、熔断分别解决什么问题 ## 附录:超时、重试、熔断、舱壁分别解决什么问题
者经常被一起提,但作用点不同,配置的时候容易搞混: 者经常被一起提,但作用点不同,配置的时候容易搞混:
- **超时(Timeout)**:解决"对方一直不回应,我方请求线程/连接被一直占着"的问题。没有超时,一个慢下游能拖垮整个调用方的线程池。这是三者里最基础、必须有的一道防线 - **超时(Timeout)**:解决"对方一直不回应,我方线程/连接被一直占着"的问题。这是最基础、必须有的一道防线。同步栈下它由 HTTP 客户端提供,不是 Resilience4j 提供的
- **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。重试的前提是**幂等**——`fetchProcurementList` 这种 GET 查询可以放心重试,但如果是"扣库存""创建订单"这类有副作用的调用,重试前要先确认接口本身幂等(比如带幂等 key),否则重试可能造成重复下单这类更严重的问题 - **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。前提是**幂等**。
- **熔断(Circuit Breaker**:解决"对方已经持续故障,继续重试只是在浪费资源、拖慢自己"的问题。熔断器统计一个滑动窗口内的失败率,超阈值后直接短路请求(进入 `OPEN` 状态,一段时间内不再真的发请求,直接走 fallback),过一段时间放几个探测请求(`HALF_OPEN`)判断对方是否恢复。 - **熔断(Circuit Breaker**:解决"对方已经持续故障,继续请求只是在浪费资源、拖慢自己"的问题。统计滑动窗口内的失败率(和慢调用率),超阈值后直接短路走 fallback`OPEN`),过一段时间放几个探测请求(`HALF_OPEN`)判断是否恢复。
- **舱壁(Bulkhead**:解决"一个慢下游把我方所有工作线程吃光"的问题——**这一道在同步栈下尤其重要**。算笔账:Tomcat 默认 200 个工作线程,F6 读超时 2 秒,如果 F6 全面卡住且没有并发限制,200 个线程会在 2 秒内全部堵在 F6 上,此时这个应用连登录、连查本地数据库的接口都不可用了,一个外部依赖直接把整个服务拖死。配上 `max-concurrent-calls: 20` 之后,最多 20 个线程能进去,剩下 180 个照常干活,超出的请求立刻走降级——**用一个功能的降级换整个服务的存活**。
三者组合的顺序也有讲究:一次调用先看熔断器状态(`OPEN` 直接 fallback,不发请求)→ 没熔断就真的发请求 → 超时控制这次请求最多等多久 → 超时或失败了再看要不要重试。上面 Resilience4j 的注解顺序(`@CircuitBreaker` 在最外层,`@Retry``@TimeLimiter`在内层)就是按这个语义叠加的 响应式栈下这道防线的必要性没这么强(event-loop 天生不会被阻塞占死),这也是从 `WebClient` 换到 `RestClient` 后必须补上它的原因
舱壁上限和连接池要对齐:`max-connections-per-route` 必须 ≥ `max-concurrent-calls`,否则真正的瓶颈会变成"抢连接",请求会堵在 `connectionRequestTimeout` 上,而不是被舱壁干脆利落地挡掉。
## 附录二:叠加顺序由配置决定,不是由注解顺序决定
**一个常见误解是"注解写在上面的就在外层"——不是的。** Resilience4j 的各个切面都是独立的 Spring AOP Aspect,它们的嵌套顺序由 `resilience4j.<type>.<type>-aspect-order` 属性(也就是 Spring 的 `@Order` 语义)决定,跟注解在方法上的书写顺序完全无关。框架的默认顺序是 **Retry 在最外层**
```
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 真实调用 ) ) ) ) )
```
顺序不同,语义差别很实在:
- **Retry 在外(默认,我们采用)**:每一次重试都会各自经过熔断器,所以一次失败的调用会往熔断器的统计窗口里记 2 笔(`max-attempts: 2`)。好处是下游真出问题时熔断器打开得更快;代价是窗口里的样本数会被重试放大,`sliding-window-size` 要按放大后的量来估。这也是为什么必须把 `CallNotPermittedException` 加进 `ignore-exceptions`——熔断打开后抛的就是它,不排除掉的话每次请求还要白白多等一轮重试。
- **CircuitBreaker 在外**:整组重试合起来只在熔断器上记 1 笔,统计更"干净",但下游故障时熔断打开会慢一些。
**建议**:像上面 yaml 那样把三个 `*-aspect-order` 显式写出来,不要依赖默认值——默认值可能随版本变化,而这个顺序一变,熔断的触发速度就跟着变了,还很难从现象上察觉。另外,各版本对"order 数值大是更外层还是更内层"的约定容易记反,配完之后**用一个必定失败的集成测试实际验证一次**叠加顺序(比如断言下游被调用了几次、熔断器记录了几笔),比查文档可靠,写法见 [10-testing.md](./10-testing.md)。
## 待补充 ## 待补充
- 具体超时/重试参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。 - 具体超时/重试/舱壁参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。
- 熔断后降级返回的数据结构约定(`degraded()` 具体字段)。 - 熔断后降级返回的数据结构约定(`degraded()` 具体字段,以及 APP 侧如何展示"这块数据是降级的")。
- F6 换票具体协议细节(对接 [webview-ticket](./04-security-auth.md) 的会话失效联动)。 - F6 换票具体协议细节(对接 [04-security-auth.md](./04-security-auth.md) 的切店联动失效)。
## 参考链接 ## 参考链接
- [Resilience4j 官方文档](https://resilience4j.readme.io/docs) - [Resilience4j 官方文档](https://resilience4j.readme.io/docs)
- [Spring WebFlux WebClient](https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html) - [Spring Framework: RestClient](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient)
- [Apache HttpClient 5 连接管理](https://hc.apache.org/httpcomponents-client-5.4.x/current/tutorial/html/connmgmt.html)
- [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html) - [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html)
- [Release It! 中的 Bulkhead 模式](https://learn.microsoft.com/azure/architecture/patterns/bulkhead)
+161 -37
View File
@@ -10,12 +10,16 @@ REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 dom
platform-web/ platform-web/
ApiResult<T> # { code, message, data, traceId } 统一响应包装 ApiResult<T> # { code, message, data, traceId } 统一响应包装
GlobalExceptionHandler # 统一异常 -> ApiResult 转换 GlobalExceptionHandler # 统一异常 -> ApiResult 转换
ErrorCode # 错误码常量/枚举 ErrorCode # 错误码常量
BusinessException # 业务异常基类,带错误码
domains/xxx/api/ domains/xxx/api/
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑 XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
request/ Xxx*Request # 请求 DTO request/ Xxx*Request # 请求 DTO
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
domains/xxx/application/
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct
``` ```
## `ApiResult` + 全局异常处理示例 ## `ApiResult` + 全局异常处理示例
@@ -30,10 +34,10 @@ data class ApiResult<T>(
) { ) {
companion object { companion object {
fun <T> ok(data: T): ApiResult<T> = 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> = 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 INVALID_PARAM = 10001
const val UNAUTHORIZED = 10401 const val UNAUTHORIZED = 10401
const val FORBIDDEN = 10403 const val FORBIDDEN = 10403
const val NOT_FOUND = 10404
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
const val INTERNAL_ERROR = 10500 const val INTERNAL_ERROR = 10500
// 11xxx 认证与门店 // 11xxx 认证与门店
const val STORE_NOT_ACCESSIBLE = 11001 const val STORE_NOT_ACCESSIBLE = 11001
const val NO_STORE_PERMISSION = 11002 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/.../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 @RestControllerAdvice
class GlobalExceptionHandler { class GlobalExceptionHandler {
@@ -67,14 +96,22 @@ class GlobalExceptionHandler {
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> = fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常")) 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) @ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> { fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md // 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md
log.error("未处理异常", ex)
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试")) 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 示例 ## Controller + DTO 示例
```kotlin ```kotlin
@@ -85,39 +122,34 @@ class StoreController(
private val storeAppService: StoreAppService, private val storeAppService: StoreAppService,
) { ) {
@Operation(summary = "查询当前用户可访问的门店列表") @Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping @GetMapping("/accessible")
fun listStores(): ApiResult<List<StoreResponse>> = fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listStores()) ApiResult.ok(storeAppService.listAccessibleStores())
@Operation(summary = "切换当前门店") @Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/switch") @PostMapping("/{storeId}/switch")
fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult<Unit> { fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
storeAppService.switchStore(request.storeId) ApiResult.ok(storeAppService.switchStore(storeId))
return ApiResult.ok(Unit)
}
} }
// api/request/SwitchStoreRequest.kt
data class SwitchStoreRequest(
@field:NotNull(message = "storeId 不能为空")
val storeId: Long?,
)
// api/response/StoreResponse.kt // api/response/StoreResponse.kt
data class StoreResponse( data class StoreResponse(
val id: Long, val id: Long,
val name: String, 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 ```groovy
// build.gradleKotlin 项目用 kapt 做注解处理) // build.gradleKotlin 项目用 kapt 做注解处理MapStruct 目前仍不支持 KSP
plugins { plugins {
id 'org.jetbrains.kotlin.kapt' id 'org.jetbrains.kotlin.kapt'
} }
@@ -129,29 +161,121 @@ dependencies {
``` ```
```kotlin ```kotlin
// api/mapper/StoreMapper.kt // application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
@Mapper(componentModel = "spring") @Mapper(componentModel = "spring")
interface StoreMapper { interface StoreMapper {
fun toResponse(entity: StoreEntity): StoreResponse fun toResponse(view: StoreView): StoreResponse
@Mapping(target = "displayName", source = "name") @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` 一起打。 - **不允许在业务代码里写裸数字**,一律走 `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)),不需要靠时间戳模糊查找。 - `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。 - 新增一种失败场景时,只需要新增一个 `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))保持同步。 - **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`[../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
## 参考链接 ## 参考链接
+70 -8
View File
@@ -70,7 +70,8 @@ spec:
## 启用 Spring Cloud Kubernetes 配置热更新 ## 启用 Spring Cloud Kubernetes 配置热更新
```groovy ```groovy
// bootstrap/build.gradle // bootstrap/build.gradle —— 版本由根工程的 spring-cloud-dependencies BOM2025.1.2)统一管理,
// 见 01-project-structure.md 的版本基线表
dependencies { dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config' implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config'
} }
@@ -87,7 +88,8 @@ spring:
- name: conti-backend-config - name: conti-backend-config
reload: reload:
enabled: true enabled: true
mode: polling # 定期轮询 ConfigMap 变化,无需重启 Pod mode: polling # 怎么发现变化:定期轮询 ConfigMap(另一个选项 event 需要 watch 权限)
strategy: refresh # 发现变化后做什么:只刷新 @RefreshScope bean,不重启容器
period: 15s period: 15s
``` ```
@@ -101,6 +103,20 @@ class WorkbenchProperties {
} }
``` ```
**`mode``strategy` 是两件事,别混**`mode` 决定怎么感知 ConfigMap 变化,`strategy` 决定感知到之后做什么。只配 `mode` 而漏掉 `strategy`,行为会依赖框架默认值。
**并不是所有配置都能热更新**,这一点要在改配置之前想清楚,否则会出现"改了 ConfigMap、观察半天没生效"的困惑:
| 配置 | 能否热更新 | 说明 |
| --- | --- | --- |
| 自定义的 `@RefreshScope` + `@ConfigurationProperties`(如 `workbench.*`) | ✅ | 这是热更新真正的适用范围 |
| 日志级别 | ✅ | 也可以直接用 `/actuator/loggers` 端点改,更直接 |
| `resilience4j.*` 的熔断/重试参数 | ⚠️ | 这些实例在启动时创建,普通 `refresh` 刷不到。真要动,用 `strategy: restart_context`(会重建应用上下文,等于一次内部重启)或者干脆走发布流程 |
| 数据源、连接池、JPA 相关 | ❌ | 走发布流程 |
| Secret 里的值 | ❌ | 见下面"关键规则"Secret 变更需要重启 Pod |
上面 ConfigMap 示例里同时放了 `resilience4j``workbench` 两段,正是为了说明这个差别:`workbench.degradeMessage` 改完 15 秒内生效,`failure-rate-threshold` 不会。
## 本地开发怎么跑(不需要真的连 K8s) ## 本地开发怎么跑(不需要真的连 K8s)
`spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案: `spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案:
@@ -117,23 +133,26 @@ spring:
reload: reload:
enabled: false enabled: false
datasource: datasource:
url: jdbc:postgresql://localhost:5432/conti_backend url: jdbc:mysql://localhost:3306/?connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
username: conti username: conti
password: conti_local_password # 仅本地开发用,不是真实密钥 password: conti_local_password # 仅本地开发用,不是真实密钥
security: security:
jwt: jwt:
secret: local-dev-only-secret-not-for-real-use active-key-id: local
keys:
# 仅本地开发用的假密钥;HS256 要求 base64 解码后 ≥ 32 字节,见 04-security-auth.md
local: bG9jYWwtZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcmVhbC11c2UtMzJi
``` ```
```bash ```bash
# 本地起依赖(DB 等),配合 docker-compose 用 # 本地起依赖(DB 等),配合 docker-compose 用
docker compose up -d postgres docker compose up -d mysql
# 用 local profile 启动 # 用 local profile 启动
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
``` ```
`application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也是为什么 [06-api-design.md](./06-api-design.md) 强调的"配置外置"很重要:业务代码不知道、也不需要知道配置到底来自 K8s 还是本地文件 `application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也正是**配置外置**的价值所在:业务代码不知道、也不需要知道配置到底来自 K8s ConfigMap 还是本地文件,换配置来源不需要改一行 Kotlin
这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。 这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。
@@ -232,10 +251,53 @@ spec:
两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。 两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。
## 配置绑定与启动期校验
**所有配置一律绑定到 `@ConfigurationProperties` 类,不散着写 `@Value`。** `@Value` 散落在各处时,没人说得清这个应用到底需要哪些配置项,漏配一个只能等到运行到那行代码才报错。
```kotlin
@ConfigurationProperties(prefix = "integration.clients.f6")
@Validated
data class F6ClientProperties(
@field:NotBlank val baseUrl: String,
@field:Min(100) @field:Max(10_000) val readTimeoutMs: Long = 2000,
@field:Min(1) val maxConcurrentCalls: Int = 20,
)
```
配上 `@Validated` 之后,配置缺失或越界会在**启动时**直接失败,Pod 起不来,K8s 的就绪探针不通过,滚动更新会自动停住并保留旧版本(见 [09-build-deploy.md](./09-build-deploy.md))。这比"启动成功了,但半夜某个接口因为超时配成 0 而全线失败"要好得多——**配错了就别起来**是这里的核心原则。
同理,[04-security-auth.md](./04-security-auth.md) 里 JWT 密钥长度的校验也放在启动期,绝不允许带着弱密钥跑起来。
## 命名规范
| 对象 | 规范 | 示例 |
| --- | --- | --- |
| namespace | `retailapp-<env>` | `retailapp-uat` |
| ConfigMap | `conti-backend-config` | 每个环境一份,同名不同 namespace |
| Secret | `conti-backend-secret` | 同上 |
| ConfigMap 里的 key | `application-<profile>.yml` | `application-uat.yml` |
| Secret 里的 key | 全大写下划线,与 Spring 的 relaxed binding 对齐 | `DB_PASSWORD``spring.datasource.password`(配合 `${DB_PASSWORD}` 引用) |
| Key Vault secret 名 | 中划线小写,与配置路径对应 | `security-jwt-secret` |
| 自定义配置前缀 | 模块名小驼峰 | `workbench.*``integration.clients.*` |
**同名不同 namespace** 这一点是有意的:环境差异全部体现在 namespace 和 ConfigMap 内容上,Deployment yaml 在各环境之间只差镜像 tag 和 namespace,减少"UAT 好好的、生产漏配了一项"这类问题。
## 数据库与外部依赖的实例形态
| 依赖 | dev(内网 k3s | UAT / ProdAzure AKS |
| --- | --- | --- |
| MySQL | 集群内一个 MySQL 容器(数据可丢,随时重建) | **Azure Database for MySQL Flexible Server**,通过 **Private Endpoint** 接入 AKS 所在 VNet,不开公网访问 |
| 密钥 | k3s Secret,值由内网 CI 注入 | Azure Key Vault + Private Endpoint(见下一节) |
| 缓存 | 无(不引入 Redis,理由见 [04-security-auth.md](./04-security-auth.md) | 同左 |
生产 MySQL 的连接信息(host/账号)走 ConfigMap + Secret 注入,应用侧配置见 [03-persistence.md](./03-persistence.md)。**连接串里必须带 `sslMode=REQUIRED`**——Flexible Server 默认要求 TLS,漏了会连不上;同时也别为了图省事把它降级成 `DISABLED`
数据库账号分两个:Flyway 迁移用的账号有 DDL 权限(只在部署流程中使用),应用运行时账号只有 DML 权限,理由见 03。
## 待补充 ## 待补充
- 具体 ConfigMap/Secret 命名规范和 namespace 划分细节 - 多环境 profile 的完整参数清单(等各 domain 的配置项定下来后汇总成一张表)
- 多环境 profile 的详细参数列表。
## 参考链接 ## 参考链接
+199 -56
View File
@@ -4,49 +4,103 @@
统一 Trace ID + 结构化(JSON)日志 + Micrometer 指标,对应架构图 `Cross-Cutting` 里的 `Observability` 要求;关键行为单独走审计日志通道,对应 `Audit / Security` 统一 Trace ID + 结构化(JSON)日志 + Micrometer 指标,对应架构图 `Cross-Cutting` 里的 `Observability` 要求;关键行为单独走审计日志通道,对应 `Audit / Security`
traceId **用 Spring Boot 自带的 Micrometer Tracing 生成和传播,不自己写 `TraceIdFilter`**——理由见下一节。
## 结构约定 ## 结构约定
``` ```
platform-observability/ platform-observability/
TraceIdFilter # 入口生成/透传 traceId,写入 MDC ClientTraceIdBridgeFilter # 把客户端的 X-Trace-Id 接进 Micrometer 的 trace 上下文
logback-spring.xml # 结构化日志格式配置 TraceResponseFilter # 把最终生效的 traceId 写回响应头
logback-spring.xml # 结构化日志格式 + 脱敏配置
MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus
AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志 AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志
``` ```
## `TraceIdFilter` 示例 ## traceId:用 Micrometer Tracing
```groovy
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.micrometer:micrometer-tracing-bridge-otel' // 版本由 Boot BOM 管
```
```yaml
management:
tracing:
enabled: true # 开着:这是 traceId 进 MDC 的前提,关掉连日志里的 traceId 都没有了
export:
enabled: false # 但不往任何后端上报 span —— 我们现在只要日志关联,不建全链路追踪系统
sampling:
probability: 1.0 # 不上报就没有采样成本,全采即可,避免部分请求日志里没有 traceId
```
加上依赖之后,Spring Boot 会自动:
- 为每个进来的 HTTP 请求创建一个 span,把 `traceId` / `spanId` **自动放进 MDC**
- 出站的 `RestClient` 调用自动带上 W3C 标准的 `traceparent` 头(前提是用注入的 `RestClient.Builder` 构建,见 [05-integration-layer.md](./05-integration-layer.md));
- 线程池、`@Async``@Scheduled` 等场景由 Micrometer 的上下文传播机制接管,不需要自己搬运 MDC。
**为什么不自己写 `TraceIdFilter`**:自己写的版本只覆盖"HTTP 入口 + 手工加请求头"这两个点,一旦出现线程切换(并行聚合、异步任务)或者需要跟别的系统按标准协议对接,就要自己一点点补;而这些正是最容易漏、漏了又最难查的地方(日志断链时你只会觉得"这个请求怎么没日志")。Micrometer Tracing 是 Boot 的一等公民,这些点框架都已经处理好,而且将来真要接 APMJaeger/Zipkin/Application Insights)时,只需要加一个 exporter 依赖、把 `export.enabled` 打开,代码一行不用改。
### 与客户端 `X-Trace-Id` 的对接契约
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../05-networking.md](../05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
**契约(同时解决客户端文档里挂着的那条待确认项)**
1. 客户端生成的 `X-Trace-Id` **必须是 32 位小写十六进制字符**(UUID 去掉四个横线正好 32 位 hex,直接用即可),且不能全为 `0`
2. 后端校验通过则用它作为本次请求的 `traceId`;**校验不通过就忽略它,自行生成**——绝不把一个未经校验的请求头值直接当 ID 用。
3. 后端在响应头里回写最终生效的 `X-Trace-Id`,客户端以响应头为准(这样客户端能发现自己的值被丢弃了)。
4. `ApiResult.traceId` 返回的也是这个最终生效的值。
```kotlin ```kotlin
// platform-observability/.../TraceIdFilter.kt // platform-observability/.../ClientTraceIdBridgeFilter.kt
class TraceIdFilter : OncePerRequestFilter() { @Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 必须排在 Micrometer 的 observation filter 之前
class ClientTraceIdBridgeFilter : OncePerRequestFilter() {
companion object {
// 只接受 W3C trace-id 格式。这条正则同时也是安全边界:
// 请求头的值会进日志,不校验就等于允许任何人往日志里注入内容(换行伪造日志行、
// 塞进超长字符串撑爆日志存储、塞进控制字符干扰下游日志解析)。
private val TRACE_ID = Regex("^[0-9a-f]{32}$")
private const val INVALID = "00000000000000000000000000000000"
}
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val traceId = request.getHeader("X-Trace-Id") ?: UUID.randomUUID().toString() val clientTraceId = request.getHeader("X-Trace-Id")
MDC.put("traceId", traceId) if (clientTraceId == null || !TRACE_ID.matches(clientTraceId) || clientTraceId == INVALID) {
response.setHeader("X-Trace-Id", traceId) chain.doFilter(request, response) // 不合法就当没传,让 Micrometer 自己生成
try { return
chain.doFilter(request, response)
} finally {
MDC.clear() // 必须清理,否则线程池复用线程会带出上一个请求的 traceId
} }
// 合成一个 W3C traceparent,让 Micrometer 把它当作父上下文接上,
// 于是服务端这次请求的 traceId 就等于客户端传来的值。
val traceparent = "00-$clientTraceId-${randomSpanId()}-01"
chain.doFilter(TraceparentRequestWrapper(request, traceparent), response)
} }
} }
object TraceIdHolder { // platform-observability/.../TraceResponseFilter.kt —— 排在 observation filter 之后,此时 MDC 已有 traceId
fun current(): String = MDC.get("traceId") ?: "unknown" @Component
class TraceResponseFilter : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
MDC.get("traceId")?.let { response.setHeader("X-Trace-Id", it) }
chain.doFilter(request, response)
}
} }
``` ```
调用 F6/Mini 域时,把当前 `traceId` 透传到下游请求头,方便跨系统关联日志:
```kotlin ```kotlin
webClient.get() // platform-web/.../TraceIdSupport.kt —— 06-api-design.md 里 ApiResult 用的就是它
.uri("/f6/procurement/list") fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
.header("X-Trace-Id", TraceIdHolder.current())
.retrieve()
// ...
``` ```
## 结构化日志配置示例 出站调用侧,除了 Micrometer 自动加的 `traceparent`,还会额外发一个 `X-Trace-Id` 给 F6 这类只认自定义头的外部系统,见 [05-integration-layer.md](./05-integration-layer.md) 的 `TracePropagationInterceptor`
## 结构化日志
```xml ```xml
<!-- logback-spring.xml --> <!-- logback-spring.xml -->
@@ -54,7 +108,17 @@ webClient.get()
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"> <encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName> <includeMdcKeyName>traceId</includeMdcKeyName>
<customFields>{"app":"conti-backend"}</customFields> <includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"app":"conti-backend","env":"${SPRING_PROFILES_ACTIVE:-local}"}</customFields>
<!-- 兜底脱敏:即使有人不小心把整个对象打进日志,这些字段的值也会被替换掉。
它是最后一道防线,不是免死金牌 —— 主要靠下面"日志规范"里的规则。 -->
<jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
<path>password</path>
<path>accessToken</path>
<path>refreshToken</path>
<path>authorization</path>
</jsonGeneratorDecorator>
</encoder> </encoder>
</appender> </appender>
<root level="INFO"> <root level="INFO">
@@ -65,15 +129,44 @@ webClient.get()
```groovy ```groovy
// build.gradle // build.gradle
implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'net.logstash.logback:logstash-logback-encoder:9.0' // 9.0 起用 Jackson 3,匹配 Boot 4
``` ```
输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以直接`traceId` 过滤出一次请求的完整链路日志。 输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以按 `traceId` 过滤出一次请求的完整链路日志。
### 日志级别规范
统一标准,避免"所有人都打 INFO"导致真正重要的信息被淹没:
| 级别 | 用在什么地方 | 是否告警 |
| --- | --- | --- |
| `ERROR` | 需要人介入处理的问题:未预期异常、数据不一致、下游持续不可用 | 是 |
| `WARN` | 系统自己处理掉了但值得关注:降级返回、重试成功、乐观锁冲突、参数校验失败率异常 | 聚合后看趋势 |
| `INFO` | 关键业务节点:登录、切店、换票、外部系统调用的结果 | 否 |
| `DEBUG` | 排查用的中间状态 | 生产环境默认关闭 |
| `TRACE` | 不在生产环境使用 | — |
- **用户输入错误不是 ERROR**。参数校验失败、token 过期、无权限属于正常业务流,打 `WARN``INFO`,打成 ERROR 会让告警彻底失去意义。
- **catch 住并处理掉的异常不打 ERROR**,打 `WARN` 并说明降级动作。
- **异常要把 `Throwable` 作为最后一个参数传进去**`log.error("xxx 失败", ex)`),不要 `log.error("xxx 失败: " + ex.message)`——后者丢掉堆栈,等于放弃了排查的主要线索。
- 生产环境日志级别可以通过 `/actuator/loggers` 端点临时调整(该端点仅内网可达),排查完记得调回来。
### 敏感信息脱敏
**绝不进日志**:密码、`Authorization` 头及其中的 token、refresh token、JWT 签名密钥、数据库密码、F6 API key。
**需要脱敏后才能进日志**:手机号(`138****8000`)、姓名、身份证号、车牌号、VIN、详细地址、银行卡号。这套系统涉及车主和车辆信息,车牌和 VIN 是能定位到具体个人的,按 PII 对待。
落地规则:
1. **禁止把整个请求体/Entity 对象直接打进日志**`log.info("req={}", request)`)。今天这个对象里没有敏感字段,不代表明天加一个字段之后还没有——而加字段的人不会想起来去检查有哪些地方打过这个对象的日志。要打就显式列出需要的字段。
2. 确实要打的敏感字段走统一的脱敏工具方法(`Mask.phone(...)``Mask.plateNo(...)`),不各写各的。
3. 上面 logback 里的 `MaskingJsonGeneratorDecorator` 是兜底,防的是"不小心写漏了",不是"有它就可以随便打"——它只按字段名匹配 JSON 结构,拼在字符串里的敏感值它一个都拦不住。
4. **异常堆栈也可能带出敏感信息**(比如 SQL 参数、请求 URL 上的查询串),所以 URL 上不放敏感参数——需要传就放请求体。
## Micrometer / Actuator 配置 ## Micrometer / Actuator 配置
```yaml ```yaml
# application.yml
management: management:
endpoints: endpoints:
web: web:
@@ -83,12 +176,12 @@ management:
health: health:
probes: probes:
enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用 enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用
metrics:
tags:
application: conti-backend
``` ```
```groovy `/actuator/prometheus``/actuator/loggers` **不对公网暴露**:只在集群内可达(`SecurityConfig` 里只 permitAll 了 `/actuator/health/**`,见 [04-security-auth.md](./04-security-auth.md)),Prometheus 从集群内抓取。
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
```
## K8s 探针配置(liveness / readiness ## K8s 探针配置(liveness / readiness
@@ -99,20 +192,26 @@ implementation 'io.micrometer:micrometer-registry-prometheus'
spec: spec:
containers: containers:
- name: conti-backend - name: conti-backend
startupProbe: # 启动阶段专用,跑通之前 liveness/readiness 都不生效
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 30 # 最多给 150 秒完成 JVM 启动 + Flyway 迁移
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /actuator/health/liveness path: /actuator/health/liveness
port: 8080 port: 8080
initialDelaySeconds: 30 # 给 JVM 启动、Flyway migration 留够时间,太短会导致刚启动就被误杀重启
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /actuator/health/readiness path: /actuator/health/readiness
port: 8080 port: 8080
initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
``` ```
`startupProbe` 而不是给 liveness 配一个很大的 `initialDelaySeconds`:后者是"所有情况下都固定等这么久",启动快的时候白等,启动慢的时候(比如某次迁移脚本比较大)仍然会被误杀;`startupProbe` 是"给足上限、就绪即结束",两头都照顾到。
两者失败后的处理完全不同,容易搞混: 两者失败后的处理完全不同,容易搞混:
- **`livenessProbe` 失败** → K8s 认为这个 Pod 已经"死掉"(比如死锁、内存泄漏导致完全无响应),直接**重启**这个 Pod。 - **`livenessProbe` 失败** → K8s 认为这个 Pod 已经"死掉"(比如死锁、内存泄漏导致完全无响应),直接**重启**这个 Pod。
@@ -120,24 +219,64 @@ spec:
`readiness` group 默认会包含数据库连接(`DataSourceHealthIndicator`)等下游依赖检查,`liveness` group 默认只检查应用自身状态(不含外部依赖)——这个区分本身也是为了避免"F6 挂了导致 liveness 失败、Pod 被不断重启"这种误杀,外部依赖异常应该走 [05-integration-layer.md](./05-integration-layer.md) 的熔断降级,而不是拖累 K8s 探针。 `readiness` group 默认会包含数据库连接(`DataSourceHealthIndicator`)等下游依赖检查,`liveness` group 默认只检查应用自身状态(不含外部依赖)——这个区分本身也是为了避免"F6 挂了导致 liveness 失败、Pod 被不断重启"这种误杀,外部依赖异常应该走 [05-integration-layer.md](./05-integration-layer.md) 的熔断降级,而不是拖累 K8s 探针。
**不要把 F6 之类的外部依赖加进 readiness**,理由同上:F6 抖一下不应该让我们所有 Pod 同时被摘出负载均衡,那是自己把自己搞挂。
## Resilience4j 指标接入 Micrometer ## Resilience4j 指标接入 Micrometer
[05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的超时、重试、熔断器,本身的运行状态(比如熔断器当前是 `CLOSED`/`OPEN`/`HALF_OPEN`,重试了多少次)也应该能在监控里看到,不然只能等到线上报错才知道降级生效了: [05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的熔断、重试、舱壁,本身的运行状态也应该能在监控里看到,不然只能等到线上报错才知道降级生效了:
```groovy ```groovy
// build.gradle implementation 'io.github.resilience4j:resilience4j-micrometer:2.4.0'
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
``` ```
加上这个依赖后,`CircuitBreakerRegistry`/`RetryRegistry`/`TimeLimiterRegistry` 会自动把状态注册成 Micrometer meter,不需要手写埋点代码,跟着现有的 `/actuator/prometheus` 一起暴露出去,常用的几个: 加上这个依赖后,各个 registry 会自动把状态注册成 Micrometer meter,不需要手写埋点,跟着现有的 `/actuator/prometheus` 一起暴露出去
- `resilience4j_circuitbreaker_state{name="f6-api", state="open"}`:熔断器当前状态(0/1),可以直接在 Grafana 上画出"F6 熔断器什么时候跳闸"的时间线。 ## 关键指标与告警
- `resilience4j_circuitbreaker_calls{name="f6-api", kind="failed"}`:调用失败次数,配合 `kind="successful"` 算出实时失败率。
- `resilience4j_retry_calls{name="f6-api", kind="successful_with_retry"}`:重试后成功的次数,能看出"降级到底靠不靠重试兜住的"。
这几个指标配合 [Prometheus 告警规则](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/),可以在熔断器进入 `OPEN` 状态时直接告警,而不是等用户反馈"下单功能卡住了"才发现。 指标分三类看,缺一类都会有盲区:
## 审计日志示例 **① 系统健康**
| 指标 | 告警起点 |
| --- | --- |
| `http_server_requests_seconds_count{status=~"5.."}` 占比 | 5 分钟内 > 1% |
| `http_server_requests_seconds` P99 | > 2s 持续 5 分钟 |
| `jvm_memory_used_bytes{area="heap"}` / max | > 85% 持续 10 分钟 |
| `hikaricp_connections_pending` | > 0 持续 1 分钟(有线程在排队等数据库连接,见 [03-persistence.md](./03-persistence.md) 的池子容量算法) |
| Pod 重启次数 | 10 分钟内 ≥ 2 次 |
**② 依赖健康**
| 指标 | 告警起点 |
| --- | --- |
| `resilience4j_circuitbreaker_state{state="open"}` | 出现即告警——熔断器跳闸说明下游已经持续故障 |
| `resilience4j_bulkhead_available_concurrent_calls` | 降到 0 持续 1 分钟(并发名额被打满,正在丢请求) |
| `resilience4j_retry_calls{kind="failed_with_retry"}` 速率 | 明显抬升即关注 |
**③ 业务健康**(这一类最容易被忘,但恰恰是"系统全绿、用户用不了"时唯一能发现问题的指标)
| 指标 | 埋点方式 | 告警起点 |
| --- | --- | --- |
| 登录成功率 | `Counter``result` 打标签 | 5 分钟内 < 90% |
| 切店失败数 | 同上 | 突增 |
| WebView 换票失败率 | 同上 | 5 分钟内 > 5% |
| 首页聚合降级 tile 数 | `Counter``tile` 打标签 | 单个 tile 降级率 > 20% |
```kotlin
// 业务埋点示例:不要自己维护计数器,用 MeterRegistry
@Service
class LoginService(private val meterRegistry: MeterRegistry) {
fun login(...): LoginResult {
val result = doLogin(...)
meterRegistry.counter("business.login", "result", if (result.success) "success" else "failure").increment()
return result
}
}
```
告警阈值都是**起点,不是最终值**——上线后按实际曲线调整。阈值定得过敏感导致告警疲劳,比没有告警更糟糕,因为团队会开始习惯性忽略它。
## 审计日志
```kotlin ```kotlin
// platform-observability/.../Audited.kt // platform-observability/.../Audited.kt
@@ -148,18 +287,18 @@ annotation class Audited(val action: String)
// platform-observability/.../AuditLogAspect.kt // platform-observability/.../AuditLogAspect.kt
@Aspect @Aspect
@Component @Component
class AuditLogAspect(private val storeContextHolder: StoreContextHolder) { class AuditLogAspect(private val storeContextHolder: ObjectFactory<StoreContextHolder>) {
private val auditLog = LoggerFactory.getLogger("AUDIT") private val auditLog = LoggerFactory.getLogger("AUDIT")
@Around("@annotation(audited)") @Around("@annotation(audited)")
fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? { fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? {
val result = runCatching { joinPoint.proceed() } val result = runCatching { joinPoint.proceed() }
val ctx = runCatching { storeContextHolder.`object` }.getOrNull()
auditLog.info( auditLog.info(
"action={} userId={} storeId={} traceId={} success={}", "action={} userId={} storeId={} traceId={} success={}",
audited.action, storeContextHolder.userId, storeContextHolder.storeId, audited.action, ctx?.userId, ctx?.storeId, currentTraceId(), result.isSuccess,
TraceIdHolder.current(), result.isSuccess,
) )
return result.getOrThrow() return result.getOrThrow() // 审计失败不能影响业务;反过来业务异常也照常抛出
} }
} }
@@ -168,29 +307,33 @@ class AuditLogAspect(private val storeContextHolder: StoreContextHolder) {
fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... } fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... }
``` ```
审计日志走独立 logger`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计日志文件/索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。 审计日志走独立 logger`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。
需要审计的行为:登录/登出、切换门店、WebView 换票、权限变更、任何写操作失败、外部系统调用失败。审计日志**只记"谁在什么时候对什么做了什么、成功与否",不记业务数据内容**——记内容就要面对上面那一整套脱敏问题。
## 关键规则 ## 关键规则
- `traceId` 从入口 filter 生成,贯穿到 `f6-integration` / `mini-clients` 调用外部系统,失败时把 `traceId` 一起返回给前端(已经在 [06-api-design.md](./06-api-design.md)`ApiResult`),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。 - `traceId` 由 Micrometer Tracing 生成(或复用客户端合法的 `X-Trace-Id`,贯穿到 `integration/*` 调用外部系统,并随 `ApiResult` 返回给前端( [06-api-design.md](./06-api-design.md)),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。
- 审计相关的关键行为(登录、换票、供应商调用失败)走单独的审计日志通道,不和普通业务日志混在一起 - 审计相关的关键行为走单独的审计日志通道
- 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认。 - 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认;无论哪种,**应用只往 stdout 打日志、不自己写文件**——容器里写文件意味着要处理轮转、磁盘占用和 Pod 销毁后日志丢失
## 附录:为什么要在 MDC 里放 traceId,而不是每条日志手动传参 ## 附录:为什么 traceId 走 MDC,而不是每条日志手动传参
不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",在 filter 里设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可,业务代码完全不需要感知 `traceId` 的传递 不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可。
代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层`ThreadLocal` 实现的,异步线程池、协程切换线程的场景需要手动透传(`MDC.getCopyOfContextMap()` 传给子线程),我们当前同步 Servlet 栈下不需要特殊处理,但如果某个模块引入异步处理要注意这一点 代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层`ThreadLocal`,**换了线程就丢**。这一点在同步 Servlet 栈下大部分时候不用操心,但**有一个真实的例外**:`workbench` 首页把多个下游并行拉起来时会用到自己的线程池,子线程里默认拿不到 `traceId`,那部分日志会断链。Micrometer Tracing 提供了 `ContextPropagatingTaskDecorator`(或 `ContextSnapshot`)来搬运上下文,配置线程池时必须带上——具体写法见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。这也是选 Micrometer 而不是手写 filter 的又一个理由:这套搬运机制是现成的
## 待补充 ## 待补充
- 具体接入现有 ELK / APM 的方式和字段规范。 - 具体接入现有 ELK / APM 的方式和字段规范。
- 审计日志的存储和保留策略 - 审计日志的存储位置和保留期限(需要和安全/合规确认)
## 参考链接 ## 参考链接
- [Spring Boot: Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html)
- [Micrometer Tracing 官方文档](https://docs.micrometer.io/tracing/reference/)
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
- [W3C Trace Context 规范](https://www.w3.org/TR/trace-context/)
- [SLF4J MDC 官方文档](https://www.slf4j.org/manual.html#mdc) - [SLF4J MDC 官方文档](https://www.slf4j.org/manual.html#mdc)
- [Micrometer 官方文档](https://docs.micrometer.io/micrometer/reference/)
- [Spring Boot Actuator 官方文档](https://docs.spring.io/spring-boot/reference/actuator/index.html)
- [Spring Boot Kubernetes Probes 官方文档](https://docs.spring.io/spring-boot/reference/actuator/kubernetes-probes.html) - [Spring Boot Kubernetes Probes 官方文档](https://docs.spring.io/spring-boot/reference/actuator/kubernetes-probes.html)
- [Resilience4j Micrometer 官方文档](https://resilience4j.readme.io/docs/micrometer) - [logstash-logback-encoder: Masking](https://github.com/logfellow/logstash-logback-encoder#masking)
+210 -18
View File
@@ -7,29 +7,129 @@ Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三
## 结构约定 ## 结构约定
``` ```
settings.gradle # include 所有 platform-* / domains/*(见 01-project-structure.md settings.gradle # include 所有 platform-* / domains/* / integration/*(见 01-project-structure.md
build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束 build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
bootstrap/build.gradle # bootJar,构建出可执行 jar bootstrap/build.gradle # bootJar,构建出可执行 jar
Dockerfile # 基于 bootstrap 的 jar 打镜像 Dockerfile # 基于 CI 已构建好的 jar 打镜像(不在镜像里重新编译)
.gitlab-ci.yml # build -> test -> docker build/push -> deploy k8s/ # Deployment / ConfigMap / PodDisruptionBudget 等清单
.gitlab-ci.yml # validate -> package -> release -> deploy-uat -> deploy-prod
``` ```
## Dockerfile 示例(多阶段构建) ## Dockerfile:复用 CI 产物 + 分层解包
```dockerfile ```dockerfile
# Dockerfile # Dockerfile
FROM eclipse-temurin:21-jdk AS build # 前提:CI 的 validate 阶段已经跑过 ./gradlew :bootstrap:bootJar
WORKDIR /workspace # 产物通过 GitLab artifacts 传递到 package 阶段,这里直接用,不重新编译。
COPY . . FROM eclipse-temurin:21-jre AS layers
RUN ./gradlew :bootstrap:bootJar --no-daemon WORKDIR /layers
COPY bootstrap/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
WORKDIR /app WORKDIR /app
COPY --from=build /workspace/bootstrap/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] # 非 root 运行:容器内一旦被攻破,攻击者拿到的也只是一个无特权用户
RUN useradd --system --uid 10001 --create-home appuser
USER 10001
# 按变更频率从低到高逐层 COPY,前三层几乎不变,可以吃满 Docker layer 缓存,
# 每次发版真正推送到 ACR 的通常只有最后一层(几百 KB 的业务代码)
COPY --from=layers --chown=10001:10001 /layers/dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/spring-boot-loader/ ./
COPY --from=layers --chown=10001:10001 /layers/snapshot-dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/application/ ./
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
``` ```
多阶段构建的好处:最终镜像只包含 JRE + 一个 jar,不带 Gradle 缓存、源码、编译工具链,镜像体积和攻击面都更小。 三个容易被忽略但都会真正咬人的点:
1. **不在镜像里重新构建**。上一版 Dockerfile 是 `COPY . . && ./gradlew bootJar`,等于 CI 已经编译测试过一遍,打镜像时又原样编译一遍——既浪费流水线时间,又违背本篇自己的"Build once"原则(这次构建的产物和 CI 里验证过的产物严格来说不是同一个)。改为直接消费 CI 产物,`docker-build-push` job 用 `needs:` 声明依赖 `build-package` 的 artifacts。
2. **`-XX:MaxRAMPercentage`**。JVM 在容器里会读 cgroup 限制推算堆大小,但默认上限只有可用内存的 **25%**——给 Pod 配 2Gi,堆只用 512Mi,剩下的白白浪费,然后在流量高峰时莫名其妙地 OOM 或频繁 Full GC。配 75% 把剩余空间留给 metaspace、线程栈和堆外内存。配套的 `ExitOnOutOfMemoryError` 让 OOM 直接结束进程,交给 K8s 重启,而不是留一个半死不活、探针还返回健康的 Pod。
3. **非 root + 数字 UID**`USER 10001` 写数字而不是 `appuser`,是为了让 K8s 的 `runAsNonRoot: true` 能在启动前静态校验通过(K8s 无法解析镜像里的用户名,只认数字 UID)。
## Deployment 清单:资源、优雅停机与滚动更新
```yaml
# k8s/deployment-uat.yaml(节选。探针配置见 08-observability.md「K8s 探针配置」一节,合并到同一份清单里)
apiVersion: apps/v1
kind: Deployment
metadata:
name: conti-backend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # 更新期间不允许可用副本数低于 replicas,先起新的再停旧的
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 45 # 必须 > preStop 等待 + 应用 graceful 超时
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: conti-backend
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "2Gi" # 只限内存,不限 CPU —— 理由见下
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
volumeMounts:
- name: tmp
mountPath: /tmp # readOnlyRootFilesystem 的必需配套:内嵌 Tomcat 要可写的临时目录
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: conti-backend-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: conti-backend
```
配套的应用侧配置:
```yaml
server:
shutdown: graceful # Boot 默认是 immediate,收到 SIGTERM 直接掐断在途请求
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
```
### 为什么 preStop 要 `sleep 10`
这是滚动更新期间最常见的"零星 502"的根因。Pod 进入 Terminating 时,K8s 会**并行**做两件事:给容器发 SIGTERM,以及把 Pod 从 Service Endpoints 里摘掉。后者要经过 kube-proxy/Ingress 逐节点更新转发规则,**不是瞬时的**。如果应用收到 SIGTERM 立刻开始停机,这几百毫秒到几秒的窗口里仍然会有新请求被转发进来,而它已经不接了。
`preStop``sleep 10` 把 SIGTERM 推迟 10 秒,这段时间里应用照常服务,而 Endpoints 摘除早已完成——等真正开始停机时,已经没有新流量进来了。然后 `server.shutdown: graceful` 负责把已经在处理的请求跑完(最多 25s)。三个数字的关系必须是:`terminationGracePeriodSeconds (45) > preStop (10) + timeout-per-shutdown-phase (25)`,否则超时后 K8s 直接 SIGKILL,优雅停机等于白配。
### 为什么只限内存、不限 CPU
内存超限的后果是 Pod 被 OOMKilled,必须设 limit 防止一个 Pod 拖垮整个节点。CPU 则不同:Linux 的 CPU limit 通过 cfs quota 实现,一旦触及就**限流(throttling)**——表现为请求延迟毫无规律地抖动,而监控上 CPU 使用率看起来还很健康,极难排查。JVM 启动阶段(JIT 编译)尤其吃 CPU,配了 limit 会显著拉长启动时间甚至拖垮 startupProbe。设好 `requests` 保证调度到有余量的节点即可;节点整体过载靠 `ResourceQuota` 和扩容解决,不靠 per-Pod 限流。
`PodDisruptionBudget` 保证节点维护、集群升级这类**自愿中断**时至少留一个副本在跑——没有它,AKS 节点池升级可能把两个副本同时驱逐,造成一次没人预料到的短暂全站不可用。
## 环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/ProdAzure AKS ## 环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/ProdAzure AKS
@@ -55,7 +155,7 @@ set -euo pipefail
IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"} IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"}
kubectl create secret generic conti-backend-secret -n retailapp-dev \ kubectl create secret generic conti-backend-secret -n retailapp-dev \
--from-literal=SECURITY_JWT_SECRET="dev-only-fake-secret" \ --from-literal=SECURITY_JWT_KEYS_V1="dev-only-fake-secret-at-least-32-bytes-long" \
--from-literal=DB_PASSWORD="dev-only-fake-password" \ --from-literal=DB_PASSWORD="dev-only-fake-password" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/configmap-dev.yaml -n retailapp-dev kubectl apply -f k8s/configmap-dev.yaml -n retailapp-dev
@@ -115,20 +215,50 @@ build-package:
stage: validate stage: validate
script: script:
- ./gradlew :bootstrap:bootJar --no-daemon # Build/Package - ./gradlew :bootstrap:bootJar --no-daemon # Build/Package
artifacts:
paths:
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
expire_in: 1 week
security-scan: security-scan:
stage: validate stage: validate
script: script:
- ./gradlew dependencyCheckAnalyze # Security/Quality Scan(依赖漏洞扫描) # 依赖漏洞扫描。注意:从 2023 年起 NVD API 对匿名调用限流极严,
# 不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
# NVD_API_KEY 需去 https://nvd.nist.gov/developers/request-an-api-key 免费申请,存为 CI masked variable。
- ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
cache:
key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
paths:
- build/dependency-check-data
``` ```
这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。 这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。
`dependencyCheckAnalyze` 只覆盖**我们自己声明的依赖**,管不到基础镜像里的 OS 包(glibc、openssl 这类),而那恰恰是镜像 CVE 的大头。所以镜像层面要单独扫,并同时产出 SBOM:
```yaml
image-scan:
stage: package
needs: [docker-build-push]
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
# 而不是挨个把历史镜像拉下来重新扫一遍。
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
artifacts:
paths: [sbom.json]
```
`--ignore-unfixed` 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。基础镜像的 tag 建议钉到 digest,并定期(比如每月)主动升一次,而不是长期用 `21-jre` 这个内容会漂移的浮动 tag。
### 阶段三:Artifact & Release Controls(制品与发布控制) ### 阶段三:Artifact & Release Controls(制品与发布控制)
```yaml ```yaml
docker-build-push: docker-build-push:
stage: package stage: package
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG' - if: '$CI_COMMIT_TAG'
@@ -167,10 +297,10 @@ deploy-uat:
- az login --identity # Runner 用 Managed Identity 登录 Azure - az login --identity # Runner 用 Managed Identity 登录 Azure
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# Key Vault / Config Retrieval:从 Key Vault 读值渲染成 K8s Secret(见 07-config-governance.md 方式一) # Key Vault / Config Retrieval:从 Key Vault 读值渲染成 K8s Secret(见 07-config-governance.md 方式一)
- JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv) - JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv) - DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv)
- kubectl create secret generic conti-backend-secret -n retailapp-uat - kubectl create secret generic conti-backend-secret -n retailapp-uat
--from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" --from-literal=SECURITY_JWT_KEYS_V1="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD" --from-literal=DB_PASSWORD="$DB_PASSWORD"
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat - kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
@@ -204,7 +334,13 @@ rollback-prod:
script: script:
- az login --identity - az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$LAST_APPROVED_TAG -n retailapp-prod # ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量
#GitLab 手动 job 支持在点击时输入变量值)。
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s - kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
``` ```
@@ -215,12 +351,58 @@ rollback-prod:
- **Key Vault/Config Retrieval**Runner 用 `az keyvault secret show` 现取值,`kubectl create secret --dry-run=client -o yaml | kubectl apply -f -` 渲染成 K8s Secret(见 [07-config-governance.md](./07-config-governance.md#azure-上-secret-的真正来源key-vault不是手写-k8s-secret));密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。 - **Key Vault/Config Retrieval**Runner 用 `az keyvault secret show` 现取值,`kubectl create secret --dry-run=client -o yaml | kubectl apply -f -` 渲染成 K8s Secret(见 [07-config-governance.md](./07-config-governance.md#azure-上-secret-的真正来源key-vault不是手写-k8s-secret));密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。
- **`when: manual` = Promotion Gate**UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。 - **`when: manual` = Promotion Gate**UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。
- **部署的是 tag 不是 commit-sha**`deploy-uat`/`deploy-prod` 用的镜像引用都是 `$CI_COMMIT_TAG`(对应阶段三里 `az acr import` 生成的不可变发布版本),而不是重新拿 commit-sha 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。 - **部署的是 tag 不是 commit-sha**`deploy-uat`/`deploy-prod` 用的镜像引用都是 `$CI_COMMIT_TAG`(对应阶段三里 `az acr import` 生成的不可变发布版本),而不是重新拿 commit-sha 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。
- **回滚**不重新跑构建流水线,只是把 `LAST_APPROVED_TAG`上一个已在 Prod 跑过的 release tag,记录在部署记录/GitLab Environment 历史里)重新 `kubectl set image` 一次,几秒钟内完成,这也是为什么"发布版本必须不可变"很重要——回滚目标必须是确定性的、镜像内容不会变的一个 tag。 - **回滚**不重新跑构建流水线,只是把 `ROLLBACK_TAG`(操作人从 GitLab Environment 部署历史里选定的、上一个已在 Prod 正常跑过的 release tag)重新 `kubectl set image` 一次,几秒钟内完成,这也是为什么"发布版本必须不可变"很重要——回滚目标必须是确定性的、镜像内容不会变的一个 tag。**但镜像能回滚不代表整个系统能回滚**,数据库那一半见下一节。
### 阶段五:Azure Environments(部署目标) ### 阶段五:Azure Environments(部署目标)
UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace`retailapp-uat` / `retailapp-prod`),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 [07-config-governance.md](./07-config-governance.md))、各自的资源配额(`ResourceQuota`/`LimitRange`,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。 UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace`retailapp-uat` / `retailapp-prod`),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 [07-config-governance.md](./07-config-governance.md))、各自的资源配额(`ResourceQuota`/`LimitRange`,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。
数据库不在集群里:UAT/Prod 用 **Azure Database for MySQL Flexible Server**,通过 Private Endpoint 接入 AKS 所在 VNet(见 [07-config-governance.md](./07-config-governance.md)),不对公网开放。UAT 和 Prod 是**两个独立的 server 实例**,不是同一个实例上的两个 database——共用实例意味着 UAT 的一次压测或一条慢查询能直接影响生产。
## 数据库迁移与回滚的协同(最容易翻车的一环)
镜像可以秒回滚,**数据库不能**。Flyway 社区版没有 `undo`(那是商业版功能),而且即使有,`drop column` 之后的数据也回不来。再叠加滚动更新的机制——`maxUnavailable: 0` 意味着更新期间**新旧两个版本的 Pod 同时在线,连的是同一个数据库**——就得出一条硬约束:
> **每一个迁移脚本都必须同时兼容"上一个版本的代码"和"这个版本的代码"。**
不满足这条,滚动更新的中间态就会直接报错(旧 Pod 查一个已经被删掉的列),而且此时想回滚镜像也救不了,因为库已经改了。
### expand-contract:把破坏性变更拆成两次发布
以"把 `user.phone` 改名为 `user.mobile`"为例,一次改完必然出事,正确做法是拆成两个 release:
| 阶段 | 迁移脚本 | 代码 | 中间态是否安全 |
| --- | --- | --- | --- |
| **Expand**v1.4.0 | `add column mobile`,回填历史数据,加触发器/双写保持两列同步 | 读 `mobile`,同时写 `phone``mobile` | 安全:旧 Pod 读写 `phone` 照常 |
| (观察期,至少一个发布周期) | — | — | 此时回滚到 v1.3.0 完全安全 |
| **Contract**v1.5.0 | `drop column phone` | 只读写 `mobile` | 安全:线上已无代码引用 `phone` |
对应到常见变更类型:
| 变更 | 能否一次做完 | 做法 |
| --- | --- | --- |
| 加表、加可空列、加索引 | 可以 | 直接加。旧代码看不见它,不受影响 |
| 加**非空**列 | 不可以 | 先加可空列 + 默认值 → 回填 → 下个版本再加 `not null` |
| 删列、删表 | 不可以 | 先发一个版本让代码不再引用它,下个版本再删 |
| 改列名、改类型 | 不可以 | 按上表的 expand-contract 走 |
| 加唯一约束 | 谨慎 | 先查历史数据有没有重复,有重复会导致迁移失败、Pod 起不来 |
### 已发布的迁移脚本不可修改
Flyway 会校验每个脚本的 checksum。改一个已经在任何环境执行过的 `V*.sql`,下次启动会直接 `Validate failed`,应用起不来。要改就新写一个版本号更大的脚本。**这一条对 Dev 环境也适用**——Dev 上随手改了脚本,等到 UAT 部署时才炸,那时候已经不知道当初改了什么。
### 迁移在哪跑
沿用 [03-persistence.md](./03-persistence.md) 的方案:迁移由应用启动时执行(`DomainFlywayConfig` 保证在 JPA `validate` 之前跑完)。`maxSurge: 1` 保证同时只有一个新 Pod 启动,加上 Flyway 自身的表级锁,不会出现多个副本并发迁移。
代价是:**迁移失败 = Pod 起不来 = 部署卡住但线上服务不受影响**(旧 Pod 还在跑,因为 `maxUnavailable: 0`)。这个失败模式是可接受的——比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。
大表变更(几百万行以上加索引/改列)是这个方案的例外:它会让启动探针超时、Pod 被反复重启,同时还可能长时间锁表。这类变更走单独的 K8s `Job` 在业务低峰期执行,执行完再发应用版本,不要塞进启动流程。
### 迁移脚本的数据库账号
迁移用的账号需要 DDL 权限,运行时账号只需要 DML 权限,两者必须分开(见 [07-config-governance.md](./07-config-governance.md))——运行时账号如果有 `drop table` 权限,一个 SQL 注入的破坏半径就完全不一样了。
## 关键规则 ## 关键规则
- UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。 - UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。
@@ -228,6 +410,10 @@ UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace
- CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送 ACR → 打 release tag → 按 UAT(人工晋升)/Prod(人工审批)顺序部署;Dev 不在这条流水线里,需要时手动执行部署脚本。 - CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送 ACR → 打 release tag → 按 UAT(人工晋升)/Prod(人工审批)顺序部署;Dev 不在这条流水线里,需要时手动执行部署脚本。
- 部署到私有 AKS 的 job 必须跑在能访问集群私有网络的 self-hosted Runner 上,公网共享 Runner 无法执行这些 job(网络层面直接不通,不是权限层面的限制);Dev 环境没有 Runner,直接由团队成员在自己电脑上连 VPN 手动执行部署脚本。 - 部署到私有 AKS 的 job 必须跑在能访问集群私有网络的 self-hosted Runner 上,公网共享 Runner 无法执行这些 job(网络层面直接不通,不是权限层面的限制);Dev 环境没有 Runner,直接由团队成员在自己电脑上连 VPN 手动执行部署脚本。
- 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 `Dockerfile` 和 CI job,工程结构上已经按模块划好边界(见 [01-project-structure.md](./01-project-structure.md)),拆分成本较低——本质上是把 `bootstrap` 依赖的某个 `domains/xxx` 模块摘出来,单独套一层 `@SpringBootApplication` 入口和自己的 `Dockerfile` - 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 `Dockerfile` 和 CI job,工程结构上已经按模块划好边界(见 [01-project-structure.md](./01-project-structure.md)),拆分成本较低——本质上是把 `bootstrap` 依赖的某个 `domains/xxx` 模块摘出来,单独套一层 `@SpringBootApplication` 入口和自己的 `Dockerfile`
- **镜像里不编译代码**Dockerfile 消费 CI `validate` 阶段产出的 jar artifact,保证部署的字节就是被测试验证过的字节。
- **每个迁移脚本必须前向兼容**(旧版本代码在新 schema 上能正常跑),破坏性变更一律走 expand-contract 两次发布。已经执行过的迁移脚本不可修改。
- 容器以非 rootUID 10001)运行,`readOnlyRootFilesystem` + `drop ALL capabilities`,堆内存用 `-XX:MaxRAMPercentage` 而不是写死 `-Xmx`
- 优雅停机三件套必须同时配齐且数值满足 `terminationGracePeriodSeconds > preStop sleep + timeout-per-shutdown-phase`,缺一个滚动更新期间就会掉请求。
## 附录:为什么坚持"一个镜像走所有环境" ## 附录:为什么坚持"一个镜像走所有环境"
@@ -240,10 +426,16 @@ UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace
- 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。 - 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
- Gradle 构建缓存/并行构建的 CI 加速配置。 - Gradle 构建缓存/并行构建的 CI 加速配置。
- self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。 - self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。
- HPA(水平自动扩缩)的指标与阈值——目前 `replicas` 是写死的。
- 数据库备份与恢复演练周期(Azure Flexible Server 自带 PITR,但"能恢复"和"演练过能恢复"是两回事)。
## 参考链接 ## 参考链接
- [The Twelve-Factor App](https://12factor.net/zh_cn/) - [The Twelve-Factor App](https://12factor.net/zh_cn/)
- [Gradle 官方 Docker 集成建议](https://docs.gradle.org/current/userguide/multi_project_builds.html)
- [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html) - [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html)
- [Spring Boot: Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
- [Kubernetes: Pod 生命周期与终止流程](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination)
- [Kubernetes: PodDisruptionBudget](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/disruptions/)
- [Flyway: 零停机迁移与 expand-contract](https://documentation.red-gate.com/fd/zero-downtime-deployments-268173154.html)
- [OWASP: Kubernetes Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Kubernetes_Security_Cheat_Sheet.html)
- [k3s 官方文档](https://docs.k3s.io/) - [k3s 官方文档](https://docs.k3s.io/)
+350 -73
View File
@@ -2,15 +2,38 @@
## 决策 ## 决策
JUnit 5 + MockK 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。 JUnit 5 + MockK 做单元测试,TestcontainersMySQL做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 [02-layering.md](./02-layering.md)。
## 测试依赖基线
```groovy
// 各模块 build.gradle。版本能由 BOM 管的一律不写死(根工程已引入 Spring Boot BOM,见 01-project-structure.md
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test' // JUnit 5 + AssertJ + Mockito
testImplementation 'io.mockk:mockk' // Kotlin 友好的 mock 库
testImplementation 'com.ninja-squad:springmockk:5.0.1' // 提供 @MockkBean / @MockkSpyBean
// Testcontainers:版本由 Spring Boot BOM 统一管理,不要手动 pin。
// 如果确实要覆盖版本,注意跨大版本时坐标和包路径可能变动,改完先跑一遍再提交。
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
}
```
`@MockkBean` **不是 Spring 自带的**,它来自 `springmockk`——这个依赖漏了的话,注解根本不存在,编译期就红。Spring 自带的是 `@MockitoBean`Boot 3.4+ 取代了 `@MockBean`),但 Mockito 对 Kotlin 的 final class 和协程支持不如 MockK,这套代码库统一用 MockK 一套到底,避免两套 mock 语法混着写。
## 分层测试策略 ## 分层测试策略
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。 | 层 | 手段 | 起 Spring 容器 | 数量 |
- **application 层**`@SpringBootTest` 或轻量 slice testmock 掉 infrastructure 层。 | --- | --- | --- | --- |
- **infrastructure 层**:用 Testcontainers 起真实数据库跑 repository 测试,避免 H2 和生产数据库行为差异导致的假通过。 | `domain` | 纯 JUnit + MockKmock 掉 repository/client 接口 | 否 | 最多 |
- **api 层**`@WebMvcTest` 验证参数校验、异常处理、响应结构是否符合 [06-api-design.md](./06-api-design.md) 的约定。 | `application` | slice test 或 `@SpringBootTest`mock 掉 infrastructure | 轻量 | 多 |
- **f6-integration / mini-clients**:对外部依赖用 WireMock 打桩,覆盖超时/重试/熔断路径。 | `infrastructure` | Testcontainers 起真实 MySQL 跑 repository 测试 | 是(`@DataJpaTest` | 中 |
| `api` | `@WebMvcTest` 验证参数校验、异常处理、响应结构(见 [06-api-design.md](./06-api-design.md) | 是(web slice | 中 |
| `integration/*` | WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 | 视用例 | 少 |
| 架构规则 | ArchUnit,全代码库扫描 | 否 | 一组 |
## `domain` 层单元测试示例(对应 02 里的换票场景) ## `domain` 层单元测试示例(对应 02 里的换票场景)
@@ -48,26 +71,24 @@ class IssueWebviewTicketServiceTest {
不起 Spring 容器、不连数据库,纯 JVM 内存跑完,这类测试应该是数量最多、跑得最快的一层。 不起 Spring 容器、不连数据库,纯 JVM 内存跑完,这类测试应该是数量最多、跑得最快的一层。
## `infrastructure` 层集成测试示例(Testcontainers **注意 `Clock` 是构造参数注入的,不是 `Instant.now()` 硬编码**。所有涉及时间的业务代码都必须注入 `Clock`(生产环境 `Clock.systemUTC()``platform-*` 提供一个 `@Bean`),否则"过期判断"这类逻辑根本没法稳定测试,只能靠 `Thread.sleep` 硬等——那是慢测试和随机失败的主要来源。
## `infrastructure` 层集成测试示例(Testcontainers + MySQL
```kotlin ```kotlin
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt // domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
@DataJpaTest @DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers @Testcontainers
class StoreJpaRepositoryTest { class StoreJpaRepositoryTest {
companion object { companion object {
@Container @Container
@ServiceConnection // Boot 自动把容器的 url/user/password 注入 DataSource
@JvmStatic @JvmStatic
val postgres = PostgreSQLContainer("postgres:16") val mysql = MySQLContainer("mysql:8.4")
.withUrlParam("connectionTimeZone", "UTC") // 与生产一致,见 03-persistence.md
@JvmStatic .withUrlParam("preserveInstants", "true")
@DynamicPropertySource
fun props(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
} }
@Autowired lateinit var repository: StoreJpaRepository @Autowired lateinit var repository: StoreJpaRepository
@@ -84,67 +105,175 @@ class StoreJpaRepositoryTest {
} }
``` ```
用真实 PostgreSQL(而不是 H2)跑测试,是因为 Flyway migration 里的 SQL 方言、JPA 对特定数据库函数的行为,H2 不一定能完全模拟,容易出现"H2 测试通过、生产环境报错"的假阳性。 两个注解缺一不可,而且都是"不加就静默走错路"的类型:
- **`@ServiceConnection`**:Boot 3.1+ 提供,自动从容器推导连接信息。没有它就得手写一堆 `@DynamicPropertySource`,容易漏配(比如漏了时区参数,测试全绿但生产时间差 8 小时)。
- **`@AutoConfigureTestDatabase(replace = NONE)`**`@DataJpaTest` **默认会把 DataSource 替换成嵌入式内存库**。如果 classpath 上恰好有 H2,容器白起了,测试实际跑在 H2 上——而且不会有任何报错,只会在某天遇到 MySQL 特有行为时才暴露。这是本篇最值得单独强调的一个坑。
用真实 MySQL 而不是 H2,是因为要验证的恰恰是对 MySQL 的假设:Flyway 迁移脚本里的 DDL 方言、`utf8mb4_0900_ai_ci` 排序规则下的中文比较、`datetime(6)` 的精度截断、唯一索引在 3072 字节限制下能不能建起来(见 [03-persistence.md](./03-persistence.md))。这些 H2 一个都模拟不了。
**容器复用**:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 `~/.testcontainers.properties` 里设 `testcontainers.reuse.enable=true` 并给容器加 `.withReuse(true)`;**CI 上不要开**——CI 需要的是每次都干净的环境。
## `api` 层测试示例(`@WebMvcTest` ## `api` 层测试示例(`@WebMvcTest`
```kotlin ```kotlin
@WebMvcTest(StoreController::class) @WebMvcTest(StoreController::class)
@Import(GlobalExceptionHandler::class) // slice test 默认不装配 platform-web 里的 advice,要显式引入
class StoreControllerTest { class StoreControllerTest {
@Autowired lateinit var mockMvc: MockMvc @Autowired lateinit var mockMvc: MockMvc
@MockkBean lateinit var storeAppService: StoreAppService @MockkBean lateinit var storeAppService: StoreAppService
@Test @Test
fun `切换门店参数为空时返回参数校验错误`() { @WithMockUser
mockMvc.post("/api/v1/stores/switch") { fun `切换到无权访问的门店返回 10403`() {
contentType = MediaType.APPLICATION_JSON every { storeAppService.switchStore(any(), 999) } throws StoreNotAccessibleException()
content = """{}"""
}.andExpect { mockMvc.post("/api/v1/stores/999/switch") // 路径与 06-api-design.md、客户端保持一致
status { isBadRequest() } .andExpect {
jsonPath("$.code") { value(ErrorCode.INVALID_PARAM) } // 对应 06-api-design.md 的 ApiResult 结构 status { isForbidden() }
jsonPath("$.code") { value(ErrorCode.STORE_NOT_ACCESSIBLE) } // Int,见 06-api-design.md
jsonPath("$.traceId") { exists() }
} }
} }
} }
``` ```
## 外部依赖打桩示例(WireMock,覆盖 F6 超时/熔断路径) `@WebMvcTest` 只装配 web 层(Controller、`@ControllerAdvice`、消息转换器、参数校验),不装配 Service/Repository——所以 `StoreAppService` 必须 mock 掉,否则容器起不来。这一层测的是**协议**:状态码对不对、错误码对不对、字段名和 JSON 结构对不对,而不是业务逻辑。
## 外部依赖打桩示例(WireMock,覆盖 F6 降级路径)
```kotlin ```kotlin
@Testcontainers // integration/f6-adapter/src/test/kotlin/.../F6ApiClientResilienceTest.kt
@SpringBootTest
class F6ApiClientResilienceTest { class F6ApiClientResilienceTest {
companion object { companion object {
@Container
@JvmStatic @JvmStatic
val wireMock = WireMockContainer("wiremock/wiremock:3.9.1") val wireMock = WireMockServer(options().dynamicPort()).apply { start() }
@JvmStatic
@DynamicPropertySource
fun props(registry: DynamicPropertyRegistry) {
registry.add("integration.f6.base-url") { wireMock.baseUrl() }
registry.add("resilience4j.circuitbreaker.instances.f6.minimum-number-of-calls") { 3 }
} }
}
@Autowired lateinit var f6ApiClient: F6ApiClient
@Test @Test
fun `F6 响应超时后走 fallback 返回降级数据`() { fun `F6 响应超时后走 fallback 返回降级数据`() {
wireMock.stubFor( wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list")) get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withFixedDelay(5000).withStatus(200)) // 模拟超过 timeout 配置的慢响应 .willReturn(aResponse().withFixedDelay(5_000).withStatus(200)), // 超过 responseTimeout 配置
) )
val result = f6ApiClient.fetchProcurementList(storeId = 1).block() // 同步 RestClient,直接拿返回值,没有 .block()(见 05-integration-layer.md
val result = f6ApiClient.fetchProcurementList(storeId = 1)
assertTrue(result!!.degraded) // 验证 05-integration-layer.md 里配置的 2s 超时 + fallback 生效 assertTrue(result.degraded) // 验证超时后 @Retry 耗尽 → fallback 生效
}
@Test
fun `连续失败后熔断器打开并直接走 fallback`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(503)),
)
repeat(5) { f6ApiClient.fetchProcurementList(storeId = 1) }
// 熔断打开后不应该再发出真实请求 —— 这是"熔断真的生效了"的唯一硬证据,
// 只断言返回降级数据是不够的(重试耗尽也会返回降级数据,两者分不开)
val callsBefore = wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size
f6ApiClient.fetchProcurementList(storeId = 1)
assertEquals(callsBefore, wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size)
} }
} }
``` ```
## `ArchUnit`:把分层规则变成可执行的测试 熔断相关的测试**必须在测试里把 `minimum-number-of-calls` 调小**(生产配置通常是 20~50),否则要打满几十次请求才会触发,测试又慢又难读。
[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的依赖方向规则(`domain` 不依赖 Spring/JPA、domains 之间不互相依赖、`api` 不直接依赖 `infrastructure`),光靠 code review 肉眼盯着容易漏,模块一多更盯不过来。用 [ArchUnit](https://www.archunit.org/) 把这些规则写成测试,每次构建自动检查: ## `ArchUnit`:把架构规则变成可执行的测试
[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 `architecture-test` 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):
```groovy ```groovy
// build.gradle(专门放架构测试的模块,或加进 bootstrap 的 test 依赖) // architecture-test/build.gradle
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
testImplementation project(':bootstrap')
}
``` ```
```kotlin ```kotlin
// architecture-test/src/test/kotlin/.../LayeringRulesTest.kt // architecture-test/src/test/kotlin/.../ArchitectureRulesTest.kt
class LayeringRulesTest { class ArchitectureRulesTest {
private val classes = ClassFileImporter().importPackages("com.continental.retailapp")
private val classes = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeTests())
.importPackages("com.continental.retailapp")
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
// —— 规则组一:模块边界(01-project-structure.md)——
@Test
fun `domain 之间只能通过 contract 包互相依赖`() {
domains.forEach { source ->
domains.filter { it != source }.forEach { target ->
noClasses()
.that().resideInAPackage("..retailapp.$source..")
.should().dependOnClassesThat(
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
.and(DescribedPredicate.not(
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract..")))
)
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
.check(classes)
}
}
}
@Test
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
noClasses()
.that().resideInAPackage("..retailapp.*.contract..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
.check(classes)
}
// —— 规则组二:层内方向(02-layering.md)——
@Test
fun `层依赖方向`() {
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("api").definedBy("..retailapp.*.api..")
.layer("application").definedBy("..retailapp.*.application..")
.layer("domain").definedBy("..retailapp.*.domain..")
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
.whereLayer("api").mayNotBeAccessedByAnyLayer()
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
// 写成 mayNotBeAccessedByAnyLayer() 会把 02 自己的示例判红 —— 规则必须和文档一致,
// 真正的红线是下面那条"api 不能碰 infrastructure"。
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
.check(classes)
}
@Test
fun `api 层不能依赖 infrastructure 层`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.because("02-layering.mdapi 层不 import infrastructure 包下的任何类型(包括 Entity)")
.check(classes)
}
@Test @Test
fun `domain 层不能依赖 Spring 或 JPA`() { fun `domain 层不能依赖 Spring 或 JPA`() {
@@ -154,33 +283,133 @@ class LayeringRulesTest {
.check(classes) .check(classes)
} }
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
@Test @Test
fun `domains 之间不能互相依赖(bff-orchestration 除外)`() { fun `Entity 只能待在 infrastructure 包里`() {
slices() classes()
.matching("com.continental.retailapp.(*)..") .that().haveSimpleNameEndingWith("Entity")
.should().notDependOnEachOther() // platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
.ignoreDependency( // 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
DescribedPredicate.describe("来自 bff-orchestration") { it.resideInAPackage("..bffOrchestration..") }, // 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
DescribedPredicate.alwaysTrue(), .and().resideOutsideOfPackage("..retailapp.platform..")
) // bff-orchestration 允许依赖多个 domain,是唯一的例外,见 02-layering.md .should().resideInAPackage("..infrastructure..")
.check(classes) .check(classes)
} }
@Test @Test
fun `api 层不能直接依赖 infrastructure 层`() { fun `api 层不能触碰 Entity`() {
noClasses() noClasses()
.that().resideInAPackage("..api..") .that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..") .should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
.check(classes)
}
// —— 规则组四:编码约定 ——
@Test
fun `Controller 的端点方法必须返回 ApiResult`() {
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
// 用 metaAnnotatedWith 而不是 arePublic@GetMapping/@PostMapping 都是 @RequestMapping 的
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
.and().areMetaAnnotatedWith(RequestMapping::class.java)
.should().haveRawReturnType(ApiResult::class.java)
.because("统一响应结构,见 06-api-design.md")
.check(classes)
}
@Test
fun `禁止使用 java 时间类型的老 API`() {
noClasses()
.should().dependOnClassesThat()
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
.because("统一用 InstantUTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes) .check(classes)
} }
} }
``` ```
这类测试一次写好,覆盖的是全代码库范围的结构性规则,跑在 CI Validation 阶段(见 [09-build-deploy.md](./09-build-deploy.md)),比"等 code review 时人工发现某个 domain 偷偷 import 了另一个 domain 的 entity"要可靠得多,而且不区分改动大小——哪怕只加了一行 `import`,只要违反规则就会立刻挂红。 两个使用上的注意点:
1. **`ImportOption.DoNotIncludeTests()` 不能省**。测试代码里 mock、构造 Entity、跨层引用都是正常的,不排除掉会误报一片。
2. **匹配不到任何类的规则默认会失败**ArchUnit 1.x 的 `allowEmptyShould` 行为)。比如某个 domain 还没建 contract 包,规则就会空转失败。要么用 `.allowEmptyShould(true)`,要么等这个模块真的建起来再加规则——不要因为这个把整条规则删掉。
### 原生 SQL 跨库扫描测试([03-persistence.md](./03-persistence.md) 承诺的那条)
MySQL 里 schema ≡ database**跨 database join 只要账号有权限就是合法的**,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:
```kotlin
// architecture-test/src/test/kotlin/.../NativeQueryScanTest.kt
class NativeQueryScanTest {
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
private val ownerByModule = mapOf(
"identity-store" to "identity_store",
"workbench" to "workbench",
"webview-ticket" to "webview_ticket",
)
private val allDatabases = ownerByModule.values.toSet()
@Test
fun `原生 SQL 里不能出现其他 domain 的库名`() {
val violations = Files.walk(Path.of("../domains"))
.filter { it.toString().endsWith(".kt") }
.toList()
.flatMap { file ->
val path = file.toString().replace('\\', '/')
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
val text = Files.readString(file)
(allDatabases - setOfNotNull(owner))
.filter { text.contains("$it.") }
.map { "$path 引用了 $it" }
}
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
}
}
```
这是**近似检查、不是严密证明**(它只做字符串匹配,绕过它很容易)。它的价值在于把"不小心写了个跨库 join"这种最常见的情况挡在 CI 上;真正的硬约束是生产账号只 GRANT 本域 database 的权限。这一点要如实认识,不要因为有这个测试就以为边界被守住了。
### Resilience4j 切面顺序验证测试([05-integration-layer.md](./05-integration-layer.md) 承诺的那条)
05 里说了切面叠加顺序由 `*-aspect-order` 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:
```kotlin
@SpringBootTest
class ResilienceAspectOrderTest {
// WireMock 的搭建同上一节的 F6ApiClientResilienceTest,这里只列关键断言
@Autowired lateinit var f6ApiClient: F6ApiClient
@Autowired lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
@Test
fun `Retry 在外层时每次重试都计入熔断统计`() {
wireMock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(503)))
val cb = circuitBreakerRegistry.circuitBreaker("f6")
f6ApiClient.fetchProcurementList(storeId = 1) // 1 次调用 + 2 次重试
// Retry 在外 → 熔断器看到 3 次失败;Retry 在内 → 熔断器只看到 1 次。
// 这个断言就是"配置到底生效成什么样"的答案,改配置后它会立刻告诉你方向反没反。
assertEquals(3, cb.metrics.numberOfFailedCalls)
}
}
```
## CI 里跑 Testcontainers 的前提条件 ## CI 里跑 Testcontainers 的前提条件
`infrastructure` 层的集成测试(前面 `StoreJpaRepositoryTest` 那个例子)依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**不是随便一个 Runner 都能跑,两种常见配置: 集成测试依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,两种常见配置:
**方式一:Docker-in-Dockerdind),托管 Runner 的默认选择** **方式一:Docker-in-Dockerdind),托管 Runner 的默认选择**
@@ -210,64 +439,112 @@ unit-integration-test:
方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。 方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。
## 覆盖率门禁(Jacoco ## 覆盖率门禁(Jacoco 多模块聚合
**先说清楚为什么必须聚合**:多模块工程里如果每个模块各算各的覆盖率,会出现两个问题——① 根工程自己没有测试,在根上跑 `jacocoTestCoverageVerification` 直接就是 0/0 通过,门禁形同虚设;② `architecture-test` 模块跑的测试覆盖到的是**其他模块**的代码,按模块统计时这部分贡献会被完全丢掉。用 Gradle 自带的 `jacoco-report-aggregation` 插件把所有模块的执行数据合并成一份报告:
```groovy ```groovy
// build.gradle // build.gradle
plugins { plugins {
id 'jacoco' id 'jacoco-report-aggregation'
} }
jacocoTestReport { dependencies {
dependsOn test // 依赖 bootstrap 即可 —— 它传递性地依赖了所有 platform-* / domains/* / integration/*
jacocoAggregation project(':bootstrap')
jacocoAggregation project(':architecture-test')
}
subprojects {
apply plugin: 'jacoco'
}
// 聚合报告的具体配置属性名在 Gradle 各版本间有过调整,
// 升级 Gradle 后先跑一次 ./gradlew testCodeCoverageReport 确认任务还在、报告路径没变。
reporting {
reports { reports {
xml.required = true // CI 里给 GitLab 覆盖率可视化用 testCodeCoverageReport(JacocoCoverageReport) {
} testSuiteName = 'test'
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.70
}
} }
} }
} }
```
check.dependsOn jacocoTestCoverageVerification // 覆盖率不达标,./gradlew check 直接失败 排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:
```groovy
subprojects {
tasks.withType(JacocoReport).configureEach {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/*MapperImpl*', // MapStruct 生成的代码
'**/*Request*', '**/*Response*', '**/*Dto*', // 纯数据类,没有逻辑
'**/*Entity*', // 同上
'**/*Config*', '**/*Properties*',
'**/BootstrapApplication*',
])
}))
}
}
``` ```
```yaml ```yaml
# .gitlab-ci.ymlCI Validation 阶段追加覆盖率门禁 # .gitlab-ci.yml
unit-integration-test: unit-integration-test:
stage: validate stage: validate
script: script:
- ./gradlew test jacocoTestCoverageVerification --no-daemon - ./gradlew test testCodeCoverageReport --no-daemon
artifacts: artifacts:
reports: reports:
junit: '**/build/test-results/test/TEST-*.xml'
coverage_report: coverage_report:
coverage_format: cobertura coverage_format: cobertura
path: '**/build/reports/jacoco/test/jacocoTestReport.xml' path: 'build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml'
``` ```
阈值定在 70% 而不是追求 90%+`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖",统一定一个较低的整体阈值,作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样容易导致为了凑覆盖率写没有意义的测试 阈值定在 **70%** 而不是追求 90%+`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖"。这个门禁的作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样只会催生出一堆 `assertNotNull(result)` 式的假测试,覆盖率好看了,实际保护力反而下降
## 测试数据构造约定
集成测试里最容易失控的地方是"每个测试自己 new 一个有 15 个字段的 Entity",字段一改,几十个测试同时爆掉。统一用**测试数据构建器**,只显式写这个测试真正关心的字段:
```kotlin
// src/test/kotlin/.../fixtures/StoreFixtures.kt
fun aStore(
name: String = "示例门店",
code: String = "S001",
status: StoreStatus = StoreStatus.ACTIVE,
) = StoreEntity(name = name, code = code, status = status)
// 用法:读者一眼就知道这个测试关心的只有 status
val inactive = aStore(status = StoreStatus.INACTIVE)
```
其他几条:
- **测试之间不共享状态**。`@DataJpaTest` 默认每个测试方法跑在会回滚的事务里;用 `@SpringBootTest` 时事务不会自动回滚,需要显式 `@Transactional` 或在 `@AfterEach` 里清理。
- **不依赖测试执行顺序**。JUnit 5 的默认顺序是确定但不保证的,任何"必须先跑 A 再跑 B"的设计都要拆掉。
- **断言要具体**。`assertTrue(list.isNotEmpty())` 在回归时几乎抓不到问题,`assertEquals(listOf("S001"), list.map { it.code })` 才有价值。
- **测试名用反引号中文描述行为**(如上面示例),不要 `test1``testSwitchStore2`——失败时 CI 报告上那一行就是问题描述本身。
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖 ## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。 这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实 MySQL、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。 Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
## 待补充 ## 待补充
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。 - 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
- 性能/压测基线(至少要有一条:首页聚合接口在 N 并发下的 P99)。
## 参考链接 ## 参考链接
- [MockK 官方文档](https://mockk.io/) - [MockK 官方文档](https://mockk.io/)
- [springmockk`@MockkBean`](https://github.com/Ninja-Squad/springmockk)
- [Testcontainers 官方文档](https://testcontainers.com/) - [Testcontainers 官方文档](https://testcontainers.com/)
- [Spring Boot: Testcontainers 与 @ServiceConnection](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)
- [WireMock 官方文档](https://wiremock.org/docs/) - [WireMock 官方文档](https://wiremock.org/docs/)
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html) - [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html)
- [Jacoco Gradle Plugin 官方文档](https://docs.gradle.org/current/userguide/jacoco_plugin.html) - [Gradle: JaCoCo Report Aggregation Plugin](https://docs.gradle.org/current/userguide/jacoco_report_aggregation_plugin.html)
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
+288
View File
@@ -0,0 +1,288 @@
# 11. 跨域协作与聚合
## 决策
跨 domain 的**读**走契约模块(`-contract`)里的接口,跨 domain 的**写/状态联动**走 Spring `ApplicationEvent` + `@TransactionalEventListener`,暂不引入消息中间件。`bff-orchestration` / `workbench` 的并行聚合用一个专用的有界线程池,带上下文传播和整体超时预算,按 tile 局部降级。
这一篇填的是原来整套文档最大的一个空白:[04-security-auth.md](./04-security-auth.md) 里写了"切店 → 通知 webview-ticket 失效"走事件,但事件机制本身从来没有被定义过。
## 一、跨域读:契约模块
模块结构和约束见 [01-project-structure.md](./01-project-structure.md),这里只讲使用规则。
```kotlin
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreQueryService.kt
interface StoreQueryService {
fun listStoresByUserId(userId: Long): List<StoreInfo>
fun findStore(storeId: Long): StoreInfo?
}
data class StoreInfo(
val storeId: Long,
val name: String,
val code: String,
)
```
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreQueryServiceImpl.kt
@Service
class StoreQueryServiceImpl(private val storeRepository: StoreRepository) : StoreQueryService {
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
}
```
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
@Service
class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // 注入的是契约里的接口,不是 identity-store 的实现类
) { ... }
```
### 契约设计的四条规则
1. **契约模型是独立的数据结构,不是 Entity 的别名**`StoreInfo` 只含调用方真正需要的字段。让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺,改一个列名就要动三个 domain。
2. **契约只承诺调用方需要的最小能力**。不要一上来就写 `findAll()``update()`——契约里出现的每个方法都是未来的约束。
3. **契约变更要向后兼容**。加方法、加可空字段没问题;删方法、改语义要先确认所有调用方,跟对客户端的 API 一个待遇。
4. **契约模块不含实现、不依赖 Spring Web/JPA**ArchUnit 会检查,见 [10-testing.md](./10-testing.md))。
### 什么时候不该用契约,而应该重新划边界
如果 `workbench` 要用 `identity-store` 的契约方法超过五六个,甚至开始要求对方加"给我拼好这个结构"的定制方法,那说明**边界划错了**——这块逻辑本来就该在一边,或者本来就该单独成域。契约模块变厚是个明确的设计告警,不要靠往里加方法来消化它。
## 二、跨域写:领域事件
### 为什么写操作不走契约接口
`workbench` 直接调 `identityStore.doSomething()` 意味着:workbench 要知道 identity-store 内部该做什么,identity-store 的事务边界被外部方法调用拉长,而且以后每多一个关心"切店"的域,就要在切店逻辑里多加一行调用——切店代码变成一个不断膨胀的通知中心。
事件反转了这个依赖方向:**发布方不知道谁在听**。切店只管发一个"门店切换了"的事实,谁关心谁自己订阅。
### 事件定义放在契约模块
```kotlin
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreSwitchedEvent.kt
data class StoreSwitchedEvent(
val userId: Long,
val fromStoreId: Long?,
val toStoreId: Long,
val occurredAt: Instant,
)
```
事件类型必须放在契约模块,否则订阅方要 import 发布方的内部类型,边界又破了。
### 发布:在事务内发布
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreSwitchAppService.kt
@Service
class StoreSwitchAppService(
private val events: ApplicationEventPublisher,
private val clock: Clock,
) {
@Transactional
fun switchStore(userId: Long, targetStoreId: Long): StoreContext {
val store = findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
// ... 更新当前门店、重新签发 access token(见 04-security-auth.md
events.publishEvent(StoreSwitchedEvent(userId, currentStoreId, targetStoreId, clock.instant()))
return context
}
}
```
### 订阅:`@TransactionalEventListener`
```kotlin
// domains/webview-ticket/src/main/kotlin/.../webviewticket/application/StoreSwitchedListener.kt
@Component
class StoreSwitchedListener(private val ticketRepository: WebviewTicketRepository) {
private val log = LoggerFactory.getLogger(javaClass)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun onStoreSwitched(event: StoreSwitchedEvent) {
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
}
}
```
这段代码里每个注解和写法都在解决一个具体问题:
| 写法 | 解决什么 |
| --- | --- |
| `@TransactionalEventListener(AFTER_COMMIT)` | 普通 `@EventListener` 是**同步、在同一事务内**执行的。用它意味着"切店事务回滚了,但票据已经作废"这种不一致;`AFTER_COMMIT` 保证只在主事务真正提交后才触发 |
| `@Transactional(REQUIRES_NEW)` | `AFTER_COMMIT` 阶段原事务已经提交,此时**没有活跃事务**。不开新事务的话,监听器里的写操作要么报错要么自动提交,行为不可控 |
| `runCatching` + 记日志 | 监听器抛异常**不会**回滚主事务(主事务已提交),只会让这次副作用静默丢失。必须显式捕获并留下可排查的日志 |
### 同步还是异步
**默认同步**`AFTER_COMMIT` 但仍在同一线程)。理由:同步下 traceId、`@RequestScope` 的门店上下文都还在,出问题能直接顺着日志查下去;异步则要额外处理上下文传播,而目前这些副作用(作废票据、清缓存)都很轻,没必要付这个复杂度。
只有当某个监听器确实耗时(比如要调外部系统)时才加 `@Async`,并且必须:指定专用线程池(不要用默认的 `SimpleAsyncTaskExecutor`,它每次新建线程且无上界)、加上下文传播的 `TaskDecorator`(见下文第三节)、想清楚失败后怎么办。
### 事件的可靠性边界(必须如实认识)
`ApplicationEvent` 是**进程内、内存中**的:应用在事件发出后、监听器执行前崩溃,这个事件就永久丢了,没有重试、没有补偿。
所以这套机制**只能用于"丢了不致命"的副作用**——作废一张票据(下次换票时本来也会重新校验)、清一个缓存、记一条审计日志。**不能用于**:扣款、发货、任何丢了会造成数据不一致且无法自愈的操作。
在当前这套系统里,跨域事件的用途就是切店后作废票据这一类,符合这个边界。
### 什么时候升级到消息中间件
出现下面任意一条,就该引入 RabbitMQ/Kafka + 事务性发件箱(transactional outbox),而不是继续给 `ApplicationEvent` 打补丁:
- 事件消费失败需要**自动重试**,或者需要死信队列;
- 事件丢失会造成**业务上的资金/库存不一致**;
- 消费方被拆成了**独立进程**(模块化单体拆微服务时的必然结果);
- 需要一个事件被**多个消费组各自独立消费**,且各自有独立的消费进度。
事务性发件箱的做法(记在这里备查,现在不实施):在业务事务里往 `outbox` 表插一条记录(与业务写在同一个事务,天然原子),另有一个轮询任务把 outbox 里的记录投递到消息中间件并标记已发送。它解决的是"写库成功但发消息失败"这个用 `AFTER_COMMIT` 无论如何都消除不掉的窗口。
现在不做的理由很直接:引入中间件意味着多一套需要部署、监控、排障的基础设施,而当前唯一的跨域事件场景丢了也不致命。**等到有第一个"丢了会出事"的事件时再做**,那时候需求也更清楚。
## 三、聚合:`bff-orchestration` 与 `workbench` 的并行 fan-out
首页要同时拉采购、保修、门店信息等多个 tile(架构图 Flow 3),串行调用意味着总耗时是各下游耗时之和。同步栈下必须显式用线程池做并行。
### 专用线程池
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/infrastructure/config/WorkbenchExecutorConfig.kt
// 模块内的 @Configuration 一律放 infrastructure/config/(见 01-project-structure.md 的脚手架),
// 不要散在模块根包下——那样它既不属于任何一层,ArchUnit 的分层规则也管不到它。
@Configuration
class WorkbenchExecutorConfig {
@Bean("workbenchExecutor")
fun workbenchExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
corePoolSize = 8
maxPoolSize = 16
queueCapacity = 32 // 有界!无界队列会让 maxPoolSize 永远不生效
setThreadNamePrefix("workbench-")
setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
// 关键:把 MDCtraceId)和 RequestContext(门店上下文)带到子线程
setTaskDecorator(ContextPropagatingTaskDecorator())
setWaitForTasksToCompleteOnShutdown(true)
setAwaitTerminationSeconds(20) // 配合 09 的优雅停机
initialize()
}
}
```
四个必须这么写的点:
1. **必须是专用池,不能用公共 `@Async` 默认池**。聚合任务和别的异步任务共用一个池,一个下游变慢就会把池占满,波及所有异步任务——这正是舱壁模式要防的事(见 [05-integration-layer.md](./05-integration-layer.md))。
2. **队列必须有界**`ThreadPoolTaskExecutor``queueCapacity` 默认是 `Integer.MAX_VALUE`,即无界——后果是任务全部堆进队列,线程数永远不会从 core 涨到 max,然后在某次流量高峰把堆内存吃光。
3. **`CallerRunsPolicy`**:池满时任务退回调用线程(Tomcat 线程)自己执行。这是一种天然的背压——聚合变慢了,但不会丢请求、不会抛 `RejectedExecutionException`
4. **`ContextPropagatingTaskDecorator`**Micrometer 提供的上下文传播装饰器,把 MDC 里的 traceId、`@RequestScope` 的门店上下文搬到子线程。**没有它,子线程里的日志全部断链,且 `StoreContextHolder` 直接取不到值——这是同步栈下并行聚合最典型的翻车方式**([08-observability.md](./08-observability.md) 附录也点了这一处)。
### 整体超时预算
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
@Service
class WorkbenchAppService(
@Qualifier("workbenchExecutor") private val executor: Executor,
private val f6ApiClient: F6ApiClient,
private val o2oClient: O2OClient,
private val storeQueryService: StoreQueryService,
) {
private val log = LoggerFactory.getLogger(javaClass)
companion object {
private val TOTAL_BUDGET = Duration.ofSeconds(3) // 整个首页接口的总预算
}
fun loadHomepage(userId: Long, storeId: Long): HomepageResponse {
val deadline = System.nanoTime() + TOTAL_BUDGET.toNanos()
val procurement = supply("procurement") { f6ApiClient.fetchProcurementList(storeId) }
val warranty = supply("warranty") { o2oClient.fetchWarrantySummary(storeId) }
val store = supply("store") { storeQueryService.findStore(storeId) }
return HomepageResponse(
procurement = await(procurement, deadline, "procurement"),
warranty = await(warranty, deadline, "warranty"),
store = await(store, deadline, "store"),
)
}
private fun <T> supply(tile: String, block: () -> T): CompletableFuture<Tile<T>> =
CompletableFuture.supplyAsync({
runCatching(block)
.map { Tile.ok(it) }
.getOrElse { log.warn("tile={} 加载失败,降级", tile, it); Tile.degraded() }
}, executor)
private fun <T> await(future: CompletableFuture<Tile<T>>, deadlineNanos: Long, tile: String): Tile<T> {
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) return Tile.degraded()
return runCatching { future.get(remaining, TimeUnit.NANOSECONDS) }
.getOrElse { log.warn("tile={} 超出总预算,降级", tile); Tile.degraded() }
}
}
```
**总预算不等于各下游超时之和**。三个下游各配 2 秒超时,串行最坏 6 秒、并行最坏 2 秒——但这算的是单次调用,叠上 `@Retry`(3 次)之后单个 tile 最坏可能到 6 秒。所以必须有一个独立于下游配置的**接口级总预算**(这里 3 秒),到点就把还没回来的 tile 全部降级返回。没有这道闸,首页接口的最坏耗时是由下游配置的乘积决定的,不可控。
### 局部降级的响应约定
降级必须**对客户端可见**,不能悄悄返回空数据——客户端要能区分"这块真的没数据"和"这块没拉到",才能决定是显示空态还是显示"加载失败,点击重试"。
```kotlin
data class Tile<T>(
val data: T?,
val status: TileStatus, // OK / DEGRADED
) {
companion object {
fun <T> ok(data: T) = Tile(data, TileStatus.OK)
fun <T> degraded() = Tile<T>(null, TileStatus.DEGRADED)
}
}
```
对应的响应体:
```json
{
"code": 0,
"data": {
"procurement": { "status": "OK", "data": { "pendingCount": 12 } },
"warranty": { "status": "DEGRADED", "data": null },
"store": { "status": "OK", "data": { "storeId": 1, "name": "示例门店" } }
},
"traceId": "..."
}
```
规则:**只要主数据(门店上下文)拿到了,首页接口就返回 `code: 0`**,个别 tile 降级不会让整个接口失败。这正是架构图 Flow 3 里"局部降级"的含义——一个外部系统抖动不应该让用户连首页都打不开。
## 关键规则
- 跨 domain 读走 `-contract` 接口,跨 domain 写/状态联动走领域事件;两者都不允许直接 import 对方的内部类型。
- 事件类型定义在契约模块;监听器一律 `@TransactionalEventListener(AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)` + 自己兜住异常。
- `ApplicationEvent` 只用于"丢了不致命"的副作用;出现需要重试/不能丢的场景,升级到消息中间件 + 事务性发件箱,不要给现有机制打补丁。
- 并行聚合必须用专用**有界**线程池 + `ContextPropagatingTaskDecorator` + `CallerRunsPolicy`
- 聚合接口必须有独立于下游配置的**总超时预算**,超时的 tile 降级返回而不是整体失败。
- 降级状态必须在响应里显式表达(`status: DEGRADED`),不能用空数据冒充。
## 待补充
- 各 tile 的具体超时预算分配(需要先有真实的下游耗时数据)。
- 首页聚合结果的本地缓存策略——见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## 参考链接
- [Spring: Application Events](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events)
- [Spring: `@TransactionalEventListener`](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html)
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
- [microservices.io: Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
- [Simon Brown: Modular Monoliths](https://www.youtube.com/watch?v=5OjqD-ow8GE)
+306
View File
@@ -0,0 +1,306 @@
# 12. 并发、事务与定时任务
## 决策
事务边界统一放在 application 层;写接口用幂等键防重复提交;并发冲突用 `@Version` 乐观锁 + 有限重试;定时任务在多副本下用 ShedLock 保证只跑一次;缓存暂不引入 Redis,只用 Caffeine 本地缓存,且严格限定在"能容忍副本间不一致"的数据上。
## 一、事务边界
### 规则:事务开在 application 层的用例方法上
```kotlin
@Service
class StoreSwitchAppService(...) {
@Transactional // ← 事务边界在这里
fun switchStore(userId: Long, targetStoreId: Long): StoreContext { ... }
}
```
- **不在 Controller 上开事务**Controller 属于 api 层,开事务等于把 HTTP 序列化过程也圈进事务里,事务被无谓拉长。
- **不在 Repository 方法上开事务**:一个用例往往包含多次写,各自开事务就没有原子性可言了。
- **只读查询加 `@Transactional(readOnly = true)`**Hibernate 会跳过脏检查(dirty checking),减少一次全量快照比对;对只读为主的查询接口是免费的性能收益。
### 事务里绝对不能做的三件事
1. **调外部 HTTP 接口**。F6 慢一点,数据库连接和行锁就被一起占着不放,几个请求就能把连接池打满(见 [03-persistence.md](./03-persistence.md) 的池子容量算法)。外部调用要么放在事务开始前,要么放在提交之后(`AFTER_COMMIT` 事件,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md))。
2. **`Thread.sleep` / 等待用户输入 / 等锁**。同上。
3. **catch 掉异常却继续用同一个事务**。Spring 默认在 `RuntimeException` 时把事务标记为 rollback-only,这之后再做任何写操作,最终提交时都会抛 `UnexpectedRollbackException`——而且报错点离真正的错误现场很远,非常难查。
### 自调用失效:Spring 事务最经典的坑
```kotlin
@Service
class OrderAppService {
fun createBatch(items: List<Item>) {
items.forEach { create(it) } // ← 这里的 @Transactional 完全不生效
}
@Transactional
fun create(item: Item) { ... }
}
```
`@Transactional` 靠 AOP 代理实现,**同一个类内部的方法调用不经过代理**,注解形同虚设。这一条对 `@Async``@Cacheable`、Resilience4j 的注解全部适用。解法是把被调方法挪到另一个 bean 里,让调用真的穿过代理。
### 传播行为:只用这两个
| 传播行为 | 什么时候用 |
| --- | --- |
| `REQUIRED`(默认) | 绝大多数情况。有事务就加入,没有就新建 |
| `REQUIRES_NEW` | 必须独立提交/回滚的场景:`AFTER_COMMIT` 事件监听器、审计记录、失败次数累加(登录失败计数必须在认证失败回滚后仍然保留) |
其余传播行为(`NESTED``SUPPORTS``MANDATORY`…)在这套系统里没有需要它们的场景,用了只会增加理解成本。真遇到时先怀疑是不是边界划错了。
### 隔离级别
统一 `READ COMMITTED`(在 [03-persistence.md](./03-persistence.md) 里通过 `transaction-isolation` 全局配置,覆盖 MySQL 默认的 `REPEATABLE READ`),不在方法上单独指定。需要更强一致性的地方用显式锁(`SELECT ... FOR UPDATE`JPA 的 `@Lock(PESSIMISTIC_WRITE)`)或乐观锁,而不是靠调隔离级别——后者影响面是整个连接,副作用难以预料。
## 二、幂等
### 哪些接口需要
客户端在弱网下会重试(见客户端 `05-networking.md`),用户也会连点两次。**所有会产生副作用且重复执行会出问题的写接口**都需要幂等保护:换票、切店、任何创建类操作。
天然幂等的不需要额外处理:`GET`、把状态设为某个确定值的更新(`status = INACTIVE`)、按主键的删除。
### 方案:客户端生成幂等键
```
POST /api/v1/webview/tickets
Idempotency-Key: 7f3a9c1e-... # 客户端生成的 UUID,重试时复用同一个值
```
```kotlin
// platform/platform-web/src/main/kotlin/.../platform/web/idempotency/IdempotencyGuard.kt
// 放 platform 而不是某个 domain:换票、切店、创建类操作分散在多个 domain,
// 而 domain 之间不能互相依赖——公共能力只能住在 platform-*(见 01-project-structure.md)。
@Service
class IdempotencyGuard(private val recordRepository: IdempotencyRecordRepository) {
/**
* 同一个 key 在有效期内只会真正执行一次;重复请求直接返回首次的结果。
*/
@Transactional
fun <T> execute(key: String, userId: Long, block: () -> T): T {
val existing = recordRepository.findByKeyAndUserId(key, userId)
if (existing != null) return deserialize(existing.response)
val result = block()
// 唯一索引兜底:两个并发请求同时走到这里,第二个会因为 uk_idem_key_user 冲突而失败
recordRepository.save(IdempotencyRecordEntity(key, userId, serialize(result)))
return result
}
}
```
```sql
-- 建在 platform 共用的库里(和下面的 shedlock 表同库),不属于任何 domain
create table idempotency_record (
id bigint not null auto_increment,
idem_key varchar(64) not null,
user_id bigint not null,
response text not null,
created_at datetime(6) not null,
primary key (id),
unique key uk_idem_key_user (idem_key, user_id)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
三个要点:
- **唯一索引是真正的保证,代码里的"先查再写"不是**。两个并发请求会同时查到"没有记录"然后同时执行——只有数据库的唯一约束能挡住。捕获 `DataIntegrityViolationException` 后重新读一次已有结果即可。
- **幂等键要带 `userId`**:只用 key 做唯一索引意味着不同用户的 key 会互相冲撞,而 key 是客户端生成的,我们不控制它的全局唯一性。
- **记录要定期清理**(见下面定时任务一节),保留期取"客户端最长可能重试的窗口",比如 24 小时,不需要永久保存。
## 三、并发冲突:乐观锁
### 用 `@Version`,不用悲观锁
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/infrastructure/persistence/StoreEntity.kt
@Entity
class StoreEntity : VersionedEntity() { // VersionedEntity 带 @Version,见 03-persistence.md
var name: String = ""
}
```
更新时如果 version 已经被别人改过,Hibernate 抛 `ObjectOptimisticLockingFailureException`[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler` 会把它转成 `10409 CONFLICT`
选乐观锁而不是 `SELECT ... FOR UPDATE`:这套系统是典型的低冲突场景(同一门店的同一条记录被两个人同时改的概率很低),悲观锁的代价是每次读都要持锁,把并发度砍掉换一个几乎用不上的保证。
### 什么时候自动重试,什么时候返回 409
| 场景 | 处理 |
| --- | --- |
| 用户提交的业务更新(改门店信息) | **返回 409**,让用户看到"数据已被他人修改,请刷新后重试"。自动重试会静默覆盖别人的修改 |
| 内部的计数器/状态推进(登录失败次数、票据状态流转) | **自动重试**,用户不需要知道内部发生了冲突 |
```kotlin
@Retryable(
retryFor = [ObjectOptimisticLockingFailureException::class],
maxAttempts = 3,
backoff = Backoff(delay = 50, multiplier = 2.0, random = true), // 抖动,避免两方同步重试同步再撞
)
@Transactional
fun incrementFailedAttempts(userId: Long) { ... }
```
**重试必须在事务外层**——`@Retryable` 要包住 `@Transactional`,因为冲突发生时事务已经标记回滚,必须开一个全新的事务重新读、重新算、重新写。在事务内部重试是无效的(而且会撞上 rollback-only)。由于两个注解在同一个方法上时代理顺序容易搞错,稳妥做法是把重试和事务拆到两个 bean 上:外层 bean 负责 `@Retryable`,内层 bean 负责 `@Transactional`
## 四、定时任务在多副本下的重复执行
### 问题
[04-security-auth.md](./04-security-auth.md) 里有一个清理过期 refresh token 的 `@Scheduled` 任务,加上上面幂等记录的清理任务。**`@Scheduled` 在每个 Pod 上都会独立执行**——2 个副本就是每次跑 2 遍。清理任务跑两遍问题不大(删除是幂等的),但只要出现一个"发通知""生成对账单"式的任务,重复执行就是事故。
这一条在原来的文档里完全没有提到,而 [09-build-deploy.md](./09-build-deploy.md) 明确配了 `replicas: 2`——也就是说按现有文档实施,上线当天就是重复执行状态。
### 方案:ShedLock
```groovy
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'
```
```sql
-- 放在 platform 共用的库里,不属于任何 domain
create table shedlock (
name varchar(64) not null,
lock_until datetime(6) not null,
locked_at datetime(6) not null,
locked_by varchar(255) not null,
primary key (name)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
```kotlin
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/scheduling/SchedulingConfig.kt
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
class SchedulingConfig {
@Bean
fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(JdbcTemplate(dataSource))
.usingDbTime() // 用数据库时间而不是各 Pod 的本地时间,避免时钟漂移导致锁失效
.build(),
)
}
// domains/identity-store/src/main/kotlin/.../identitystore/application/RefreshTokenCleanupJob.kt
// 定时任务放 application 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
@Component
class RefreshTokenCleanupJob(private val repository: RefreshTokenRepository, private val clock: Clock) {
@Scheduled(cron = "0 17 3 * * *") // 每天凌晨 3:17,见下方"为什么不用整点"
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
fun cleanup() {
val deleted = repository.deleteExpiredBefore(clock.instant())
LoggerFactory.getLogger(javaClass).info("清理过期 refresh token,删除 {} 条", deleted)
}
}
```
两个参数的含义容易混:
- **`lockAtMostFor`**:锁的最长持有时间,防死锁。持锁的 Pod 被 kill 掉时,锁不会自动释放(数据库里的记录还在),到这个时间才失效。**必须显著大于任务的正常执行时间**,否则任务还没跑完锁就过期了,另一个 Pod 会同时开跑。
- **`lockAtLeastFor`**:锁的最短持有时间。防的是"任务执行极快 + 各 Pod 时钟有偏差"导致同一个调度点被跑两次。
**`usingDbTime()` 不能省**:不加的话 ShedLock 用各个 Pod 的本地时间写锁,Pod 之间有几秒时钟漂移就可能同时抢到锁——那这套机制就白配了。
### 为什么 cron 不用整点
`0 0 3 * * *` 这种整点时间,全世界所有系统的定时任务都挤在同一秒——数据库、外部依赖、监控在那一刻集体尖峰。错开几分钟(`0 17 3 * * *`)没有任何业务代价,但能把这个尖峰摊平。
### 定时任务的其他约定
- **必须记日志**:开始、结束、处理条数。没有日志的定时任务出问题时你连"它有没有跑"都不知道。
- **必须限制单次处理量**`delete from ... where expires_at < ? limit 1000` 分批删,不要一条 SQL 删几百万行——那会长时间持有行锁并撑爆 binlog。
- **必须自己兜住异常**`@Scheduled` 方法抛异常只会被 Spring 记一条日志,任务本身不会重试,但下次调度照常。如果失败需要告警,自己 catch 后打 `ERROR`(见 [08-observability.md](./08-observability.md) 的告警规则)。
- **任务执行情况应该有指标**:至少一个"上次成功执行时间",用于告警"某个任务已经 3 天没成功跑过了"——这类静默失败光看错误日志是发现不了的。
### 替代方案:K8s CronJob
对于"跑一次就结束、不需要常驻"的任务(比如大表数据归档),更合适的做法是 K8s `CronJob` 起一个独立 Pod:天然只跑一份,不需要 ShedLock,也不会和在线请求抢应用的线程和连接池。代价是要额外维护一套镜像入口和清单。
判断标准:**任务需要用到应用内的业务逻辑 → `@Scheduled` + ShedLock;任务本质是一段独立的数据操作 → CronJob**。
## 五、缓存:只用 Caffeine 本地缓存
### 为什么暂不引入 Redis
引入 Redis 意味着多一个需要部署、监控、备份、排障的有状态组件,以及一整套新的失败模式(连接抖动、大 key、缓存穿透/雪崩)。当前的数据量和并发量还远没有到需要它的程度,而 [04-security-auth.md](./04-security-auth.md) 的 refresh token 已经明确落库、[11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md) 的事件也不需要外部存储——没有哪个场景是非它不可的。
### Caffeine 的适用边界(这是重点)
本地缓存的本质特征是:**每个 Pod 各缓存一份,副本之间必然不一致,且无法主动失效其他副本的缓存**。所以只能缓存满足这两个条件的数据:
1. 变更频率极低;
2. **短时间内读到旧值不会造成业务错误**
| 数据 | 能不能用本地缓存 | 说明 |
| --- | --- | --- |
| 菜单/权限元数据定义 | 可以 | 几乎不变,改了之后延迟几分钟生效可接受 |
| 门店基础信息(名称、编码) | 可以 | 同上 |
| 字典/枚举配置 | 可以 | 同上 |
| **用户对门店的可访问权限** | **不可以** | 收回权限后必须立即生效,这是安全边界(见 [04-security-auth.md](./04-security-auth.md) |
| **WebView 票据状态** | **不可以** | 作废必须立即生效 |
| **任何用户维度的业务数据** | **不可以** | 用户在 Pod A 改的数据,请求打到 Pod B 会读到旧值 |
```kotlin
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/cache/CacheConfig.kt
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
setCaffeine(
Caffeine.newBuilder()
.maximumSize(1_000) // 必须设上限,否则就是内存泄漏
.expireAfterWrite(Duration.ofMinutes(10)), // 必须设过期,这是副本间最终一致的唯一保证
)
}
}
// domains/identity-store/.../identitystore/application/StoreQueryServiceImpl.kt
// 缓存加在 application 层的查询方法上,返回的是契约模型而不是 Entity——
// 缓存里躺着一个游离态的 JPA Entity 是另一类难查的问题(见 02-layering.md)。
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
fun findStoreBasicInfo(storeId: Long): StoreInfo? { ... }
```
`maximumSize``expireAfterWrite` 都是**强制**的,不是可选优化:没有 `maximumSize` 的缓存就是一个慢速内存泄漏;没有 `expireAfterWrite` 的本地缓存永远不会和其他副本收敛。
**用 `@Cacheable` 时同样注意自调用失效**——它和 `@Transactional` 一样走代理,同类内部调用不生效。
### 什么时候升级到 Redis
- 需要**跨副本立即失效**某个缓存;
- 需要跨副本共享状态(分布式限流计数、在线用户数);
- 缓存数据量大到单 Pod 内存放不下。
到那时 Redis 的取舍是清楚的,现在提前引入只是提前承担成本。
## 关键规则
- 事务边界在 application 层的用例方法上;事务内不做 HTTP 调用、不 sleep。
- 注意自调用失效:`@Transactional` / `@Async` / `@Cacheable` / Resilience4j 注解在同类内部调用时全部不生效。
- 有副作用的写接口用 `Idempotency-Key` + 唯一索引做幂等;唯一索引才是保证,"先查再写"不是。
- 并发冲突用 `@Version` 乐观锁:用户提交的更新返回 `10409`,内部状态推进自动重试(重试包在事务外层)。
- 所有 `@Scheduled` 任务必须加 `@SchedulerLock``LockProvider` 必须 `usingDbTime()`cron 时间避开整点。
- 只用 Caffeine 本地缓存,且只缓存"读到旧值不会出错"的数据;`maximumSize``expireAfterWrite` 强制配置。
## 待补充
- 幂等记录的具体保留期(需要和客户端确认最长重试窗口)。
- 是否需要接口级限流(当前只有对下游的舱壁,没有对上游的限流)。
## 参考链接
- [Spring: Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction.html)
- [Spring Retry](https://github.com/spring-projects/spring-retry)
- [ShedLock](https://github.com/lukas-krecan/ShedLock)
- [Caffeine](https://github.com/ben-manes/caffeine/wiki)
- [Stripe: Designing robust and predictable APIs with idempotency](https://stripe.com/blog/idempotency)