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:
Guangfei.Zhao
2026-08-12 18:23:11 +08:00
parent 54c001a793
commit 1e0cbb86a2
11 changed files with 1460 additions and 1 deletions
+210
View File
@@ -0,0 +1,210 @@
# 01. 工程结构(后端)
## 技术栈
Kotlin + Spring Boot + GradleGroovy 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 CenterF6 WebView 换票、会话绑定
f6-integration/ # Integration LayerF6 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/)
+147
View File
@@ -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 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)
+154
View File
@@ -0,0 +1,154 @@
# 03. 持久层方案
## 决策
Spring Data JPA + Hibernate 作为默认 ORMFlyway 做 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)
+152
View File
@@ -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)
- [jjwtJWT 库)](https://github.com/jwtk/jjwt)
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
+131
View File
@@ -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)
+126
View File
@@ -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)
+129
View File
@@ -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 AlibabaNacos)的原因:部署环境已经是 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/)
+147
View File
@@ -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)
+96
View File
@@ -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)
+151
View File
@@ -0,0 +1,151 @@
# 10. 测试策略
## 决策
JUnit 5 + MockK 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。
## 分层测试策略
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。
- **application 层**`@SpringBootTest` 或轻量 slice testmock 掉 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)