feat: Add comprehensive documentation for integration layer, API design, config governance, observability, build/deploy, and testing strategies
- Introduced integration layer design with Resilience4j for external vendor calls. - Established API design standards with unified response structures and global exception handling. - Defined configuration and service governance using Kubernetes native solutions. - Implemented observability practices including trace ID propagation and structured logging. - Outlined build and multi-environment deployment strategies using Gradle and GitLab CI/CD. - Specified testing strategies across different layers, utilizing JUnit, MockK, Testcontainers, and WireMock.
This commit is contained in:
@@ -36,9 +36,25 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
|||||||
|
|
||||||
`.drawio` 文件可用 [draw.io 桌面版](https://github.com/jgraph/drawio-desktop) 或 VS Code 的 Draw.io Integration 插件打开查看。
|
`.drawio` 文件可用 [draw.io 桌面版](https://github.com/jgraph/drawio-desktop) 或 VS Code 的 Draw.io Integration 插件打开查看。
|
||||||
|
|
||||||
|
### backend/
|
||||||
|
|
||||||
|
后端(Kotlin + Spring Boot + Gradle)架构决策文档,按序号阅读:
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| [backend/01-project-structure.md](./backend/01-project-structure.md) | 工程结构 / 模块划分(Gradle 多模块,模块化单体) |
|
||||||
|
| [backend/02-layering.md](./backend/02-layering.md) | 分层规范(api/application/domain/infrastructure) |
|
||||||
|
| [backend/03-persistence.md](./backend/03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate,Flyway) |
|
||||||
|
| [backend/04-security-auth.md](./backend/04-security-auth.md) | 安全与认证方案(Spring Security + JWT,门店上下文) |
|
||||||
|
| [backend/05-integration-layer.md](./backend/05-integration-layer.md) | 集成层设计(F6 Adapter / Mini 域客户端) |
|
||||||
|
| [backend/06-api-design.md](./backend/06-api-design.md) | API 设计规范(统一响应、DTO、版本化) |
|
||||||
|
| [backend/07-config-governance.md](./backend/07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret) |
|
||||||
|
| [backend/08-observability.md](./backend/08-observability.md) | 可观测性(Trace ID、日志、审计) |
|
||||||
|
| [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Gradle、Docker、GitLab CI/CD) |
|
||||||
|
| [backend/10-testing.md](./backend/10-testing.md) | 测试策略 |
|
||||||
|
|
||||||
## 待补充
|
## 待补充
|
||||||
|
|
||||||
- Backend 设计文档
|
|
||||||
- API 文档
|
- API 文档
|
||||||
|
|
||||||
## 语言约定
|
## 语言约定
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 01. 工程结构(后端)
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Kotlin + Spring Boot + Gradle(Groovy DSL,`build.gradle`)。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
单一 Gradle 多模块工程,落地为**模块化单体(Modular Monolith)**:只有一个可执行部署单元,内部按 [architecture-diagram](../Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。
|
||||||
|
|
||||||
|
选择模块化单体而不是一开始就上微服务,原因很直接:
|
||||||
|
|
||||||
|
- 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。
|
||||||
|
- App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。
|
||||||
|
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-integration`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
|
||||||
|
|
||||||
|
## 模块结构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
conti-backend/
|
||||||
|
settings.gradle
|
||||||
|
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot BOM)
|
||||||
|
bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像
|
||||||
|
build.gradle # 依赖所有 platform-* / domains/* 模块 + @SpringBootApplication 启动类
|
||||||
|
platform/
|
||||||
|
platform-web/ # 统一异常处理、Result包装、参数校验、GlobalExceptionHandler
|
||||||
|
platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入
|
||||||
|
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 配置
|
||||||
|
platform-observability/ # 日志格式、Trace ID 透传、Micrometer配置
|
||||||
|
platform-integration/ # WebClient/Resilience4j 基础封装(超时/重试/熔断通用能力)
|
||||||
|
domains/
|
||||||
|
identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限
|
||||||
|
bff-orchestration/ # BFF Orchestration:面向APP的统一接口聚合与协议标准化
|
||||||
|
workbench/ # Workbench Aggregation:首页聚合、局部降级
|
||||||
|
webview-ticket/ # WebView Ticket Center:F6 WebView 换票、会话绑定
|
||||||
|
f6-integration/ # Integration Layer:F6 Adapter,对应架构图里的适配层
|
||||||
|
mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装
|
||||||
|
```
|
||||||
|
|
||||||
|
(模块名称先按架构图职责命名,实际开发中如果和团队习惯冲突可以再改,不影响这套结构本身。)
|
||||||
|
|
||||||
|
## 依赖规则(编译期强制边界,是这套结构的核心价值)
|
||||||
|
|
||||||
|
- `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*` 和 `platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
|
||||||
|
- `domains/*` 之间**不允许**互相依赖,`bff-orchestration` 是唯一例外——它是编排层,天然需要依赖其他所有 domain 才能做聚合。
|
||||||
|
- `platform-*` 不含业务逻辑,各 `domains/*` 均可依赖;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
|
||||||
|
- 跨 domain 的数据/事件传递,只能走 `bff-orchestration` 编排,或者在某个 `platform-*` 定义抽象接口、各 domain 各自实现(依赖倒置),不允许 `identity-store` 直接 import `workbench` 的类。
|
||||||
|
|
||||||
|
这些规则由 Gradle 的模块依赖机制**物理强制**:`domains/identity-store` 的 `build.gradle` 里根本不会声明对 `domains/workbench` 的依赖,编译时 import 不到,不是靠 code review 口头约束。
|
||||||
|
|
||||||
|
### 根 `build.gradle` 示例
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
plugins {
|
||||||
|
id 'org.jetbrains.kotlin.jvm' version '2.0.20' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.spring' version '2.0.20' apply false
|
||||||
|
id 'org.springframework.boot' version '3.3.4' apply false
|
||||||
|
id 'io.spring.dependency-management' version '1.1.6' apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply plugin: 'org.jetbrains.kotlin.jvm'
|
||||||
|
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
|
||||||
|
group = 'com.continental.retailapp'
|
||||||
|
version = '0.1.0-SNAPSHOT'
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyManagement {
|
||||||
|
imports {
|
||||||
|
mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add('-Xjsr305=strict')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(Test).configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `settings.gradle` 示例
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
rootProject.name = 'conti-backend'
|
||||||
|
|
||||||
|
include 'bootstrap'
|
||||||
|
include 'platform:platform-web'
|
||||||
|
include 'platform:platform-security'
|
||||||
|
include 'platform:platform-persistence'
|
||||||
|
include 'platform:platform-observability'
|
||||||
|
include 'platform:platform-integration'
|
||||||
|
include 'domains:identity-store'
|
||||||
|
include 'domains:bff-orchestration'
|
||||||
|
include 'domains:workbench'
|
||||||
|
include 'domains:webview-ticket'
|
||||||
|
include 'domains:f6-integration'
|
||||||
|
include 'domains:mini-clients'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `domains/workbench/build.gradle` 示例(体现依赖规则)
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
apply plugin: 'org.springframework.boot' // 只用来获得 bootJar 之外的 starter 依赖管理,不产出可执行 jar
|
||||||
|
|
||||||
|
bootJar {
|
||||||
|
enabled = false // 非 bootstrap 模块不产出可执行 jar
|
||||||
|
}
|
||||||
|
jar {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':platform:platform-web')
|
||||||
|
implementation project(':platform:platform-persistence')
|
||||||
|
implementation project(':platform:platform-integration')
|
||||||
|
implementation project(':domains:mini-clients') // 允许:workbench 依赖只读客户端封装
|
||||||
|
|
||||||
|
// 不允许出现 implementation project(':domains:identity-store') 这种同级 domain 依赖
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `bootstrap/build.gradle` 示例
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
apply plugin: 'org.springframework.boot'
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':platform:platform-web')
|
||||||
|
implementation project(':platform:platform-security')
|
||||||
|
implementation project(':platform:platform-persistence')
|
||||||
|
implementation project(':platform:platform-observability')
|
||||||
|
implementation project(':platform:platform-integration')
|
||||||
|
implementation project(':domains:identity-store')
|
||||||
|
implementation project(':domains:bff-orchestration')
|
||||||
|
implementation project(':domains:workbench')
|
||||||
|
implementation project(':domains:webview-ticket')
|
||||||
|
implementation project(':domains:f6-integration')
|
||||||
|
implementation project(':domains:mini-clients')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// bootstrap/src/main/kotlin/com/continental/retailapp/BootstrapApplication.kt
|
||||||
|
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp"])
|
||||||
|
class BootstrapApplication
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
runApplication<BootstrapApplication>(*args)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增一个 domain 模块的标准脚手架
|
||||||
|
|
||||||
|
```
|
||||||
|
domains/xxx/
|
||||||
|
build.gradle # 依赖需要的 platform-* 模块,bootJar 禁用
|
||||||
|
src/main/kotlin/com/continental/retailapp/xxx/
|
||||||
|
api/ # Controller、请求/响应 DTO
|
||||||
|
application/ # Service,编排用例
|
||||||
|
domain/ # 可选,见 02-layering.md
|
||||||
|
infrastructure/ # repository 实现、外部 client 实现
|
||||||
|
src/main/resources/db/migration/xxx/
|
||||||
|
V1__init.sql
|
||||||
|
src/test/kotlin/...
|
||||||
|
```
|
||||||
|
|
||||||
|
新增模块后记得在 `settings.gradle` 里 `include`,并在 `bootstrap/build.gradle` 里加上依赖——这两步是唯一需要"手动接线"的地方,其余边界规则都由各模块自己的 `build.gradle` 保证。
|
||||||
|
|
||||||
|
## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层
|
||||||
|
|
||||||
|
给还没接触过这套多模块习惯的同学看的入门说明。
|
||||||
|
|
||||||
|
### 要解决的问题
|
||||||
|
|
||||||
|
如果整个后端只是一个 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 多模块和微服务解决的是同一类问题(业务边界隔离),但选择了不同的代价:
|
||||||
|
|
||||||
|
- 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。
|
||||||
|
- 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。
|
||||||
|
|
||||||
|
我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [Gradle Multi-Project Builds](https://docs.gradle.org/current/userguide/multi_project_builds.html)
|
||||||
|
- [Spring Boot Gradle Plugin](https://docs.spring.io/spring-boot/gradle-plugin/index.html)
|
||||||
|
- [Modular Monolith: A Primer (Kamil Grzybek)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer)
|
||||||
|
- [ArchUnit(可选的架构规则静态检查工具)](https://www.archunit.org/)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 02. 分层规范(后端)
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../02-layering.md) 保持一致的思路:
|
||||||
|
|
||||||
|
```
|
||||||
|
domains/xxx/
|
||||||
|
src/main/kotlin/com/continental/retailapp/xxx/
|
||||||
|
api/ # Controller、请求/响应 DTO
|
||||||
|
application/ # Service,编排用例、跨 repository 协调
|
||||||
|
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
|
||||||
|
infrastructure/ # JPA repository 实现、外部 client 实现
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各层职责
|
||||||
|
|
||||||
|
- **api**:`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,不直接依赖 `infrastructure` 的具体实现类。
|
||||||
|
- **application**:`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。
|
||||||
|
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
|
||||||
|
- **infrastructure**:`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、`WebClient`/Feign 外部调用实现。
|
||||||
|
|
||||||
|
## 何时可以跳过 domain 层
|
||||||
|
|
||||||
|
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
|
||||||
|
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain`,`infrastructure` 反向实现。
|
||||||
|
|
||||||
|
## 依赖方向
|
||||||
|
|
||||||
|
```
|
||||||
|
api → application → domain(或直接 → infrastructure 的接口,若跳过 domain)
|
||||||
|
domain → 不依赖 api / infrastructure
|
||||||
|
infrastructure → 依赖 domain 的接口(若有),依赖 platform-persistence / platform-integration
|
||||||
|
```
|
||||||
|
|
||||||
|
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
|
||||||
|
|
||||||
|
## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// domain/model/WebviewTicket.kt
|
||||||
|
data class WebviewTicket(
|
||||||
|
val ticketId: String,
|
||||||
|
val storeId: Long,
|
||||||
|
val userId: Long,
|
||||||
|
val status: TicketStatus,
|
||||||
|
val expiresAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class TicketStatus { ISSUED, CONSUMED, EXPIRED, INVALIDATED }
|
||||||
|
|
||||||
|
// domain/repository/WebviewTicketRepository.kt
|
||||||
|
interface WebviewTicketRepository {
|
||||||
|
fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket?
|
||||||
|
fun save(ticket: WebviewTicket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/service/IssueWebviewTicketService.kt
|
||||||
|
class IssueWebviewTicketService(
|
||||||
|
private val ticketRepository: WebviewTicketRepository,
|
||||||
|
private val clock: Clock,
|
||||||
|
) {
|
||||||
|
fun issue(userId: Long, storeId: Long): WebviewTicket {
|
||||||
|
val existing = ticketRepository.findActiveTicket(userId, storeId)
|
||||||
|
if (existing != null && existing.status == TicketStatus.ISSUED && existing.expiresAt.isAfter(clock.instant())) {
|
||||||
|
return existing // 已有有效票据,直接复用,不重复签发
|
||||||
|
}
|
||||||
|
val ticket = WebviewTicket(
|
||||||
|
ticketId = UUID.randomUUID().toString(),
|
||||||
|
storeId = storeId,
|
||||||
|
userId = userId,
|
||||||
|
status = TicketStatus.ISSUED,
|
||||||
|
expiresAt = clock.instant().plus(Duration.ofMinutes(5)),
|
||||||
|
)
|
||||||
|
ticketRepository.save(ticket)
|
||||||
|
return ticket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// application/WebviewTicketAppService.kt
|
||||||
|
@Service
|
||||||
|
class WebviewTicketAppService(
|
||||||
|
private val issueWebviewTicketService: IssueWebviewTicketService,
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun issueTicket(userId: Long, storeId: Long): WebviewTicketResponse {
|
||||||
|
val ticket = issueWebviewTicketService.issue(userId, storeId)
|
||||||
|
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// infrastructure/persistence/WebviewTicketRepositoryImpl.kt
|
||||||
|
@Repository
|
||||||
|
class WebviewTicketRepositoryImpl(
|
||||||
|
private val jpaRepository: WebviewTicketJpaRepository,
|
||||||
|
) : WebviewTicketRepository {
|
||||||
|
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
|
||||||
|
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
|
||||||
|
|
||||||
|
override fun save(ticket: WebviewTicket) {
|
||||||
|
jpaRepository.save(ticket.toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`IssueWebviewTicketService` 的复用/过期判断规则可以直接用一个假的 `WebviewTicketRepository` 实现做单元测试,不需要起 Spring 容器或真实数据库,也不需要 mock HTTP。
|
||||||
|
|
||||||
|
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// infrastructure/persistence/StoreRepository.kt
|
||||||
|
interface StoreRepository {
|
||||||
|
fun findStoresByUserId(userId: Long): List<StoreEntity>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
|
||||||
|
@Query("select s from StoreEntity s join UserStoreEntity us on us.storeId = s.id where us.userId = :userId")
|
||||||
|
override fun findStoresByUserId(userId: Long): List<StoreEntity>
|
||||||
|
}
|
||||||
|
|
||||||
|
// application/StoreAppService.kt
|
||||||
|
@Service
|
||||||
|
class StoreAppService(
|
||||||
|
private val storeRepository: StoreRepository,
|
||||||
|
) {
|
||||||
|
fun listStores(userId: Long): List<StoreResponse> =
|
||||||
|
storeRepository.findStoresByUserId(userId).map { StoreResponse(it.id, it.name) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure`,`application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
|
||||||
|
|
||||||
|
## 附录:为什么要分层,以及依赖倒置在这里怎么体现
|
||||||
|
|
||||||
|
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `new WebClient.create(...)` 调用 F6——短期能跑,但会导致:
|
||||||
|
|
||||||
|
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
|
||||||
|
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `WebClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
|
||||||
|
|
||||||
|
分层的关键不是"分了几层",而是**依赖方向单向流动**,`domain` 只定义接口("我需要一个能查到 `WebviewTicket` 的东西"),不关心 `infrastructure` 具体怎么实现——这是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
|
||||||
|
- [The Clean Architecture(Uncle 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)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# 03. 持久层方案
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
Spring Data JPA + Hibernate 作为默认 ORM,Flyway 做 schema 迁移。
|
||||||
|
|
||||||
|
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
|
||||||
|
|
||||||
|
- Kotlin + Spring Boot 生态里 JPA 是最主流、文档和踩坑资料最多的组合,团队上手成本低。
|
||||||
|
- 大部分 domain 模块(`identity-store`、`webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL(`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
|
||||||
|
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-persistence/
|
||||||
|
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
|
||||||
|
PageResult<T> # 统一分页返回封装
|
||||||
|
JpaAuditingConfig # 开启 Spring Data JPA Auditing
|
||||||
|
|
||||||
|
domains/xxx/
|
||||||
|
src/main/kotlin/.../xxx/infrastructure/persistence/
|
||||||
|
XxxEntity # JPA entity
|
||||||
|
XxxJpaRepository # : JpaRepository<XxxEntity, Long>
|
||||||
|
src/main/resources/db/migration/xxx/
|
||||||
|
V1__init.sql # Flyway migration,按 domain 分子目录
|
||||||
|
```
|
||||||
|
|
||||||
|
## `BaseEntity` 示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// platform-persistence/src/main/kotlin/.../BaseEntity.kt
|
||||||
|
@MappedSuperclass
|
||||||
|
@EntityListeners(AuditingEntityListener::class)
|
||||||
|
abstract class BaseEntity {
|
||||||
|
@CreatedDate
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
var createdAt: Instant = Instant.EPOCH
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(nullable = false)
|
||||||
|
var updatedAt: Instant = Instant.EPOCH
|
||||||
|
|
||||||
|
@CreatedBy
|
||||||
|
@Column(updatable = false, length = 64)
|
||||||
|
var createdBy: String? = null
|
||||||
|
|
||||||
|
@LastModifiedBy
|
||||||
|
@Column(length = 64)
|
||||||
|
var updatedBy: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt
|
||||||
|
@Configuration
|
||||||
|
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
|
||||||
|
class JpaAuditingConfig {
|
||||||
|
@Bean
|
||||||
|
fun auditorAware(): AuditorAware<String> = AuditorAware {
|
||||||
|
Optional.ofNullable(StoreContextHolder.currentUserIdOrNull()?.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
|
||||||
|
|
||||||
|
## Entity + Repository + Migration 示例(`identity-store` 里的门店表)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// infrastructure/persistence/StoreEntity.kt
|
||||||
|
@Entity
|
||||||
|
@Table(name = "store", schema = "identity_store")
|
||||||
|
class StoreEntity(
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
val id: Long = 0,
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 128)
|
||||||
|
var name: String,
|
||||||
|
|
||||||
|
@Column(name = "code", nullable = false, unique = true, length = 32)
|
||||||
|
var code: String,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
var status: StoreStatus,
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
|
||||||
|
fun findByCode(code: String): StoreEntity?
|
||||||
|
fun findByStatus(status: StoreStatus): List<StoreEntity>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- src/main/resources/db/migration/identity_store/V1__init.sql
|
||||||
|
create schema if not exists identity_store;
|
||||||
|
|
||||||
|
create table identity_store.store (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
name varchar(128) not null,
|
||||||
|
code varchar(32) not null unique,
|
||||||
|
status varchar(16) not null,
|
||||||
|
created_at timestamp not null,
|
||||||
|
updated_at timestamp not null,
|
||||||
|
created_by varchar(64),
|
||||||
|
updated_by varchar(64)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Flyway 版本号(`V1`、`V2`…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。
|
||||||
|
|
||||||
|
## 跨 domain 数据访问规则
|
||||||
|
|
||||||
|
**每个 domain 独立 schema**:即使同一个数据库实例,各 `domains/*` 的表也归属各自 schema,不允许跨 domain 直接 `join` 表——需要数据时通过对方模块暴露的 `application` 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分)。
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 错误示范:workbench 直接 join 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
|
||||||
|
class WorkbenchAppService(
|
||||||
|
private val storeQueryService: StoreQueryService, // identity-store 模块对外暴露的接口
|
||||||
|
private val tileRepository: WorkbenchTileRepository,
|
||||||
|
) {
|
||||||
|
fun listTiles(userId: Long): List<TileResponse> {
|
||||||
|
val stores = storeQueryService.listStoresByUserId(userId) // 走 application 层调用,不查表
|
||||||
|
val tiles = tileRepository.findByUserId(userId)
|
||||||
|
return buildTiles(tiles, stores)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附录:为什么坚持"每个 domain 独立 schema"
|
||||||
|
|
||||||
|
模块化单体最容易被破坏的地方就是数据库——代码层面 Gradle 依赖规则挡住了跨 domain import 类,但如果两个 domain 的表都在同一个 schema 下,写 SQL 的时候很容易"顺手 join 一下",这条规则完全不受编译器约束,只能靠约定。所以我们把 schema 拆开:`workbench` 的 `DataSource` 配置的默认 schema 是 `workbench`,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。
|
||||||
|
|
||||||
|
代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
|
||||||
|
- 复杂查询是否引入 QueryDSL/jOOQ(`Specification` 不够用时再决定)。
|
||||||
|
- 各 domain 的实际表结构,等开发到对应模块时再补。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/)
|
||||||
|
- [Flyway 官方文档](https://documentation.red-gate.com/fd)
|
||||||
|
- [Spring Data JPA Auditing](https://docs.spring.io/spring-data/jpa/reference/auditing.html)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 04. 安全与认证方案
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
Spring Security + JWT,由 `identity-store` 模块统一签发和校验,对应架构图里 `Auth and Token Center`;门店/角色上下文通过统一 filter 解析后注入 `RequestScope`,供各 domain 读取,对应架构图 `Store Context` 的职责。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-security/
|
||||||
|
JwtTokenProvider # 签发/解析/刷新 token
|
||||||
|
JwtAuthenticationFilter # 统一 filter:解析 JWT,写入 SecurityContext + StoreContextHolder
|
||||||
|
StoreContextHolder # RequestScope bean,持有当前 用户+门店+角色
|
||||||
|
SecurityConfigSupport # 各 domain 复用的 Spring Security 通用配置片段
|
||||||
|
|
||||||
|
domains/identity-store/
|
||||||
|
负责登录、token 签发/刷新/失效、门店列表、菜单权限
|
||||||
|
```
|
||||||
|
|
||||||
|
## `JwtTokenProvider` 示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// platform-security/.../JwtTokenProvider.kt
|
||||||
|
@Component
|
||||||
|
class JwtTokenProvider(
|
||||||
|
@Value("\${security.jwt.secret}") secret: String,
|
||||||
|
@Value("\${security.jwt.access-token-ttl-minutes:30}") private val accessTokenTtlMinutes: Long,
|
||||||
|
) {
|
||||||
|
private val key = Keys.hmacShaKeyFor(secret.toByteArray())
|
||||||
|
|
||||||
|
fun issueAccessToken(userId: Long, storeId: Long, roles: List<String>): String =
|
||||||
|
Jwts.builder()
|
||||||
|
.subject(userId.toString())
|
||||||
|
.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/.../StoreContextHolder.kt
|
||||||
|
@Component
|
||||||
|
@RequestScope
|
||||||
|
class StoreContextHolder {
|
||||||
|
var userId: Long? = null
|
||||||
|
var storeId: Long? = null
|
||||||
|
var roles: List<String> = emptyList()
|
||||||
|
|
||||||
|
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
|
||||||
|
}
|
||||||
|
|
||||||
|
// platform-security/.../JwtAuthenticationFilter.kt
|
||||||
|
class JwtAuthenticationFilter(
|
||||||
|
private val jwtTokenProvider: JwtTokenProvider,
|
||||||
|
private val storeContextHolder: StoreContextHolder,
|
||||||
|
) : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||||
|
val token = request.getHeader("Authorization")?.removePrefix("Bearer ")
|
||||||
|
if (token != null) {
|
||||||
|
val claims = jwtTokenProvider.parse(token).payload
|
||||||
|
storeContextHolder.userId = claims.subject.toLong()
|
||||||
|
storeContextHolder.storeId = (claims["storeId"] as Number).toLong()
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
storeContextHolder.roles = claims["roles"] as List<String>
|
||||||
|
|
||||||
|
val authorities = storeContextHolder.roles.map { SimpleGrantedAuthority("ROLE_$it") }
|
||||||
|
SecurityContextHolder.getContext().authentication =
|
||||||
|
UsernamePasswordAuthenticationToken(storeContextHolder.userId, null, authorities)
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
其他 domain 里的 `application` 层直接注入 `StoreContextHolder` 拿当前上下文,不自己解析 token:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Service
|
||||||
|
class WorkbenchAppService(
|
||||||
|
private val storeContextHolder: StoreContextHolder,
|
||||||
|
private val tileRepository: WorkbenchTileRepository,
|
||||||
|
) {
|
||||||
|
fun listTiles(): List<TileResponse> {
|
||||||
|
val userId = storeContextHolder.currentUserId()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `SecurityConfigSupport` 示例(各 domain/bootstrap 复用)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
class SecurityConfig(
|
||||||
|
private val jwtTokenProvider: JwtTokenProvider,
|
||||||
|
private val storeContextHolder: StoreContextHolder,
|
||||||
|
) {
|
||||||
|
@Bean
|
||||||
|
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
http
|
||||||
|
.csrf { it.disable() } // 无状态 API,不需要 CSRF token
|
||||||
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
|
.authorizeHttpRequests {
|
||||||
|
it.requestMatchers("/actuator/health", "/api/v1/auth/login").permitAll()
|
||||||
|
it.anyRequest().authenticated()
|
||||||
|
}
|
||||||
|
.addFilterBefore(
|
||||||
|
JwtAuthenticationFilter(jwtTokenProvider, storeContextHolder),
|
||||||
|
UsernamePasswordAuthenticationFilter::class.java,
|
||||||
|
)
|
||||||
|
return http.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
|
||||||
|
- 门店/角色上下文在统一 filter 里解析 JWT 后写入 `StoreContextHolder`,各 domain 通过它读取当前上下文,不各自解析 token——避免"每个 domain 有一份自己的 token 解析逻辑"这种重复和不一致。
|
||||||
|
- 切换门店会使当前上下文失效,`webview-ticket` 相关会话需要联动失效(对应架构图 Flow 2 的规则):`identity-store` 切换门店成功后,需要通知 `webview-ticket` 使当前 ticket 状态置为 `INVALIDATED`(走 `bff-orchestration` 编排或事件通知,不是 `identity-store` 直接改 `webview-ticket` 的表)。
|
||||||
|
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
|
||||||
|
|
||||||
|
## 附录:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
|
||||||
|
|
||||||
|
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
|
||||||
|
|
||||||
|
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
|
||||||
|
2. **响应式/协程场景失效**:一旦引入 `WebClient` 的异步回调或 Kotlin 协程切换线程,`ThreadLocal` 绑定的线程和实际处理请求的线程可能不是同一个。
|
||||||
|
|
||||||
|
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。当前阶段 domain 内部都是同步 Servlet 栈(Spring MVC),`RequestScope` 完全够用;如果未来某个模块换成 WebFlux(响应式),需要改用 Reactor Context 传递上下文,不能直接照搬 `RequestScope`。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- JWT 具体 claims 结构、refresh token 的存储方式(Redis?)。
|
||||||
|
- 权限模型细节(菜单权限 vs 接口级权限,是否需要单独的权限表)。
|
||||||
|
- 与 K8s ConfigMap/Secret 配合的密钥轮换方式,见 [07-config-governance.md](./07-config-governance.md)。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
|
||||||
|
- [jjwt(JWT 库)](https://github.com/jwtk/jjwt)
|
||||||
|
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 05. 集成层设计(F6 / Mini 域)
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
供应商(F6)和历史 Mini 域的调用统一收口在 `f6-integration` / `mini-clients` 模块,业务 domain 不直接持有 `WebClient` 或 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-integration/
|
||||||
|
WebClientConfig # 统一封装 WebClient(连接池、超时基线配置)
|
||||||
|
Resilience4jDefaults # 超时/重试/熔断的公共默认配置
|
||||||
|
|
||||||
|
domains/f6-integration/
|
||||||
|
负责:换票、供应商访问上下文准备、超时/重试/熔断策略、异常转换为内部标准错误码
|
||||||
|
|
||||||
|
domains/mini-clients/
|
||||||
|
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
|
||||||
|
```
|
||||||
|
|
||||||
|
## `WebClient` + Resilience4j 配置示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
resilience4j:
|
||||||
|
timelimiter:
|
||||||
|
instances:
|
||||||
|
f6-api:
|
||||||
|
timeout-duration: 2s
|
||||||
|
mini-o2o:
|
||||||
|
timeout-duration: 1s
|
||||||
|
retry:
|
||||||
|
instances:
|
||||||
|
f6-api:
|
||||||
|
max-attempts: 2
|
||||||
|
wait-duration: 200ms
|
||||||
|
retry-exceptions:
|
||||||
|
- java.net.SocketTimeoutException
|
||||||
|
- org.springframework.web.reactive.function.client.WebClientRequestException
|
||||||
|
circuitbreaker:
|
||||||
|
instances:
|
||||||
|
f6-api:
|
||||||
|
sliding-window-size: 20
|
||||||
|
failure-rate-threshold: 50
|
||||||
|
wait-duration-in-open-state: 10s
|
||||||
|
permitted-number-of-calls-in-half-open-state: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// domains/f6-integration/.../F6ApiClient.kt
|
||||||
|
@Component
|
||||||
|
class F6ApiClient(
|
||||||
|
private val webClient: WebClient, // 来自 platform-integration 的统一封装
|
||||||
|
) {
|
||||||
|
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
|
||||||
|
@Retry(name = "f6-api")
|
||||||
|
@TimeLimiter(name = "f6-api")
|
||||||
|
fun fetchProcurementList(storeId: Long): Mono<ProcurementListResponse> =
|
||||||
|
webClient.get()
|
||||||
|
.uri("/f6/procurement/list?storeId={storeId}", storeId)
|
||||||
|
.retrieve()
|
||||||
|
.onStatus({ it.isError }) { resp ->
|
||||||
|
resp.bodyToMono(String::class.java)
|
||||||
|
.map { body -> F6IntegrationException("F6 采购列表调用失败: ${resp.statusCode()} $body") }
|
||||||
|
}
|
||||||
|
.bodyToMono(ProcurementListResponse::class.java)
|
||||||
|
|
||||||
|
// Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型一致
|
||||||
|
fun fallbackProcurementList(storeId: Long, ex: Throwable): Mono<ProcurementListResponse> =
|
||||||
|
Mono.just(ProcurementListResponse.degraded())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 统一异常转换:F6IntegrationException -> 内部标准错误码,业务层不感知供应商原始协议
|
||||||
|
class F6IntegrationException(message: String) : RuntimeException(message)
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
class F6ExceptionHandler {
|
||||||
|
@ExceptionHandler(F6IntegrationException::class)
|
||||||
|
fun handle(ex: F6IntegrationException): ResponseEntity<ApiResult<Nothing>> =
|
||||||
|
ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||||
|
.body(ApiResult.error(code = "F6_UNAVAILABLE", message = "供应商服务暂不可用,请稍后重试"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mini 域客户端示例(内部系统,策略更宽松)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// domains/mini-clients/.../O2OClient.kt
|
||||||
|
@Component
|
||||||
|
class O2OClient(private val webClient: WebClient) {
|
||||||
|
|
||||||
|
@TimeLimiter(name = "mini-o2o") // 只兜底超时,不需要熔断(内部系统,稳定性相对可控)
|
||||||
|
fun fetchOrderSummary(storeId: Long): Mono<OrderSummary> =
|
||||||
|
webClient.get()
|
||||||
|
.uri("/o2o/orders/summary?storeId={storeId}", storeId)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(OrderSummary::class.java)
|
||||||
|
.onErrorResume { Mono.just(OrderSummary.empty()) } // 局部降级,见 workbench 聚合规则
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- **F6 是外部供应商域**,稳定性不可控,必须配置超时 + 重试 + 熔断,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
|
||||||
|
- **异常统一转换**:F6 / Mini 域返回的异常或非标准错误,在 `f6-integration` / `mini-clients` 内部转换成内部标准错误码(如 `F6_UNAVAILABLE`),业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
|
||||||
|
- **Mini 域调用相对可控**(内部系统),熔断策略可以比 F6 宽松(示例里只加超时兜底),但仍需要超时兜底,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"的要求。
|
||||||
|
- 业务 domain(如 `workbench`)只依赖 `mini-clients` / `f6-integration` 暴露的接口,不自己 `new WebClient` 发请求。
|
||||||
|
|
||||||
|
## 附录:超时、重试、熔断分别解决什么问题
|
||||||
|
|
||||||
|
三者经常被一起提,但作用点不同,配置的时候容易搞混:
|
||||||
|
|
||||||
|
- **超时(Timeout)**:解决"对方一直不回应,我方请求线程/连接被一直占着"的问题。没有超时,一个慢下游能拖垮整个调用方的线程池。这是三者里最基础、必须有的一道防线。
|
||||||
|
- **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。重试的前提是**幂等**——`fetchProcurementList` 这种 GET 查询可以放心重试,但如果是"扣库存""创建订单"这类有副作用的调用,重试前要先确认接口本身幂等(比如带幂等 key),否则重试可能造成重复下单这类更严重的问题。
|
||||||
|
- **熔断(Circuit Breaker)**:解决"对方已经持续故障,继续重试只是在浪费资源、拖慢自己"的问题。熔断器统计一个滑动窗口内的失败率,超过阈值后直接短路请求(进入 `OPEN` 状态,一段时间内不再真的发请求,直接走 fallback),过一段时间放几个探测请求(`HALF_OPEN`)判断对方是否恢复。
|
||||||
|
|
||||||
|
三者组合的顺序也有讲究:一次调用先看熔断器状态(`OPEN` 直接 fallback,不发请求)→ 没熔断就真的发请求 → 超时控制这次请求最多等多久 → 超时或失败了再看要不要重试。上面 Resilience4j 的注解顺序(`@CircuitBreaker` 在最外层,`@Retry`、`@TimeLimiter`在内层)就是按这个语义叠加的。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 具体超时/重试参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。
|
||||||
|
- 熔断后降级返回的数据结构约定(`degraded()` 具体字段)。
|
||||||
|
- F6 换票具体协议细节(对接 [webview-ticket](./04-security-auth.md) 的会话失效联动)。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [Resilience4j 官方文档](https://resilience4j.readme.io/docs)
|
||||||
|
- [Spring WebFlux WebClient](https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html)
|
||||||
|
- [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# 06. API 设计规范
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 domain 的返回标准化成 APP 需要的形态。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-web/
|
||||||
|
ApiResult<T> # { code, message, data, traceId } 统一响应包装
|
||||||
|
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
|
||||||
|
ErrorCode # 错误码常量/枚举
|
||||||
|
|
||||||
|
domains/xxx/api/
|
||||||
|
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
|
||||||
|
request/ Xxx*Request # 请求 DTO
|
||||||
|
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
|
||||||
|
```
|
||||||
|
|
||||||
|
## `ApiResult` + 全局异常处理示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// platform-web/.../ApiResult.kt
|
||||||
|
data class ApiResult<T>(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val data: T?,
|
||||||
|
val traceId: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun <T> ok(data: T): ApiResult<T> =
|
||||||
|
ApiResult("OK", "success", data, TraceIdHolder.current())
|
||||||
|
|
||||||
|
fun error(code: String, message: String): ApiResult<Nothing> =
|
||||||
|
ApiResult(code, message, null, TraceIdHolder.current())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// platform-web/.../GlobalExceptionHandler.kt
|
||||||
|
@RestControllerAdvice
|
||||||
|
class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||||
|
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResult<Nothing>> {
|
||||||
|
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
|
||||||
|
return ResponseEntity.badRequest().body(ApiResult.error("INVALID_PARAM", message))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException::class)
|
||||||
|
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
|
||||||
|
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常"))
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception::class)
|
||||||
|
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
|
||||||
|
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md)
|
||||||
|
return ResponseEntity.internalServerError().body(ApiResult.error("INTERNAL_ERROR", "系统繁忙,请稍后重试"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controller + DTO 示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// api/StoreController.kt
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/stores")
|
||||||
|
class StoreController(
|
||||||
|
private val storeAppService: StoreAppService,
|
||||||
|
) {
|
||||||
|
@Operation(summary = "查询当前用户可访问的门店列表")
|
||||||
|
@GetMapping
|
||||||
|
fun listStores(): ApiResult<List<StoreResponse>> =
|
||||||
|
ApiResult.ok(storeAppService.listStores())
|
||||||
|
|
||||||
|
@Operation(summary = "切换当前门店")
|
||||||
|
@PostMapping("/switch")
|
||||||
|
fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult<Unit> {
|
||||||
|
storeAppService.switchStore(request.storeId)
|
||||||
|
return ApiResult.ok(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// api/request/SwitchStoreRequest.kt
|
||||||
|
data class SwitchStoreRequest(
|
||||||
|
@field:NotNull(message = "storeId 不能为空")
|
||||||
|
val storeId: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
// api/response/StoreResponse.kt
|
||||||
|
data class StoreResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段(比如某个只有 `infrastructure` 层需要的标记位)被不小心带出去。
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- Controller 不直接返回 entity,统一走 `Xxx*Response` DTO,避免持久层字段变更影响 API 契约。
|
||||||
|
- 路径版本化:`/api/v1/...`,未来 breaking change 走 `/api/v2/...`,不在原路径上做不兼容修改。
|
||||||
|
- 用 [springdoc-openapi](https://springdoc.org/) 自动生成接口文档,Controller 上写清楚的 `@Operation` 描述;`build.gradle` 加 `implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'` 即可在 `/swagger-ui.html` 看到文档。
|
||||||
|
- `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),从入口 filter 生成,写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。
|
||||||
|
|
||||||
|
## 附录:为什么要统一响应包装,而不是直接返回业务对象
|
||||||
|
|
||||||
|
不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后:
|
||||||
|
|
||||||
|
- 前端只需要判断 `code == "OK"` 就知道成功与否,不用对着 HTTP status code 猜。
|
||||||
|
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
|
||||||
|
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
|
||||||
|
|
||||||
|
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- DTO 与 entity 的转换方式(MapStruct 还是手写 mapper,模块变多之后再评估)。
|
||||||
|
- 分页/排序参数的统一约定。
|
||||||
|
- 错误码表(按 domain 分段还是全局统一编码)。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [springdoc-openapi](https://springdoc.org/)
|
||||||
|
- [Spring 官方 Bean Validation 指南](https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html)
|
||||||
|
- [Microsoft REST API 设计指南](https://github.com/microsoft/api-guidelines)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 07. 配置与服务治理
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
K8s 原生方案:ConfigMap + Secret + Spring Cloud Kubernetes,不引入 Nacos 等额外治理组件,贴合已有 Azure + GitLab CI/CD 部署方式(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../Architecture-Diagram/deployment-architecture-diagram.drawio))。
|
||||||
|
|
||||||
|
选这条路而不是 Spring Cloud Alibaba(Nacos)的原因:部署环境已经是 K8s,K8s 本身自带 Service(服务发现)、ConfigMap/Secret(配置)、Deployment 滚动更新(发布),再引入 Nacos 意味着多维护一套集群、多一套"配置到底以谁为准"的心智负担。除非未来有 Nacos 才能提供、K8s 原生方案覆盖不了的能力(比如更细粒度的灰度配置推送),否则先用 K8s 原生的就够。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
- **非敏感配置**(菜单开关、超时参数、日志级别等)放 ConfigMap,挂载成 `application-{profile}.yml` 或环境变量。
|
||||||
|
- **敏感配置**(DB 密码、JWT secret、F6 API key)放 Secret,都不进代码库、不进镜像。
|
||||||
|
- 各环境(dev/uat/prod)对应各自 namespace 下的 ConfigMap/Secret + Spring profile,`bootstrap` 按 `SPRING_PROFILES_ACTIVE` 加载对应配置。
|
||||||
|
|
||||||
|
## ConfigMap / Secret 示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/configmap-workbench-uat.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: conti-backend-config
|
||||||
|
namespace: retailapp-uat
|
||||||
|
data:
|
||||||
|
application-uat.yml: |
|
||||||
|
resilience4j:
|
||||||
|
circuitbreaker:
|
||||||
|
instances:
|
||||||
|
f6-api:
|
||||||
|
failure-rate-threshold: 50
|
||||||
|
workbench:
|
||||||
|
degrade-message: "部分数据暂时无法显示,请稍后刷新"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/secret-uat.yaml(实际值由 CI/CD 从密钥管理服务注入,不手写明文提交)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: conti-backend-secret
|
||||||
|
namespace: retailapp-uat
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
SECURITY_JWT_SECRET: "__injected_by_pipeline__"
|
||||||
|
DB_PASSWORD: "__injected_by_pipeline__"
|
||||||
|
F6_API_KEY: "__injected_by_pipeline__"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/deployment-uat.yaml(节选)
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: conti-backend
|
||||||
|
image: registry.example.com/conti-backend:__TAG__
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: conti-backend-secret
|
||||||
|
env:
|
||||||
|
- name: SPRING_PROFILES_ACTIVE
|
||||||
|
value: uat
|
||||||
|
volumeMounts:
|
||||||
|
- name: config-volume
|
||||||
|
mountPath: /app/config
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: conti-backend-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启用 Spring Cloud Kubernetes 配置热更新
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
// bootstrap/build.gradle
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
spring:
|
||||||
|
cloud:
|
||||||
|
kubernetes:
|
||||||
|
config:
|
||||||
|
enabled: true
|
||||||
|
sources:
|
||||||
|
- name: conti-backend-config
|
||||||
|
reload:
|
||||||
|
enabled: true
|
||||||
|
mode: polling # 定期轮询 ConfigMap 变化,无需重启 Pod
|
||||||
|
period: 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 需要热更新的配置类加 @RefreshScope
|
||||||
|
@RefreshScope
|
||||||
|
@ConfigurationProperties(prefix = "workbench")
|
||||||
|
@Component
|
||||||
|
class WorkbenchProperties {
|
||||||
|
var degradeMessage: String = "部分数据暂时无法显示"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- 配置变更优先走 ConfigMap 热更新(`@RefreshScope` + `spring.cloud.kubernetes.reload`),不重新构建镜像;涉及 Secret 轮换的走正常发布流程(Secret 变化通常需要重启 Pod 才能生效,不像 ConfigMap 可以做到无重启热更)。
|
||||||
|
- 对应架构图里 `Config Center`(菜单配置、开关配置、WebView 入口策略)暂时也落在 ConfigMap;如果后续配置项复杂到需要审批流程、按门店灰度下发、版本回滚,再评估引入独立配置中心(比如 Apollo)。
|
||||||
|
- Pod 需要有权限读取所在 namespace 的 ConfigMap(`spring-cloud-kubernetes` 底层调用 K8s API),需要配置好对应的 `ServiceAccount` + `Role`/`RoleBinding`。
|
||||||
|
|
||||||
|
## 附录:ConfigMap 和 Secret 的本质区别,以及为什么两者都要用
|
||||||
|
|
||||||
|
两者在 K8s API 层面结构几乎一样,都是 key-value 集合,区别主要在于:
|
||||||
|
|
||||||
|
- **Secret 的值默认 base64 编码存储**(不是加密,只是编码),K8s 对 Secret 有一些额外处理:不会出现在 `kubectl describe` 的默认输出里、可以配置只挂载到内存卷(`tmpfs`)不落盘、可以对接外部密钥管理服务(Azure Key Vault 等)做真正的静态加密和访问审计。
|
||||||
|
- **ConfigMap 没有这些额外保护**,设计上就是给"泄漏了也不严重"的配置用的。
|
||||||
|
|
||||||
|
所以规则很简单:**这个值如果出现在日志里、被同事在 `kubectl get configmap -o yaml` 时看到会不会造成安全问题**——会,就放 Secret;不会,就放 ConfigMap。DB 密码、JWT 签名密钥、第三方 API key 毫无疑问要放 Secret;而"首页降级提示文案"这种放哪都无所谓的东西放 ConfigMap 就行,还能享受到热更新不用走发布流程的好处。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 具体 ConfigMap/Secret 命名规范和 namespace 划分细节。
|
||||||
|
- 多环境 profile 的详细参数列表。
|
||||||
|
- 是否需要接入 Azure Key Vault 做 Secret 的进一步加固。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [Spring Cloud Kubernetes 官方文档](https://docs.spring.io/spring-cloud-kubernetes/reference/)
|
||||||
|
- [Kubernetes ConfigMap 官方文档](https://kubernetes.io/docs/concepts/configuration/configmap/)
|
||||||
|
- [Kubernetes Secret 官方文档](https://kubernetes.io/docs/concepts/configuration/secret/)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 08. 可观测性
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
统一 Trace ID + 结构化(JSON)日志 + Micrometer 指标,对应架构图 `Cross-Cutting` 里的 `Observability` 要求;关键行为单独走审计日志通道,对应 `Audit / Security`。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
platform-observability/
|
||||||
|
TraceIdFilter # 入口生成/透传 traceId,写入 MDC
|
||||||
|
logback-spring.xml # 结构化日志格式配置
|
||||||
|
MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus
|
||||||
|
AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## `TraceIdFilter` 示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// platform-observability/.../TraceIdFilter.kt
|
||||||
|
class TraceIdFilter : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||||
|
val traceId = request.getHeader("X-Trace-Id") ?: UUID.randomUUID().toString()
|
||||||
|
MDC.put("traceId", traceId)
|
||||||
|
response.setHeader("X-Trace-Id", traceId)
|
||||||
|
try {
|
||||||
|
chain.doFilter(request, response)
|
||||||
|
} finally {
|
||||||
|
MDC.clear() // 必须清理,否则线程池复用线程会带出上一个请求的 traceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TraceIdHolder {
|
||||||
|
fun current(): String = MDC.get("traceId") ?: "unknown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
调用 F6/Mini 域时,把当前 `traceId` 透传到下游请求头,方便跨系统关联日志:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
webClient.get()
|
||||||
|
.uri("/f6/procurement/list")
|
||||||
|
.header("X-Trace-Id", TraceIdHolder.current())
|
||||||
|
.retrieve()
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 结构化日志配置示例
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- logback-spring.xml -->
|
||||||
|
<configuration>
|
||||||
|
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||||
|
<includeMdcKeyName>traceId</includeMdcKeyName>
|
||||||
|
<customFields>{"app":"conti-backend"}</customFields>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="JSON" />
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
// build.gradle
|
||||||
|
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
|
||||||
|
```
|
||||||
|
|
||||||
|
输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以直接按 `traceId` 过滤出一次请求的完整链路日志。
|
||||||
|
|
||||||
|
## Micrometer / Actuator 配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health, prometheus, info
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes:
|
||||||
|
enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用
|
||||||
|
```
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 审计日志示例
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// platform-observability/.../Audited.kt
|
||||||
|
@Target(AnnotationTarget.FUNCTION)
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class Audited(val action: String)
|
||||||
|
|
||||||
|
// platform-observability/.../AuditLogAspect.kt
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
class AuditLogAspect(private val storeContextHolder: StoreContextHolder) {
|
||||||
|
private val auditLog = LoggerFactory.getLogger("AUDIT")
|
||||||
|
|
||||||
|
@Around("@annotation(audited)")
|
||||||
|
fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? {
|
||||||
|
val result = runCatching { joinPoint.proceed() }
|
||||||
|
auditLog.info(
|
||||||
|
"action={} userId={} storeId={} traceId={} success={}",
|
||||||
|
audited.action, storeContextHolder.userId, storeContextHolder.storeId,
|
||||||
|
TraceIdHolder.current(), result.isSuccess,
|
||||||
|
)
|
||||||
|
return result.getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用方式
|
||||||
|
@Audited(action = "WEBVIEW_TICKET_ISSUE")
|
||||||
|
fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
审计日志走独立 logger(`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计日志文件/索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- `traceId` 从入口 filter 生成,贯穿到 `f6-integration` / `mini-clients` 调用外部系统,失败时把 `traceId` 一起返回给前端(已经在 [06-api-design.md](./06-api-design.md) 的 `ApiResult` 里),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。
|
||||||
|
- 审计相关的关键行为(登录、换票、供应商调用失败)走单独的审计日志通道,不和普通业务日志混在一起。
|
||||||
|
- 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认。
|
||||||
|
|
||||||
|
## 附录:为什么要在 MDC 里放 traceId,而不是每条日志手动传参
|
||||||
|
|
||||||
|
不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`(Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",在 filter 里设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可,业务代码完全不需要感知 `traceId` 的传递。
|
||||||
|
|
||||||
|
代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层也是 `ThreadLocal` 实现的,异步线程池、协程切换线程的场景需要手动透传(`MDC.getCopyOfContextMap()` 传给子线程),我们当前同步 Servlet 栈下不需要特殊处理,但如果某个模块引入异步处理要注意这一点。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 具体接入现有 ELK / APM 的方式和字段规范。
|
||||||
|
- 审计日志的存储和保留策略。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [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)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 09. 构建与多环境部署
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;沿用现有 GitLab CI/CD → Azure 部署链路(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio))。
|
||||||
|
|
||||||
|
## 结构约定
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.gradle # include 所有 platform-* / domains/*(见 01-project-structure.md)
|
||||||
|
build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
|
||||||
|
bootstrap/build.gradle # bootJar,构建出可执行 jar
|
||||||
|
Dockerfile # 基于 bootstrap 的 jar 打镜像
|
||||||
|
.gitlab-ci.yml # build -> test -> docker build/push -> deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile 示例(多阶段构建)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM eclipse-temurin:21-jdk AS build
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY . .
|
||||||
|
RUN ./gradlew :bootstrap:bootJar --no-daemon
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /workspace/bootstrap/build/libs/*.jar app.jar
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
```
|
||||||
|
|
||||||
|
多阶段构建的好处:最终镜像只包含 JRE + 一个 jar,不带 Gradle 缓存、源码、编译工具链,镜像体积和攻击面都更小。
|
||||||
|
|
||||||
|
## GitLab CI 示例(节选)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitlab-ci.yml
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- package
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- ./gradlew build -x test --no-daemon
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- ./gradlew test --no-daemon
|
||||||
|
artifacts:
|
||||||
|
reports:
|
||||||
|
junit: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
stage: package
|
||||||
|
script:
|
||||||
|
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
|
||||||
|
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||||
|
|
||||||
|
deploy-uat:
|
||||||
|
stage: deploy
|
||||||
|
environment: uat
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script:
|
||||||
|
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n retailapp-uat
|
||||||
|
```
|
||||||
|
|
||||||
|
具体 stage 划分、部署到 Azure AKS 的凭证配置沿用现有 `gitlab-cicd-azure-deployment-diagram` 里已经跑通的流程,这里只体现"单一镜像、按环境部署"这条主线。
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
- 多环境(dev/uat/prod)通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。
|
||||||
|
- CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送镜像仓库 → GitLab CI/CD 触发 Azure/K8s 部署。
|
||||||
|
- 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 `Dockerfile` 和 CI job,工程结构上已经按模块划好边界(见 [01-project-structure.md](./01-project-structure.md)),拆分成本较低——本质上是把 `bootstrap` 依赖的某个 `domains/xxx` 模块摘出来,单独套一层 `@SpringBootApplication` 入口和自己的 `Dockerfile`。
|
||||||
|
|
||||||
|
## 附录:为什么坚持"一个镜像走所有环境"
|
||||||
|
|
||||||
|
一种常见但有风险的做法是:给每个环境单独打包(比如构建时注入 `application-uat.yml` 到镜像里),这样看起来"环境隔离更彻底",但实际引入了一个更严重的问题——**UAT 验证通过的镜像和 Prod 部署的镜像,字节级别就不是同一个东西**,即使代码版本号一样,构建过程中的依赖解析、基础镜像 layer 缓存状态都可能有细微差异,理论上会出现"UAT 测过没问题,Prod 部署后行为不一致"的情况,而且事后很难证明"两次构建到底有没有差异"。
|
||||||
|
|
||||||
|
"一个镜像走所有环境"(Build once, deploy many)反过来保证:镜像本身在所有环境完全一致,环境差异只体现在外部注入的配置(ConfigMap/Secret/环境变量)上。这也是 [The Twelve-Factor App](https://12factor.net/zh_cn/build-release-run) 里"严格分离构建和运行"这条原则的直接应用。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 具体 CI pipeline 的完整 stage 定义和镜像版本/tag 策略。
|
||||||
|
- 灰度发布/回滚方案。
|
||||||
|
- Gradle 构建缓存/并行构建的 CI 加速配置。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [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)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 10. 测试策略
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
JUnit 5 + MockK 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。
|
||||||
|
|
||||||
|
## 分层测试策略
|
||||||
|
|
||||||
|
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。
|
||||||
|
- **application 层**:`@SpringBootTest` 或轻量 slice test,mock 掉 infrastructure 层。
|
||||||
|
- **infrastructure 层**:用 Testcontainers 起真实数据库跑 repository 测试,避免 H2 和生产数据库行为差异导致的假通过。
|
||||||
|
- **api 层**:`@WebMvcTest` 验证参数校验、异常处理、响应结构是否符合 [06-api-design.md](./06-api-design.md) 的约定。
|
||||||
|
- **f6-integration / mini-clients**:对外部依赖用 WireMock 打桩,覆盖超时/重试/熔断路径。
|
||||||
|
|
||||||
|
## `domain` 层单元测试示例(对应 02 里的换票场景)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// domains/webview-ticket/src/test/kotlin/.../IssueWebviewTicketServiceTest.kt
|
||||||
|
class IssueWebviewTicketServiceTest {
|
||||||
|
private val ticketRepository = mockk<WebviewTicketRepository>()
|
||||||
|
private val fixedClock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)
|
||||||
|
private val service = IssueWebviewTicketService(ticketRepository, fixedClock)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `复用未过期的已签发票据`() {
|
||||||
|
val existing = WebviewTicket("t1", storeId = 1, userId = 1, status = TicketStatus.ISSUED,
|
||||||
|
expiresAt = fixedClock.instant().plusSeconds(60))
|
||||||
|
every { ticketRepository.findActiveTicket(1, 1) } returns existing
|
||||||
|
|
||||||
|
val result = service.issue(userId = 1, storeId = 1)
|
||||||
|
|
||||||
|
assertEquals("t1", result.ticketId)
|
||||||
|
verify(exactly = 0) { ticketRepository.save(any()) } // 复用场景不应该重新签发
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `没有有效票据时签发新票据`() {
|
||||||
|
every { ticketRepository.findActiveTicket(1, 1) } returns null
|
||||||
|
every { ticketRepository.save(any()) } just Runs
|
||||||
|
|
||||||
|
val result = service.issue(userId = 1, storeId = 1)
|
||||||
|
|
||||||
|
assertEquals(TicketStatus.ISSUED, result.status)
|
||||||
|
verify(exactly = 1) { ticketRepository.save(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不起 Spring 容器、不连数据库,纯 JVM 内存跑完,这类测试应该是数量最多、跑得最快的一层。
|
||||||
|
|
||||||
|
## `infrastructure` 层集成测试示例(Testcontainers)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
|
||||||
|
@DataJpaTest
|
||||||
|
@Testcontainers
|
||||||
|
class StoreJpaRepositoryTest {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
@JvmStatic
|
||||||
|
val postgres = PostgreSQLContainer("postgres:16")
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@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
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `按 code 查询门店`() {
|
||||||
|
repository.save(StoreEntity(name = "示例门店", code = "S001", status = StoreStatus.ACTIVE))
|
||||||
|
|
||||||
|
val found = repository.findByCode("S001")
|
||||||
|
|
||||||
|
assertNotNull(found)
|
||||||
|
assertEquals("示例门店", found?.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用真实 PostgreSQL(而不是 H2)跑测试,是因为 Flyway migration 里的 SQL 方言、JPA 对特定数据库函数的行为,H2 不一定能完全模拟,容易出现"H2 测试通过、生产环境报错"的假阳性。
|
||||||
|
|
||||||
|
## `api` 层测试示例(`@WebMvcTest`)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@WebMvcTest(StoreController::class)
|
||||||
|
class StoreControllerTest {
|
||||||
|
@Autowired lateinit var mockMvc: MockMvc
|
||||||
|
@MockkBean lateinit var storeAppService: StoreAppService
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `切换门店参数为空时返回参数校验错误`() {
|
||||||
|
mockMvc.post("/api/v1/stores/switch") {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
content = """{}"""
|
||||||
|
}.andExpect {
|
||||||
|
status { isBadRequest() }
|
||||||
|
jsonPath("$.code") { value("INVALID_PARAM") } // 对应 06-api-design.md 的 ApiResult 结构
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 外部依赖打桩示例(WireMock,覆盖 F6 超时/熔断路径)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Testcontainers
|
||||||
|
class F6ApiClientResilienceTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
@JvmStatic
|
||||||
|
val wireMock = WireMockContainer("wiremock/wiremock:3.9.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `F6 响应超时后走 fallback 返回降级数据`() {
|
||||||
|
wireMock.stubFor(
|
||||||
|
get(urlPathEqualTo("/f6/procurement/list"))
|
||||||
|
.willReturn(aResponse().withFixedDelay(5000).withStatus(200)) // 模拟超过 timeout 配置的慢响应
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = f6ApiClient.fetchProcurementList(storeId = 1).block()
|
||||||
|
|
||||||
|
assertTrue(result!!.degraded) // 验证 05-integration-layer.md 里配置的 2s 超时 + fallback 生效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
|
||||||
|
|
||||||
|
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
|
||||||
|
|
||||||
|
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
|
||||||
|
|
||||||
|
## 待补充
|
||||||
|
|
||||||
|
- 各 domain 覆盖率要求。
|
||||||
|
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [MockK 官方文档](https://mockk.io/)
|
||||||
|
- [Testcontainers 官方文档](https://testcontainers.com/)
|
||||||
|
- [WireMock 官方文档](https://wiremock.org/docs/)
|
||||||
|
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
|
||||||
Reference in New Issue
Block a user