backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 镜像只需要 bootstrap/build/libs/*.jar 这一个文件(Dockerfile 不在镜像里编译),
# 其余一律排除,避免把源码、Gradle 缓存、本地配置塞进构建上下文。
*
!bootstrap/build/libs/*.jar
+31
View File
@@ -0,0 +1,31 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.{kt,kts}]
# 接入 ktlint 之后这一段会成为它的配置来源(ktlint 读 .editorconfig
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
[*.{yml,yaml,json}]
indent_size = 2
[*.md]
# Markdown 里行尾两个空格是换行语义,不能被裁掉
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
[*.sh]
end_of_line = lf
[gradlew.bat]
end_of_line = crlf
+27
View File
@@ -0,0 +1,27 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
.kotlin/
# IDE
.idea/
*.iml
*.ipr
*.iws
.vscode/
out/
# OS
.DS_Store
Thumbs.db
# 日志只输出到 stdout(见 08-observability.md),仓库里本不该出现日志文件;
# 这条是防手滑,万一有人临时配了 file appender 也不会把日志提交上来
*.log
# 本地覆盖用的配置。真实密钥一律走环境变量/K8s Secret
# 但如果有人为了调试在本地临时写了一份,绝不能进 git
application-local-override.yml
.env
*.env.local
+226
View File
@@ -0,0 +1,226 @@
# 五阶段流水线,见 09-build-deploy.md。核心原则:
# **Build once, promote across environments with versioned artifacts and gated approvals**
# ——同一个镜像走 UAT 和 Prod,环境差异只在 ConfigMap/Secret/profile 上。
#
# Dev 环境**不在**这条流水线里:内网 k3s,手动跑 scripts/deploy-dev.sh(见 09 文档「环境层级」)。
stages:
- validate
- package
- release
- deploy-uat
- deploy-prod
variables:
# Gradle 缓存进 CI cache,避免每个 job 重新下载全部依赖
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR 触发 CI Validation(不部署)
- if: '$CI_COMMIT_BRANCH == "main"' # main 分支合入触发 CI Validation
- if: '$CI_COMMIT_TAG' # 打 protected tag 触发"选择版本发布"流程
.gradle-cache: &gradle-cache
key: gradle-deps
paths:
- .gradle/caches
- .gradle/wrapper
# ---------------------------------------------------------------------------
# 阶段二:CI Validation(质量门禁)
# ---------------------------------------------------------------------------
# 待接入 ktlint/detekt/jacoco 后启用。
# 本次骨架没有引入这三个插件,job 放开就是必红——所以先注释保留占位,
# 而不是留一个 allow_failure: true 的假绿灯(那种门禁一旦长期黄着,就等于没有)。
# 启用步骤:
# 1. 根 build.gradle 的 subprojects 里加
# org.jlleitschuh.gradle.ktlint、io.gitlab.arturbosch.detekt、jacoco 三个插件;
# 2. jacoco 配 jacocoTestReport + jacocoTestCoverageVerification(阈值先定低再逐步抬);
# 3. 取消下面这个 job 和 unit-integration-test 里 coverage_report 那几行的注释。
#
# lint:
# stage: validate
# script:
# - ./gradlew ktlintCheck detekt --no-daemon
# cache: *gradle-cache
unit-integration-test:
stage: validate
# Testcontainers 需要能起 Docker(见 10-testing.md)。
# Runner 上没有 Docker 的话,这里退化成 ./gradlew test -PexcludeIntegration
# 但那样 @DataJpaTest / WireMock 这批测试就不跑了,等于放掉了最能发现问题的一层。
services:
- docker:dind
script:
- ./gradlew test --no-daemon
artifacts:
when: always # 失败时更需要看到测试报告
reports:
junit: '**/build/test-results/test/TEST-*.xml'
# 待接入 jacoco 后启用:
# coverage_report:
# coverage_format: jacoco
# path: build/reports/jacoco/aggregate/jacocoTestReport.xml
cache: *gradle-cache
build-package:
stage: validate
script:
- ./gradlew :bootstrap:bootJar --no-daemon
artifacts:
paths:
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
expire_in: 1 week
cache: *gradle-cache
# 待接入依赖漏洞扫描后启用。
# 需要先在根 build.gradle 加 org.owasp.dependencycheck 插件,
# 并去 https://nvd.nist.gov/developers/request-an-api-key 免费申请 NVD_API_KEY 存成 masked variable
# ——不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
#
# security-scan:
# stage: validate
# script:
# - ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
# cache:
# key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
# paths:
# - build/dependency-check-data
# ---------------------------------------------------------------------------
# 阶段三:Artifact & Release Controls(制品与发布控制)
# ---------------------------------------------------------------------------
docker-build-push:
stage: package
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # 推送到 Azure Container RegistryACR
image-scan:
stage: package
needs: [docker-build-push]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
script:
# dependencyCheck 只覆盖我们自己声明的依赖,管不到基础镜像里的 OS 包(glibc、openssl 这类),
# 而那恰恰是镜像 CVE 的大头,所以镜像层面要单独扫。
# --ignore-unfixed 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,
# 让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。
- >-
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
# 而不是挨个把历史镜像拉下来重新扫一遍。
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
artifacts:
paths: [sbom.json]
cut-release:
stage: release
rules:
- if: '$CI_COMMIT_TAG'
script:
# 把已经验证过的 commit-sha 镜像"打标"成不可变的发布版本,而不是重新构建。
# 后续 UAT/Prod 部署的都是这同一个镜像摘要,保证"UAT 验证过的和 Prod 部署的字节级一致"。
- >-
az acr import --name $ACR_NAME
--source $ACR_NAME.azurecr.io/conti-backend:$CI_COMMIT_SHORT_SHA
--image conti-backend:$CI_COMMIT_TAG
# ---------------------------------------------------------------------------
# 阶段四:CD to Azure
#
# AKS 是 Private ClusterAPI Server 只能通过 Private Endpoint 访问),
# GitLab 的公网共享 Runner 连不上——deploy-* 系列 job 必须跑在 VNet 内的 self-hosted Runner 上。
# tags: [azure-vnet-runner] 就是这个约束的落地:公网 Runner 没有这个 tag,天然不会被误调度。
# ---------------------------------------------------------------------------
deploy-uat:
stage: deploy-uat
tags:
- azure-vnet-runner
environment:
name: uat
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要人工点击"Promote to UAT"
script:
- az login --identity # Runner 用 Managed Identity 登录,不存长期有效的 SP 密码
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# Key Vault / Config Retrieval:现取值渲染成 K8s Secret
# 密钥只在这个 job 执行期间短暂存在,不打印到日志、不落盘到镜像
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv)
- F6_API_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name f6-api-key --query value -o tsv)
- >-
kubectl create secret generic conti-backend-secret -n retailapp-uat
--from-literal=SECURITY_JWT_SECRET="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD"
--from-literal=F6_API_KEY="$F6_API_KEY"
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/rbac.yaml
- kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
- kubectl apply -f k8s/pdb.yaml
# 部署的是 tag 不是 commit-sha:用的是 cut-release 生成的那个不可变发布版本,
# 这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-uat
- kubectl rollout status deployment/conti-backend -n retailapp-uat --timeout=180s
- curl -sf https://uat.internal.example.com/actuator/health || exit 1
deploy-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要更高权限的人工审批
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password-prod --query value -o tsv)
- F6_API_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name f6-api-key-prod --query value -o tsv)
- >-
kubectl create secret generic conti-backend-secret -n retailapp-prod
--from-literal=SECURITY_JWT_SECRET="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD"
--from-literal=F6_API_KEY="$F6_API_KEY"
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/rbac.yaml
- kubectl apply -f k8s/configmap-prod.yaml -n retailapp-prod
- kubectl apply -f k8s/pdb.yaml
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
- curl -sf https://api.example.com/actuator/health || exit 1
rollback-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
when: manual # 手动触发,回滚到"上一个已批准的镜像 tag"
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量。
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
# 提醒:镜像能秒回滚,**数据库不能**。Flyway 社区版没有 undo
# 破坏性变更必须走 expand-contract 两次发布,否则回滚镜像救不了已经改过的库
# (见 09-build-deploy.md「数据库迁移与回滚的协同」)。
+30
View File
@@ -0,0 +1,30 @@
# 前提:CI 的 validate 阶段已经跑过 ./gradlew :bootstrap:bootJar
# 产物通过 GitLab artifacts 传递到 package 阶段,这里直接用,不重新编译。
# 见 09-build-deploy.md「Dockerfile:复用 CI 产物 + 分层解包」。
FROM eclipse-temurin:21-jre AS layers
WORKDIR /layers
COPY bootstrap/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
FROM eclipse-temurin:21-jre
WORKDIR /app
# 非 root 运行:容器内一旦被攻破,攻击者拿到的也只是一个无特权用户。
# USER 写数字而不是 appuser,是为了让 K8s 的 runAsNonRoot: true 能在启动前静态校验通过
# (K8s 无法解析镜像里的用户名,只认数字 UID)。
RUN useradd --system --uid 10001 --create-home appuser
USER 10001
# 按变更频率从低到高逐层 COPY,前三层几乎不变,可以吃满 Docker layer 缓存,
# 每次发版真正推送到 ACR 的通常只有最后一层(几百 KB 的业务代码)
COPY --from=layers --chown=10001:10001 /layers/dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/spring-boot-loader/ ./
COPY --from=layers --chown=10001:10001 /layers/snapshot-dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/application/ ./
# MaxRAMPercentageJVM 在容器里默认只用可用内存的 25% 做堆,配 2Gi 的 Pod 堆只有 512Mi。
# ExitOnOutOfMemoryErrorOOM 直接结束进程交给 K8s 重启,而不是留一个探针还返回健康的半死 Pod。
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
+147
View File
@@ -0,0 +1,147 @@
# conti-backend
Continental 门店零售 App 的后端服务。**模块化单体**:14 个 Gradle 模块,一个可部署 jar
模块边界由 Gradle 依赖图 + ArchUnit 测试守着,将来要拆微服务时按模块切即可。
架构决策全部写在 [`conti-docs/backend/`](../conti-docs/backend/) 的 12 篇文档里,
本仓库是那些决策的落地。**文档是唯一事实来源**——代码和文档不一致时,改代码,不是改文档,
也不是把 ArchUnit 规则调松(规则调松了这套结构就白建了)。
## 版本基线
| | 版本 |
| --- | --- |
| Java | 21toolchain |
| Kotlin | 2.4.10 |
| Spring Boot | 4.1.0 |
| Spring Cloud | 2025.1.2 |
| Gradle | 9.5.1wrapper |
| MySQL | 8.4 |
版本对齐一律走 Gradle 原生 `platform()` BOM**不引** `io.spring.dependency-management` 插件。
## 模块结构
```
bootstrap 入口 + 全部 application*.yml;唯一产出 jar 的模块
architecture-test ArchUnit 规则 + 原生 SQL 跨库扫描
platform/ 横切基础设施。互相之间只能依赖 platform-web
platform-web ApiResult / ErrorCode / 全局异常处理 / 幂等 / AuditContext
platform-security JWT 签发与校验 / StoreContextHolder / SecurityConfig
platform-persistence BaseEntity / JPA 审计 / 多实例 Flyway / 调度 / 缓存
platform-observability traceId 桥接 / @Audited 审计日志 / logback-spring.xml
platform-integration RestClientFactory / 连接池与超时 / trace 透传
domains/ 业务域。**domain 之间不许直接依赖**
identity-store 认证与门店(本骨架里唯一实现完整的纵切)
identity-store-contract 给别的 domain 用的只读契约,不含 Spring Web / JPA
workbench 工作台聚合(并行聚合 + 按 tile 降级的示例)
webview-ticket WebView 票据(domain 层 + 领域事件的示例)
bff-orchestration 预留空模块,暂无代码
integration/ 外部系统适配器
f6-adapter F6Bulkhead + CircuitBreaker + Retry + fallback
mini-clients 小程序侧 O2O 等
```
### 依赖规则(由 `architecture-test` 强制)
```
api → application → domain
↘ infrastructure ↗
domains/A ─X─> domains/B 直接依赖:禁止
domains/A ───> domains/B-contract 只读查询:允许
domains/A ~~~> ApplicationEvent 写操作/状态联动:@TransactionalEventListener(AFTER_COMMIT)
```
具体 10 条规则见
[`architecture-test/.../ArchitectureRulesTest.kt`](architecture-test/src/test/kotlin/com/continental/retailapp/architecture/ArchitectureRulesTest.kt)
每条规则的注释里都写了它对应文档的哪一节。几条最容易踩的:
- Entity 只能待在 `infrastructure` 包里,`api` 层碰不到 Entity
- `domain` 层不许出现 Spring / JPA 的任何引用,它的 bean 由 `infrastructure/config/` 显式 `@Bean` 装配;
- Controller 的端点方法必须返回 `ApiResult`
- 不许字段注入(`@Autowired` 到字段上);
- 不许用 `java.util.Date` / `Calendar` / `SimpleDateFormat`,时间一律 `Instant`UTC)。
## 本地怎么跑
只有 MySQL 跑在 Docker 里,应用本身用 Gradle 跑,断点和热重载都还在:
```bash
docker compose up -d mysql
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
```
- 建库脚本在 `docker/mysql/init/`,表结构由 Flyway 建(本地和 dev/uat/prod 是同一套迁移脚本)。
- `local` profile 关掉了 Spring Cloud Kubernetes,不需要任何 K8s 环境。
- 种子数据由 `LocalSeedDataConfig` 在启动时写入(账号 `demo` / `demo1234`,两家门店),
**只在 `local` profile 下存在**——种子数据绝不写进 Flyway 脚本,那些脚本在 dev/uat/prod 也会跑。
- Swagger UI<http://localhost:8080/swagger-ui.html>`local`/`dev`/`uat` 开,`prod` 关)。
数据库要重来一遍:
```bash
docker compose down -v && docker compose up -d mysql
```
## 构建与测试
```bash
./gradlew build -PexcludeIntegration # 编译 + 单测 + ArchUnit,不需要 Docker,约 1 分钟
./gradlew build # 完整,含 Testcontainers / WireMockCI 跑这个)
```
需要真实 MySQL 或 WireMock 的测试都打了 `@Tag("integration")``-PexcludeIntegration` 会跳过它们。
本地日常改代码用前者,提 MR 前跑一次后者。
## 部署
| 环境 | 跑在哪 | 走 CI/CD | 怎么部署 |
| --- | --- | --- | --- |
| local | 自己电脑 | 否 | 见上面 |
| dev | 内网 Ubuntu 上的 k3s | 否 | `./scripts/deploy-dev.sh <commit-sha>`(先连 VPN |
| uat / prod | Azure Private AKS | 是 | 打 protected tag → GitLab 手动点 Promote |
- 镜像里**不编译代码**`Dockerfile` 直接消费 CI `validate` 阶段产出的 jar
保证部署的字节就是被测试验证过的字节。
- **一个镜像走所有环境**,环境差异只在 ConfigMap / Secret / `SPRING_PROFILES_ACTIVE`
- 真实密钥不在这个仓库里的任何一个文件中。`k8s/secret-uat.yaml` 里只有 `__injected_by_pipeline__`
占位符,真值由流水线从 Azure Key Vault 现取现渲染。
k8s 清单在 `k8s/`,流水线在 `.gitlab-ci.yml`
`lint`ktlint/detekt)和覆盖率两个 job 目前**以注释形式留着**——插件还没接,
放开就是必红;文件里写了启用需要补什么。
## 新增一个 domain 要接线的地方
漏掉任何一处,症状都不明显(编译过、启动过,但 Flyway 不跑、ArchUnit 不检查),所以逐条对:
1. **`settings.gradle`** 加 `include 'domains:xxx'`,并写 `domains/xxx/build.gradle`
——只依赖 `platform-*` 和别的 domain 的 `-contract`**不许**依赖别的 domain 本身。
2. **`bootstrap/build.gradle`** 加 `implementation project(':domains:xxx')`
否则这个模块根本不会被打进 jar。
3. **`DomainFlywayConfig.DOMAIN_SCHEMAS`** 加库名,并建
`domains/xxx/src/main/resources/db/migration/<库名>/V1__init.sql`
——Flyway 的 location 不能是空目录;同时在 `docker/mysql/init/01-create-databases.sql` 里加建库语句。
4. **`architecture-test`**`ArchitectureRulesTest.domains` 加包名(如 `xxx`),
`NativeQueryScanTest.ownerByModule``"xxx" to "<库名>"`
——不加的话跨域依赖和跨库 SQL 这两条规则对新模块是完全失效的。
前三步是"能跑起来",第四步是"边界还守得住"。
## 一些容易踩的坑
- **Boot 4 的包名搬过家**`@DataJpaTest``org.springframework.boot.data.jpa.test.autoconfigure`
`@AutoConfigureTestDatabase``org.springframework.boot.jdbc.test.autoconfigure`
都要单独引 `spring-boot-starter-data-jpa-test``spring-boot-starter-aop` 改叫 `spring-boot-starter-aspectj`
- **Gradle 9 不再自带 JUnit Platform launcher**,根 `build.gradle` 里那行
`testRuntimeOnly 'org.junit.platform:junit-platform-launcher'` 删掉的话所有 test 任务会直接失败。
- **已经执行过的 Flyway 脚本不可修改**,改了 checksum 对不上,下次启动直接 `Validate failed`
要改就新写一个版本号更大的脚本,这条对 Dev 环境同样适用。
- **每个迁移脚本必须前向兼容**`maxUnavailable: 0` 意味着滚动更新期间新旧两版 Pod 连同一个库,
删列改列一律走 expand-contract 两次发布。
- **门店维度的查询一律用 `StoreContextHolder.currentStoreId()`**,绝不用请求参数里的 storeId
——那是越权查询的标准入口。
+9
View File
@@ -0,0 +1,9 @@
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码。
// bootstrap 里全是 implementation 依赖,只会进 runtimeClasspath——扫描够用了。
testImplementation project(':bootstrap')
// 规则里要按类型引用 ApiResult / @RestController / @RequestMapping(比字符串匹配安全,
// 类改名了编译期就报错),这几个得显式进 compileClasspath。
testImplementation project(':platform:platform-web')
}
@@ -0,0 +1,166 @@
package com.continental.retailapp.architecture
import com.continental.retailapp.platform.web.ApiResult
import com.tngtech.archunit.base.DescribedPredicate
import com.tngtech.archunit.core.domain.JavaClass
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.core.importer.ImportOption
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* 把 01-project-structure.md / 02-layering.md 里定的规则变成可执行测试。
*
* 这个模块依赖所有其他模块,是唯一能看到全代码库字节码的地方。
*
* 两个使用上的注意点:
* 1. `ImportOption.DoNotIncludeTests()` 不能省——测试代码里 mock、构造 Entity、跨层引用都是正常的。
* 2. 匹配不到任何类的规则默认会失败,还没建起来的包要配 `.allowEmptyShould(true)`
* 而不是把整条规则删掉。
*/
class ArchitectureRulesTest {
private val classes = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeTests())
.importPackages("com.continental.retailapp")
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
// —— 规则组一:模块边界(01-project-structure.md)——
@Test
fun `domain 之间只能通过 contract 包互相依赖`() {
domains.forEach { source ->
domains.filter { it != source }.forEach { target ->
noClasses()
.that().resideInAPackage("..retailapp.$source..")
.should().dependOnClassesThat(
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
.and(
DescribedPredicate.not(
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract.."),
),
),
)
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
// bff-orchestration 目前还是空模块,规则对它空转
.allowEmptyShould(true)
.check(classes)
}
}
}
@Test
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
noClasses()
.that().resideInAPackage("..retailapp.*.contract..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
.check(classes)
}
// —— 规则组二:层内方向(02-layering.md)——
@Test
fun `层依赖方向`() {
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("api").definedBy("..retailapp.*.api..")
.layer("application").definedBy("..retailapp.*.application..")
.layer("domain").definedBy("..retailapp.*.domain..")
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
// 对文档的有意偏离 #410-testing.md 原文是 api 层 mayNotBeAccessedByAnyLayer()。
// 但 02-layering.md 和 06-api-design.md 都规定 Response DTO 定义在 api 层、
// 而 Entity/投影 → Response 的转换(含 MapStruct mapper)必须发生在 application 层——
// 那么 application 返回 api 层的 Response 类型就是文档自己示例里就有的依赖
// `StoreAppService.listAccessibleStores(): List<StoreResponse>`)。
// 按原文写会把 02 自己的示例判红,所以放开 application → api 这一条。
// 真正的红线是下面单独一条"api 不能碰 infrastructure",它保持严格。
// 这和文档已经为 infrastructure 做过的同类调整是一个道理。
.whereLayer("api").mayOnlyBeAccessedByLayers("application")
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
.check(classes)
}
@Test
fun `api 层不能依赖 infrastructure 层`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.because("02-layering.mdapi 层不 import infrastructure 包下的任何类型(包括 Entity)")
.check(classes)
}
@Test
fun `domain 层不能依赖 Spring 或 JPA`() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.check(classes)
}
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
@Test
fun `Entity 只能待在 infrastructure 包里`() {
classes()
.that().haveSimpleNameEndingWith("Entity")
// platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
// 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
// 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
.and().resideOutsideOfPackage("..retailapp.platform..")
.should().resideInAPackage("..infrastructure..")
.check(classes)
}
@Test
fun `api 层不能触碰 Entity`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
.check(classes)
}
// —— 规则组四:编码约定 ——
@Test
fun `Controller 的端点方法必须返回 ApiResult`() {
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
// 用 metaAnnotatedWith 而不是 arePublic@GetMapping/@PostMapping 都是 @RequestMapping 的
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
.and().areMetaAnnotatedWith(RequestMapping::class.java)
.should().haveRawReturnType(ApiResult::class.java)
.because("统一响应结构,见 06-api-design.md")
.check(classes)
}
@Test
fun `禁止使用 java 时间类型的老 API`() {
noClasses()
.should().dependOnClassesThat()
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
.because("统一用 InstantUTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
}
@@ -0,0 +1,43 @@
package com.continental.retailapp.architecture
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.streams.toList
/**
* 03-persistence.md 承诺的那条扫描测试。
*
* MySQL 里 schema ≡ database**跨 database join 只要账号有权限就是合法的**,
* 编译器和 ArchUnit 都拦不住——它们看的是字节码,而库名是字符串。
* 所以额外加一条源码扫描作为软约束的第二道防线。
*/
class NativeQueryScanTest {
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
private val ownerByModule = mapOf(
"identity-store" to "identity_store",
"workbench" to "workbench",
"webview-ticket" to "webview_ticket",
)
private val allDatabases = ownerByModule.values.toSet()
@Test
fun `原生 SQL 里不能出现其他 domain 的库名`() {
val violations = Files.walk(Path.of("../domains"))
.filter { it.toString().endsWith(".kt") }
.toList()
.flatMap { file ->
val path = file.toString().replace('\\', '/')
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
val text = Files.readString(file)
(allDatabases - setOfNotNull(owner))
.filter { text.contains("$it.") }
.map { "$path 引用了 $it" }
}
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
}
}
+26
View File
@@ -0,0 +1,26 @@
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:identity-store-contract')
implementation project(':domains:bff-orchestration')
implementation project(':domains:workbench')
implementation project(':domains:webview-ticket')
implementation project(':integration:f6-adapter')
implementation project(':integration:mini-clients')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}
@@ -0,0 +1,20 @@
package com.continental.retailapp
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
/**
* 唯一的可部署单元(模块化单体,见 01-project-structure.md)。
*
* `scanBasePackages` 写到 `com.continental.retailapp` 这一层:各 domain 和 platform 模块
* 的包名都在它下面,一次扫全。**模块边界不靠扫描范围来保证**——那是 Gradle 依赖图
* 加上 ArchUnit 的职责,Spring 这里只管把 bean 找齐。
*/
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp"])
@ConfigurationPropertiesScan("com.continental.retailapp")
class BootstrapApplication
fun main(args: Array<String>) {
runApplication<BootstrapApplication>(*args)
}
@@ -0,0 +1,11 @@
# dev:只放与其他环境的差异,其余走 application.yml + ConfigMap。
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
logging:
level:
com.continental.retailapp: DEBUG
@@ -0,0 +1,44 @@
# 本地开发。docker compose up -d mysql 之后:
# SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
# 种子账号 demo / demo1234 由 LocalSeedDataConfig 在启动时写入(只在这个 profile 下存在)。
spring:
cloud:
kubernetes:
config:
enabled: false # 本地不连 K8s API,配置全部走本地文件
reload:
enabled: false
datasource:
url: jdbc:mysql://localhost:3306/?connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
username: conti
password: conti_local_password # 仅本地开发用,不是真实密钥
security:
jwt:
active-key-id: local
keys:
# 仅本地开发用的假密钥;HS256 要求 base64 解码后 ≥ 32 字节,见 04-security-auth.md
local: bG9jYWwtZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcmVhbC11c2UtMzJi
integration:
clients:
# 本地没有真实的 F6 / O2O,指向一个不存在的端口即可:
# 走的是降级路径,正好能验证 fallback 的行为。
f6:
base-url: http://localhost:9901
mini:
base-url: http://localhost:9902
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
logging:
level:
com.continental.retailapp: DEBUG
# 本地看 SQL 方便,但**不要**在任何非本地环境打开:绑定参数里会有 PII
org.hibernate.SQL: DEBUG
@@ -0,0 +1,11 @@
# prod:能关的都关掉。
#
# springdoc 保持 application.yml 里的 false —— 接口文档在生产环境是攻击面,不是便利。
# SecurityConfig 里 swagger 路径的 permitAll 也只在非 prod 生效,两道都关上。
logging:
level:
root: INFO
com.continental.retailapp: INFO
# 生产环境绝不打开 SQL 日志:绑定参数里会带 PII
org.hibernate.SQL: WARN
@@ -0,0 +1,12 @@
# uat:尽量贴近 prod,只保留排查问题必需的差异。
# swagger 在 uat 还开着,方便联调;prod 一律关闭(见 SecurityConfig 的 isProd 分支)。
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
logging:
level:
com.continental.retailapp: INFO
@@ -0,0 +1,158 @@
# 所有环境共享的配置。环境差异只放在 application-{profile}.yml 与 K8s ConfigMap 里,
# 真实密钥一律走环境变量(K8s Secret),仓库和镜像里不出现任何一个真值。
# 见 07-config-governance.md。
spring:
application:
name: conti-backend
# ---- 数据源(03-persistence.md----
datasource:
# url 里不带库名:每个 domain 有自己的库,由 Flyway/JPA 的 schema 指定。
# connectionTimeZone=UTC + preserveInstants=true:让驱动按 UTC 读写,Instant 不被本地时区拧一遍。
# rewriteBatchedStatements=true:不加它 Hibernate 的 batch_size 在 MySQL 上等于没配。
url: >-
jdbc:mysql://${DB_HOST}:3306/?sslMode=REQUIRED&connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 拿不到连接就快速失败,不要让请求线程堆在这里
max-lifetime: 570000 # 略小于 MySQL 的 wait_timeout,避免用到已被服务端关闭的连接
transaction-isolation: TRANSACTION_READ_COMMITTED
jpa:
open-in-view: false # 必须显式关掉,Boot 默认是 true
hibernate:
ddl-auto: validate # 表结构只由 Flyway 改,Hibernate 只做校验
properties:
hibernate:
jdbc:
time_zone: UTC
batch_size: 50
order_inserts: true
order_updates: true
query:
fail_on_pagination_over_collection_fetch: true
flyway:
enabled: false # 关掉 Boot 的单实例自动配置,改由 DomainFlywayConfig 接管
# ---- 配置中心(07-config-governance.md----
cloud:
kubernetes:
config:
enabled: true
sources:
- name: conti-backend-config
reload:
enabled: true
mode: polling # 定期轮询 ConfigMapevent 模式需要 watch 权限)
strategy: refresh # 只刷新 @RefreshScope bean,不重启容器
period: 15s
# ---- 优雅停机(09-build-deploy.md----
lifecycle:
timeout-per-shutdown-phase: 25s
server:
shutdown: graceful # Boot 默认是 immediate,收到 SIGTERM 直接掐断在途请求
# ---- 安全(04-security-auth.md----
# 密钥来自 K8s Secretbase64 解码后必须 ≥ 32 字节,否则 JwtProperties 在启动时就报错。
security:
jwt:
active-key-id: ${SECURITY_JWT_ACTIVE_KEY_ID:v1}
keys:
v1: ${SECURITY_JWT_SECRET}
access-token-ttl-minutes: 30
refresh-token-ttl-days: 30
# ---- 外部系统(05-integration-layer.md----
integration:
clients:
f6:
base-url: ${F6_BASE_URL}
connect-timeout-ms: 1000
read-timeout-ms: 2000
connection-request-timeout-ms: 500
max-connections: 60
max-connections-per-route: 30
mini:
base-url: ${MINI_BASE_URL}
connect-timeout-ms: 500
read-timeout-ms: 1000
connection-request-timeout-ms: 300
max-connections: 60
max-connections-per-route: 30
resilience4j:
# 叠加顺序由这几个 *-aspect-order 属性决定,跟注解写在方法上的先后顺序无关。
# 这里显式写死,避免依赖框架默认值——默认值会随版本变,而顺序变了语义就变了。
retry:
retry-aspect-order: 3 # 最外层:每次重试都被熔断器单独统计
instances:
f6-api:
max-attempts: 2
wait-duration: 200ms
exponential-backoff-multiplier: 2
retry-exceptions:
# 同步栈下真实会抛出来的类型:ResourceAccessException 包住了
# SocketTimeoutException/ConnectException 等所有 IO 异常,写后者是无效配置。
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
ignore-exceptions:
# 熔断已打开时抛的异常,重试它毫无意义,只会白白多等一轮 wait-duration
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- com.continental.retailapp.integration.f6.F6ClientException
circuitbreaker:
circuit-breaker-aspect-order: 2
instances:
f6-api:
sliding-window-type: COUNT_BASED
sliding-window-size: 20
minimum-number-of-calls: 10 # 样本太少时不做判断,避免启动后头几个请求就把熔断打开
failure-rate-threshold: 50
slow-call-duration-threshold: 1500ms
slow-call-rate-threshold: 80 # 慢调用也算故障:"每次都卡满 2 秒但最终成功"也必须能熔断
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
record-exceptions:
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
bulkhead:
bulkhead-aspect-order: 1 # 最内层,贴着真实调用
instances:
f6-api:
max-concurrent-calls: 20
max-wait-duration: 0 # 拿不到名额立刻失败走降级,排队等于把阻塞换个地方
mini-o2o:
max-concurrent-calls: 20
max-wait-duration: 0
# ---- 可观测性(08-observability.md----
management:
tracing:
enabled: true # 这是 traceId 进 MDC 的前提,关掉连日志里的 traceId 都没有
export:
enabled: false # 但不往任何后端上报 span——现在只要日志关联,不建全链路追踪系统
sampling:
probability: 1.0 # 不上报就没有采样成本,全采即可
endpoints:
web:
exposure:
include: health, prometheus, info
endpoint:
health:
probes:
enabled: true # 暴露 /actuator/health/liveness、/readiness 供 K8s 探针使用
metrics:
tags:
application: conti-backend
springdoc:
api-docs:
enabled: false # 默认关掉,只在 local/dev/uat 打开(见各 profile
swagger-ui:
enabled: false
+56
View File
@@ -0,0 +1,56 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.4.10' apply false
id 'org.jetbrains.kotlin.plugin.spring' version '2.4.10' apply false
id 'org.jetbrains.kotlin.kapt' version '2.4.10' apply false
id 'org.springframework.boot' version '4.1.0' apply false
}
subprojects {
apply plugin: 'java-library'
apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
group = 'com.continental.retailapp'
version = '0.1.0-SNAPSHOT'
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
// 版本对齐一律走 Gradle 原生 platform() BOM,不引 io.spring.dependency-management 插件
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.0')
implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.2')
testImplementation platform('org.springframework.boot:spring-boot-dependencies:4.1.0')
testImplementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.2')
implementation 'org.jetbrains.kotlin:kotlin-reflect'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.mockk:mockk:1.14.2'
// Gradle 9 不再自带 JUnit Platform launcherstarter-test 也不带,必须显式声明,
// 否则 test 任务直接报 "Could not start Gradle Test Executor"。版本由 junit-bom 管。
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
kotlin {
compilerOptions {
freeCompilerArgs.add('-Xjsr305=strict')
}
}
tasks.withType(Test).configureEach {
useJUnitPlatform {
// 本地快速验证:./gradlew build -PexcludeIntegration
// CI 按 09-build-deploy.md 跑完整 ./gradlew testRunner 需能起 Docker,见 10-testing.md
if (project.hasProperty('excludeIntegration')) {
excludeTags 'integration'
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
# 本地开发依赖的外部组件。只起 MySQL——应用本身用 ./gradlew :bootstrap:bootRun 在 IDE/命令行里跑,
# 这样断点和热重载都还在。
#
# docker compose up -d mysql
# SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
#
# 这里的账号密码跟 application-local.yml 对齐,都是假值,不是任何环境的真实凭证。
services:
mysql:
image: mysql:8.4
container_name: conti-mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root_local_password
MYSQL_USER: conti
MYSQL_PASSWORD: conti_local_password
command:
# 字符集跟 UAT/Prod 的 Azure Flexible Server 对齐:8.0 之后的默认排序规则是
# utf8mb4_0900_ai_ci,本地用别的会出现"本地大小写敏感、线上不敏感"这类差异。
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
# 应用侧全用 UTC Instant 存取(见 03-persistence.md),服务端时区也钉成 UTC
- --default-time-zone=+00:00
volumes:
- conti-mysql-data:/var/lib/mysql
# 一个 domain 一个 databaseFlyway 起来之前这几个库得先存在(见 DomainFlywayConfig
- ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_local_password"]
interval: 5s
timeout: 3s
retries: 20
volumes:
conti-mysql-data:
+17
View File
@@ -0,0 +1,17 @@
-- 本地环境建库。一个 domain 一个 databaseMySQL 里 schema ≡ database),
-- 清单必须和 DomainFlywayConfig.DOMAIN_SCHEMAS 保持一致;新增 domain 时两处一起改。
-- 表结构不在这里建 —— 那是 Flyway 的事,本地和 dev/uat/prod 跑的是同一套迁移脚本。
CREATE DATABASE IF NOT EXISTS `platform` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS `identity_store` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS `workbench` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS `webview_ticket` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- 本地图省事:一个账号同时干迁移和运行时的活。
-- UAT/Prod 上这是两个账号——迁移账号有 DDL 权限,运行时账号只有 DML,
-- 否则一次 SQL 注入的破坏半径里就包含 drop table(见 09-build-deploy.md)。
GRANT ALL PRIVILEGES ON `platform`.* TO 'conti'@'%';
GRANT ALL PRIVILEGES ON `identity_store`.* TO 'conti'@'%';
GRANT ALL PRIVILEGES ON `workbench`.* TO 'conti'@'%';
GRANT ALL PRIVILEGES ON `webview_ticket`.* TO 'conti'@'%';
FLUSH PRIVILEGES;
+304
View File
@@ -0,0 +1,304 @@
# 01. 工程结构(后端)
## 技术栈
Kotlin + Spring Boot + GradleGroovy DSL`build.gradle`)。
## 版本基线
| 组件 | 版本 | 说明 |
| --- | --- | --- |
| Spring Boot | `4.1.0` | OSS 支持至 2027-07。3.x 全系列 OSS 支持已结束(3.5 于 2026-06-30 结束),新项目不应再从 3.x 起步 |
| Spring Cloud | `2025.1.2`Oakwood | Boot 4.1 需要 2025.1.2 及以上;提供 `spring-cloud-kubernetes` 5.0.2,见 [07-config-governance.md](./07-config-governance.md) |
| Kotlin | `2.4.10` | |
| Java | `21`LTS,用 Gradle toolchain 锁定) | |
Spring Boot 4 连带升级了 Spring Framework 7 / Spring Security 7 / **Jackson 3**,第三方库必须选对应版本线,各篇文档里出现的坐标已按此对齐:
| 库 | 版本 | 注意点 |
| --- | --- | --- |
| Resilience4j | `2.4.0` | artifact 是 **`resilience4j-spring-boot4`**,不是 `-spring-boot3`2.4.0 才加的 Boot 4 支持 |
| springdoc-openapi | `3.0.3` | Boot 4 对应 springdoc **3.x**Boot 3 才是 2.x |
| logstash-logback-encoder | `9.0` | 9.0 起迁到 Jackson 3,正好匹配 Boot 48.x 及以前是 Jackson 2 |
| MapStruct | `1.6.3` + kapt | MapStruct 至今没有正式的 KSP 支持,Kotlin 项目仍走 kapt |
| springmockk | `5.0.1` | Boot 4 删除了 `@MockBean`/`@SpyBean`springmockk 5.x 对应 Framework 7`@SpykBean` 已改名 `@MockkSpyBean` |
| ArchUnit | `1.4.2` | |
| Testcontainers | 跟随 Spring Boot BOM | 2.x 做了模块化拆分,坐标和包名都变了(`org.testcontainers:testcontainers-mysql` / `org.testcontainers.mysql.MySQLContainer`),升级时以 `./gradlew dependencies` 的实际解析结果为准,见 [10-testing.md](./10-testing.md) |
升级 Boot 版本时,这张表要整体复核一遍,不要只改 Boot 版本号。
## 决策
单一 Gradle 多模块工程,落地为**模块化单体(Modular Monolith)**:只有一个可执行部署单元,内部按 [architecture-diagram](../../conti-docs/Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。
选择模块化单体而不是一开始就上微服务,原因很直接:
- 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。
- App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-adapter`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
## 模块结构总览
```
conti-backend/
settings.gradle
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot / Spring Cloud BOM
bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像
build.gradle # 依赖所有 platform-* / domains/* / integration/* + @SpringBootApplication 启动类
platform/
platform-web/ # 统一异常处理、ApiResult 包装、参数校验、GlobalExceptionHandler
platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 多实例配置
platform-observability/ # 日志格式、Trace 透传、Micrometer 配置
platform-integration/ # RestClient/Resilience4j 基础封装(超时/重试/熔断/舱壁通用能力)
domains/
identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限
identity-store-contract/ # ↑ 对外契约:接口 + 传输模型 + 领域事件,其他 domain 只能依赖这个
bff-orchestration/ # BFF Orchestration:面向 APP 的统一接口聚合与协议标准化
workbench/ # Workbench Aggregation:首页聚合、局部降级
webview-ticket/ # WebView Ticket CenterF6 WebView 换票、会话绑定
integration/
f6-adapter/ # F6 Adapter:对应架构图里的适配层,换票、供应商访问上下文准备
mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装
architecture-test/ # ArchUnit 架构规则测试,见 10-testing.md
```
(模块名称先按架构图职责命名,实际开发中如果和团队习惯冲突可以再改,不影响这套结构本身。)
### 为什么把集成层从 `domains/` 里拿出来
`f6-adapter``mini-clients` 不是业务域,是**对外部系统的适配层**——架构图里 Integration Layer 本来也是单独一层。放在 `domains/` 下会直接和"domains 之间不允许互相依赖"这条规则冲突:`workbench` 要拿 O2O 的数据,就必须依赖 `mini-clients`,于是要么破规则,要么给规则打补丁。单独一组 `integration/` 之后,规则变成干净的一句"所有 domain 都可以依赖 integration",不需要例外。
## 依赖规则(编译期强制边界,是这套结构的核心价值)
- `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*``integration/*``platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
- **`domains/x` 不可以依赖 `domains/y`。**
- **`domains/x` 可以依赖 `domains/y-contract`**(契约模块,见下一节)。
- **`domains/*` 都可以依赖 `integration/*``platform-*`。**
- `platform-*` 不含业务逻辑;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
这三条规则的价值在于**可以被 ArchUnit 精确表达**,不是靠 code review 口头约束——测试写法见 [10-testing.md](./10-testing.md)。同时它们也由 Gradle 物理强制:`domains/workbench``build.gradle` 里根本不会声明对 `domains/identity-store` 的依赖,编译时 import 不到。
### 模块目录名与包名的对应关系
目录名带连字符,包名不能带——这个映射必须写死,因为 [10-testing.md](./10-testing.md) 的 ArchUnit 规则是按包名匹配的,改一个就要改另一个:
| 模块目录 | 基础包名 |
| --- | --- |
| `domains/identity-store``domains/identity-store-contract` | `com.continental.retailapp.identitystore`(契约在 `.identitystore.contract` |
| `domains/bff-orchestration` | `com.continental.retailapp.bff` |
| `domains/workbench` | `com.continental.retailapp.workbench` |
| `domains/webview-ticket` | `com.continental.retailapp.webviewticket` |
| `integration/f6-adapter` | `com.continental.retailapp.integration.f6` |
| `integration/mini-clients` | `com.continental.retailapp.integration.mini` |
| `platform/platform-*` | `com.continental.retailapp.platform.*` |
规则是**去掉连字符直接拼接**`identity-store``identitystore`),唯一的例外是 `bff-orchestration``bff``bfforchestration` 读不出来)。新增模块时按这个规则取名,并同步更新 `10-testing.md` 里 ArchUnit 的 `domains` 列表——那个列表漏了谁,谁的边界就没人管。
`integration/*``platform/*` 不按 api/application/domain/infrastructure 分四层(它们本来就不是业务域),内部按自己的职责组织包即可,见 [02-layering.md](./02-layering.md)。
### 契约模块(`-contract`):跨 domain 协作的唯一通道
`workbench` 聚合首页时需要门店名称,`webview-ticket` 需要知道"用户切了门店"——这类跨域需求是真实存在的,不可能全部塞进 `bff-orchestration`(那会让 BFF 变成什么都知道的上帝模块)。做法是让被依赖方**显式发布一个最小契约**:
```
domains/identity-store-contract/
src/main/kotlin/com/continental/retailapp/identitystore/contract/
StoreQueryService.kt # 接口:fun listStoresByUserId(userId: Long): List<StoreInfo>
StoreInfo.kt # 传输模型:只含对外承诺的字段
StoreSwitchedEvent.kt # 领域事件,见 11-cross-domain-collaboration.md
```
约束:
- **契约模块里只放接口、传输模型和事件类型**,不放实现、不依赖 Spring Web/JPA,不依赖任何其他 domain。
- **实现方(`identity-store`)依赖自己的契约模块并实现它**;调用方(`workbench`)只依赖契约模块,拿不到 `identity-store` 内部的任何类型(包括 Entity)。
- **按需创建**,不预先给每个 domain 都建一个空的 contract 模块——没有跨域调用就不需要它。
- 命名用 `-contract` 而不是 `-api`,避免和模块内部的 `api/` 包(Controller 层,见 [02-layering.md](./02-layering.md))混淆。
跨 domain 的写操作和状态联动优先走**领域事件**而不是直接调接口,规则见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。
### 根 `build.gradle` 示例
```groovy
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.4.10' apply false
id 'org.jetbrains.kotlin.plugin.spring' version '2.4.10' apply false
id 'org.jetbrains.kotlin.kapt' version '2.4.10' apply false
id 'org.springframework.boot' version '4.1.0' apply false
}
subprojects {
apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
group = 'com.continental.retailapp'
version = '0.1.0-SNAPSHOT'
// 用 toolchain 统一 Java 版本:Kotlin 插件会自动把 jvmTarget 对齐到同一个版本。
// 只写 sourceCompatibility 是不够的——Kotlin 的 jvmTarget 默认值和它无关,
// 两边不一致时 Gradle 会直接报 "Inconsistent JVM-target compatibility" 构建失败。
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
// 用 Gradle 原生的 platform() 做版本对齐,不再引入 io.spring.dependency-management 插件:
// 少一个需要跟着 Boot 一起升级的插件,行为也更符合 Gradle 自身的依赖解析语义。
// testImplementation 继承自 implementation,所以测试依赖同样受这两个 BOM 约束。
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.0')
implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.2')
implementation 'org.jetbrains.kotlin:kotlin-reflect'
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 'architecture-test'
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:identity-store-contract'
include 'domains:bff-orchestration'
include 'domains:workbench'
include 'domains:webview-ticket'
include 'integration:f6-adapter'
include 'integration:mini-clients'
```
### `domains/workbench/build.gradle` 示例(体现依赖规则)
```groovy
// 注意:库模块不 apply 'org.springframework.boot' 插件。
// 版本对齐已经由根工程的 platform() BOM 统一处理,库模块 apply Boot 插件唯一的作用
// 就是产出一个我们并不需要的 bootJar,然后再手动把它关掉——多余的一步。
// 只有 bootstrap 需要 Boot 插件。
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-persistence')
implementation project(':platform:platform-integration')
implementation project(':integration:mini-clients') // 允许:domain 可以依赖 integration
implementation project(':domains:identity-store-contract') // 允许:只依赖契约模块
// 不允许:implementation project(':domains:identity-store') // 同级 domain 的实现模块
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}
```
### `domains/identity-store-contract/build.gradle` 示例
```groovy
dependencies {
// 契约模块保持极简:不依赖 Spring Web / JPA / 其他 domain
// 这样任何 domain 依赖它都不会顺带把实现细节拉进来。
}
```
### `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:identity-store-contract')
implementation project(':domains:bff-orchestration')
implementation project(':domains:workbench')
implementation project(':domains:webview-ticket')
implementation project(':integration:f6-adapter')
implementation project(':integration: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-* / integration-* / 其他 domain 的 -contract
src/main/kotlin/com/continental/retailapp/xxx/ # xxx = 去掉连字符的模块名,见上方对应表
api/ # Controller、请求/响应 DTO
application/ # Service、定时任务;Entity → Response 的 mapper 也在这层
domain/ # 可选,见 02-layering.md
infrastructure/ # repository 实现、Entity、外部 client 实现、模块内的 @Configuration
src/main/resources/db/migration/xxx/
V1__init.sql
src/test/kotlin/...
```
新增模块后需要手动接线的地方只有三处:`settings.gradle``include``bootstrap/build.gradle` 里加依赖、[03-persistence.md](./03-persistence.md) 里给这个 domain 注册一个 Flyway 实例(因为每个 domain 有自己独立的 database 和迁移版本序列)。其余边界规则都由各模块自己的 `build.gradle` 保证。
## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层
给还没接触过这套多模块习惯的同学看的入门说明。
### 要解决的问题
如果整个后端只是一个 Gradle 模块、内部靠 `com.xxx.identity` / `com.xxx.workbench` 这样的包名分层——短期能跑,但 Java/Kotlin 的包(package**不是编译期边界**`workbench` 包里的类可以随手 `import com.xxx.identity.SomeInternalClass`,IDE 不会报错,只能靠人自觉或者 ArchUnit 这类静态检查工具事后检测。等项目大到几十人协作时,"事后检测"经常检测不过来,边界会慢慢被绕开。
**Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/workbench` 这个模块的 `build.gradle` 里没有声明对 `domains/identity-store` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。
契约模块是这个思路的延伸:不是"要么全开放、要么全封闭",而是让被依赖方自己决定**对外承诺哪些东西**,其余一律看不见。这跟微服务里"只有 HTTP API 是公开契约、数据库表是私有实现"是同一个道理,只是这里的强制手段从网络协议换成了 Gradle 依赖图。
### 跟微服务的关系
Gradle 多模块和微服务解决的是同一类问题(业务边界隔离),但选择了不同的代价:
- 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。
- 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。
我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx-contract')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。契约模块在这里额外多给了一层好处:**要拆的那个接口清单已经现成写在 contract 模块里了**,不需要先花时间考古"到底谁在用我的什么"。
## 参考链接
- [Gradle Multi-Project Builds](https://docs.gradle.org/current/userguide/multi_project_builds.html)
- [Gradle: Platforms / BOM 支持](https://docs.gradle.org/current/userguide/platforms.html)
- [Spring Boot Gradle Plugin](https://docs.spring.io/spring-boot/gradle-plugin/index.html)
- [Spring Boot 4.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide)
- [Modular Monolith: A Primer (Kamil Grzybek)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer)
- [ArchUnit](https://www.archunit.org/)
+222
View File
@@ -0,0 +1,222 @@
# 02. 分层规范(后端)
## 决策
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../../conti-retail-app/docs/02-layering.md) 保持一致的思路:
```
domains/xxx/
src/main/kotlin/com/continental/retailapp/xxx/
api/ # Controller、请求/响应 DTO
application/ # Service,编排用例、跨 repository 协调;Entity/领域模型 → Response 的转换
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
infrastructure/ # JPA repository 实现、外部 client 实现
```
`integration/*` 模块(`f6-adapter``mini-clients`,见 [01-project-structure.md](./01-project-structure.md))不套这四层——它们本身就是"别人的 infrastructure",内部只有 client 实现 + 对外暴露的接口和传输模型,规则见 [05-integration-layer.md](./05-integration-layer.md)。
## 各层职责
- **api**`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,**不 import `infrastructure` 包下的任何类型**(包括 Entity)。
- **application**`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。**`Entity`/领域模型 → `Response` 的转换在这一层完成**`application/mapper/`,见 [06-api-design.md](./06-api-design.md))。
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
- **infrastructure**`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、基于 `RestClient` 的外部调用实现。
## 对象命名约定(PO / DAO / BO / DTO / VO
Java 生态里这几个缩写来源不一、经常被混用,这里把我们实际用的名字和这些通用叫法对应清楚,避免团队内部各叫各的:
| 通用叫法 | 全称 | 所在层 | 我们的命名 |
| --- | --- | --- | --- |
| PO | Persistent Object | infrastructure | `XxxEntity`JPA entity,见 [03-persistence.md](./03-persistence.md) |
| DAO | Data Access Object | infrastructure | `XxxJpaRepository`Spring Data JPA repository 接口) |
| BO | Business Object | domain(可选) | `domain` 层的领域模型,如本文 `WebviewTicket` |
| DTO | Data Transfer Object | api | **统称**,不是单独的类;`Request`/`Response` 都是 DTO 的具体形态 |
| VO | View Object | api | `Xxx*Response`,即返回给前端的对象 |
落地规则:
- **类名统一用 `Request`/`Response` 后缀**,不额外起 `XxxDTO`/`XxxVO` 这样的名字——`Request`/`Response` 已经把方向(输入/输出)表达清楚了,`DTO`/`VO` 只是这两者的统称,没必要在类名上重复。
- **`domain` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`PO)由 `application` 层转换成 `Response`,不会凭空多出一个 BO。
- **Entity 边界规则**(这一条在 [06-api-design.md](./06-api-design.md) 和 [10-testing.md](./10-testing.md) 的 ArchUnit 规则里用的是同一句话,三处必须一致):
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
> **`Entity`/`Domain` 模型 → `Response` 的转换发生在 `application` 层。**
`domain` 层时更严一档:Entity 到 `infrastructure` 的 repository 实现为止,`application` 拿到的已经是领域模型。
这句话对应 ArchUnit 的 `noClasses().that().resideInAPackage("..api..").should().dependOnClassesThat().resideInAPackage("..infrastructure..")`,能被自动检查,不靠人盯。
### 为什么规则不是"Entity 永不跨出 infrastructure"
因为跳过 domain 层的简单 CRUD 场景下,那条更严的规则会强迫我们为每个查询凭空造一个和 Entity 字段一模一样的中间模型,只为了把它从 `infrastructure` 搬到 `application`——纯粹的样板代码,没有换来任何隔离收益(`application` 转手就把它变成 `Response` 了)。
真正需要防的是**两件事**:Entity 泄漏到 API 契约上(数据库字段一改,APP 就崩),以及 Entity 泄漏到别的模块(别的模块从此依赖上你的表结构)。上面那条规则精确地只挡这两件事,所以它既能自动检查,也不会逼出无意义的中间类。
## 何时可以跳过 domain 层
先明确一件事:**"跳过 domain 层"跳过的是领域模型和领域服务,不是跳过接口抽象**。`application` 依赖的仍然是一个接口(只不过接口定义挪到了 `infrastructure` 包内),而不是直接 `@Autowired` 一个 `JpaRepository``EntityManager` 到处用。
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain``infrastructure` 反向实现。
判断不确定时按"先跳过、需要时再补"处理:从"无 domain 层"补出一个 domain 层是局部重构(把规则从 `application` 提到 `domain`,加一层模型转换),成本可控;反过来为了对称给所有简单查询都套上 domain 层,则是持续付出的样板成本。
## 依赖方向
```
api → application → domain(或直接 → infrastructure 的接口,若跳过 domain
domain → 不依赖 api / infrastructure
infrastructure → 依赖 domain 的接口(若有),依赖 platform-persistence / platform-integration
```
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
模块之间的依赖方向(domain 之间不互相依赖、跨域只走 `-contract` 契约模块)见 [01-project-structure.md](./01-project-structure.md),两套规则一个管模块内、一个管模块间,都由 [10-testing.md](./10-testing.md) 里的 ArchUnit 测试检查。
## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验)
```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)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
// infrastructure/persistence/WebviewTicketRepositoryImpl.kt
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
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。
注意 `IssueWebviewTicketService` 上**没有 `@Service` 注解**——domain 层不依赖 Spring[10-testing.md](./10-testing.md) 有一条 ArchUnit 规则盯着这件事)。它成为 bean 的方式是在 `infrastructure/config/` 里显式声明:
```kotlin
// infrastructure/config/DomainServiceConfig.kt
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
```
多写这几行换来的是:领域规则这一层可以脱离 Spring 单独编译和测试。domain 层类不多,这个成本是可控的。
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
```kotlin
// infrastructure/persistence/StoreView.kt
// Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
// 用它而不是直接返回 StoreEntity,是为了让 application 拿到的东西不带 Entity 的
// 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
interface StoreView {
val id: Long
val name: String
val code: String
}
// infrastructure/persistence/StoreRepository.kt
interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreView>
}
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId
""",
)
override fun findStoresByUserId(userId: Long): List<StoreView>
}
// application/StoreAppService.kt
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val storeMapper: StoreMapper, // application/mapper/,见 06-api-design.md
) {
fun listAccessibleStores(userId: Long): List<StoreResponse> =
storeMapper.toResponseList(storeRepository.findStoresByUserId(userId))
}
```
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure``application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
写操作或者确实需要整个实体的场景,`StoreRepository` 也可以返回 `StoreEntity`——那时候 Entity 进到 `application` 是允许的(见上面的边界规则),只要它不出现在 `api` 层、也不跨出这个模块就行。投影是查询场景下的优选,不是硬性要求。
## 附录:为什么要分层,以及依赖倒置在这里怎么体现
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `RestClient.create(...)` 调用 F6——短期能跑,但会导致:
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `RestClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
分层的关键不是"分了几层",而是**依赖方向单向流动**,`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 Data JPA: Projections](https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html)
+356
View File
@@ -0,0 +1,356 @@
# 03. 持久层方案
## 决策
**MySQL 8.4 LTS**(生产用 Azure Database for MySQL Flexible Server,见 [09-build-deploy.md](./09-build-deploy.md)+ Spring Data JPA / Hibernate + Flyway 做 schema 迁移。
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
- Kotlin + Spring Boot 生态里 JPA 是最主流、文档和踩坑资料最多的组合,团队上手成本低。
- 大部分 domain 模块(`identity-store``webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
MySQL 一侧需要注意的是:**MySQL 里 schema 和 database 是同一个东西**`CREATE SCHEMA` 就是 `CREATE DATABASE` 的别名)。下文说"每个 domain 一个 schema"时,物理上就是"同一个 MySQL 实例里的一个 database"。这跟 PostgreSQL 的 "一个 database 里多个 schema" 不是一回事,很多网上的多 schema 方案不能直接照搬,包括权限模型(见后面的跨 domain 规则)。
## 结构约定
```
platform-persistence/
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
VersionedEntity # BaseEntity + @Version 乐观锁,有并发更新的表继承它
PageResult<T> # 统一分页返回封装
JpaAuditingConfig # 开启 Spring Data JPA Auditing
DomainFlywayConfig # 按 domain database 分别建 Flyway 实例
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,目录名 = database 名(下划线形式)
```
迁移目录名统一用**下划线形式、与 database 名一致**`identity_store``webview_ticket`),不用模块名的中划线形式(`identity-store`)——因为 `DomainFlywayConfig` 里是拿 database 名直接拼 `classpath:db/migration/$schema`,两边不一致会静默地一条迁移都不执行。
## 全局 JPA / 数据源配置
```yaml
spring:
datasource:
url: >-
jdbc:mysql://${DB_HOST}:3306/?sslMode=REQUIRED
&connectionTimeZone=UTC&preserveInstants=true
&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD} # 来自 K8s Secret,见 07-config-governance.md
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 拿不到连接就快速失败,不要让请求线程堆在这里
max-lifetime: 570000 # 略小于 MySQL 的 wait_timeout,避免用到已被服务端关闭的连接
transaction-isolation: TRANSACTION_READ_COMMITTED
jpa:
open-in-view: false # 必须显式关掉,Boot 默认是 true
hibernate:
ddl-auto: validate # 表结构只由 Flyway 改,Hibernate 只做校验
properties:
hibernate:
jdbc:
time_zone: UTC
batch_size: 50
order_inserts: true
order_updates: true
query:
fail_on_pagination_over_collection_fetch: true
flyway:
enabled: false # 关掉 Boot 的单实例自动配置,改由 DomainFlywayConfig 接管
```
几条不显眼但会实际出事的配置:
- **`open-in-view: false`**Boot 默认 `true`,意思是数据库连接会一直持有到视图渲染完(对我们来说是到 JSON 序列化完)。后果是连接被无谓占用、懒加载在 Controller 层还能"碰巧成功"从而掩盖 N+1 问题。关掉之后,`application` 层事务外访问懒加载字段会直接抛 `LazyInitializationException`——这是好事,问题会在开发期暴露而不是压测时暴露。
- **`ddl-auto: validate`**:绝不能是 `update``update` 会在应用启动时按 Entity 反推 DDL 去改生产库,且它的改法不可预测、无法评审、无法回滚。表结构的唯一事实来源是 Flyway 脚本。
- **`transaction-isolation: TRANSACTION_READ_COMMITTED`**MySQL 默认是 REPEATABLE READ,我们显式降到 READ COMMITTED。理由:RR 下的一致性读快照在整个事务期间不变,长一点的事务会读到过时数据;RR 还会用更多的 gap lock,并发插入时更容易死锁。绝大多数 Web 业务不需要 RR 的可重复读语义,需要防并发覆盖的地方我们用乐观锁(下一节)显式处理,比依赖隔离级别更清楚。
- **`rewriteBatchedStatements=true`**MySQL 驱动默认**不会**把 JDBC batch 真的合成一条多值 `INSERT`,只配 `hibernate.jdbc.batch_size` 是没用的,必须在 JDBC URL 上开这个开关。
### 时区:全链路 UTC
数据库里只存 UTC,时区转换只在客户端做。三处配置必须一起生效,缺一处就会出现"写进去和读出来差几个小时":
1. 时间列一律用 `datetime(6)`Kotlin 侧一律用 `Instant`(不用 `LocalDateTime`,它不带时区信息,语义上表达不了"某个时刻")。
2. `spring.jpa.properties.hibernate.jdbc.time_zone=UTC`——Hibernate 写库时按 UTC 转换。
3. JDBC URL 上 `connectionTimeZone=UTC&preserveInstants=true`——驱动层按 UTC 解释。
不用 MySQL 的 `timestamp` 类型:它会按会话时区自动转换(结果依赖服务器/连接的时区设置,是上面这类 bug 的常见来源),而且有 2038 年上限。API 层的时间格式约定见 [06-api-design.md](./06-api-design.md)。
## `BaseEntity` / `VersionedEntity` 示例
```kotlin
// 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/.../VersionedEntity.kt
@MappedSuperclass
abstract class VersionedEntity : BaseEntity() {
@Version
@Column(nullable = false)
var version: Long = 0
}
// platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
class JpaAuditingConfig(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) {
@Bean
fun auditorAware(): AuditorAware<String> = AuditorAware {
// StoreContextHolder 是 @RequestScope bean,定时任务/启动流程里没有请求上下文,
// 这里必须容忍拿不到的情况,否则后台任务写库会直接抛 BeanCreationException。
runCatching { storeContextHolder.`object`.userId?.toString() }
.getOrNull()
.let { Optional.ofNullable(it) }
}
}
```
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
**继承 `BaseEntity` 的表,建表脚本必须带全 `created_at` / `updated_at` / `created_by` / `updated_by` 四列**,且前两列 `not null`——漏一列,第一次插入就会直接失败。这是最容易在新表上重复踩的坑,建表脚本 review 时优先看这一条。
### 乐观锁(`@Version`
只要一条记录可能被两个请求同时改(门店信息编辑、票据状态流转、库存类数据),Entity 就继承 `VersionedEntity`,表上加一列 `version bigint not null default 0`。Hibernate 在 `update` 时自动带上 `where version = ?``version + 1`,更新影响行数为 0 时抛 `ObjectOptimisticLockingFailureException`
`application` 层要显式处理这个异常,转成业务错误码返回给 APP("数据已被他人修改,请刷新后重试"),而不是让它落到 `GlobalExceptionHandler` 变成 500。并发冲突的重试策略见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## Entity + Repository + Migration 示例(`identity-store` 里的门店表)
```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, length = 32)
var code: String,
@Enumerated(EnumType.STRING) // 存字符串,不存序号,见下面的说明
@Column(nullable = false, length = 16)
var status: StoreStatus,
) : VersionedEntity()
@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 database / usedatabase 由 Flyway 实例的
-- defaultSchema 指定(见 DomainFlywayConfig),脚本里一律用不带库名的表名。
create table store (
id bigint not null auto_increment,
name varchar(128) not null,
code varchar(32) not null,
status varchar(16) not null,
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_store_code (code),
key idx_store_status (status)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
约定说明:
- **字符集固定 `utf8mb4` + `utf8mb4_0900_ai_ci`**。MySQL 的 `utf8` 是三字节的历史遗留别名,存不了 emoji 和部分生僻汉字,一律不用。
- **索引命名**:唯一索引 `uk_<表名>_<列名>`,普通索引 `idx_<表名>_<列名>`,多列用下划线连接(`idx_store_status_created_at`)。唯一约束写成 `unique key uk_xxx (...)` 而不是列上的 `unique`,是为了让它有个能在日志和慢查询里认出来的名字。
- **InnoDB 单个索引键最长 3072 字节**,`utf8mb4` 下一个字符最多 4 字节,所以 `varchar(768)` 是能整列建索引的上限。要给长文本建索引时用前缀索引(`key idx_x_url (url(255))`)。
- **枚举用 `@Enumerated(EnumType.STRING)`**,绝不用默认的 `ORDINAL`——`ORDINAL` 存的是枚举常量的下标,以后在枚举中间插入一个值,历史数据的含义会整体错位,且这种错位没有任何报错。
- **金额用 `decimal(18, 4)`**Kotlin 侧 `BigDecimal`,永远不用 `double`/`float`
- **布尔用 `tinyint(1)`**Hibernate 对 Kotlin `Boolean` 的默认映射)。
- **外键**:同一个 database 内部可以用物理外键;**跨 database 一律只做逻辑关联**(存 ID,不建 `foreign key` 约束),否则模块边界在数据库层就被焊死了,将来任何一个域想单独拆库都要先拆约束。
- **软删除**:不做全局的 `@SQLDelete` + `@Where` 软删除(它会污染所有查询、和唯一索引冲突、还容易被忘记)。确实需要保留历史的表,显式加 `status``deleted_at` 列并在每个查询里显式过滤。
## Flyway:每个 domain database 一个独立实例
Boot 自动配置的 Flyway 只有一个实例、一张 `flyway_schema_history`。如果各 domain 目录各自从 `V1__init.sql` 开始编号,这个单实例扫到两个 `V1` 会直接报 `Found more than one migration with version 1`,启动失败。所以关掉自动配置,按 database 各建一个 Flyway 实例,各自维护自己那张历史表、各自的版本序列:
```kotlin
// platform-persistence/.../DomainFlywayConfig.kt
@Configuration
class DomainFlywayConfig {
companion object {
// 新增一个 domain 时,这里加一行 —— 见 01-project-structure.md 的脚手架说明
val DOMAIN_SCHEMAS = listOf(
"identity_store",
"workbench",
"webview_ticket",
)
}
@Bean
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
DOMAIN_SCHEMAS.forEach { schema ->
Flyway.configure()
.dataSource(dataSource)
.schemas(schema) // MySQL 下即 database;不存在时会自动创建
.defaultSchema(schema) // 历史表和脚本里的裸表名都落在这个 database
.table("flyway_schema_history")
.locations("classpath:db/migration/$schema")
.load()
.migrate()
}
return DomainFlywayMigrations
}
// 必须让 EntityManagerFactory 等迁移跑完再初始化,
// 否则 ddl-auto=validate 会在建表之前校验,启动直接失败。
// Boot 自带的这个依赖关系只认名为 flyway/flywayInitializer 的 bean,自定义实例要自己挂。
@Bean
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
}
object DomainFlywayMigrations // 只是一个用来表达依赖顺序的标记 bean
```
规则:
- 各 domain 目录内版本号独立递增,`identity_store/V2__xxx.sql``workbench/V2__yyy.sql` 互不冲突。
- **迁移脚本一旦合入主干就不可修改**(Flyway 会校验 checksum,改了会导致其他环境启动失败)。写错了就再加一个 `V(n+1)` 修正。
- 迁移脚本必须**前向兼容**:滚动更新期间新旧两个版本的应用会同时连着同一个库,所以不能有"旧代码见到就会崩"的改动。加列不删列、分两个版本走 expand-contract,规则和发布流程的配合见 [09-build-deploy.md](./09-build-deploy.md)。
- **运行账号和迁移账号分开**:Flyway 用的账号需要 DDL 权限,应用运行时只需要 DML。生产上把迁移放在部署流程里用单独的账号执行(见 09),运行时账号不给 `CREATE`/`DROP`/`ALTER`——这样即使应用被注入了 DDL,也执行不了。
## 跨 domain 数据访问规则
**每个 domain 一个独立的 database**`identity_store` / `workbench` / `webview_ticket` 各自建库,不允许跨 domain 直接 join 表。需要别的域的数据时,走对方 `-contract` 模块暴露的接口或领域事件(见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),不查对方的表。
```kotlin
// 正确做法:workbench 通过 identity-store 的契约模块获取门店信息
@Service
class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // 来自 domains/identity-store-contract
private val tileRepository: WorkbenchTileRepository,
) {
fun listTiles(userId: Long): List<TileResponse> {
val stores = storeQueryService.listStoresByUserId(userId) // 走契约接口,不查表
val tiles = tileRepository.findByUserId(userId)
return buildTiles(tiles, stores)
}
}
```
### 这条规则的强制力:哪些是硬约束、哪些不是
这里要说清楚,避免高估它的保护力度:
1. **JPQL 层面是硬约束**。JPQL 引用的是 Entity 类,`workbench` 想写 `join StoreEntity s` 就得 `import ...identitystore...StoreEntity`——而 [01-project-structure.md](./01-project-structure.md) 的 Gradle 依赖规则让 `workbench` 模块根本编译不到这个类。这一条编译期就挡死了,不需要靠自觉。
2. **原生 SQL 是软约束**`@Query(nativeQuery = true)` 里的表名是字符串,写 `join identity_store.store s` 完全能编译通过。而且——这一点必须讲清楚——**MySQL 里跨 database join 是完全合法的**,只要连接用的账号对两个库都有权限就能执行成功。我们是模块化单体,整个进程共用一个 `DataSource` 和一个数据库账号,这个账号必然对所有 domain 的库都有权限。所以在 MySQL 下,"跨库 join 会报错"这个说法**不成立**,别指望数据库替我们拦住它。
> 顺带说明:网上很多"多 schema 隔离"的方案是按 PostgreSQL 写的。PG 里 schema 是 database 内部的命名空间,可以按 schema 单独 `REVOKE` 权限从而做成硬约束;MySQL 里 schema 就是 database,我们这种单账号单数据源的结构做不到同样的效果。
兜底手段只能是流程性的:**原生 SQL 一律在 code review 里重点看**,并且加一条测试扫描所有 `@Query(nativeQuery = true)` 的字符串里有没有出现其他 domain 的 database 名(写法见 [10-testing.md](./10-testing.md))。这道防线不完美,但配合第 1 条已经覆盖了绝大多数实际会发生的情况——正常人不会为了 join 一张表专门去写原生 SQL 绕过编译错误。
3. **如果将来需要把它升级成硬约束**:做法是每个 domain 一个 `DataSource` + 一个只 GRANT 本库的数据库账号,那时跨库 join 会因为权限不足而真的失败。代价是多套 `EntityManagerFactory`/`TransactionManager`、多个连接池(连接数要重新算),并且跨 domain 的本地事务彻底不可能——最后一条其实是好事,但整体复杂度明显上升。现阶段不做,等到真的出现跨域乱查的实际问题、或者某个域准备独立拆库时再上。
代价那一面也要认:确实需要跨 domain 做一次性数据修复或报表查询时,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
## 查询规范
### N+1 与抓取策略
- **所有 `@ManyToOne` / `@OneToOne` 显式写 `fetch = FetchType.LAZY`**。JPA 规范里这两种关联默认是 `EAGER`,意味着查一个 Entity 会顺带把关联对象也查出来,列表查询时就是典型的 N+1。
- 确实需要一次带出关联数据时,用 `@EntityGraph` 或 JPQL 的 `join fetch` 显式声明,不要靠懒加载在循环里触发。
- **分页 + `join fetch` 集合属性会导致 Hibernate 把全表拉进内存再分页**。上面配置里的 `fail_on_pagination_over_collection_fetch: true` 让这种写法直接抛异常而不是悄悄变慢。
- 开发环境开 `spring.jpa.properties.hibernate.generate_statistics=true` 或用 [datasource-proxy](https://github.com/jdbc-observations/datasource-proxy) 观察每个请求实际发了多少条 SQL;关键列表接口在测试里断言 SQL 条数,比事后压测发现要早得多。
### 分页
统一用 Spring Data 的 `Pageable` + 我们自己的 `PageResult<T>` 返回(字段名和 APP 侧约定见 [06-api-design.md](./06-api-design.md)),不直接把 Spring 的 `Page` 序列化给前端——`Page` 的 JSON 结构由 Spring 版本决定,升级 Boot 时会变,属于把框架内部结构写进 API 契约。
```kotlin
data class PageResult<T>(
val list: List<T>,
val pageNum: Int,
val pageSize: Int,
val total: Long,
val hasMore: Boolean,
)
```
翻很深的页(`offset` 很大)在 MySQL 上会越来越慢,因为它必须先扫过前面所有行再丢弃。App 上的列表基本都是"下拉加载更多",这种场景优先用**游标分页**(按 `id``created_at``lastId``where id < :lastId order by id desc limit :size`),不用 `offset`
### 批量写
`hibernate.jdbc.batch_size` + JDBC URL 上的 `rewriteBatchedStatements=true` 两个都配上,批量 update/delete 才会真的合并。
但**批量 insert 有个 MySQL 特有的坑**`@GeneratedValue(strategy = IDENTITY)` 下 Hibernate 必须逐条插入才能拿回自增主键,JDBC batch 会被直接禁用,配了也没用。所以:常规写入保持 `IDENTITY` 不变(简单、够用);真正的大批量导入场景(几千行以上)绕开 JPA,直接用 `JdbcTemplate.batchUpdate` 或一条多值 `INSERT`。不要为了让 JPA 能批量插入就把主键策略换成 `TABLE` 生成器——那会引入一张全局竞争的序列表,得不偿失。
## 连接池容量怎么算
`maximum-pool-size` 不是越大越好,要和数据库实例的连接上限对齐:
```
所有 Pod 的连接总数 = maximum-pool-size × Pod 副本数(含滚动更新期间的临时多余副本)
必须 ≤ MySQL 实例的 max_connections 预留(运维/迁移/监控账号,留 20 左右)
```
Azure Database for MySQL Flexible Server 的 `max_connections` 由 SKU 规格决定(跟内存挂钩),扩副本前要先确认这个数字。按当前 3 副本、滚动更新时最多 4 副本估算,`maximum-pool-size: 15` 对应峰值 60 个连接。
池子大小本身的经验值是"略大于并发执行 SQL 的线程数",不是"等于 Tomcat 线程数"——大部分请求线程在等下游 HTTP(见 [05-integration-layer.md](./05-integration-layer.md))而不是等数据库。池子配得过大反而会让数据库承受更多并发、整体延迟变差。
## 事务
- `@Transactional` 只加在 `application` 层(见 [02-layering.md](./02-layering.md)),不加在 Controller 或 repository 上。
- **事务里不要调外部 HTTP**。一次 F6 调用可能耗时几秒,事务开着就意味着数据库连接和行锁被占几秒。把外部调用挪到事务外,或者用 `@TransactionalEventListener(AFTER_COMMIT)`
- 只读查询加 `@Transactional(readOnly = true)`:Hibernate 会跳过脏检查,省掉一次快照比对。
- 详细的事务边界、传播行为、幂等与并发冲突处理见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## 待补充
- 复杂查询是否引入 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)
- [MySQL 8.4 参考手册:字符集与排序规则](https://dev.mysql.com/doc/refman/8.4/en/charset.html)
- [MySQL Connector/J:时区处理](https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html)
- [HikariCP: About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
+527
View File
@@ -0,0 +1,527 @@
# 04. 安全与认证方案
## 决策
Spring Security + JWT,由 `identity-store` 模块统一签发,由 `platform-security` 统一校验,对应架构图里 `Auth and Token Center`;门店/角色上下文在认证之后注入 `@RequestScope` bean,供各 domain 读取,对应架构图 `Store Context` 的职责。
JWT 的签发与校验**用 Spring Security 内置的 `NimbusJwtEncoder`/`NimbusJwtDecoder`,不引入 jjwt**,理由见文末附录。
## 结构约定
```
platform-security/
JwtProperties # security.jwt.* 配置绑定 + 启动期校验
JwtEncoderConfig # NimbusJwtEncoder / NimbusJwtDecoderHS256
AccessTokenIssuer # 签发 access tokenclaims 结构见下)
StoreContextFilter # 认证之后执行:把 Jwt claims 写进 StoreContextHolder
StoreContextHolder # @RequestScope bean,持有当前 用户+门店+角色
ApiResultAuthenticationEntryPoint # 401 → ApiResult JSON
ApiResultAccessDeniedHandler # 403 → ApiResult JSON
SecurityConfig # 统一 SecurityFilterChain
domains/identity-store/
负责登录、refresh token 签发/轮换/撤销、门店列表与切换、菜单权限
```
## 端点契约(与客户端已实现的行为对齐)
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../../conti-retail-app/docs/05-networking.md)、[../11-store-context-and-session.md](../../conti-retail-app/docs/11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
| 端点 | 认证 | 说明 |
| --- | --- | --- |
| `POST /api/v1/auth/login` | 免认证 | `{ username, password }``{ accessToken, refreshToken, user }` |
| `POST /api/v1/auth/refresh` | 免认证 | `{ refreshToken }``{ accessToken, refreshToken }`。**必须免认证**:调用它的时候 access token 恰好已经过期 |
| `POST /api/v1/auth/logout` | 需认证 | `{ refreshToken }``ApiResult<Unit>`。撤销该 refresh token;客户端 3 秒超时、失败不阻断本地登出 |
| `GET /api/v1/auth/me` | 需认证 | → `{ user, currentStoreId }`。客户端冷启动时用本地 token 调它恢复会话(客户端 `11-store-context-and-session.md` 的启动流程)。**必须是轻量查询**,不做任何写操作——它在每次冷启动都会被调用 |
| `POST /api/v1/stores/{id}/switch` | 需认证 | → `StoreContext`(含新的 `accessToken` + 菜单),见下面"切换门店" |
客户端冷启动的完整顺序是 `GET /auth/me``GET /api/v1/stores/accessible`(后者见 [06-api-design.md](./06-api-design.md)):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 `ApiResult` JSON(见下面 `ApiResultAuthenticationEntryPoint`),不能是 500。
其余端点的路径与响应包装规则见 [06-api-design.md](./06-api-design.md)。
## Token 配置与校验
```kotlin
// platform-security/.../JwtProperties.kt
@ConfigurationProperties(prefix = "security.jwt")
@Validated
data class JwtProperties(
/** 当前签发用的密钥 ID,写进 JWT header 的 kid,用于密钥轮换,见"密钥轮换"一节。 */
@field:NotBlank val activeKeyId: String,
/** kid -> base64 密钥,允许同时持有多个用于校验,只有 activeKeyId 用于签发。 */
val keys: Map<String, String> = emptyMap(),
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
@field:Min(1) val refreshTokenTtlDays: Long = 30,
) {
@PostConstruct
fun validate() {
val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
require(Base64.getDecoder().decode(key).size >= 32) {
"HS256 密钥长度必须 ≥ 32 字节,当前配置不满足" // 启动即失败,不允许带着弱密钥跑起来
}
}
}
```
```kotlin
// platform-security/.../JwtEncoderConfig.kt
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtEncoderConfig(private val props: JwtProperties) {
private fun secretKey(keyId: String): SecretKey =
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
@Bean
fun jwtEncoder(): JwtEncoder {
val jwkSet = JWKSet(
props.keys.keys.map { kid ->
OctetSequenceKey.Builder(secretKey(kid)).keyID(kid).algorithm(JWSAlgorithm.HS256).build()
},
)
return NimbusJwtEncoder(ImmutableJWKSet(jwkSet))
}
@Bean
fun jwtDecoder(): JwtDecoder =
// 校验时按 header 里的 kid 选密钥,所以轮换期间新旧 token 都能验过
NimbusJwtDecoder.withSecretKey(secretKey(props.activeKeyId))
.macAlgorithm(MacAlgorithm.HS256)
.build()
}
```
```kotlin
// platform-security/.../AccessTokenIssuer.kt
@Component
class AccessTokenIssuer(
private val jwtEncoder: JwtEncoder,
private val props: JwtProperties,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
val now = clock.instant()
val claims = JwtClaimsSet.builder()
.subject(userId.toString())
.claim("storeId", storeId)
.claim("roles", roles)
.id(UUID.randomUUID().toString()) // jti
.issuedAt(now)
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
.build()
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}
}
```
### Access Token 的 claims 结构
```json
{
"sub": "1024",
"storeId": 7,
"roles": ["STORE_MANAGER"],
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
"iat": 1735600000,
"exp": 1735601800
}
```
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。
## `StoreContextHolder` 与上下文注入
```kotlin
// platform-security/.../StoreContextHolder.kt
@Component
@RequestScope // 默认 proxyMode = TARGET_CLASS,可以直接注入到单例 bean 里
class StoreContextHolder {
var userId: Long? = null
var storeId: Long? = null
var roles: List<String> = emptyList()
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
}
```
Kotlin 里这个类必须是 `open` 的(CGLIB 代理要求),`kotlin-spring` 插件会因为 `@Component` 自动放开,不需要手写 `open`——但如果哪天把 `@Component` 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。
```kotlin
// platform-security/.../StoreContextFilter.kt
// 注意:这个 filter 只负责"把已经验过的 claims 搬进 StoreContextHolder"
// 不做任何解析或验签——验签由 Spring Security 的 BearerTokenAuthenticationFilter 做完了。
// 这一点很关键:token 无效时的 401 响应由标准的 AuthenticationEntryPoint 产出,
// 不会出现"自己写的 filter 里抛异常 → @RestControllerAdvice 接不住 → 返回 500"的情况。
class StoreContextFilter(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
if (jwt != null) {
val ctx = storeContextHolder.`object`
ctx.userId = jwt.subject.toLong()
ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
// 客户端会带 X-Store-Id(见 06-api-design.md 的统一请求头约定)。
// 服务端一律以 token 里的 storeId 为准;不一致时记一条 warn 用于排查
// (切店瞬间客户端可能还在用旧 header,直接拒绝会误伤正常请求)。
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
if (headerStoreId != ctx.storeId) {
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
}
}
}
chain.doFilter(request, response)
}
}
```
其他 domain 里的 `application` 层直接注入 `StoreContextHolder` 拿当前上下文,不自己解析 token:
```kotlin
@Service
class WorkbenchAppService(
private val storeContextHolder: StoreContextHolder,
private val tileRepository: WorkbenchTileRepository,
) {
fun listTiles(): List<TileResponse> {
val userId = storeContextHolder.currentUserId()
val storeId = storeContextHolder.currentStoreId()
// ...
}
}
```
## `SecurityConfig`
```kotlin
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启 @PreAuthorize
class SecurityConfig(
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
private val entryPoint: ApiResultAuthenticationEntryPoint,
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
private val environment: Environment,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
http
.csrf { it.disable() } // 无状态 API + Bearer token,不存在 CSRF 的前提
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/actuator/health/**", // 存活/就绪探针,见 08-observability.md
"/api/v1/auth/login",
"/api/v1/auth/refresh", // 调它的时候 access token 已过期,必须免认证
).permitAll()
if (!isProd) {
// 接口文档只在非生产环境开放;生产环境走内网文档站或 CI 产物
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
}
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
oauth2.authenticationEntryPoint(entryPoint) // token 缺失/过期/签名错 → 统一 401 JSON
}
.exceptionHandling {
it.authenticationEntryPoint(entryPoint)
it.accessDeniedHandler(accessDeniedHandler) // 认证过但权限不足 → 统一 403 JSON
}
.addFilterAfter(
StoreContextFilter(storeContextHolder),
BearerTokenAuthenticationFilter::class.java, // 必须在认证之后
)
return http.build()
}
private fun rolesConverter(): Converter<Jwt, AbstractAuthenticationToken> {
val authorities = JwtGrantedAuthoritiesConverter().apply {
setAuthoritiesClaimName("roles")
setAuthorityPrefix("ROLE_")
}
return JwtAuthenticationConverter().apply { setJwtGrantedAuthoritiesConverter(authorities) }
}
}
```
```kotlin
// platform-security/.../ApiResultAuthenticationEntryPoint.kt
@Component
class ApiResultAuthenticationEntryPoint(
private val objectMapper: ObjectMapper,
) : AuthenticationEntryPoint {
override fun commence(req: HttpServletRequest, resp: HttpServletResponse, ex: AuthenticationException) {
resp.status = HttpStatus.UNAUTHORIZED.value() // 必须是 401:客户端只在 401 时触发刷新
resp.contentType = MediaType.APPLICATION_JSON_VALUE
resp.characterEncoding = Charsets.UTF_8.name()
objectMapper.writeValue(
resp.outputStream,
ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
)
}
}
```
**为什么这两个 handler 必须存在**Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../../conti-retail-app/docs/05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
## Refresh Token:不用 Redis,直接用现有 MySQL
Access token TTL 短(30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。
```kotlin
// domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt
@Entity
@Table(name = "refresh_token", schema = "identity_store")
class RefreshTokenEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val userId: Long,
@Column(name = "token_hash", nullable = false, length = 64)
val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文
@Column(nullable = false)
val expiresAt: Instant,
@Column
var revokedAt: Instant? = null,
@Column
var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放"
) : BaseEntity()
```
```sql
-- domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql
create table refresh_token (
id bigint not null auto_increment,
user_id bigint not null,
token_hash char(64) not null,
expires_at datetime(6) not null,
revoked_at datetime(6),
replaced_by_token_id bigint,
-- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_refresh_token_hash (token_hash),
key idx_refresh_token_user_id (user_id),
key idx_refresh_token_expires_at (expires_at),
constraint fk_refresh_token_user foreign key (user_id) references user (id)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
```kotlin
// domains/identity-store/application/RefreshTokenService.kt
@Service
class RefreshTokenService(
private val repository: RefreshTokenJpaRepository,
private val props: JwtProperties,
private val clock: Clock,
) {
fun issue(userId: Long): String {
val rawToken = generateOpaqueToken()
repository.save(
RefreshTokenEntity(
userId = userId,
tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
return rawToken // 返回明文给客户端,库里只有 hash
}
@Transactional
fun rotate(rawToken: String): RotatedTokens {
val existing = repository.findByTokenHash(sha256Hex(rawToken))
?: throw InvalidRefreshTokenException() // 从来不存在的 token
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
if (existing.revokedAt != null) {
repository.revokeAllByUserId(existing.userId, clock.instant())
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
throw InvalidRefreshTokenException()
}
if (existing.expiresAt.isBefore(clock.instant())) {
throw InvalidRefreshTokenException()
}
val newRawToken = generateOpaqueToken()
val rotated = repository.save(
RefreshTokenEntity(
userId = existing.userId,
tokenHash = sha256Hex(newRawToken), // 存 hash
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken) // 返回明文
}
@Transactional
fun revoke(rawToken: String) {
repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null }
?.let { it.revokedAt = clock.instant() }
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
}
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 强制下线
}
data class RotatedTokens(val userId: Long, val refreshToken: String)
```
- **轮换(rotation**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../../conti-retail-app/docs/11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
### 为什么现阶段不引入 Redis
Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束:
- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,MySQL 上一个唯一索引足够支撑。
- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
## 切换门店
```kotlin
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(private val storeAppService: StoreAppService) {
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
```
```kotlin
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val accessTokenIssuer: AccessTokenIssuer,
private val storeContextHolder: StoreContextHolder,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun switchStore(targetStoreId: Long): StoreContextResponse {
val userId = storeContextHolder.currentUserId()
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
val roles = storeRepository.findRoles(userId, targetStoreId)
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
// 只改客户端本地状态不换 token,等于门店没真的切过去。
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
// 3. 切店会使旧门店下的 WebView 票据失效(架构图 Flow 2)。
// 走领域事件,不由 identity-store 直接改 webview-ticket 的表,见 11-cross-domain-collaboration.md
eventPublisher.publishEvent(StoreSwitchedEvent(userId, from = storeContextHolder.storeId, to = targetStoreId))
return StoreContextResponse(accessToken, store.toInfo(), roles, menuOf(roles))
}
}
```
refresh token **不随切店轮换**:它绑定的是用户身份而不是门店,切店只换 access token。
## 越权与数据隔离规则
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
1. **所有涉及门店数据的查询,必须以 `storeContextHolder.currentStoreId()` 作为过滤条件**,而不是用请求参数里传来的 `storeId`。请求参数可以被任意篡改,token 里的不行。
2. **确实需要按路径/参数指定 `storeId` 的接口(如切店),必须先校验该用户对目标门店的可访问性**,校验不过返回 `STORE_NOT_ACCESSIBLE`。上面的 `findAccessibleStore` 就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。
3. **按 ID 查单条记录时,`where id = ? and store_id = ?`**,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。
4. 这三条在 code review 里是必看项,新增查询方法时优先检查。
### 接口级权限 vs 菜单权限
- **菜单权限**(切店时返回的 `menu`)只决定 APP 上显示什么,是 UI 层的便利,**不构成安全边界**。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。
- **接口级权限**用 `@PreAuthorize``application` 层(或 Controller)上显式声明,这才是真正的边界:
```kotlin
@PreAuthorize("hasRole('STORE_MANAGER')")
fun approveProcurement(id: Long) { ... }
```
- 两者的数据来源应该是同一份角色/权限配置,但**必须两边都做**。只做菜单不做接口校验,等于没做。
## 密码存储与登录保护
- **密码用 BCrypt 存储**`BCryptPasswordEncoder`,强度 12),存 hash,绝不存明文或可逆加密。用 `DelegatingPasswordEncoder`Spring Security 默认)让 hash 带 `{bcrypt}` 前缀,将来换算法时新旧可以共存。
- **登录失败限流**`user` 表上加 `failed_attempts` / `locked_until` 两列,同一账号连续失败 5 次锁定 15 分钟。放在数据库而不是内存里,是因为多副本下内存计数各算各的,等于没限。
- **IP 维度的限流放在 API Gateway 做**,后端不重复实现——后端看到的往往是网关的 IP,自己做也做不准。
- **登录失败的响应不区分"用户不存在"和"密码错误"**,统一返回同一个错误码和文案,避免被用来枚举账号。
- 密码、token、`Authorization` 头等敏感字段**不允许进日志**,脱敏规则见 [08-observability.md](./08-observability.md)。
## 密钥轮换
JWT 签名密钥放在 K8s Secret 里(见 [07-config-governance.md](./07-config-governance.md)),轮换按 `kid` 分三步走,全程不需要让用户重新登录:
1. **加新密钥**:在 `security.jwt.keys` 里加一个新 `kid`,但 `activeKeyId` 保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。
2. **切签发**:把 `activeKeyId` 改成新 `kid`。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。
3. **删旧密钥**:等超过一个 access token TTL30 分钟)后,从 `keys` 里移除旧 `kid`。
三步之间必须各自完成一次滚动更新,不能合并成一次改动——合并了会出现"某些实例已经用新密钥签发,另一些实例还不认识新密钥"的窗口,表现为随机 401。
## 关键规则
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
- 门店/角色上下文在统一 filter 里从已认证的 `Jwt` 写入 `StoreContextHolder`,各 domain 通过它读取,不各自解析 token。
- 切换门店必须重新签发 access token,并联动使 `webview-ticket` 的当前票据失效(架构图 Flow 2)——走领域事件,不跨模块直接改表。
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
## 附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / [01-project-structure.md](./01-project-structure.md))下它有个具体问题:jjwt 的 JSON 序列化模块 `jjwt-jackson` 依赖 **Jackson 2**`com.fasterxml.jackson`),而 Boot 4 默认用的是 **Jackson 3**`tools.jackson`)。两者包名不同、不二进制兼容,用 jjwt 就意味着要在同一个应用里同时带两套 Jackson——能跑,但多一份依赖、多一处升级时要照顾的地方,还容易让人误以为 `ObjectMapper` 只有一个。
Spring Security 本身就自带基于 Nimbus JOSE + JWT 的 `JwtEncoder`/`JwtDecoder``spring-boot-starter-oauth2-resource-server`),版本由 Boot BOM 管,不引入第二套 JSON 库。更重要的是,用它就能顺带用上 `oauth2ResourceServer` 这套标准链路:**token 的解析和验签由框架的 filter 完成,失败时走标准的 `AuthenticationEntryPoint` 返回 401**——而自己写 filter 解析 token 时,最常见的 bug 恰恰是异常没接住导致返回 500(详见上面 handler 那一节)。选它是同时解决依赖和正确性两个问题。
代价:Nimbus 的 API 比 jjwt 的链式 builder 略啰嗦一点(多一个 `JwtClaimsSet`/`JwsHeader` 的概念),以及如果团队里有人熟悉 jjwt 需要适应一下。如果后续确实要换回 jjwt,注意版本要选和 Jackson 3 兼容的,或者自己实现 jjwt 的 `Serializer`/`Deserializer` 接口接到 Jackson 3 上。
## 附录二:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
2. **线程切换场景失效**:一旦把工作交给另一个线程池(比如 `workbench` 首页的并行聚合,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),`ThreadLocal` 绑定的线程和实际干活的线程就不是同一个了。
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
需要注意的是 `@RequestScope` 也**不会自动跨线程传播**:并行聚合时子线程里拿不到它。那种场景要么把需要的值(`userId`/`storeId`)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 `TaskDecorator` 显式搬运上下文——两种做法的取舍见 11 篇。
## 待补充
- 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。
## 参考链接
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
- [Spring Security: JWT 支持(JwtEncoder / JwtDecoder](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
- [OWASP 认证 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)
+315
View File
@@ -0,0 +1,315 @@
# 05. 集成层设计(F6 / Mini 域)
## 决策
供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`
### 为什么是同步 `RestClient` 而不是 `WebClient`
`WebClient` 是 Spring 官方推荐的现代 HTTP 客户端,但它的返回值是 `Mono`/`Flux`,把响应式编程模型带进了整个调用链。在我们这套以 Spring MVC(同步 Servlet 栈)为主的应用里,这会造成几个实际问题:
- **上下文会丢**。`StoreContextHolder``@RequestScope` bean、日志的 `traceId` 在 MDC 里,这两样都绑在请求线程上。一旦回调跑在 Reactor 的 event-loop 线程上,两者都拿不到——日志断链、上下文取不到值,而且这类 bug 只在特定时序下出现,很难查。
- **收益用不上**。响应式的价值是用少量线程扛住大量并发连接,前提是**整条链路都是非阻塞的**。我们的链路里有 JPA(阻塞 JDBC),只要还在用 JPA,中间夹一段响应式并不会减少线程占用,只是把复杂度带进来。
- **心智成本**。`Mono` 一旦进入业务代码,测试、异常处理、事务边界都要按另一套规则写,团队要同时掌握两套模型。
`RestClient`Spring 6.1+)提供的是和 `WebClient` 一样的流式 API,但返回值是普通对象、执行是同步的。等到将来整条链路真的要转非阻塞(比如换掉 JPA),再统一切换到 `WebClient` 不迟——那时是一次有明确收益的整体迁移,而不是现在这样局部引入。
## 结构约定
```
platform-integration/
RestClientConfig # 按下游系统构建 RestClient(连接池、超时、拦截器)
IntegrationClientProperties # integration.clients.* 配置绑定
TracePropagationInterceptor # 统一给出站请求加 X-Trace-Id
IntegrationException # 集成层异常基类(继承 platform-web 的 BusinessException
integration/f6-adapter/
负责:换票、供应商访问上下文准备、超时/重试/熔断/舱壁策略、异常转换为内部标准错误码
integration/mini-clients/
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
```
`platform-integration` 依赖 `platform-web`(为了复用 `BusinessException``ErrorCode`)是本次允许的少数几个 platform 间依赖之一。
## `RestClient` 配置
```groovy
// platform-integration/build.gradle
dependencies {
implementation project(':platform:platform-web')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.apache.httpcomponents.client5:httpclient5' // 版本由 Boot BOM 管
implementation 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0'
}
```
```kotlin
// platform-integration/.../RestClientConfig.kt
@Configuration
@EnableConfigurationProperties(IntegrationClientProperties::class)
class RestClientConfig(private val props: IntegrationClientProperties) {
@Bean
fun f6RestClient(builder: RestClient.Builder): RestClient = build(builder, props.f6) { req, resp ->
// 5xx / 连接类问题 → 可重试;4xx → 请求本身有问题,重试没有意义
if (resp.statusCode.is5xxServerError) {
throw F6ServerException("F6 返回 ${resp.statusCode}")
}
throw F6ClientException("F6 拒绝了请求: ${resp.statusCode}")
}
@Bean
fun miniRestClient(builder: RestClient.Builder): RestClient = build(builder, props.mini) { _, resp ->
throw MiniIntegrationException("Mini 域返回 ${resp.statusCode}")
}
private fun build(
builder: RestClient.Builder, // 注入 Boot 提供的 Builder,而不是 RestClient.create()
config: ClientConfig, // 这样 Micrometer 的 observation/W3C traceparent 会自动挂上(见 08
statusHandler: RestClient.ResponseSpec.ErrorHandler,
): RestClient = builder
.baseUrl(config.baseUrl)
.requestFactory(requestFactory(config))
.requestInterceptor(TracePropagationInterceptor())
.defaultStatusHandler(HttpStatusCode::isError, statusHandler)
.build()
private fun requestFactory(config: ClientConfig): ClientHttpRequestFactory {
val connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(config.maxConnections)
.setMaxConnPerRoute(config.maxConnectionsPerRoute) // 必须 ≥ 对应的舱壁并发上限,理由见下
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(config.connectTimeoutMs))
.setValidateAfterInactivity(TimeValue.ofSeconds(5)) // 复用前探活,避开对端已断的空闲连接
.build(),
)
.build()
val httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(
RequestConfig.custom()
// 从连接池拿连接的等待上限。漏配它是最隐蔽的一个坑:
// 池子被占满时线程会无限期地等在这里,前面所有超时配置全部失效。
.setConnectionRequestTimeout(Timeout.ofMilliseconds(config.connectionRequestTimeoutMs))
.setResponseTimeout(Timeout.ofMilliseconds(config.readTimeoutMs))
.build(),
)
.evictIdleConnections(TimeValue.ofSeconds(30))
.build()
return HttpComponentsClientHttpRequestFactory(httpClient)
}
}
```
```yaml
integration:
clients:
f6:
base-url: ${F6_BASE_URL}
connect-timeout-ms: 1000
read-timeout-ms: 2000
connection-request-timeout-ms: 500
max-connections: 60
max-connections-per-route: 30
mini:
base-url: ${MINI_BASE_URL}
connect-timeout-ms: 500
read-timeout-ms: 1000
connection-request-timeout-ms: 300
max-connections: 60
max-connections-per-route: 30
```
**超时由 HTTP 客户端承担,不用 `@TimeLimiter`。** Resilience4j 的 `@TimeLimiter` 只能作用于 `CompletableFuture` 或响应式返回值——同步方法上加了不会报错,但**完全不生效**,是个非常容易误配的注解。同步栈下真正管用的是上面三个:`connectTimeout`(建连)、`responseTimeout`(等响应)、`connectionRequestTimeout`(等连接池)。
## Resilience4j 配置
```yaml
resilience4j:
# 叠加顺序由这几个 *-aspect-order 属性决定,跟注解写在方法上的先后顺序无关(详见文末附录)。
# 这里显式写死,避免依赖框架默认值——默认值会随版本变,而顺序变了语义就变了。
retry:
retry-aspect-order: 3 # 最外层
instances:
f6-api:
max-attempts: 2
wait-duration: 200ms
exponential-backoff-multiplier: 2
retry-exceptions:
# 同步栈下真实会抛出来的类型:
# - ResourceAccessException 包住了 SocketTimeoutException/ConnectException 等所有 IO 异常,
# 业务代码永远不会直接见到 SocketTimeoutException,写它是无效配置
# - F6ServerException 是我们在 statusHandler 里对 5xx 的转换
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
ignore-exceptions:
# 熔断已打开时抛的异常,重试它毫无意义,只会白白多等一轮 wait-duration
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- com.continental.retailapp.integration.f6.F6ClientException
circuitbreaker:
circuit-breaker-aspect-order: 2
instances:
f6-api:
sliding-window-type: COUNT_BASED
sliding-window-size: 20
minimum-number-of-calls: 10 # 样本太少时不做判断,避免启动后头几个请求就把熔断打开
failure-rate-threshold: 50
slow-call-duration-threshold: 1500ms
slow-call-rate-threshold: 80 # 慢调用也算故障:只统计失败率的话,"每次都卡满 2 秒但最终成功"永远不会熔断
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
record-exceptions:
- org.springframework.web.client.ResourceAccessException
- com.continental.retailapp.integration.f6.F6ServerException
bulkhead:
bulkhead-aspect-order: 1 # 最内层,贴着真实调用
instances:
f6-api:
max-concurrent-calls: 20
max-wait-duration: 0 # 拿不到名额立刻失败走降级,不排队 —— 排队等于把阻塞换个地方而已
mini-o2o:
max-concurrent-calls: 20
max-wait-duration: 0
```
## F6 客户端示例
```kotlin
// integration/f6-adapter/.../F6ApiClient.kt
@Component
class F6ApiClient(
private val f6RestClient: RestClient, // 来自 platform-integration 的统一封装
) {
@Bulkhead(name = "f6-api") // 默认 SEMAPHORE 类型,不额外起线程
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
@Retry(name = "f6-api")
fun fetchProcurementList(storeId: Long): ProcurementListResponse =
f6RestClient.get()
.uri("/f6/procurement/list?storeId={storeId}", storeId)
.retrieve()
.body(ProcurementListResponse::class.java)!!
// Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型与原方法一致(同步下就是 T)
fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementListResponse {
log.warn("F6 采购列表降级返回,storeId={}, cause={}", storeId, ex.toString())
return ProcurementListResponse.degraded()
}
}
```
```kotlin
// platform-integration/.../IntegrationException.kt
// 继承 BusinessException(见 06-api-design.md),复用 GlobalExceptionHandler
// 不再单独写一个 @RestControllerAdvice —— 少一个会和全局处理器抢优先级的地方。
open class IntegrationException(
code: Int,
message: String,
httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY,
) : BusinessException(code, message, httpStatus)
// integration/f6-adapter/.../F6Exceptions.kt
class F6ServerException(message: String) :
IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试")
class F6ClientException(message: String) :
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝")
```
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../../conti-retail-app/docs/12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
## Mini 域客户端示例(内部系统,策略更宽松)
```kotlin
// integration/mini-clients/.../O2OClient.kt
@Component
class O2OClient(private val miniRestClient: RestClient) {
@Bulkhead(name = "mini-o2o") // 只做超时 + 并发隔离,不加熔断(内部系统,稳定性相对可控)
fun fetchOrderSummary(storeId: Long): OrderSummary =
try {
miniRestClient.get()
.uri("/o2o/orders/summary?storeId={storeId}", storeId)
.retrieve()
.body(OrderSummary::class.java)!!
} catch (ex: Exception) {
log.warn("O2O 订单摘要降级返回,storeId={}", storeId, ex)
OrderSummary.empty() // 局部降级:首页某个 tile 空着,好过整个首页 500
}
}
```
## traceId 透传
```kotlin
// platform-integration/.../TracePropagationInterceptor.kt
class TracePropagationInterceptor : ClientHttpRequestInterceptor {
override fun intercept(
request: HttpRequest,
body: ByteArray,
execution: ClientHttpRequestExecution,
): ClientHttpResponse {
MDC.get("traceId")?.let { request.headers.set("X-Trace-Id", it) }
return execution.execute(request, body)
}
}
```
同步栈下拦截器和业务代码跑在同一个线程上,MDC 一定取得到,业务代码零感知。W3C 标准的 `traceparent` 头由 Micrometer Tracing 自动加(前提是用注入的 `RestClient.Builder` 构建,见上面的注释);这里额外发的 `X-Trace-Id` 是给 F6 这类只认自定义头的外部系统用的,两者并存,规则见 [08-observability.md](./08-observability.md)。
## 关键规则
- **F6 是外部供应商域**,稳定性不可控,必须配齐超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
- **异常统一转换**:F6 / Mini 域的异常和非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
- **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
- **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。
- 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。
- `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。
## 附录一:超时、重试、熔断、舱壁分别解决什么问题
四者经常被一起提,但作用点不同,配置的时候容易搞混:
- **超时(Timeout)**:解决"对方一直不回应,我方线程/连接被一直占着"的问题。这是最基础、必须有的一道防线。同步栈下它由 HTTP 客户端提供,不是 Resilience4j 提供的。
- **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。前提是**幂等**。
- **熔断(Circuit Breaker**:解决"对方已经持续故障,继续请求只是在浪费资源、拖慢自己"的问题。统计滑动窗口内的失败率(和慢调用率),超阈值后直接短路走 fallback(`OPEN`),过一段时间放几个探测请求(`HALF_OPEN`)判断是否恢复。
- **舱壁(Bulkhead**:解决"一个慢下游把我方所有工作线程吃光"的问题——**这一道在同步栈下尤其重要**。算笔账:Tomcat 默认 200 个工作线程,F6 读超时 2 秒,如果 F6 全面卡住且没有并发限制,200 个线程会在 2 秒内全部堵在 F6 上,此时这个应用连登录、连查本地数据库的接口都不可用了,一个外部依赖直接把整个服务拖死。配上 `max-concurrent-calls: 20` 之后,最多 20 个线程能进去,剩下 180 个照常干活,超出的请求立刻走降级——**用一个功能的降级换整个服务的存活**。
响应式栈下这道防线的必要性没这么强(event-loop 天生不会被阻塞占死),这也是从 `WebClient` 换到 `RestClient` 后必须补上它的原因。
舱壁上限和连接池要对齐:`max-connections-per-route` 必须 ≥ `max-concurrent-calls`,否则真正的瓶颈会变成"抢连接",请求会堵在 `connectionRequestTimeout` 上,而不是被舱壁干脆利落地挡掉。
## 附录二:叠加顺序由配置决定,不是由注解顺序决定
**一个常见误解是"注解写在上面的就在外层"——不是的。** Resilience4j 的各个切面都是独立的 Spring AOP Aspect,它们的嵌套顺序由 `resilience4j.<type>.<type>-aspect-order` 属性(也就是 Spring 的 `@Order` 语义)决定,跟注解在方法上的书写顺序完全无关。框架的默认顺序是 **Retry 在最外层**
```
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 真实调用 ) ) ) ) )
```
顺序不同,语义差别很实在:
- **Retry 在外(默认,我们采用)**:每一次重试都会各自经过熔断器,所以一次失败的调用会往熔断器的统计窗口里记 2 笔(`max-attempts: 2`)。好处是下游真出问题时熔断器打开得更快;代价是窗口里的样本数会被重试放大,`sliding-window-size` 要按放大后的量来估。这也是为什么必须把 `CallNotPermittedException` 加进 `ignore-exceptions`——熔断打开后抛的就是它,不排除掉的话每次请求还要白白多等一轮重试。
- **CircuitBreaker 在外**:整组重试合起来只在熔断器上记 1 笔,统计更"干净",但下游故障时熔断打开会慢一些。
**建议**:像上面 yaml 那样把三个 `*-aspect-order` 显式写出来,不要依赖默认值——默认值可能随版本变化,而这个顺序一变,熔断的触发速度就跟着变了,还很难从现象上察觉。另外,各版本对"order 数值大是更外层还是更内层"的约定容易记反,配完之后**用一个必定失败的集成测试实际验证一次**叠加顺序(比如断言下游被调用了几次、熔断器记录了几笔),比查文档可靠,写法见 [10-testing.md](./10-testing.md)。
## 待补充
- 具体超时/重试/舱壁参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。
- 熔断后降级返回的数据结构约定(`degraded()` 的具体字段,以及 APP 侧如何展示"这块数据是降级的")。
- F6 换票的具体协议细节(对接 [04-security-auth.md](./04-security-auth.md) 的切店联动失效)。
## 参考链接
- [Resilience4j 官方文档](https://resilience4j.readme.io/docs)
- [Spring Framework: RestClient](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient)
- [Apache HttpClient 5 连接管理](https://hc.apache.org/httpcomponents-client-5.4.x/current/tutorial/html/connmgmt.html)
- [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html)
- [Release It! 中的 Bulkhead 模式](https://learn.microsoft.com/azure/architecture/patterns/bulkhead)
+300
View File
@@ -0,0 +1,300 @@
# 06. API 设计规范
## 决策
REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 domain 的返回标准化成 APP 需要的形态。
## 结构约定
```
platform-web/
ApiResult<T> # { code, message, data, traceId } 统一响应包装
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
ErrorCode # 错误码常量
BusinessException # 业务异常基类,带错误码
domains/xxx/api/
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
request/ Xxx*Request # 请求 DTO
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
domains/xxx/application/
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct
```
## `ApiResult` + 全局异常处理示例
```kotlin
// platform-web/.../ApiResult.kt
data class ApiResult<T>(
val code: Int, // 0 = 成功;非 0 见下面的错误码分段
val message: String,
val data: T?,
val traceId: String,
) {
companion object {
fun <T> ok(data: T): ApiResult<T> =
ApiResult(ErrorCode.OK, "success", data, currentTraceId())
fun error(code: Int, message: String): ApiResult<Nothing> =
ApiResult(code, message, null, currentTraceId())
}
}
// platform-web/.../ErrorCode.kt
object ErrorCode {
const val OK = 0
// 10xxx 平台通用
const val INVALID_PARAM = 10001
const val UNAUTHORIZED = 10401
const val FORBIDDEN = 10403
const val NOT_FOUND = 10404
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
const val INTERNAL_ERROR = 10500
// 11xxx 认证与门店
const val STORE_NOT_ACCESSIBLE = 11001
const val NO_STORE_PERMISSION = 11002
// 20xxx 采购 / 21xxx 库存,各 domain 在自己的段内分配
// 30xxx F6 集成,见 05-integration-layer.md
const val F6_UNAVAILABLE = 30001 // 熔断/超时/连不上
const val F6_BUSINESS_ERROR = 30002 // F6 明确拒绝了请求
// 31xxx Mini 域集成
const val MINI_UNAVAILABLE = 31001
}
// platform-web/.../GlobalExceptionHandler.kt
// platform-web/.../BusinessException.kt
// 所有可预期的业务失败都抛它(或它的子类,如 05-integration-layer.md 的 IntegrationException)。
// httpStatus 有默认值但可以覆盖:错误码是给客户端做分支的,HTTP 状态码是给中间层(网关、监控、
// 客户端拦截器)做粗粒度判断的,两者职责不同,不能只留一个。
open class BusinessException(
val code: Int,
override val message: String,
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) : RuntimeException(message)
// 用法示例:需要客户端走"无权限"分支时,必须显式给 403,
// 否则默认的 400 会让客户端把它当成参数错误
throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
@RestControllerAdvice
class GlobalExceptionHandler {
@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(ErrorCode.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(ObjectOptimisticLockingFailureException::class)
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md
log.error("未处理异常", ex)
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
}
}
```
**`GlobalExceptionHandler` 接不到 Spring Security 过滤器里抛的异常**(它们在 `DispatcherServlet` 之前),401/403 由 [04-security-auth.md](./04-security-auth.md) 里的 `AuthenticationEntryPoint`/`AccessDeniedHandler` 产出同样结构的 JSON。两处必须返回同一套结构,客户端才只需要一套解析逻辑。
## Controller + DTO 示例
```kotlin
// api/StoreController.kt
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
private val storeAppService: StoreAppService,
) {
@Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping("/accessible")
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listAccessibleStores())
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
// api/response/StoreResponse.kt
data class StoreResponse(
val id: Long,
val name: String,
val code: String,
)
```
路径和响应体是照着客户端 [../11-store-context-and-session.md](../../conti-retail-app/docs/11-store-context-and-session.md)、[../05-networking.md](../../conti-retail-app/docs/05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
## DTO 转换:MapStruct,放在 application 层
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
```groovy
// build.gradleKotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP
plugins {
id 'org.jetbrains.kotlin.kapt'
}
dependencies {
implementation 'org.mapstruct:mapstruct:1.6.3'
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
}
```
```kotlin
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
@Mapper(componentModel = "spring")
interface StoreMapper {
fun toResponse(view: StoreView): StoreResponse
@Mapping(target = "displayName", source = "name")
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
fun toResponseList(views: List<StoreView>): List<StoreResponse>
}
```
**mapper 必须放在 `application` 层,不能放在 `api/mapper/`**:它的入参是 `Entity` 或投影(`infrastructure` 里的类型),放在 `api` 层就等于让 `api` 依赖 `infrastructure`,会被 [10-testing.md](./10-testing.md) 里的 ArchUnit 规则判红。对应 [02-layering.md](./02-layering.md) 的那句边界规则:
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
> **`Entity`/领域模型 → `Response` 的转换发生在 `application` 层。**
`componentModel = "spring"` 让生成的实现类自动注册成 Spring bean`application` 层直接注入使用。
## 统一请求头约定
客户端每个请求固定携带以下头(见 [../05-networking.md](../../conti-retail-app/docs/05-networking.md)),后端的处理规则:
| 请求头 | 必带 | 后端处理 |
| --- | --- | --- |
| `Authorization: Bearer <accessToken>` | 除免认证端点外 | 见 [04-security-auth.md](./04-security-auth.md) |
| `X-Trace-Id` | 是 | **优先复用**客户端传来的值作为本次请求的 traceId,格式非法时丢弃并自行生成,见 [08-observability.md](./08-observability.md) |
| `X-Store-Id` | 是 | **仅用于日志与排查**。数据范围一律以 token 里的 `storeId` 为准;不一致时记 warn,不拒绝请求 |
| `X-App-Version` | 是 | 用于版本兼容判断(见下)与埋点维度 |
| `X-Device-Id` | 是 | 用于日志关联和风控,不作为身份凭证 |
**关键规则:请求头里的任何值都不构成身份或权限依据。** `X-Store-Id``X-Device-Id` 都是客户端可以随手改的,只有 `Authorization` 里签过名的 claims 才算数。
## 数据格式约定
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../12-error-and-api-contract.md](../../conti-retail-app/docs/12-error-and-api-contract.md)。
- **时间**:一律 ISO-8601 UTC 字符串,带毫秒和 `Z` 后缀——`"2026-08-14T03:21:45.123Z"`。Kotlin 侧类型是 `Instant`。**不传时间戳数字**(数字看不出单位是秒还是毫秒,出过太多次事),**不传本地时间**(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 [03-persistence.md](./03-persistence.md)。
- **金额**Kotlin 侧 `BigDecimal`,序列化成**字符串**`"1234.56"`)而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,`0.1 + 0.2` 那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。
- **枚举**:序列化成大写下划线字符串(`"STORE_MANAGER"`),不传序号。**客户端遇到未知枚举值必须能容错**(降级成"未知"而不是崩溃),否则后端加一个枚举值就得等所有用户升级 APP。
- **布尔**:真正的 `true`/`false`,不用 `0`/`1`,不用 `"Y"`/`"N"`
- **ID**`Long`,序列化成 JSON number。当前量级不会超过 JS 安全整数范围(2^53),如果将来引入雪花 ID 之类的大数字,必须改成字符串——这一条到时候是 breaking change,需要走版本升级。
- **null 策略****不做全局的 null 字段剔除**(不配 `NON_NULL`)。响应里保留 `"field": null`,让客户端能区分"这个字段服务端明确说了是空"和"服务端根本没返回这个字段"。集合类型永远返回 `[]` 而不是 `null`,客户端就不用到处判空。
- **字段命名**:JSON 用小驼峰(`storeId``createdAt`),与 Kotlin 属性名一致,不做下划线转换。
## 分页与排序约定
(这条同时解决客户端 [../05-networking.md](../../conti-retail-app/docs/05-networking.md) 里挂着的"分页字段名待定"。)
**请求参数**
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `pageNum` | Int | 1 | **从 1 开始**。注意 Spring Data 的 `Pageable` 是从 0 开始的,转换在 Controller 层做完,不要把这个差异漏给客户端 |
| `pageSize` | Int | 20 | 上限 100,超过按 100 处理,防止被一次拉全表 |
| `sort` | String | 各接口自定 | `字段名,asc|desc`,如 `createdAt,desc`。**允许排序的字段必须是白名单**,不能把参数直接拼进 SQL/JPQL |
**响应结构**(对应 [03-persistence.md](./03-persistence.md) 的 `PageResult<T>`):
```json
{
"code": 0,
"message": "success",
"traceId": "...",
"data": {
"list": [],
"pageNum": 1,
"pageSize": 20,
"total": 134,
"hasMore": true
}
}
```
不直接把 Spring Data 的 `Page` 序列化出去——它的 JSON 结构由 Spring 版本决定(Boot 3.3 起还会为此打警告并推荐 `PagedModel`),升级框架就可能悄悄改掉 API 契约。
**游标分页**:数据量大或要求"下拉加载不重不漏"的列表,用 `lastId` + `pageSize`,响应里返回 `nextCursor`。理由和写法见 [03-persistence.md](./03-persistence.md) 的分页一节。哪些接口用哪种,在接口文档里写清楚。
## 错误码规则
- **错误码是数字,`0` 表示成功**,按 domain 分段:
| 段 | 归属 |
| --- | --- |
| `10xxx` | 平台通用(参数、认证、鉴权、系统错误) |
| `11xxx` | 认证与门店 |
| `20xxx` / `21xxx` | 采购 / 库存 |
| `30xxx` | F6 集成 |
| `31xxx` | Mini 域集成 |
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code``message` 一起打。
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
- 新增错误码时**同步更新客户端的 `ApiCode`**[../12-error-and-api-contract.md](../../conti-retail-app/docs/12-error-and-api-contract.md)),两边分段方案必须一致。
## 版本与兼容
- **路径版本化**`/api/v1/...`。**只有 breaking change 才升 `/v2`**,且 v1 必须保留到监控数据显示旧版本 APP 的活跃量足够低为止——APP 不像网页能强制刷新,用户手机上永远会有旧版本。
- **兼容性判定**(这是 API 改动 review 的检查表):
| 改动 | 兼容? |
| --- | --- |
| 响应里加字段 | ✅ 兼容 |
| 请求里加**可选**参数 | ✅ 兼容 |
| 放宽校验规则 | ✅ 兼容 |
| 删字段 / 改字段名 / 改字段类型 | ❌ 破坏性 |
| 加必填参数 / 收紧校验 | ❌ 破坏性 |
| 改字段语义(值域、单位、时区) | ❌ 破坏性,且**最危险**——编译不报错,测试可能也过,只有线上数据是错的 |
- **字段废弃流程**:① 新字段上线、旧字段继续双写,OpenAPI 上给旧字段标 `@Schema(deprecated = true)`;② 观察埋点,确认使用旧字段的 APP 版本占比降到可接受;③ 下一个版本移除。整个过程至少跨两个 APP 发版周期,不要图快跳步。
- **最低版本控制**:确实需要强制升级时,由后端根据 `X-App-Version` 返回一个专门的错误码,APP 弹强制升级引导。这个能力要提前留出来(哪怕暂时不用),否则真需要的时候一点办法都没有。
- 接口文档用 [springdoc-openapi](https://springdoc.org/) 自动生成,`implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'`Boot 4 对应 springdoc 3.x),Controller 上写清楚 `@Operation` 描述。**文档 UI 只在非生产环境开放**,见 [04-security-auth.md](./04-security-auth.md)。
- `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。
## 附录:为什么要统一响应包装,而不是直接返回业务对象
不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后:
- 前端只需要判断 `code == 0` 就知道成功与否,不用对着 HTTP status code 猜。
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。我们的折中是**两个都给对**:`code` 给客户端用,HTTP status code 给网关/监控/日志用。
## 待补充
- **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`[../12-error-and-api-contract.md](../../conti-retail-app/docs/12-error-and-api-contract.md))保持同步。
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
## 参考链接
- [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)
- [MapStruct 官方文档](https://mapstruct.org/documentation/stable/reference/html/)
+306
View File
@@ -0,0 +1,306 @@
# 07. 配置与服务治理
## 决策
K8s 原生方案:ConfigMap + Secret + Spring Cloud Kubernetes,不引入 Nacos 等额外治理组件,贴合已有 Azure + GitLab CI/CD 部署方式(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../../conti-docs/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 —— 版本由根工程的 spring-cloud-dependencies BOM2025.1.2)统一管理,
// 见 01-project-structure.md 的版本基线表
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(另一个选项 event 需要 watch 权限)
strategy: refresh # 发现变化后做什么:只刷新 @RefreshScope bean,不重启容器
period: 15s
```
```kotlin
// 需要热更新的配置类加 @RefreshScope
@RefreshScope
@ConfigurationProperties(prefix = "workbench")
@Component
class WorkbenchProperties {
var degradeMessage: String = "部分数据暂时无法显示"
}
```
**`mode``strategy` 是两件事,别混**`mode` 决定怎么感知 ConfigMap 变化,`strategy` 决定感知到之后做什么。只配 `mode` 而漏掉 `strategy`,行为会依赖框架默认值。
**并不是所有配置都能热更新**,这一点要在改配置之前想清楚,否则会出现"改了 ConfigMap、观察半天没生效"的困惑:
| 配置 | 能否热更新 | 说明 |
| --- | --- | --- |
| 自定义的 `@RefreshScope` + `@ConfigurationProperties`(如 `workbench.*`) | ✅ | 这是热更新真正的适用范围 |
| 日志级别 | ✅ | 也可以直接用 `/actuator/loggers` 端点改,更直接 |
| `resilience4j.*` 的熔断/重试参数 | ⚠️ | 这些实例在启动时创建,普通 `refresh` 刷不到。真要动,用 `strategy: restart_context`(会重建应用上下文,等于一次内部重启)或者干脆走发布流程 |
| 数据源、连接池、JPA 相关 | ❌ | 走发布流程 |
| Secret 里的值 | ❌ | 见下面"关键规则"Secret 变更需要重启 Pod |
上面 ConfigMap 示例里同时放了 `resilience4j``workbench` 两段,正是为了说明这个差别:`workbench.degradeMessage` 改完 15 秒内生效,`failure-rate-threshold` 不会。
## 本地开发怎么跑(不需要真的连 K8s)
`spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案:
**方案一(推荐,日常开发默认用这个):本地 profile 直接关掉 Spring Cloud Kubernetes**
```yaml
# application-local.yml
spring:
cloud:
kubernetes:
config:
enabled: false # 本地不连 K8s API,配置全部走本地文件
reload:
enabled: false
datasource:
url: jdbc:mysql://localhost:3306/?connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
username: conti
password: conti_local_password # 仅本地开发用,不是真实密钥
security:
jwt:
active-key-id: local
keys:
# 仅本地开发用的假密钥;HS256 要求 base64 解码后 ≥ 32 字节,见 04-security-auth.md
local: bG9jYWwtZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcmVhbC11c2UtMzJi
```
```bash
# 本地起依赖(DB 等),配合 docker-compose 用
docker compose up -d mysql
# 用 local profile 启动
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
```
`application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也正是**配置外置**的价值所在:业务代码不知道、也不需要知道配置到底来自 K8s ConfigMap 还是本地文件,换配置来源不需要改一行 Kotlin。
这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。
**方案二(需要验证 ConfigMap 热更新等 K8s 特有行为时才用):本地起一个真实的小集群**
用 Docker Desktop 自带的 Kubernetes、[Kind](https://kind.sigs.k8s.io/)Kubernetes in Docker)或 [Minikube](https://minikube.sigs.k8s.io/) 在本机跑一个单节点集群,把上面"ConfigMap / Secret 示例"里的 yaml 应用到本地集群,验证 `@RefreshScope` 热更新、`ServiceAccount` 权限这些真正依赖 K8s API 的行为:
```bash
kind create cluster --name conti-local
kubectl config use-context kind-conti-local
kubectl create namespace retailapp-local
kubectl apply -f k8s/configmap-local.yaml -n retailapp-local
kubectl apply -f k8s/secret-local.yaml -n retailapp-local
# 应用本身也可以跑成本地集群里的 Pod(用本地构建的镜像),
# 或者在宿主机直接跑 jar、用 KUBECONFIG 指向 kind 集群验证配置读取
export KUBECONFIG=~/.kube/config
SPRING_PROFILES_ACTIVE=dev ./gradlew :bootstrap:bootRun
```
日常业务开发用方案一就够了,只有专门验证"配置中心相关能力本身"(比如调试 `spring.cloud.kubernetes.reload` 轮询逻辑)才需要方案二。
## 关键规则
- 配置变更优先走 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 就行,还能享受到热更新不用走发布流程的好处。
## Azure 上 Secret 的真正来源:Key Vault(不是手写 K8s Secret
前面 `k8s/secret-uat.yaml` 示例里 `stringData` 写的是占位符(`__injected_by_pipeline__`),这一节说清楚这个占位符具体是怎么被替换成真实值的。
根据现有部署架构(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../../conti-docs/Architecture-Diagram/deployment-architecture-diagram.drawio)),我们的 AKS 是 **Private AKS Cluster**Key Vault 也是通过 **Private Endpoint**`privatelink.vaultcore.azure.net`)访问的——也就是说真实密钥长期存在 Azure Key Vault 里,代码库、镜像、Git 历史里都不出现明文。落地到 K8s Secret 有两种方式,我们现在用的是方式一(跟 CI/GitLab 侧的配置习惯一致,不需要额外在 AKS 上装东西)。
**方式一(现用):CI/CD 流水线在部署前从 Key Vault 读值,渲染成 K8s Secret**
```bash
# GitLab CI job 里(Runner 需要有权限访问 Key Vault,见 09-build-deploy.md
JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv)
kubectl create secret generic conti-backend-secret \
--namespace retailapp-uat \
--from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" \
--dry-run=client -o yaml | kubectl apply -f -
```
- 密钥值只在 CI job 的执行过程中短暂出现(不会写进 CI 日志、不会落盘到镜像里),`kubectl apply` 之后就是一个普通的 K8s Secret,`Deployment` 照常用 `envFrom.secretRef` 引用(见前面 `k8s/deployment-uat.yaml` 示例)。
- 好处是**不需要在 AKS 上额外装插件、不需要给节点/Pod 配置 Managed Identity 绑定**,全部配置集中在 GitLab(CI 变量里存 Runner 访问 Key Vault 所需的 Service Principal/Managed Identity,业务代码和 K8s yaml 完全不感知 Key Vault 的存在),跟本地开发用 `application-local.yml` 手工填值、只是"谁来填值"变成了流水线,心智负担更小。
- 权衡:Key Vault 里密钥更新后,**不会自动同步**到已经跑着的 Secret,需要重新跑一次部署(或专门加一个"仅同步 Secret,不发版本"的 job)才能生效——这点上不如方式二自动。日常密钥轮换频率不高的情况下这个权衡是划算的。
**方式二(可选的未来增强):CSI Secret Store Driver,运行时直接挂载,K8s Secret 不落地明文**
<details>
<summary>展开查看(AKS 需装 `azure-keyvault-secrets-provider` 插件 + 配置 Managed Identity,运维成本更高,暂不采用)</summary>
```yaml
# k8s/secretproviderclass-uat.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: conti-backend-kv-uat
namespace: retailapp-uat
spec:
provider: azure
parameters:
usePodIdentity: "false"
useVMManagedIdentity: "true" # 用 AKS 节点/Pod 的 Managed Identity 免密访问 Key Vault
userAssignedIdentityID: "<managed-identity-client-id>"
keyvaultName: "conti-backend-kv"
tenantId: "<azure-tenant-id>"
objects: |
array:
- |
objectName: security-jwt-secret
objectType: secret
secretObjects: # 顺便同步成一个 K8s Secret,供 envFrom 引用
- secretName: conti-backend-secret
type: Opaque
data:
- objectName: security-jwt-secret
key: SECURITY_JWT_SECRET
```
优点是密钥更新后 CSI driver 会定期轮询自动同步、Pod 用 Managed Identity 直连 Key Vault 不经过 CI;代价是要在 AKS 上启用插件、每个环境配置对应的 `SecretProviderClass` 和身份绑定,运维配置面更大。如果以后密钥轮换频率变高、或者审计要求"密钥不能经过 CI 执行上下文",再切换到这条路径,现阶段先用方式一。
</details>
两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。
## 配置绑定与启动期校验
**所有配置一律绑定到 `@ConfigurationProperties` 类,不散着写 `@Value`。** `@Value` 散落在各处时,没人说得清这个应用到底需要哪些配置项,漏配一个只能等到运行到那行代码才报错。
```kotlin
@ConfigurationProperties(prefix = "integration.clients.f6")
@Validated
data class F6ClientProperties(
@field:NotBlank val baseUrl: String,
@field:Min(100) @field:Max(10_000) val readTimeoutMs: Long = 2000,
@field:Min(1) val maxConcurrentCalls: Int = 20,
)
```
配上 `@Validated` 之后,配置缺失或越界会在**启动时**直接失败,Pod 起不来,K8s 的就绪探针不通过,滚动更新会自动停住并保留旧版本(见 [09-build-deploy.md](./09-build-deploy.md))。这比"启动成功了,但半夜某个接口因为超时配成 0 而全线失败"要好得多——**配错了就别起来**是这里的核心原则。
同理,[04-security-auth.md](./04-security-auth.md) 里 JWT 密钥长度的校验也放在启动期,绝不允许带着弱密钥跑起来。
## 命名规范
| 对象 | 规范 | 示例 |
| --- | --- | --- |
| namespace | `retailapp-<env>` | `retailapp-uat` |
| ConfigMap | `conti-backend-config` | 每个环境一份,同名不同 namespace |
| Secret | `conti-backend-secret` | 同上 |
| ConfigMap 里的 key | `application-<profile>.yml` | `application-uat.yml` |
| Secret 里的 key | 全大写下划线,与 Spring 的 relaxed binding 对齐 | `DB_PASSWORD``spring.datasource.password`(配合 `${DB_PASSWORD}` 引用) |
| Key Vault secret 名 | 中划线小写,与配置路径对应 | `security-jwt-secret` |
| 自定义配置前缀 | 模块名小驼峰 | `workbench.*``integration.clients.*` |
**同名不同 namespace** 这一点是有意的:环境差异全部体现在 namespace 和 ConfigMap 内容上,Deployment yaml 在各环境之间只差镜像 tag 和 namespace,减少"UAT 好好的、生产漏配了一项"这类问题。
## 数据库与外部依赖的实例形态
| 依赖 | dev(内网 k3s | UAT / ProdAzure AKS |
| --- | --- | --- |
| MySQL | 集群内一个 MySQL 容器(数据可丢,随时重建) | **Azure Database for MySQL Flexible Server**,通过 **Private Endpoint** 接入 AKS 所在 VNet,不开公网访问 |
| 密钥 | k3s Secret,值由内网 CI 注入 | Azure Key Vault + Private Endpoint(见下一节) |
| 缓存 | 无(不引入 Redis,理由见 [04-security-auth.md](./04-security-auth.md) | 同左 |
生产 MySQL 的连接信息(host/账号)走 ConfigMap + Secret 注入,应用侧配置见 [03-persistence.md](./03-persistence.md)。**连接串里必须带 `sslMode=REQUIRED`**——Flexible Server 默认要求 TLS,漏了会连不上;同时也别为了图省事把它降级成 `DISABLED`
数据库账号分两个:Flyway 迁移用的账号有 DDL 权限(只在部署流程中使用),应用运行时账号只有 DML 权限,理由见 03。
## 待补充
- 多环境 profile 的完整参数清单(等各 domain 的配置项定下来后汇总成一张表)。
## 参考链接
- [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/)
+339
View File
@@ -0,0 +1,339 @@
# 08. 可观测性
## 决策
统一 Trace ID + 结构化(JSON)日志 + Micrometer 指标,对应架构图 `Cross-Cutting` 里的 `Observability` 要求;关键行为单独走审计日志通道,对应 `Audit / Security`
traceId **用 Spring Boot 自带的 Micrometer Tracing 生成和传播,不自己写 `TraceIdFilter`**——理由见下一节。
## 结构约定
```
platform-observability/
ClientTraceIdBridgeFilter # 把客户端的 X-Trace-Id 接进 Micrometer 的 trace 上下文
TraceResponseFilter # 把最终生效的 traceId 写回响应头
logback-spring.xml # 结构化日志格式 + 脱敏配置
MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus
AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志
```
## traceId:用 Micrometer Tracing
```groovy
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.micrometer:micrometer-tracing-bridge-otel' // 版本由 Boot BOM 管
```
```yaml
management:
tracing:
enabled: true # 开着:这是 traceId 进 MDC 的前提,关掉连日志里的 traceId 都没有了
export:
enabled: false # 但不往任何后端上报 span —— 我们现在只要日志关联,不建全链路追踪系统
sampling:
probability: 1.0 # 不上报就没有采样成本,全采即可,避免部分请求日志里没有 traceId
```
加上依赖之后,Spring Boot 会自动:
- 为每个进来的 HTTP 请求创建一个 span,把 `traceId` / `spanId` **自动放进 MDC**
- 出站的 `RestClient` 调用自动带上 W3C 标准的 `traceparent` 头(前提是用注入的 `RestClient.Builder` 构建,见 [05-integration-layer.md](./05-integration-layer.md));
- 线程池、`@Async``@Scheduled` 等场景由 Micrometer 的上下文传播机制接管,不需要自己搬运 MDC。
**为什么不自己写 `TraceIdFilter`**:自己写的版本只覆盖"HTTP 入口 + 手工加请求头"这两个点,一旦出现线程切换(并行聚合、异步任务)或者需要跟别的系统按标准协议对接,就要自己一点点补;而这些正是最容易漏、漏了又最难查的地方(日志断链时你只会觉得"这个请求怎么没日志")。Micrometer Tracing 是 Boot 的一等公民,这些点框架都已经处理好,而且将来真要接 APMJaeger/Zipkin/Application Insights)时,只需要加一个 exporter 依赖、把 `export.enabled` 打开,代码一行不用改。
### 与客户端 `X-Trace-Id` 的对接契约
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../05-networking.md](../../conti-retail-app/docs/05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
**契约(同时解决客户端文档里挂着的那条待确认项)**
1. 客户端生成的 `X-Trace-Id` **必须是 32 位小写十六进制字符**(UUID 去掉四个横线正好 32 位 hex,直接用即可),且不能全为 `0`
2. 后端校验通过则用它作为本次请求的 `traceId`;**校验不通过就忽略它,自行生成**——绝不把一个未经校验的请求头值直接当 ID 用。
3. 后端在响应头里回写最终生效的 `X-Trace-Id`,客户端以响应头为准(这样客户端能发现自己的值被丢弃了)。
4. `ApiResult.traceId` 返回的也是这个最终生效的值。
```kotlin
// platform-observability/.../ClientTraceIdBridgeFilter.kt
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 必须排在 Micrometer 的 observation filter 之前
class ClientTraceIdBridgeFilter : OncePerRequestFilter() {
companion object {
// 只接受 W3C trace-id 格式。这条正则同时也是安全边界:
// 请求头的值会进日志,不校验就等于允许任何人往日志里注入内容(换行伪造日志行、
// 塞进超长字符串撑爆日志存储、塞进控制字符干扰下游日志解析)。
private val TRACE_ID = Regex("^[0-9a-f]{32}$")
private const val INVALID = "00000000000000000000000000000000"
}
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val clientTraceId = request.getHeader("X-Trace-Id")
if (clientTraceId == null || !TRACE_ID.matches(clientTraceId) || clientTraceId == INVALID) {
chain.doFilter(request, response) // 不合法就当没传,让 Micrometer 自己生成
return
}
// 合成一个 W3C traceparent,让 Micrometer 把它当作父上下文接上,
// 于是服务端这次请求的 traceId 就等于客户端传来的值。
val traceparent = "00-$clientTraceId-${randomSpanId()}-01"
chain.doFilter(TraceparentRequestWrapper(request, traceparent), response)
}
}
// platform-observability/.../TraceResponseFilter.kt —— 排在 observation filter 之后,此时 MDC 已有 traceId
@Component
class TraceResponseFilter : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
MDC.get("traceId")?.let { response.setHeader("X-Trace-Id", it) }
chain.doFilter(request, response)
}
}
```
```kotlin
// platform-web/.../TraceIdSupport.kt —— 06-api-design.md 里 ApiResult 用的就是它
fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
```
出站调用侧,除了 Micrometer 自动加的 `traceparent`,还会额外发一个 `X-Trace-Id` 给 F6 这类只认自定义头的外部系统,见 [05-integration-layer.md](./05-integration-layer.md) 的 `TracePropagationInterceptor`
## 结构化日志
```xml
<!-- logback-spring.xml -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"app":"conti-backend","env":"${SPRING_PROFILES_ACTIVE:-local}"}</customFields>
<!-- 兜底脱敏:即使有人不小心把整个对象打进日志,这些字段的值也会被替换掉。
它是最后一道防线,不是免死金牌 —— 主要靠下面"日志规范"里的规则。 -->
<jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
<path>password</path>
<path>accessToken</path>
<path>refreshToken</path>
<path>authorization</path>
</jsonGeneratorDecorator>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</configuration>
```
```groovy
// build.gradle
implementation 'net.logstash.logback:logstash-logback-encoder:9.0' // 9.0 起用 Jackson 3,匹配 Boot 4
```
输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以按 `traceId` 过滤出一次请求的完整链路日志。
### 日志级别规范
统一标准,避免"所有人都打 INFO"导致真正重要的信息被淹没:
| 级别 | 用在什么地方 | 是否告警 |
| --- | --- | --- |
| `ERROR` | 需要人介入处理的问题:未预期异常、数据不一致、下游持续不可用 | 是 |
| `WARN` | 系统自己处理掉了但值得关注:降级返回、重试成功、乐观锁冲突、参数校验失败率异常 | 聚合后看趋势 |
| `INFO` | 关键业务节点:登录、切店、换票、外部系统调用的结果 | 否 |
| `DEBUG` | 排查用的中间状态 | 生产环境默认关闭 |
| `TRACE` | 不在生产环境使用 | — |
- **用户输入错误不是 ERROR**。参数校验失败、token 过期、无权限属于正常业务流,打 `WARN``INFO`,打成 ERROR 会让告警彻底失去意义。
- **catch 住并处理掉的异常不打 ERROR**,打 `WARN` 并说明降级动作。
- **异常要把 `Throwable` 作为最后一个参数传进去**`log.error("xxx 失败", ex)`),不要 `log.error("xxx 失败: " + ex.message)`——后者丢掉堆栈,等于放弃了排查的主要线索。
- 生产环境日志级别可以通过 `/actuator/loggers` 端点临时调整(该端点仅内网可达),排查完记得调回来。
### 敏感信息脱敏
**绝不进日志**:密码、`Authorization` 头及其中的 token、refresh token、JWT 签名密钥、数据库密码、F6 API key。
**需要脱敏后才能进日志**:手机号(`138****8000`)、姓名、身份证号、车牌号、VIN、详细地址、银行卡号。这套系统涉及车主和车辆信息,车牌和 VIN 是能定位到具体个人的,按 PII 对待。
落地规则:
1. **禁止把整个请求体/Entity 对象直接打进日志**`log.info("req={}", request)`)。今天这个对象里没有敏感字段,不代表明天加一个字段之后还没有——而加字段的人不会想起来去检查有哪些地方打过这个对象的日志。要打就显式列出需要的字段。
2. 确实要打的敏感字段走统一的脱敏工具方法(`Mask.phone(...)``Mask.plateNo(...)`),不各写各的。
3. 上面 logback 里的 `MaskingJsonGeneratorDecorator` 是兜底,防的是"不小心写漏了",不是"有它就可以随便打"——它只按字段名匹配 JSON 结构,拼在字符串里的敏感值它一个都拦不住。
4. **异常堆栈也可能带出敏感信息**(比如 SQL 参数、请求 URL 上的查询串),所以 URL 上不放敏感参数——需要传就放请求体。
## Micrometer / Actuator 配置
```yaml
management:
endpoints:
web:
exposure:
include: health, prometheus, info
endpoint:
health:
probes:
enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用
metrics:
tags:
application: conti-backend
```
`/actuator/prometheus``/actuator/loggers` **不对公网暴露**:只在集群内可达(`SecurityConfig` 里只 permitAll 了 `/actuator/health/**`,见 [04-security-auth.md](./04-security-auth.md)),Prometheus 从集群内抓取。
## K8s 探针配置(liveness / readiness
`management.endpoint.health.probes.enabled=true` 只是让 Spring Boot 暴露出 `/actuator/health/liveness``/actuator/health/readiness` 两个分组端点,真正让 K8s 用起来还需要在 Deployment 里配置探针指向这两个端点:
```yaml
# k8s/deployment-uat.yaml(节选,补充探针配置)
spec:
containers:
- name: conti-backend
startupProbe: # 启动阶段专用,跑通之前 liveness/readiness 都不生效
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 30 # 最多给 150 秒完成 JVM 启动 + Flyway 迁移
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
```
`startupProbe` 而不是给 liveness 配一个很大的 `initialDelaySeconds`:后者是"所有情况下都固定等这么久",启动快的时候白等,启动慢的时候(比如某次迁移脚本比较大)仍然会被误杀;`startupProbe` 是"给足上限、就绪即结束",两头都照顾到。
两者失败后的处理完全不同,容易搞混:
- **`livenessProbe` 失败** → K8s 认为这个 Pod 已经"死掉"(比如死锁、内存泄漏导致完全无响应),直接**重启**这个 Pod。
- **`readinessProbe` 失败** → K8s 只是把这个 Pod 从 Service 的 Endpoints 里**摘除**(不再转发流量给它),不重启;等探针恢复健康后自动重新加回来——典型场景是数据库连接池暂时耗尽、正在处理慢请求,这种情况不需要重启,只需要暂时别把新流量导过去。
`readiness` group 默认会包含数据库连接(`DataSourceHealthIndicator`)等下游依赖检查,`liveness` group 默认只检查应用自身状态(不含外部依赖)——这个区分本身也是为了避免"F6 挂了导致 liveness 失败、Pod 被不断重启"这种误杀,外部依赖异常应该走 [05-integration-layer.md](./05-integration-layer.md) 的熔断降级,而不是拖累 K8s 探针。
**不要把 F6 之类的外部依赖加进 readiness**,理由同上:F6 抖一下不应该让我们所有 Pod 同时被摘出负载均衡,那是自己把自己搞挂。
## Resilience4j 指标接入 Micrometer
[05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的熔断、重试、舱壁,本身的运行状态也应该能在监控里看到,不然只能等到线上报错才知道降级生效了:
```groovy
implementation 'io.github.resilience4j:resilience4j-micrometer:2.4.0'
```
加上这个依赖后,各个 registry 会自动把状态注册成 Micrometer meter,不需要手写埋点,跟着现有的 `/actuator/prometheus` 一起暴露出去。
## 关键指标与告警
指标分三类看,缺一类都会有盲区:
**① 系统健康**
| 指标 | 告警起点 |
| --- | --- |
| `http_server_requests_seconds_count{status=~"5.."}` 占比 | 5 分钟内 > 1% |
| `http_server_requests_seconds` P99 | > 2s 持续 5 分钟 |
| `jvm_memory_used_bytes{area="heap"}` / max | > 85% 持续 10 分钟 |
| `hikaricp_connections_pending` | > 0 持续 1 分钟(有线程在排队等数据库连接,见 [03-persistence.md](./03-persistence.md) 的池子容量算法) |
| Pod 重启次数 | 10 分钟内 ≥ 2 次 |
**② 依赖健康**
| 指标 | 告警起点 |
| --- | --- |
| `resilience4j_circuitbreaker_state{state="open"}` | 出现即告警——熔断器跳闸说明下游已经持续故障 |
| `resilience4j_bulkhead_available_concurrent_calls` | 降到 0 持续 1 分钟(并发名额被打满,正在丢请求) |
| `resilience4j_retry_calls{kind="failed_with_retry"}` 速率 | 明显抬升即关注 |
**③ 业务健康**(这一类最容易被忘,但恰恰是"系统全绿、用户用不了"时唯一能发现问题的指标)
| 指标 | 埋点方式 | 告警起点 |
| --- | --- | --- |
| 登录成功率 | `Counter``result` 打标签 | 5 分钟内 < 90% |
| 切店失败数 | 同上 | 突增 |
| WebView 换票失败率 | 同上 | 5 分钟内 > 5% |
| 首页聚合降级 tile 数 | `Counter``tile` 打标签 | 单个 tile 降级率 > 20% |
```kotlin
// 业务埋点示例:不要自己维护计数器,用 MeterRegistry
@Service
class LoginService(private val meterRegistry: MeterRegistry) {
fun login(...): LoginResult {
val result = doLogin(...)
meterRegistry.counter("business.login", "result", if (result.success) "success" else "failure").increment()
return result
}
}
```
告警阈值都是**起点,不是最终值**——上线后按实际曲线调整。阈值定得过敏感导致告警疲劳,比没有告警更糟糕,因为团队会开始习惯性忽略它。
## 审计日志
```kotlin
// 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: ObjectFactory<StoreContextHolder>) {
private val auditLog = LoggerFactory.getLogger("AUDIT")
@Around("@annotation(audited)")
fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? {
val result = runCatching { joinPoint.proceed() }
val ctx = runCatching { storeContextHolder.`object` }.getOrNull()
auditLog.info(
"action={} userId={} storeId={} traceId={} success={}",
audited.action, ctx?.userId, ctx?.storeId, currentTraceId(), result.isSuccess,
)
return result.getOrThrow() // 审计失败不能影响业务;反过来业务异常也照常抛出
}
}
// 使用方式
@Audited(action = "WEBVIEW_TICKET_ISSUE")
fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... }
```
审计日志走独立 logger`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。
需要审计的行为:登录/登出、切换门店、WebView 换票、权限变更、任何写操作失败、外部系统调用失败。审计日志**只记"谁在什么时候对什么做了什么、成功与否",不记业务数据内容**——记内容就要面对上面那一整套脱敏问题。
## 关键规则
- `traceId` 由 Micrometer Tracing 生成(或复用客户端合法的 `X-Trace-Id`),贯穿到 `integration/*` 调用外部系统,并随 `ApiResult` 返回给前端(见 [06-api-design.md](./06-api-design.md)),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。
- 审计相关的关键行为走单独的审计日志通道。
- 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认;无论哪种,**应用只往 stdout 打日志、不自己写文件**——容器里写文件意味着要处理轮转、磁盘占用和 Pod 销毁后日志丢失。
## 附录:为什么 traceId 走 MDC,而不是每条日志手动传参
不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可。
代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层就是 `ThreadLocal`,**换了线程就丢**。这一点在同步 Servlet 栈下大部分时候不用操心,但**有一个真实的例外**:`workbench` 首页把多个下游并行拉起来时会用到自己的线程池,子线程里默认拿不到 `traceId`,那部分日志会断链。Micrometer Tracing 提供了 `ContextPropagatingTaskDecorator`(或 `ContextSnapshot`)来搬运上下文,配置线程池时必须带上——具体写法见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。这也是选 Micrometer 而不是手写 filter 的又一个理由:这套搬运机制是现成的。
## 待补充
- 具体接入现有 ELK / APM 的方式和字段规范。
- 审计日志的存储位置和保留期限(需要和安全/合规确认)。
## 参考链接
- [Spring Boot: Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html)
- [Micrometer Tracing 官方文档](https://docs.micrometer.io/tracing/reference/)
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
- [W3C Trace Context 规范](https://www.w3.org/TR/trace-context/)
- [SLF4J MDC 官方文档](https://www.slf4j.org/manual.html#mdc)
- [Spring Boot Kubernetes Probes 官方文档](https://docs.spring.io/spring-boot/reference/actuator/kubernetes-probes.html)
- [logstash-logback-encoder: Masking](https://github.com/logfellow/logstash-logback-encoder#masking)
+441
View File
@@ -0,0 +1,441 @@
# 09. 构建与多环境部署
## 决策
Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三个环境里 UAT/Prod 部署到 Azure AKS(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../../conti-docs/Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio)),Dev 部署到公司内网一台 Ubuntu 服务器上的 k3s 集群(团队通过公司 VPN 访问,手动执行部署脚本,不接入 CI/CD 自动触发),个人日常调试用的是更轻量的 `local` profile(不经过任何 K8s,见下文区分)。
## 结构约定
```
settings.gradle # include 所有 platform-* / domains/* / integration/*(见 01-project-structure.md
build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
bootstrap/build.gradle # bootJar,构建出可执行 jar
Dockerfile # 基于 CI 已构建好的 jar 打镜像(不在镜像里重新编译)
k8s/ # Deployment / ConfigMap / PodDisruptionBudget 等清单
.gitlab-ci.yml # validate -> package -> release -> deploy-uat -> deploy-prod
```
## Dockerfile:复用 CI 产物 + 分层解包
```dockerfile
# Dockerfile
# 前提:CI 的 validate 阶段已经跑过 ./gradlew :bootstrap:bootJar
# 产物通过 GitLab artifacts 传递到 package 阶段,这里直接用,不重新编译。
FROM eclipse-temurin:21-jre AS layers
WORKDIR /layers
COPY bootstrap/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
FROM eclipse-temurin:21-jre
WORKDIR /app
# 非 root 运行:容器内一旦被攻破,攻击者拿到的也只是一个无特权用户
RUN useradd --system --uid 10001 --create-home appuser
USER 10001
# 按变更频率从低到高逐层 COPY,前三层几乎不变,可以吃满 Docker layer 缓存,
# 每次发版真正推送到 ACR 的通常只有最后一层(几百 KB 的业务代码)
COPY --from=layers --chown=10001:10001 /layers/dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/spring-boot-loader/ ./
COPY --from=layers --chown=10001:10001 /layers/snapshot-dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/application/ ./
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
```
三个容易被忽略但都会真正咬人的点:
1. **不在镜像里重新构建**。上一版 Dockerfile 是 `COPY . . && ./gradlew bootJar`,等于 CI 已经编译测试过一遍,打镜像时又原样编译一遍——既浪费流水线时间,又违背本篇自己的"Build once"原则(这次构建的产物和 CI 里验证过的产物严格来说不是同一个)。改为直接消费 CI 产物,`docker-build-push` job 用 `needs:` 声明依赖 `build-package` 的 artifacts。
2. **`-XX:MaxRAMPercentage`**。JVM 在容器里会读 cgroup 限制推算堆大小,但默认上限只有可用内存的 **25%**——给 Pod 配 2Gi,堆只用 512Mi,剩下的白白浪费,然后在流量高峰时莫名其妙地 OOM 或频繁 Full GC。配 75% 把剩余空间留给 metaspace、线程栈和堆外内存。配套的 `ExitOnOutOfMemoryError` 让 OOM 直接结束进程,交给 K8s 重启,而不是留一个半死不活、探针还返回健康的 Pod。
3. **非 root + 数字 UID**`USER 10001` 写数字而不是 `appuser`,是为了让 K8s 的 `runAsNonRoot: true` 能在启动前静态校验通过(K8s 无法解析镜像里的用户名,只认数字 UID)。
## Deployment 清单:资源、优雅停机与滚动更新
```yaml
# k8s/deployment-uat.yaml(节选。探针配置见 08-observability.md「K8s 探针配置」一节,合并到同一份清单里)
apiVersion: apps/v1
kind: Deployment
metadata:
name: conti-backend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # 更新期间不允许可用副本数低于 replicas,先起新的再停旧的
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 45 # 必须 > preStop 等待 + 应用 graceful 超时
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: conti-backend
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "2Gi" # 只限内存,不限 CPU —— 理由见下
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
volumeMounts:
- name: tmp
mountPath: /tmp # readOnlyRootFilesystem 的必需配套:内嵌 Tomcat 要可写的临时目录
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: conti-backend-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: conti-backend
```
配套的应用侧配置:
```yaml
server:
shutdown: graceful # Boot 默认是 immediate,收到 SIGTERM 直接掐断在途请求
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
```
### 为什么 preStop 要 `sleep 10`
这是滚动更新期间最常见的"零星 502"的根因。Pod 进入 Terminating 时,K8s 会**并行**做两件事:给容器发 SIGTERM,以及把 Pod 从 Service Endpoints 里摘掉。后者要经过 kube-proxy/Ingress 逐节点更新转发规则,**不是瞬时的**。如果应用收到 SIGTERM 立刻开始停机,这几百毫秒到几秒的窗口里仍然会有新请求被转发进来,而它已经不接了。
`preStop``sleep 10` 把 SIGTERM 推迟 10 秒,这段时间里应用照常服务,而 Endpoints 摘除早已完成——等真正开始停机时,已经没有新流量进来了。然后 `server.shutdown: graceful` 负责把已经在处理的请求跑完(最多 25s)。三个数字的关系必须是:`terminationGracePeriodSeconds (45) > preStop (10) + timeout-per-shutdown-phase (25)`,否则超时后 K8s 直接 SIGKILL,优雅停机等于白配。
### 为什么只限内存、不限 CPU
内存超限的后果是 Pod 被 OOMKilled,必须设 limit 防止一个 Pod 拖垮整个节点。CPU 则不同:Linux 的 CPU limit 通过 cfs quota 实现,一旦触及就**限流(throttling)**——表现为请求延迟毫无规律地抖动,而监控上 CPU 使用率看起来还很健康,极难排查。JVM 启动阶段(JIT 编译)尤其吃 CPU,配了 limit 会显著拉长启动时间甚至拖垮 startupProbe。设好 `requests` 保证调度到有余量的节点即可;节点整体过载靠 `ResourceQuota` 和扩容解决,不靠 per-Pod 限流。
`PodDisruptionBudget` 保证节点维护、集群升级这类**自愿中断**时至少留一个副本在跑——没有它,AKS 节点池升级可能把两个副本同时驱逐,造成一次没人预料到的短暂全站不可用。
## 环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/ProdAzure AKS
三层环境的定位不一样,容易混淆,先说清楚区别:
| 环境 | 跑在哪 | 是否过 K8s | 是否走 CI/CD | 访问方式 |
| --- | --- | --- | --- | --- |
| **local** | 开发者自己电脑 | 否,`local` profile 直接关掉 Spring Cloud Kubernetes(见 [07-config-governance.md](./07-config-governance.md#本地开发怎么跑不需要真的连-k8s) | 否 | 只有自己,localhost |
| **Dev** | 公司内网一台 Ubuntu 服务器,跑 [k3s](https://k3s.io/)(轻量级单节点 K8s 发行版) | 是,真实 K8s 集群 | 否,手动执行部署脚本(见下文) | 团队通过公司 **VPN** 访问(连上 VPN 后即可直接访问这台机器的内网地址) |
| **UAT / Prod** | Azure **Private AKS** | 是 | 是 | 见后面阶段四/五 |
`local` 是纯个人编码调试用的,跑得最快、依赖最少;`Dev` 是团队共享的、真实跑在 K8s 里的验证环境,行为上(ConfigMap 热更新、Secret 挂载方式、Deployment 滚动更新)跟 UAT/Prod 是一致的,只是物理上跑在公司内网的一台 Ubuntu 服务器而不是 Azure——这也是为什么选 k3s 这样一个真实的、哪怕是单节点的 K8s 发行版,而不是简单用 `docker compose` 起一堆容器:**能验证真实 K8s 行为,而不只是"能不能跑起来"**。这台机器本身就是 Ubuntu(跟 AKS 节点同为 Linux),k3s 直接跑在宿主机上,没有额外的虚拟化层。
### Dev 环境怎么部署(手动脚本,不接入 CI/CD 自动触发)
Dev 不需要跟 UAT/Prod 一样接自动化流水线,谁想更新 Dev 环境,连上公司 VPN,本机配置好指向这台机器的 `KUBECONFIG`,手动跑一下部署脚本就行:
```bash
#!/usr/bin/env bash
# scripts/deploy-dev.sh
# 用法:./scripts/deploy-dev.sh <commit-sha 或 release tag>
set -euo pipefail
IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"}
kubectl create secret generic conti-backend-secret -n retailapp-dev \
--from-literal=SECURITY_JWT_KEYS_V1="dev-only-fake-secret-at-least-32-bytes-long" \
--from-literal=DB_PASSWORD="dev-only-fake-password" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/configmap-dev.yaml -n retailapp-dev
kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$IMAGE_TAG -n retailapp-dev
kubectl rollout status deployment/conti-backend -n retailapp-dev --timeout=180s
```
几个和 UAT/Prod 不一样的地方:
- **不需要 Runner,也不需要接进 `.gitlab-ci.yml`**:镜像已经由阶段三的 `docker-build-push` job(合入 `main` 时自动触发)推到 ACR 了,Dev 这一步只是"把已经存在的镜像 apply 到这台机器",谁需要验证最新代码,自己连 VPN 跑一下脚本,不需要为此单独维护一条自动化流水线。
- **不用 Key Vault**:这台机器到不了 Azure Key Vault 的 Private EndpointDev 环境的 Secret 就是写死的假值(跟 `local` profile 里的假密码同一个思路),不是真实密钥,本来 Dev 环境也不该碰生产密钥。
- **镜像可以是任意 commit-sha**:想验证哪次提交,脚本参数传哪个 tag,不需要等到打 release tag,因为 Dev 不参与"Build once, promote across environments"这条只针对 UAT/Prod 的发布晋升链路。
- 需要提前把 `KUBECONFIG`k3s 默认生成在 `/etc/rancher/k3s/k3s.yaml`,把里面 `https://127.0.0.1:6443` 换成机器的内网 IP)分发给需要部署/排查 Dev 环境的团队成员,连上 VPN 后即可直接用。
## CI/CD 到 Kubernetes 的完整流程
整体沿用 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../../conti-docs/Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio) 里已经确认的流水线阶段,核心原则是 **"Build once, promote across environments with versioned artifacts and gated approvals"**——这条原则针对的是 UAT/Prod 之间的晋升;Dev 不在这条流水线里(见上一节,手动脚本部署)。下面按阶段展开 UAT/Prod 这条主链路,并结合我们 [部署架构](../../conti-docs/Architecture-Diagram/deployment-architecture-diagram.drawio) 是 **Private AKS**(只能通过 Private Endpoint 访问)这个关键约束说明每一步具体怎么落地。
### 阶段一:Source & Triggers(触发)
```yaml
# .gitlab-ci.yml(节选)
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR 触发 CI Validation(不部署)
- if: '$CI_COMMIT_BRANCH == "main"' # main 分支合入触发 CI Validation
- if: '$CI_COMMIT_TAG' # 打 protected tag 触发"选择版本发布"流程
```
- 日常开发:Developer 提 Merge Request → 触发 **CI Validation**(下一阶段),只做质量门禁,不产出可发布制品。
- 发布:在 `main` 分支上打一个 **protected tag**(如 `v1.4.0`),触发"选择要部署的版本"这条链路——这是唯一能进入 Artifact & Release Controls 之后阶段的入口,普通分支/MR 流水线到 CI Validation 就结束,避免任何未评审代码意外流入生产。
### 阶段二:CI Validation(质量门禁)
```yaml
stages:
- validate
- package
- release
- deploy-uat
- deploy-prod
lint:
stage: validate
script:
- ./gradlew ktlintCheck detekt --no-daemon # Lint / Static Checks
unit-integration-test:
stage: validate
script:
- ./gradlew test --no-daemon # Unit/Integration Tests,含 Testcontainers(见 10-testing.md
artifacts:
reports:
junit: '**/build/test-results/test/TEST-*.xml'
build-package:
stage: validate
script:
- ./gradlew :bootstrap:bootJar --no-daemon # Build/Package
artifacts:
paths:
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
expire_in: 1 week
security-scan:
stage: validate
script:
# 依赖漏洞扫描。注意:从 2023 年起 NVD API 对匿名调用限流极严,
# 不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
# NVD_API_KEY 需去 https://nvd.nist.gov/developers/request-an-api-key 免费申请,存为 CI masked variable。
- ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
cache:
key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
paths:
- build/dependency-check-data
```
这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。
`dependencyCheckAnalyze` 只覆盖**我们自己声明的依赖**,管不到基础镜像里的 OS 包(glibc、openssl 这类),而那恰恰是镜像 CVE 的大头。所以镜像层面要单独扫,并同时产出 SBOM:
```yaml
image-scan:
stage: package
needs: [docker-build-push]
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
# 而不是挨个把历史镜像拉下来重新扫一遍。
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
artifacts:
paths: [sbom.json]
```
`--ignore-unfixed` 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。基础镜像的 tag 建议钉到 digest,并定期(比如每月)主动升一次,而不是长期用 `21-jre` 这个内容会漂移的浮动 tag。
### 阶段三:Artifact & Release Controls(制品与发布控制)
```yaml
docker-build-push:
stage: package
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG'
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # 推送到 Azure Container RegistryACR
cut-release:
stage: release
rules:
- if: '$CI_COMMIT_TAG'
script:
# 把已经验证过的 commit-sha 镜像"打标"成不可变的发布版本,而不是重新构建
- az acr import --name $ACR_NAME --source $ACR_NAME.azurecr.io/conti-backend:$CI_COMMIT_SHORT_SHA --image conti-backend:$CI_COMMIT_TAG
```
- `docker-build-push` 把镜像推到 **Azure Container Registry**Runner 需要有 ACR 的 `AcrPush` 权限(通过 Managed Identity 或 Service Principal 认证,不使用固定密码)。
- `cut-release` 是"Promotion Gate"的起点:只有打了 tag 才会生成一个**不可变的发布版本**(`az acr import` 把 commit-sha 镜像复制成一个语义化 tag,源镜像内容不变,只是多一个别名),后续 UAT/Prod 部署的都是这同一个镜像摘要(digest),保证"UAT 验证过的和 Prod 部署的字节级一致",呼应前面"一个镜像走所有环境"的原则。
- 回滚就是这一层的直接应用:出问题时不重新构建,而是把 Deployment 的镜像 tag 改回上一个已批准的 release 版本(见下面 `rollback` job)。
### 阶段四:CD to Azure(部署到 UAT/Prod
**私有 AKS 对 Runner 的网络要求**:架构图确认 AKS 是 **Private Cluster**Kubernetes API Server 只能通过 Private Endpoint 访问),这意味着 GitLab 默认的共享公网 Runner **连不上**这个 API Server。落地方式:在 AKS 所在 VNet(或对等互联的 VNet)内部署 **self-hosted GitLab Runner**(跑成 AKS 里的一个专门 namespace,或者 VNet 里的一台 VM/VMSS),只有这个 Runner 能执行 `deploy-*` 系列 job。
```yaml
deploy-uat:
stage: deploy-uat
tags:
- azure-vnet-runner # 指定跑在能访问私有 AKS 的 self-hosted runner 上
environment:
name: uat
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要人工点击"Promote to UAT"
script:
- az login --identity # Runner 用 Managed Identity 登录 Azure
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# Key Vault / Config Retrieval:从 Key Vault 读值渲染成 K8s Secret(见 07-config-governance.md 方式一)
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --query value -o tsv)
- DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv)
- kubectl create secret generic conti-backend-secret -n retailapp-uat
--from-literal=SECURITY_JWT_KEYS_V1="$JWT_KEY"
--from-literal=DB_PASSWORD="$DB_PASSWORD"
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-uat
- kubectl rollout status deployment/conti-backend -n retailapp-uat --timeout=180s
- curl -sf https://uat.internal.example.com/actuator/health || exit 1
deploy-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG'
when: manual # Promotion Gate:需要更高权限的人工审批
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
- curl -sf https://api.example.com/actuator/health || exit 1
rollback-prod:
stage: deploy-prod
tags:
- azure-vnet-runner
environment:
name: production
when: manual # 手动触发,回滚到"上一个已批准的镜像 tag"
script:
- az login --identity
- az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
# ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量
#GitLab 手动 job 支持在点击时输入变量值)。
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_TAG -n retailapp-prod
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
```
几个关键点:
- **`tags: [azure-vnet-runner]`**:强制这几个 job 只能被部署在 AKS 私有网络内、能直连 API Server 的 self-hosted Runner 执行,公网共享 Runner 没有这个 tag,天然不会被误调度去执行部署。
- **Runner 认证 Azure 用 Managed Identity**`az login --identity`),不在 CI 变量里存长期有效的 Service Principal 密码,减少凭证泄漏面。
- **Key Vault/Config Retrieval**Runner 用 `az keyvault secret show` 现取值,`kubectl create secret --dry-run=client -o yaml | kubectl apply -f -` 渲染成 K8s Secret(见 [07-config-governance.md](./07-config-governance.md#azure-上-secret-的真正来源key-vault不是手写-k8s-secret));密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。
- **`when: manual` = Promotion Gate**UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。
- **部署的是 tag 不是 commit-sha**`deploy-uat`/`deploy-prod` 用的镜像引用都是 `$CI_COMMIT_TAG`(对应阶段三里 `az acr import` 生成的不可变发布版本),而不是重新拿 commit-sha 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。
- **回滚**不重新跑构建流水线,只是把 `ROLLBACK_TAG`(操作人从 GitLab Environment 部署历史里选定的、上一个已在 Prod 正常跑过的 release tag)重新 `kubectl set image` 一次,几秒钟内完成,这也是为什么"发布版本必须不可变"很重要——回滚目标必须是确定性的、镜像内容不会变的一个 tag。**但镜像能回滚不代表整个系统能回滚**,数据库那一半见下一节。
### 阶段五:Azure Environments(部署目标)
UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace`retailapp-uat` / `retailapp-prod`),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 [07-config-governance.md](./07-config-governance.md))、各自的资源配额(`ResourceQuota`/`LimitRange`,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。
数据库不在集群里:UAT/Prod 用 **Azure Database for MySQL Flexible Server**,通过 Private Endpoint 接入 AKS 所在 VNet(见 [07-config-governance.md](./07-config-governance.md)),不对公网开放。UAT 和 Prod 是**两个独立的 server 实例**,不是同一个实例上的两个 database——共用实例意味着 UAT 的一次压测或一条慢查询能直接影响生产。
## 数据库迁移与回滚的协同(最容易翻车的一环)
镜像可以秒回滚,**数据库不能**。Flyway 社区版没有 `undo`(那是商业版功能),而且即使有,`drop column` 之后的数据也回不来。再叠加滚动更新的机制——`maxUnavailable: 0` 意味着更新期间**新旧两个版本的 Pod 同时在线,连的是同一个数据库**——就得出一条硬约束:
> **每一个迁移脚本都必须同时兼容"上一个版本的代码"和"这个版本的代码"。**
不满足这条,滚动更新的中间态就会直接报错(旧 Pod 查一个已经被删掉的列),而且此时想回滚镜像也救不了,因为库已经改了。
### expand-contract:把破坏性变更拆成两次发布
以"把 `user.phone` 改名为 `user.mobile`"为例,一次改完必然出事,正确做法是拆成两个 release:
| 阶段 | 迁移脚本 | 代码 | 中间态是否安全 |
| --- | --- | --- | --- |
| **Expand**v1.4.0 | `add column mobile`,回填历史数据,加触发器/双写保持两列同步 | 读 `mobile`,同时写 `phone``mobile` | 安全:旧 Pod 读写 `phone` 照常 |
| (观察期,至少一个发布周期) | — | — | 此时回滚到 v1.3.0 完全安全 |
| **Contract**v1.5.0 | `drop column phone` | 只读写 `mobile` | 安全:线上已无代码引用 `phone` |
对应到常见变更类型:
| 变更 | 能否一次做完 | 做法 |
| --- | --- | --- |
| 加表、加可空列、加索引 | 可以 | 直接加。旧代码看不见它,不受影响 |
| 加**非空**列 | 不可以 | 先加可空列 + 默认值 → 回填 → 下个版本再加 `not null` |
| 删列、删表 | 不可以 | 先发一个版本让代码不再引用它,下个版本再删 |
| 改列名、改类型 | 不可以 | 按上表的 expand-contract 走 |
| 加唯一约束 | 谨慎 | 先查历史数据有没有重复,有重复会导致迁移失败、Pod 起不来 |
### 已发布的迁移脚本不可修改
Flyway 会校验每个脚本的 checksum。改一个已经在任何环境执行过的 `V*.sql`,下次启动会直接 `Validate failed`,应用起不来。要改就新写一个版本号更大的脚本。**这一条对 Dev 环境也适用**——Dev 上随手改了脚本,等到 UAT 部署时才炸,那时候已经不知道当初改了什么。
### 迁移在哪跑
沿用 [03-persistence.md](./03-persistence.md) 的方案:迁移由应用启动时执行(`DomainFlywayConfig` 保证在 JPA `validate` 之前跑完)。`maxSurge: 1` 保证同时只有一个新 Pod 启动,加上 Flyway 自身的表级锁,不会出现多个副本并发迁移。
代价是:**迁移失败 = Pod 起不来 = 部署卡住但线上服务不受影响**(旧 Pod 还在跑,因为 `maxUnavailable: 0`)。这个失败模式是可接受的——比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。
大表变更(几百万行以上加索引/改列)是这个方案的例外:它会让启动探针超时、Pod 被反复重启,同时还可能长时间锁表。这类变更走单独的 K8s `Job` 在业务低峰期执行,执行完再发应用版本,不要塞进启动流程。
### 迁移脚本的数据库账号
迁移用的账号需要 DDL 权限,运行时账号只需要 DML 权限,两者必须分开(见 [07-config-governance.md](./07-config-governance.md))——运行时账号如果有 `drop table` 权限,一个 SQL 注入的破坏半径就完全不一样了。
## 关键规则
- UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。
- Dev 是独立的一层:跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,不接入 CI/CD 自动触发,谁需要更新就手动跑 `scripts/deploy-dev.sh`(不接 Key Vault),跟 UAT/Prod 的"打 tag 才能晋升"这条链路是分开的,见前面"环境层级"一节。
- CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送 ACR → 打 release tag → 按 UAT(人工晋升)/Prod(人工审批)顺序部署;Dev 不在这条流水线里,需要时手动执行部署脚本。
- 部署到私有 AKS 的 job 必须跑在能访问集群私有网络的 self-hosted Runner 上,公网共享 Runner 无法执行这些 job(网络层面直接不通,不是权限层面的限制);Dev 环境没有 Runner,直接由团队成员在自己电脑上连 VPN 手动执行部署脚本。
- 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 `Dockerfile` 和 CI job,工程结构上已经按模块划好边界(见 [01-project-structure.md](./01-project-structure.md)),拆分成本较低——本质上是把 `bootstrap` 依赖的某个 `domains/xxx` 模块摘出来,单独套一层 `@SpringBootApplication` 入口和自己的 `Dockerfile`
- **镜像里不编译代码**Dockerfile 消费 CI `validate` 阶段产出的 jar artifact,保证部署的字节就是被测试验证过的字节。
- **每个迁移脚本必须前向兼容**(旧版本代码在新 schema 上能正常跑),破坏性变更一律走 expand-contract 两次发布。已经执行过的迁移脚本不可修改。
- 容器以非 rootUID 10001)运行,`readOnlyRootFilesystem` + `drop ALL capabilities`,堆内存用 `-XX:MaxRAMPercentage` 而不是写死 `-Xmx`
- 优雅停机三件套必须同时配齐且数值满足 `terminationGracePeriodSeconds > preStop sleep + timeout-per-shutdown-phase`,缺一个滚动更新期间就会掉请求。
## 附录:为什么坚持"一个镜像走所有环境"
一种常见但有风险的做法是:给每个环境单独打包(比如构建时注入 `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) 里"严格分离构建和运行"这条原则的直接应用。
## 待补充
- 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
- Gradle 构建缓存/并行构建的 CI 加速配置。
- self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。
- HPA(水平自动扩缩)的指标与阈值——目前 `replicas` 是写死的。
- 数据库备份与恢复演练周期(Azure Flexible Server 自带 PITR,但"能恢复"和"演练过能恢复"是两回事)。
## 参考链接
- [The Twelve-Factor App](https://12factor.net/zh_cn/)
- [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html)
- [Spring Boot: Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
- [Kubernetes: Pod 生命周期与终止流程](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination)
- [Kubernetes: PodDisruptionBudget](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/disruptions/)
- [Flyway: 零停机迁移与 expand-contract](https://documentation.red-gate.com/fd/zero-downtime-deployments-268173154.html)
- [OWASP: Kubernetes Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Kubernetes_Security_Cheat_Sheet.html)
- [k3s 官方文档](https://docs.k3s.io/)
+550
View File
@@ -0,0 +1,550 @@
# 10. 测试策略
## 决策
JUnit 5 + MockK 做单元测试,TestcontainersMySQL)做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 [02-layering.md](./02-layering.md)。
## 测试依赖基线
```groovy
// 各模块 build.gradle。版本能由 BOM 管的一律不写死(根工程已引入 Spring Boot BOM,见 01-project-structure.md
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test' // JUnit 5 + AssertJ + Mockito
testImplementation 'io.mockk:mockk' // Kotlin 友好的 mock 库
testImplementation 'com.ninja-squad:springmockk:5.0.1' // 提供 @MockkBean / @MockkSpyBean
// Testcontainers:版本由 Spring Boot BOM 统一管理,不要手动 pin。
// 如果确实要覆盖版本,注意跨大版本时坐标和包路径可能变动,改完先跑一遍再提交。
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
}
```
`@MockkBean` **不是 Spring 自带的**,它来自 `springmockk`——这个依赖漏了的话,注解根本不存在,编译期就红。Spring 自带的是 `@MockitoBean`Boot 3.4+ 取代了 `@MockBean`),但 Mockito 对 Kotlin 的 final class 和协程支持不如 MockK,这套代码库统一用 MockK 一套到底,避免两套 mock 语法混着写。
## 分层测试策略
| 层 | 手段 | 起 Spring 容器 | 数量 |
| --- | --- | --- | --- |
| `domain` | 纯 JUnit + MockKmock 掉 repository/client 接口 | 否 | 最多 |
| `application` | slice test 或 `@SpringBootTest`mock 掉 infrastructure | 轻量 | 多 |
| `infrastructure` | Testcontainers 起真实 MySQL 跑 repository 测试 | 是(`@DataJpaTest` | 中 |
| `api` | `@WebMvcTest` 验证参数校验、异常处理、响应结构(见 [06-api-design.md](./06-api-design.md) | 是(web slice | 中 |
| `integration/*` | WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 | 视用例 | 少 |
| 架构规则 | ArchUnit,全代码库扫描 | 否 | 一组 |
## `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 内存跑完,这类测试应该是数量最多、跑得最快的一层。
**注意 `Clock` 是构造参数注入的,不是 `Instant.now()` 硬编码**。所有涉及时间的业务代码都必须注入 `Clock`(生产环境 `Clock.systemUTC()``platform-*` 提供一个 `@Bean`),否则"过期判断"这类逻辑根本没法稳定测试,只能靠 `Thread.sleep` 硬等——那是慢测试和随机失败的主要来源。
## `infrastructure` 层集成测试示例(Testcontainers + MySQL
```kotlin
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class StoreJpaRepositoryTest {
companion object {
@Container
@ServiceConnection // Boot 自动把容器的 url/user/password 注入 DataSource
@JvmStatic
val mysql = MySQLContainer("mysql:8.4")
.withUrlParam("connectionTimeZone", "UTC") // 与生产一致,见 03-persistence.md
.withUrlParam("preserveInstants", "true")
}
@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)
}
}
```
两个注解缺一不可,而且都是"不加就静默走错路"的类型:
- **`@ServiceConnection`**:Boot 3.1+ 提供,自动从容器推导连接信息。没有它就得手写一堆 `@DynamicPropertySource`,容易漏配(比如漏了时区参数,测试全绿但生产时间差 8 小时)。
- **`@AutoConfigureTestDatabase(replace = NONE)`**`@DataJpaTest` **默认会把 DataSource 替换成嵌入式内存库**。如果 classpath 上恰好有 H2,容器白起了,测试实际跑在 H2 上——而且不会有任何报错,只会在某天遇到 MySQL 特有行为时才暴露。这是本篇最值得单独强调的一个坑。
用真实 MySQL 而不是 H2,是因为要验证的恰恰是对 MySQL 的假设:Flyway 迁移脚本里的 DDL 方言、`utf8mb4_0900_ai_ci` 排序规则下的中文比较、`datetime(6)` 的精度截断、唯一索引在 3072 字节限制下能不能建起来(见 [03-persistence.md](./03-persistence.md))。这些 H2 一个都模拟不了。
**容器复用**:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 `~/.testcontainers.properties` 里设 `testcontainers.reuse.enable=true` 并给容器加 `.withReuse(true)`;**CI 上不要开**——CI 需要的是每次都干净的环境。
## `api` 层测试示例(`@WebMvcTest`
```kotlin
@WebMvcTest(StoreController::class)
@Import(GlobalExceptionHandler::class) // slice test 默认不装配 platform-web 里的 advice,要显式引入
class StoreControllerTest {
@Autowired lateinit var mockMvc: MockMvc
@MockkBean lateinit var storeAppService: StoreAppService
@Test
@WithMockUser
fun `切换到无权访问的门店返回 10403`() {
every { storeAppService.switchStore(any(), 999) } throws StoreNotAccessibleException()
mockMvc.post("/api/v1/stores/999/switch") // 路径与 06-api-design.md、客户端保持一致
.andExpect {
status { isForbidden() }
jsonPath("$.code") { value(ErrorCode.STORE_NOT_ACCESSIBLE) } // Int,见 06-api-design.md
jsonPath("$.traceId") { exists() }
}
}
}
```
`@WebMvcTest` 只装配 web 层(Controller、`@ControllerAdvice`、消息转换器、参数校验),不装配 Service/Repository——所以 `StoreAppService` 必须 mock 掉,否则容器起不来。这一层测的是**协议**:状态码对不对、错误码对不对、字段名和 JSON 结构对不对,而不是业务逻辑。
## 外部依赖打桩示例(WireMock,覆盖 F6 降级路径)
```kotlin
// integration/f6-adapter/src/test/kotlin/.../F6ApiClientResilienceTest.kt
@SpringBootTest
class F6ApiClientResilienceTest {
companion object {
@JvmStatic
val wireMock = WireMockServer(options().dynamicPort()).apply { start() }
@JvmStatic
@DynamicPropertySource
fun props(registry: DynamicPropertyRegistry) {
registry.add("integration.f6.base-url") { wireMock.baseUrl() }
registry.add("resilience4j.circuitbreaker.instances.f6.minimum-number-of-calls") { 3 }
}
}
@Autowired lateinit var f6ApiClient: F6ApiClient
@Test
fun `F6 响应超时后走 fallback 返回降级数据`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withFixedDelay(5_000).withStatus(200)), // 超过 responseTimeout 配置
)
// 同步 RestClient,直接拿返回值,没有 .block()(见 05-integration-layer.md
val result = f6ApiClient.fetchProcurementList(storeId = 1)
assertTrue(result.degraded) // 验证超时后 @Retry 耗尽 → fallback 生效
}
@Test
fun `连续失败后熔断器打开并直接走 fallback`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(503)),
)
repeat(5) { f6ApiClient.fetchProcurementList(storeId = 1) }
// 熔断打开后不应该再发出真实请求 —— 这是"熔断真的生效了"的唯一硬证据,
// 只断言返回降级数据是不够的(重试耗尽也会返回降级数据,两者分不开)
val callsBefore = wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size
f6ApiClient.fetchProcurementList(storeId = 1)
assertEquals(callsBefore, wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size)
}
}
```
熔断相关的测试**必须在测试里把 `minimum-number-of-calls` 调小**(生产配置通常是 20~50),否则要打满几十次请求才会触发,测试又慢又难读。
## `ArchUnit`:把架构规则变成可执行的测试
[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 `architecture-test` 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):
```groovy
// architecture-test/build.gradle
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
testImplementation project(':bootstrap')
}
```
```kotlin
// architecture-test/src/test/kotlin/.../ArchitectureRulesTest.kt
class ArchitectureRulesTest {
private val classes = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeTests())
.importPackages("com.continental.retailapp")
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
// —— 规则组一:模块边界(01-project-structure.md)——
@Test
fun `domain 之间只能通过 contract 包互相依赖`() {
domains.forEach { source ->
domains.filter { it != source }.forEach { target ->
noClasses()
.that().resideInAPackage("..retailapp.$source..")
.should().dependOnClassesThat(
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
.and(DescribedPredicate.not(
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract..")))
)
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
.check(classes)
}
}
}
@Test
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
noClasses()
.that().resideInAPackage("..retailapp.*.contract..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
.check(classes)
}
// —— 规则组二:层内方向(02-layering.md)——
@Test
fun `层依赖方向`() {
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("api").definedBy("..retailapp.*.api..")
.layer("application").definedBy("..retailapp.*.application..")
.layer("domain").definedBy("..retailapp.*.domain..")
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
.whereLayer("api").mayNotBeAccessedByAnyLayer()
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
// 写成 mayNotBeAccessedByAnyLayer() 会把 02 自己的示例判红 —— 规则必须和文档一致,
// 真正的红线是下面那条"api 不能碰 infrastructure"。
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
.check(classes)
}
@Test
fun `api 层不能依赖 infrastructure 层`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.because("02-layering.mdapi 层不 import infrastructure 包下的任何类型(包括 Entity)")
.check(classes)
}
@Test
fun `domain 层不能依赖 Spring 或 JPA`() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.check(classes)
}
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
@Test
fun `Entity 只能待在 infrastructure 包里`() {
classes()
.that().haveSimpleNameEndingWith("Entity")
// platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
// 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
// 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
.and().resideOutsideOfPackage("..retailapp.platform..")
.should().resideInAPackage("..infrastructure..")
.check(classes)
}
@Test
fun `api 层不能触碰 Entity`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
.check(classes)
}
// —— 规则组四:编码约定 ——
@Test
fun `Controller 的端点方法必须返回 ApiResult`() {
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
// 用 metaAnnotatedWith 而不是 arePublic@GetMapping/@PostMapping 都是 @RequestMapping 的
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
.and().areMetaAnnotatedWith(RequestMapping::class.java)
.should().haveRawReturnType(ApiResult::class.java)
.because("统一响应结构,见 06-api-design.md")
.check(classes)
}
@Test
fun `禁止使用 java 时间类型的老 API`() {
noClasses()
.should().dependOnClassesThat()
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
.because("统一用 InstantUTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
}
```
两个使用上的注意点:
1. **`ImportOption.DoNotIncludeTests()` 不能省**。测试代码里 mock、构造 Entity、跨层引用都是正常的,不排除掉会误报一片。
2. **匹配不到任何类的规则默认会失败**ArchUnit 1.x 的 `allowEmptyShould` 行为)。比如某个 domain 还没建 contract 包,规则就会空转失败。要么用 `.allowEmptyShould(true)`,要么等这个模块真的建起来再加规则——不要因为这个把整条规则删掉。
### 原生 SQL 跨库扫描测试([03-persistence.md](./03-persistence.md) 承诺的那条)
MySQL 里 schema ≡ database**跨 database join 只要账号有权限就是合法的**,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:
```kotlin
// architecture-test/src/test/kotlin/.../NativeQueryScanTest.kt
class NativeQueryScanTest {
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
private val ownerByModule = mapOf(
"identity-store" to "identity_store",
"workbench" to "workbench",
"webview-ticket" to "webview_ticket",
)
private val allDatabases = ownerByModule.values.toSet()
@Test
fun `原生 SQL 里不能出现其他 domain 的库名`() {
val violations = Files.walk(Path.of("../domains"))
.filter { it.toString().endsWith(".kt") }
.toList()
.flatMap { file ->
val path = file.toString().replace('\\', '/')
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
val text = Files.readString(file)
(allDatabases - setOfNotNull(owner))
.filter { text.contains("$it.") }
.map { "$path 引用了 $it" }
}
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
}
}
```
这是**近似检查、不是严密证明**(它只做字符串匹配,绕过它很容易)。它的价值在于把"不小心写了个跨库 join"这种最常见的情况挡在 CI 上;真正的硬约束是生产账号只 GRANT 本域 database 的权限。这一点要如实认识,不要因为有这个测试就以为边界被守住了。
### Resilience4j 切面顺序验证测试([05-integration-layer.md](./05-integration-layer.md) 承诺的那条)
05 里说了切面叠加顺序由 `*-aspect-order` 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:
```kotlin
@SpringBootTest
class ResilienceAspectOrderTest {
// WireMock 的搭建同上一节的 F6ApiClientResilienceTest,这里只列关键断言
@Autowired lateinit var f6ApiClient: F6ApiClient
@Autowired lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
@Test
fun `Retry 在外层时每次重试都计入熔断统计`() {
wireMock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(503)))
val cb = circuitBreakerRegistry.circuitBreaker("f6")
f6ApiClient.fetchProcurementList(storeId = 1) // 1 次调用 + 2 次重试
// Retry 在外 → 熔断器看到 3 次失败;Retry 在内 → 熔断器只看到 1 次。
// 这个断言就是"配置到底生效成什么样"的答案,改配置后它会立刻告诉你方向反没反。
assertEquals(3, cb.metrics.numberOfFailedCalls)
}
}
```
## CI 里跑 Testcontainers 的前提条件
集成测试依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,两种常见配置:
**方式一:Docker-in-Dockerdind),托管 Runner 的默认选择**
```yaml
# .gitlab-ci.yml
unit-integration-test:
stage: validate
image: eclipse-temurin:21-jdk
services:
- docker:24-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- ./gradlew test --no-daemon
```
**方式二:挂载宿主机 Docker socket,适合 [09-build-deploy.md](./09-build-deploy.md) 里那种自建 self-hosted Runner`azure-vnet-runner`**
```toml
# GitLab Runner 的 config.toml
[[runners]]
[runners.docker]
privileged = true
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
```
方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。
## 覆盖率门禁(Jacoco 多模块聚合)
**先说清楚为什么必须聚合**:多模块工程里如果每个模块各算各的覆盖率,会出现两个问题——① 根工程自己没有测试,在根上跑 `jacocoTestCoverageVerification` 直接就是 0/0 通过,门禁形同虚设;② `architecture-test` 模块跑的测试覆盖到的是**其他模块**的代码,按模块统计时这部分贡献会被完全丢掉。用 Gradle 自带的 `jacoco-report-aggregation` 插件把所有模块的执行数据合并成一份报告:
```groovy
// 根 build.gradle
plugins {
id 'jacoco-report-aggregation'
}
dependencies {
// 依赖 bootstrap 即可 —— 它传递性地依赖了所有 platform-* / domains/* / integration/*
jacocoAggregation project(':bootstrap')
jacocoAggregation project(':architecture-test')
}
subprojects {
apply plugin: 'jacoco'
}
// 聚合报告的具体配置属性名在 Gradle 各版本间有过调整,
// 升级 Gradle 后先跑一次 ./gradlew testCodeCoverageReport 确认任务还在、报告路径没变。
reporting {
reports {
testCodeCoverageReport(JacocoCoverageReport) {
testSuiteName = 'test'
}
}
}
```
排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:
```groovy
subprojects {
tasks.withType(JacocoReport).configureEach {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/*MapperImpl*', // MapStruct 生成的代码
'**/*Request*', '**/*Response*', '**/*Dto*', // 纯数据类,没有逻辑
'**/*Entity*', // 同上
'**/*Config*', '**/*Properties*',
'**/BootstrapApplication*',
])
}))
}
}
```
```yaml
# .gitlab-ci.yml
unit-integration-test:
stage: validate
script:
- ./gradlew test testCodeCoverageReport --no-daemon
artifacts:
reports:
junit: '**/build/test-results/test/TEST-*.xml'
coverage_report:
coverage_format: cobertura
path: 'build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml'
```
阈值定在 **70%** 而不是追求 90%+`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖"。这个门禁的作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样只会催生出一堆 `assertNotNull(result)` 式的假测试,覆盖率好看了,实际保护力反而下降。
## 测试数据构造约定
集成测试里最容易失控的地方是"每个测试自己 new 一个有 15 个字段的 Entity",字段一改,几十个测试同时爆掉。统一用**测试数据构建器**,只显式写这个测试真正关心的字段:
```kotlin
// src/test/kotlin/.../fixtures/StoreFixtures.kt
fun aStore(
name: String = "示例门店",
code: String = "S001",
status: StoreStatus = StoreStatus.ACTIVE,
) = StoreEntity(name = name, code = code, status = status)
// 用法:读者一眼就知道这个测试关心的只有 status
val inactive = aStore(status = StoreStatus.INACTIVE)
```
其他几条:
- **测试之间不共享状态**。`@DataJpaTest` 默认每个测试方法跑在会回滚的事务里;用 `@SpringBootTest` 时事务不会自动回滚,需要显式 `@Transactional` 或在 `@AfterEach` 里清理。
- **不依赖测试执行顺序**。JUnit 5 的默认顺序是确定但不保证的,任何"必须先跑 A 再跑 B"的设计都要拆掉。
- **断言要具体**。`assertTrue(list.isNotEmpty())` 在回归时几乎抓不到问题,`assertEquals(listOf("S001"), list.map { it.code })` 才有价值。
- **测试名用反引号中文描述行为**(如上面示例),不要 `test1``testSwitchStore2`——失败时 CI 报告上那一行就是问题描述本身。
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实 MySQL、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
## 待补充
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
- 性能/压测基线(至少要有一条:首页聚合接口在 N 并发下的 P99)。
## 参考链接
- [MockK 官方文档](https://mockk.io/)
- [springmockk`@MockkBean`](https://github.com/Ninja-Squad/springmockk)
- [Testcontainers 官方文档](https://testcontainers.com/)
- [Spring Boot: Testcontainers 与 @ServiceConnection](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)
- [WireMock 官方文档](https://wiremock.org/docs/)
- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html)
- [Gradle: JaCoCo Report Aggregation Plugin](https://docs.gradle.org/current/userguide/jacoco_report_aggregation_plugin.html)
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
+288
View File
@@ -0,0 +1,288 @@
# 11. 跨域协作与聚合
## 决策
跨 domain 的**读**走契约模块(`-contract`)里的接口,跨 domain 的**写/状态联动**走 Spring `ApplicationEvent` + `@TransactionalEventListener`,暂不引入消息中间件。`bff-orchestration` / `workbench` 的并行聚合用一个专用的有界线程池,带上下文传播和整体超时预算,按 tile 局部降级。
这一篇填的是原来整套文档最大的一个空白:[04-security-auth.md](./04-security-auth.md) 里写了"切店 → 通知 webview-ticket 失效"走事件,但事件机制本身从来没有被定义过。
## 一、跨域读:契约模块
模块结构和约束见 [01-project-structure.md](./01-project-structure.md),这里只讲使用规则。
```kotlin
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreQueryService.kt
interface StoreQueryService {
fun listStoresByUserId(userId: Long): List<StoreInfo>
fun findStore(storeId: Long): StoreInfo?
}
data class StoreInfo(
val storeId: Long,
val name: String,
val code: String,
)
```
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreQueryServiceImpl.kt
@Service
class StoreQueryServiceImpl(private val storeRepository: StoreRepository) : StoreQueryService {
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
}
```
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
@Service
class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // 注入的是契约里的接口,不是 identity-store 的实现类
) { ... }
```
### 契约设计的四条规则
1. **契约模型是独立的数据结构,不是 Entity 的别名**`StoreInfo` 只含调用方真正需要的字段。让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺,改一个列名就要动三个 domain。
2. **契约只承诺调用方需要的最小能力**。不要一上来就写 `findAll()``update()`——契约里出现的每个方法都是未来的约束。
3. **契约变更要向后兼容**。加方法、加可空字段没问题;删方法、改语义要先确认所有调用方,跟对客户端的 API 一个待遇。
4. **契约模块不含实现、不依赖 Spring Web/JPA**ArchUnit 会检查,见 [10-testing.md](./10-testing.md))。
### 什么时候不该用契约,而应该重新划边界
如果 `workbench` 要用 `identity-store` 的契约方法超过五六个,甚至开始要求对方加"给我拼好这个结构"的定制方法,那说明**边界划错了**——这块逻辑本来就该在一边,或者本来就该单独成域。契约模块变厚是个明确的设计告警,不要靠往里加方法来消化它。
## 二、跨域写:领域事件
### 为什么写操作不走契约接口
`workbench` 直接调 `identityStore.doSomething()` 意味着:workbench 要知道 identity-store 内部该做什么,identity-store 的事务边界被外部方法调用拉长,而且以后每多一个关心"切店"的域,就要在切店逻辑里多加一行调用——切店代码变成一个不断膨胀的通知中心。
事件反转了这个依赖方向:**发布方不知道谁在听**。切店只管发一个"门店切换了"的事实,谁关心谁自己订阅。
### 事件定义放在契约模块
```kotlin
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreSwitchedEvent.kt
data class StoreSwitchedEvent(
val userId: Long,
val fromStoreId: Long?,
val toStoreId: Long,
val occurredAt: Instant,
)
```
事件类型必须放在契约模块,否则订阅方要 import 发布方的内部类型,边界又破了。
### 发布:在事务内发布
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreSwitchAppService.kt
@Service
class StoreSwitchAppService(
private val events: ApplicationEventPublisher,
private val clock: Clock,
) {
@Transactional
fun switchStore(userId: Long, targetStoreId: Long): StoreContext {
val store = findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
// ... 更新当前门店、重新签发 access token(见 04-security-auth.md
events.publishEvent(StoreSwitchedEvent(userId, currentStoreId, targetStoreId, clock.instant()))
return context
}
}
```
### 订阅:`@TransactionalEventListener`
```kotlin
// domains/webview-ticket/src/main/kotlin/.../webviewticket/application/StoreSwitchedListener.kt
@Component
class StoreSwitchedListener(private val ticketRepository: WebviewTicketRepository) {
private val log = LoggerFactory.getLogger(javaClass)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun onStoreSwitched(event: StoreSwitchedEvent) {
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
}
}
```
这段代码里每个注解和写法都在解决一个具体问题:
| 写法 | 解决什么 |
| --- | --- |
| `@TransactionalEventListener(AFTER_COMMIT)` | 普通 `@EventListener` 是**同步、在同一事务内**执行的。用它意味着"切店事务回滚了,但票据已经作废"这种不一致;`AFTER_COMMIT` 保证只在主事务真正提交后才触发 |
| `@Transactional(REQUIRES_NEW)` | `AFTER_COMMIT` 阶段原事务已经提交,此时**没有活跃事务**。不开新事务的话,监听器里的写操作要么报错要么自动提交,行为不可控 |
| `runCatching` + 记日志 | 监听器抛异常**不会**回滚主事务(主事务已提交),只会让这次副作用静默丢失。必须显式捕获并留下可排查的日志 |
### 同步还是异步
**默认同步**`AFTER_COMMIT` 但仍在同一线程)。理由:同步下 traceId、`@RequestScope` 的门店上下文都还在,出问题能直接顺着日志查下去;异步则要额外处理上下文传播,而目前这些副作用(作废票据、清缓存)都很轻,没必要付这个复杂度。
只有当某个监听器确实耗时(比如要调外部系统)时才加 `@Async`,并且必须:指定专用线程池(不要用默认的 `SimpleAsyncTaskExecutor`,它每次新建线程且无上界)、加上下文传播的 `TaskDecorator`(见下文第三节)、想清楚失败后怎么办。
### 事件的可靠性边界(必须如实认识)
`ApplicationEvent` 是**进程内、内存中**的:应用在事件发出后、监听器执行前崩溃,这个事件就永久丢了,没有重试、没有补偿。
所以这套机制**只能用于"丢了不致命"的副作用**——作废一张票据(下次换票时本来也会重新校验)、清一个缓存、记一条审计日志。**不能用于**:扣款、发货、任何丢了会造成数据不一致且无法自愈的操作。
在当前这套系统里,跨域事件的用途就是切店后作废票据这一类,符合这个边界。
### 什么时候升级到消息中间件
出现下面任意一条,就该引入 RabbitMQ/Kafka + 事务性发件箱(transactional outbox),而不是继续给 `ApplicationEvent` 打补丁:
- 事件消费失败需要**自动重试**,或者需要死信队列;
- 事件丢失会造成**业务上的资金/库存不一致**;
- 消费方被拆成了**独立进程**(模块化单体拆微服务时的必然结果);
- 需要一个事件被**多个消费组各自独立消费**,且各自有独立的消费进度。
事务性发件箱的做法(记在这里备查,现在不实施):在业务事务里往 `outbox` 表插一条记录(与业务写在同一个事务,天然原子),另有一个轮询任务把 outbox 里的记录投递到消息中间件并标记已发送。它解决的是"写库成功但发消息失败"这个用 `AFTER_COMMIT` 无论如何都消除不掉的窗口。
现在不做的理由很直接:引入中间件意味着多一套需要部署、监控、排障的基础设施,而当前唯一的跨域事件场景丢了也不致命。**等到有第一个"丢了会出事"的事件时再做**,那时候需求也更清楚。
## 三、聚合:`bff-orchestration` 与 `workbench` 的并行 fan-out
首页要同时拉采购、保修、门店信息等多个 tile(架构图 Flow 3),串行调用意味着总耗时是各下游耗时之和。同步栈下必须显式用线程池做并行。
### 专用线程池
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/infrastructure/config/WorkbenchExecutorConfig.kt
// 模块内的 @Configuration 一律放 infrastructure/config/(见 01-project-structure.md 的脚手架),
// 不要散在模块根包下——那样它既不属于任何一层,ArchUnit 的分层规则也管不到它。
@Configuration
class WorkbenchExecutorConfig {
@Bean("workbenchExecutor")
fun workbenchExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
corePoolSize = 8
maxPoolSize = 16
queueCapacity = 32 // 有界!无界队列会让 maxPoolSize 永远不生效
setThreadNamePrefix("workbench-")
setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
// 关键:把 MDCtraceId)和 RequestContext(门店上下文)带到子线程
setTaskDecorator(ContextPropagatingTaskDecorator())
setWaitForTasksToCompleteOnShutdown(true)
setAwaitTerminationSeconds(20) // 配合 09 的优雅停机
initialize()
}
}
```
四个必须这么写的点:
1. **必须是专用池,不能用公共 `@Async` 默认池**。聚合任务和别的异步任务共用一个池,一个下游变慢就会把池占满,波及所有异步任务——这正是舱壁模式要防的事(见 [05-integration-layer.md](./05-integration-layer.md))。
2. **队列必须有界**`ThreadPoolTaskExecutor``queueCapacity` 默认是 `Integer.MAX_VALUE`,即无界——后果是任务全部堆进队列,线程数永远不会从 core 涨到 max,然后在某次流量高峰把堆内存吃光。
3. **`CallerRunsPolicy`**:池满时任务退回调用线程(Tomcat 线程)自己执行。这是一种天然的背压——聚合变慢了,但不会丢请求、不会抛 `RejectedExecutionException`
4. **`ContextPropagatingTaskDecorator`**Micrometer 提供的上下文传播装饰器,把 MDC 里的 traceId、`@RequestScope` 的门店上下文搬到子线程。**没有它,子线程里的日志全部断链,且 `StoreContextHolder` 直接取不到值——这是同步栈下并行聚合最典型的翻车方式**([08-observability.md](./08-observability.md) 附录也点了这一处)。
### 整体超时预算
```kotlin
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
@Service
class WorkbenchAppService(
@Qualifier("workbenchExecutor") private val executor: Executor,
private val f6ApiClient: F6ApiClient,
private val o2oClient: O2OClient,
private val storeQueryService: StoreQueryService,
) {
private val log = LoggerFactory.getLogger(javaClass)
companion object {
private val TOTAL_BUDGET = Duration.ofSeconds(3) // 整个首页接口的总预算
}
fun loadHomepage(userId: Long, storeId: Long): HomepageResponse {
val deadline = System.nanoTime() + TOTAL_BUDGET.toNanos()
val procurement = supply("procurement") { f6ApiClient.fetchProcurementList(storeId) }
val warranty = supply("warranty") { o2oClient.fetchWarrantySummary(storeId) }
val store = supply("store") { storeQueryService.findStore(storeId) }
return HomepageResponse(
procurement = await(procurement, deadline, "procurement"),
warranty = await(warranty, deadline, "warranty"),
store = await(store, deadline, "store"),
)
}
private fun <T> supply(tile: String, block: () -> T): CompletableFuture<Tile<T>> =
CompletableFuture.supplyAsync({
runCatching(block)
.map { Tile.ok(it) }
.getOrElse { log.warn("tile={} 加载失败,降级", tile, it); Tile.degraded() }
}, executor)
private fun <T> await(future: CompletableFuture<Tile<T>>, deadlineNanos: Long, tile: String): Tile<T> {
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) return Tile.degraded()
return runCatching { future.get(remaining, TimeUnit.NANOSECONDS) }
.getOrElse { log.warn("tile={} 超出总预算,降级", tile); Tile.degraded() }
}
}
```
**总预算不等于各下游超时之和**。三个下游各配 2 秒超时,串行最坏 6 秒、并行最坏 2 秒——但这算的是单次调用,叠上 `@Retry`(3 次)之后单个 tile 最坏可能到 6 秒。所以必须有一个独立于下游配置的**接口级总预算**(这里 3 秒),到点就把还没回来的 tile 全部降级返回。没有这道闸,首页接口的最坏耗时是由下游配置的乘积决定的,不可控。
### 局部降级的响应约定
降级必须**对客户端可见**,不能悄悄返回空数据——客户端要能区分"这块真的没数据"和"这块没拉到",才能决定是显示空态还是显示"加载失败,点击重试"。
```kotlin
data class Tile<T>(
val data: T?,
val status: TileStatus, // OK / DEGRADED
) {
companion object {
fun <T> ok(data: T) = Tile(data, TileStatus.OK)
fun <T> degraded() = Tile<T>(null, TileStatus.DEGRADED)
}
}
```
对应的响应体:
```json
{
"code": 0,
"data": {
"procurement": { "status": "OK", "data": { "pendingCount": 12 } },
"warranty": { "status": "DEGRADED", "data": null },
"store": { "status": "OK", "data": { "storeId": 1, "name": "示例门店" } }
},
"traceId": "..."
}
```
规则:**只要主数据(门店上下文)拿到了,首页接口就返回 `code: 0`**,个别 tile 降级不会让整个接口失败。这正是架构图 Flow 3 里"局部降级"的含义——一个外部系统抖动不应该让用户连首页都打不开。
## 关键规则
- 跨 domain 读走 `-contract` 接口,跨 domain 写/状态联动走领域事件;两者都不允许直接 import 对方的内部类型。
- 事件类型定义在契约模块;监听器一律 `@TransactionalEventListener(AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)` + 自己兜住异常。
- `ApplicationEvent` 只用于"丢了不致命"的副作用;出现需要重试/不能丢的场景,升级到消息中间件 + 事务性发件箱,不要给现有机制打补丁。
- 并行聚合必须用专用**有界**线程池 + `ContextPropagatingTaskDecorator` + `CallerRunsPolicy`
- 聚合接口必须有独立于下游配置的**总超时预算**,超时的 tile 降级返回而不是整体失败。
- 降级状态必须在响应里显式表达(`status: DEGRADED`),不能用空数据冒充。
## 待补充
- 各 tile 的具体超时预算分配(需要先有真实的下游耗时数据)。
- 首页聚合结果的本地缓存策略——见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
## 参考链接
- [Spring: Application Events](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events)
- [Spring: `@TransactionalEventListener`](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html)
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
- [microservices.io: Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
- [Simon Brown: Modular Monoliths](https://www.youtube.com/watch?v=5OjqD-ow8GE)
+306
View File
@@ -0,0 +1,306 @@
# 12. 并发、事务与定时任务
## 决策
事务边界统一放在 application 层;写接口用幂等键防重复提交;并发冲突用 `@Version` 乐观锁 + 有限重试;定时任务在多副本下用 ShedLock 保证只跑一次;缓存暂不引入 Redis,只用 Caffeine 本地缓存,且严格限定在"能容忍副本间不一致"的数据上。
## 一、事务边界
### 规则:事务开在 application 层的用例方法上
```kotlin
@Service
class StoreSwitchAppService(...) {
@Transactional // ← 事务边界在这里
fun switchStore(userId: Long, targetStoreId: Long): StoreContext { ... }
}
```
- **不在 Controller 上开事务**Controller 属于 api 层,开事务等于把 HTTP 序列化过程也圈进事务里,事务被无谓拉长。
- **不在 Repository 方法上开事务**:一个用例往往包含多次写,各自开事务就没有原子性可言了。
- **只读查询加 `@Transactional(readOnly = true)`**Hibernate 会跳过脏检查(dirty checking),减少一次全量快照比对;对只读为主的查询接口是免费的性能收益。
### 事务里绝对不能做的三件事
1. **调外部 HTTP 接口**。F6 慢一点,数据库连接和行锁就被一起占着不放,几个请求就能把连接池打满(见 [03-persistence.md](./03-persistence.md) 的池子容量算法)。外部调用要么放在事务开始前,要么放在提交之后(`AFTER_COMMIT` 事件,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md))。
2. **`Thread.sleep` / 等待用户输入 / 等锁**。同上。
3. **catch 掉异常却继续用同一个事务**。Spring 默认在 `RuntimeException` 时把事务标记为 rollback-only,这之后再做任何写操作,最终提交时都会抛 `UnexpectedRollbackException`——而且报错点离真正的错误现场很远,非常难查。
### 自调用失效:Spring 事务最经典的坑
```kotlin
@Service
class OrderAppService {
fun createBatch(items: List<Item>) {
items.forEach { create(it) } // ← 这里的 @Transactional 完全不生效
}
@Transactional
fun create(item: Item) { ... }
}
```
`@Transactional` 靠 AOP 代理实现,**同一个类内部的方法调用不经过代理**,注解形同虚设。这一条对 `@Async``@Cacheable`、Resilience4j 的注解全部适用。解法是把被调方法挪到另一个 bean 里,让调用真的穿过代理。
### 传播行为:只用这两个
| 传播行为 | 什么时候用 |
| --- | --- |
| `REQUIRED`(默认) | 绝大多数情况。有事务就加入,没有就新建 |
| `REQUIRES_NEW` | 必须独立提交/回滚的场景:`AFTER_COMMIT` 事件监听器、审计记录、失败次数累加(登录失败计数必须在认证失败回滚后仍然保留) |
其余传播行为(`NESTED``SUPPORTS``MANDATORY`…)在这套系统里没有需要它们的场景,用了只会增加理解成本。真遇到时先怀疑是不是边界划错了。
### 隔离级别
统一 `READ COMMITTED`(在 [03-persistence.md](./03-persistence.md) 里通过 `transaction-isolation` 全局配置,覆盖 MySQL 默认的 `REPEATABLE READ`),不在方法上单独指定。需要更强一致性的地方用显式锁(`SELECT ... FOR UPDATE`JPA 的 `@Lock(PESSIMISTIC_WRITE)`)或乐观锁,而不是靠调隔离级别——后者影响面是整个连接,副作用难以预料。
## 二、幂等
### 哪些接口需要
客户端在弱网下会重试(见客户端 `05-networking.md`),用户也会连点两次。**所有会产生副作用且重复执行会出问题的写接口**都需要幂等保护:换票、切店、任何创建类操作。
天然幂等的不需要额外处理:`GET`、把状态设为某个确定值的更新(`status = INACTIVE`)、按主键的删除。
### 方案:客户端生成幂等键
```
POST /api/v1/webview/tickets
Idempotency-Key: 7f3a9c1e-... # 客户端生成的 UUID,重试时复用同一个值
```
```kotlin
// platform/platform-web/src/main/kotlin/.../platform/web/idempotency/IdempotencyGuard.kt
// 放 platform 而不是某个 domain:换票、切店、创建类操作分散在多个 domain,
// 而 domain 之间不能互相依赖——公共能力只能住在 platform-*(见 01-project-structure.md)。
@Service
class IdempotencyGuard(private val recordRepository: IdempotencyRecordRepository) {
/**
* 同一个 key 在有效期内只会真正执行一次;重复请求直接返回首次的结果。
*/
@Transactional
fun <T> execute(key: String, userId: Long, block: () -> T): T {
val existing = recordRepository.findByKeyAndUserId(key, userId)
if (existing != null) return deserialize(existing.response)
val result = block()
// 唯一索引兜底:两个并发请求同时走到这里,第二个会因为 uk_idem_key_user 冲突而失败
recordRepository.save(IdempotencyRecordEntity(key, userId, serialize(result)))
return result
}
}
```
```sql
-- 建在 platform 共用的库里(和下面的 shedlock 表同库),不属于任何 domain
create table idempotency_record (
id bigint not null auto_increment,
idem_key varchar(64) not null,
user_id bigint not null,
response text not null,
created_at datetime(6) not null,
primary key (id),
unique key uk_idem_key_user (idem_key, user_id)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
三个要点:
- **唯一索引是真正的保证,代码里的"先查再写"不是**。两个并发请求会同时查到"没有记录"然后同时执行——只有数据库的唯一约束能挡住。捕获 `DataIntegrityViolationException` 后重新读一次已有结果即可。
- **幂等键要带 `userId`**:只用 key 做唯一索引意味着不同用户的 key 会互相冲撞,而 key 是客户端生成的,我们不控制它的全局唯一性。
- **记录要定期清理**(见下面定时任务一节),保留期取"客户端最长可能重试的窗口",比如 24 小时,不需要永久保存。
## 三、并发冲突:乐观锁
### 用 `@Version`,不用悲观锁
```kotlin
// domains/identity-store/src/main/kotlin/.../identitystore/infrastructure/persistence/StoreEntity.kt
@Entity
class StoreEntity : VersionedEntity() { // VersionedEntity 带 @Version,见 03-persistence.md
var name: String = ""
}
```
更新时如果 version 已经被别人改过,Hibernate 抛 `ObjectOptimisticLockingFailureException`[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler` 会把它转成 `10409 CONFLICT`
选乐观锁而不是 `SELECT ... FOR UPDATE`:这套系统是典型的低冲突场景(同一门店的同一条记录被两个人同时改的概率很低),悲观锁的代价是每次读都要持锁,把并发度砍掉换一个几乎用不上的保证。
### 什么时候自动重试,什么时候返回 409
| 场景 | 处理 |
| --- | --- |
| 用户提交的业务更新(改门店信息) | **返回 409**,让用户看到"数据已被他人修改,请刷新后重试"。自动重试会静默覆盖别人的修改 |
| 内部的计数器/状态推进(登录失败次数、票据状态流转) | **自动重试**,用户不需要知道内部发生了冲突 |
```kotlin
@Retryable(
retryFor = [ObjectOptimisticLockingFailureException::class],
maxAttempts = 3,
backoff = Backoff(delay = 50, multiplier = 2.0, random = true), // 抖动,避免两方同步重试同步再撞
)
@Transactional
fun incrementFailedAttempts(userId: Long) { ... }
```
**重试必须在事务外层**——`@Retryable` 要包住 `@Transactional`,因为冲突发生时事务已经标记回滚,必须开一个全新的事务重新读、重新算、重新写。在事务内部重试是无效的(而且会撞上 rollback-only)。由于两个注解在同一个方法上时代理顺序容易搞错,稳妥做法是把重试和事务拆到两个 bean 上:外层 bean 负责 `@Retryable`,内层 bean 负责 `@Transactional`
## 四、定时任务在多副本下的重复执行
### 问题
[04-security-auth.md](./04-security-auth.md) 里有一个清理过期 refresh token 的 `@Scheduled` 任务,加上上面幂等记录的清理任务。**`@Scheduled` 在每个 Pod 上都会独立执行**——2 个副本就是每次跑 2 遍。清理任务跑两遍问题不大(删除是幂等的),但只要出现一个"发通知""生成对账单"式的任务,重复执行就是事故。
这一条在原来的文档里完全没有提到,而 [09-build-deploy.md](./09-build-deploy.md) 明确配了 `replicas: 2`——也就是说按现有文档实施,上线当天就是重复执行状态。
### 方案:ShedLock
```groovy
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'
```
```sql
-- 放在 platform 共用的库里,不属于任何 domain
create table shedlock (
name varchar(64) not null,
lock_until datetime(6) not null,
locked_at datetime(6) not null,
locked_by varchar(255) not null,
primary key (name)
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
```
```kotlin
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/scheduling/SchedulingConfig.kt
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
class SchedulingConfig {
@Bean
fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(JdbcTemplate(dataSource))
.usingDbTime() // 用数据库时间而不是各 Pod 的本地时间,避免时钟漂移导致锁失效
.build(),
)
}
// domains/identity-store/src/main/kotlin/.../identitystore/application/RefreshTokenCleanupJob.kt
// 定时任务放 application 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
@Component
class RefreshTokenCleanupJob(private val repository: RefreshTokenRepository, private val clock: Clock) {
@Scheduled(cron = "0 17 3 * * *") // 每天凌晨 3:17,见下方"为什么不用整点"
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
fun cleanup() {
val deleted = repository.deleteExpiredBefore(clock.instant())
LoggerFactory.getLogger(javaClass).info("清理过期 refresh token,删除 {} 条", deleted)
}
}
```
两个参数的含义容易混:
- **`lockAtMostFor`**:锁的最长持有时间,防死锁。持锁的 Pod 被 kill 掉时,锁不会自动释放(数据库里的记录还在),到这个时间才失效。**必须显著大于任务的正常执行时间**,否则任务还没跑完锁就过期了,另一个 Pod 会同时开跑。
- **`lockAtLeastFor`**:锁的最短持有时间。防的是"任务执行极快 + 各 Pod 时钟有偏差"导致同一个调度点被跑两次。
**`usingDbTime()` 不能省**:不加的话 ShedLock 用各个 Pod 的本地时间写锁,Pod 之间有几秒时钟漂移就可能同时抢到锁——那这套机制就白配了。
### 为什么 cron 不用整点
`0 0 3 * * *` 这种整点时间,全世界所有系统的定时任务都挤在同一秒——数据库、外部依赖、监控在那一刻集体尖峰。错开几分钟(`0 17 3 * * *`)没有任何业务代价,但能把这个尖峰摊平。
### 定时任务的其他约定
- **必须记日志**:开始、结束、处理条数。没有日志的定时任务出问题时你连"它有没有跑"都不知道。
- **必须限制单次处理量**`delete from ... where expires_at < ? limit 1000` 分批删,不要一条 SQL 删几百万行——那会长时间持有行锁并撑爆 binlog。
- **必须自己兜住异常**`@Scheduled` 方法抛异常只会被 Spring 记一条日志,任务本身不会重试,但下次调度照常。如果失败需要告警,自己 catch 后打 `ERROR`(见 [08-observability.md](./08-observability.md) 的告警规则)。
- **任务执行情况应该有指标**:至少一个"上次成功执行时间",用于告警"某个任务已经 3 天没成功跑过了"——这类静默失败光看错误日志是发现不了的。
### 替代方案:K8s CronJob
对于"跑一次就结束、不需要常驻"的任务(比如大表数据归档),更合适的做法是 K8s `CronJob` 起一个独立 Pod:天然只跑一份,不需要 ShedLock,也不会和在线请求抢应用的线程和连接池。代价是要额外维护一套镜像入口和清单。
判断标准:**任务需要用到应用内的业务逻辑 → `@Scheduled` + ShedLock;任务本质是一段独立的数据操作 → CronJob**。
## 五、缓存:只用 Caffeine 本地缓存
### 为什么暂不引入 Redis
引入 Redis 意味着多一个需要部署、监控、备份、排障的有状态组件,以及一整套新的失败模式(连接抖动、大 key、缓存穿透/雪崩)。当前的数据量和并发量还远没有到需要它的程度,而 [04-security-auth.md](./04-security-auth.md) 的 refresh token 已经明确落库、[11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md) 的事件也不需要外部存储——没有哪个场景是非它不可的。
### Caffeine 的适用边界(这是重点)
本地缓存的本质特征是:**每个 Pod 各缓存一份,副本之间必然不一致,且无法主动失效其他副本的缓存**。所以只能缓存满足这两个条件的数据:
1. 变更频率极低;
2. **短时间内读到旧值不会造成业务错误**
| 数据 | 能不能用本地缓存 | 说明 |
| --- | --- | --- |
| 菜单/权限元数据定义 | 可以 | 几乎不变,改了之后延迟几分钟生效可接受 |
| 门店基础信息(名称、编码) | 可以 | 同上 |
| 字典/枚举配置 | 可以 | 同上 |
| **用户对门店的可访问权限** | **不可以** | 收回权限后必须立即生效,这是安全边界(见 [04-security-auth.md](./04-security-auth.md) |
| **WebView 票据状态** | **不可以** | 作废必须立即生效 |
| **任何用户维度的业务数据** | **不可以** | 用户在 Pod A 改的数据,请求打到 Pod B 会读到旧值 |
```kotlin
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/cache/CacheConfig.kt
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
setCaffeine(
Caffeine.newBuilder()
.maximumSize(1_000) // 必须设上限,否则就是内存泄漏
.expireAfterWrite(Duration.ofMinutes(10)), // 必须设过期,这是副本间最终一致的唯一保证
)
}
}
// domains/identity-store/.../identitystore/application/StoreQueryServiceImpl.kt
// 缓存加在 application 层的查询方法上,返回的是契约模型而不是 Entity——
// 缓存里躺着一个游离态的 JPA Entity 是另一类难查的问题(见 02-layering.md)。
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
fun findStoreBasicInfo(storeId: Long): StoreInfo? { ... }
```
`maximumSize``expireAfterWrite` 都是**强制**的,不是可选优化:没有 `maximumSize` 的缓存就是一个慢速内存泄漏;没有 `expireAfterWrite` 的本地缓存永远不会和其他副本收敛。
**用 `@Cacheable` 时同样注意自调用失效**——它和 `@Transactional` 一样走代理,同类内部调用不生效。
### 什么时候升级到 Redis
- 需要**跨副本立即失效**某个缓存;
- 需要跨副本共享状态(分布式限流计数、在线用户数);
- 缓存数据量大到单 Pod 内存放不下。
到那时 Redis 的取舍是清楚的,现在提前引入只是提前承担成本。
## 关键规则
- 事务边界在 application 层的用例方法上;事务内不做 HTTP 调用、不 sleep。
- 注意自调用失效:`@Transactional` / `@Async` / `@Cacheable` / Resilience4j 注解在同类内部调用时全部不生效。
- 有副作用的写接口用 `Idempotency-Key` + 唯一索引做幂等;唯一索引才是保证,"先查再写"不是。
- 并发冲突用 `@Version` 乐观锁:用户提交的更新返回 `10409`,内部状态推进自动重试(重试包在事务外层)。
- 所有 `@Scheduled` 任务必须加 `@SchedulerLock``LockProvider` 必须 `usingDbTime()`cron 时间避开整点。
- 只用 Caffeine 本地缓存,且只缓存"读到旧值不会出错"的数据;`maximumSize``expireAfterWrite` 强制配置。
## 待补充
- 幂等记录的具体保留期(需要和客户端确认最长重试窗口)。
- 是否需要接口级限流(当前只有对下游的舱壁,没有对上游的限流)。
## 参考链接
- [Spring: Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction.html)
- [Spring Retry](https://github.com/spring-projects/spring-retry)
- [ShedLock](https://github.com/lukas-krecan/ShedLock)
- [Caffeine](https://github.com/ben-manes/caffeine/wiki)
- [Stripe: Designing robust and predictable APIs with idempotency](https://stripe.com/blog/idempotency)
+44
View File
@@ -0,0 +1,44 @@
# 后端架构决策文档
Kotlin 2.4 + Spring Boot 4.1 + Java 21 + Gradle 多模块(模块化单体)的架构决策记录,按序号阅读。
**这些文档是本仓库代码的约束来源。** 代码和文档不一致时,以文档为准改代码——包括不要为了让代码通过而把
`architecture-test` 里的 ArchUnit 规则调松。骨架落地时对文档有意偏离的几处,都在对应代码的注释里注明了原因。
| 文档 | 内容 |
| --- | --- |
| [01-project-structure.md](./01-project-structure.md) | 工程结构 / 模块划分(模块化单体,`-contract` 契约模块,版本基线) |
| [02-layering.md](./02-layering.md) | 分层规范(api/application/domain/infrastructureEntity 边界) |
| [03-persistence.md](./03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate + MySQLFlyway 多实例) |
| [04-security-auth.md](./04-security-auth.md) | 安全与认证(Spring Security + JWTrefresh 轮换,门店上下文与越权隔离) |
| [05-integration-layer.md](./05-integration-layer.md) | 集成层设计(同步 RestClient + Resilience4jF6 Adapter / Mini 客户端) |
| [06-api-design.md](./06-api-design.md) | API 设计规范(统一响应、错误码、请求头、分页与序列化约定) |
| [07-config-governance.md](./07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret、Key Vault、启动期校验) |
| [08-observability.md](./08-observability.md) | 可观测性(Micrometer Tracing、结构化日志与脱敏、指标与告警、审计) |
| [09-build-deploy.md](./09-build-deploy.md) | 构建与多环境部署(Docker、GitLab CI/CD、优雅停机、迁移与回滚协同) |
| [10-testing.md](./10-testing.md) | 测试策略(Testcontainers、WireMock、ArchUnit、覆盖率聚合) |
| [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md) | 跨域协作与聚合(契约模块、领域事件、并行 fan-out 与局部降级) |
| [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md) | 并发、事务与定时任务(事务边界、幂等、乐观锁、ShedLock、本地缓存) |
## 这份副本从哪来、怎么更新
原件在 **[`conti-docs`](../../conti-docs/backend/)** 仓库的 `backend/` 目录,那里是唯一事实来源。
这里是一份副本,目的是让在本仓库里干活的人(和 AI)不用切仓库就能查到约束。
代价是会漂移。**改文档去 `conti-docs` 改,然后同步过来**,不要只改这一边:
```bash
cp ../../conti-docs/backend/[0-9][0-9]-*.md ./
# 复制完把跨仓链接改回来(原件里是 ../Architecture-Diagram/ 和 ../0X-*.md
sed -i 's|](\.\./Architecture-Diagram/|](../../conti-docs/Architecture-Diagram/|g' ./[0-9][0-9]-*.md
sed -i 's|](\.\./\([0-9][0-9]-[^)]*\.md\)|](../../conti-retail-app/docs/\1|g' ./[0-9][0-9]-*.md
```
跨仓链接(架构图、App 侧文档)按 `Continental-App/` 下三个仓库平级摆放来写相对路径。
换了目录结构这些链接就断了,重跑一次上面的 `sed` 即可。
## 相关
- **App 侧架构决策**[`conti-retail-app/docs/`](../../conti-retail-app/docs/)Flutter01-14
- **架构图与 PRD**[`conti-docs/Architecture-Diagram/`](../../conti-docs/Architecture-Diagram/)
- **未决阻塞项**(错误码表未定等):见 [`conti-docs/README.md`](../../conti-docs/README.md) 的「跨文档的阻塞项」
+26
View File
@@ -0,0 +1,26 @@
# bff-orchestration
面向客户端的编排层:把多个 domain 的用例拼成"一个屏幕一个接口"的形状。
## 现在为什么是空的
首页聚合当前落在 `domains/workbench` 里(见 `WorkbenchAppService`),因为它只跨了
`identity-store` + 两个外部系统,用 domain 自己的 application 层就够了。
**在出现第二个"必须跨多个 domain 才能拼出来、且明显只服务于某一个客户端屏幕"的接口之前,
这个模块保持为空。** 提前把编排逻辑搬进来只会多一层转发。
## 什么时候开始往里写
同时满足这几条时,把编排从 domain 里搬进来:
- 一个接口需要 3 个以上 domain 的数据,且这些 domain 之间没有业务上的从属关系;
- 响应结构明显是为某个客户端页面定制的(换个端就得换一套);
- 编排逻辑放在任何一个 domain 里都显得越界。
## 写进来时的约束
- 只能依赖各 domain 的 `-contract` 模块和 `platform-*`,**不能依赖任何 domain 的实现模块**;
- 自己不建表、不写 Flyway 迁移——BFF 没有自己的数据;
- 并行聚合照 `workbench` 的做法:专用有界线程池 + 上下文传播 + 接口级总超时预算
(见 `../../conti-docs/backend/11-cross-domain-collaboration.md`)。
+4
View File
@@ -0,0 +1,4 @@
// 骨架阶段这个模块只占位,见 README.md
dependencies {
implementation project(':platform:platform-web')
}
@@ -0,0 +1,4 @@
// 契约模块:只放接口、传输模型和事件,不携带任何技术栈。
// ArchUnit 有一条规则盯着它不依赖 Spring Web / JPA(见 10-testing.md)。
dependencies {
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.identitystore.contract
/**
* identity-store 对外发布的读能力,见 11-cross-domain-collaboration.md。
*
* 契约设计四条规则里最重要的两条:
* 1. 只承诺调用方真正需要的最小能力——这里出现的每个方法都是未来的约束;
* 2. [StoreInfo] 是独立的数据结构,不是 `StoreEntity` 的别名,不跟着表结构一起长。
*/
interface StoreQueryService {
fun listStoresByUserId(userId: Long): List<StoreInfo>
fun findStore(storeId: Long): StoreInfo?
}
data class StoreInfo(
val storeId: Long,
val name: String,
val code: String,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.contract
import java.time.Instant
/**
* 门店已切换,见 11-cross-domain-collaboration.md。
*
* 事件类型必须定义在契约模块,否则订阅方要 import 发布方的内部类型,模块边界就破了。
*
* 可靠性边界要如实认识:`ApplicationEvent` 是**进程内、内存中**的,
* 应用在发布后、监听器执行前崩溃这个事件就永久丢了。所以它只用于"丢了不致命"的副作用
* (作废票据、清缓存、记审计),不能用于扣款、发货这类丢了会造成不一致的操作。
*/
data class StoreSwitchedEvent(
val userId: Long,
val fromStoreId: Long?,
val toStoreId: Long,
val occurredAt: Instant,
)
+26
View File
@@ -0,0 +1,26 @@
apply plugin: 'org.jetbrains.kotlin.kapt'
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
implementation project(':platform:platform-observability')
implementation project(':domains:identity-store-contract')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.mapstruct:mapstruct:1.6.3'
kapt 'org.mapstruct:mapstruct-processor:1.6.3'
testImplementation 'com.ninja-squad:springmockk:5.0.1'
// Boot 4 把测试切片按技术栈拆成了独立模块:@DataJpaTest / @AutoConfigureTestDatabase
// 不再随 spring-boot-starter-test 一起来,要单独引这个 starter
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mysql'
testRuntimeOnly 'com.mysql:mysql-connector-j'
}
@@ -0,0 +1,56 @@
package com.continental.retailapp.identitystore.api
import com.continental.retailapp.identitystore.api.request.LoginRequest
import com.continental.retailapp.identitystore.api.request.RefreshTokenRequest
import com.continental.retailapp.identitystore.api.response.LoginResponse
import com.continental.retailapp.identitystore.api.response.MeResponse
import com.continental.retailapp.identitystore.api.response.TokenResponse
import com.continental.retailapp.identitystore.application.AuthAppService
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.platform.web.ApiResult
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* 认证端点。路径与响应体是照着客户端已实现的契约写的(04-security-auth.md),
* **后端对齐客户端,不是反过来**。
*
* Controller 只做参数校验 + 调用 `application`,不含任何业务分支。
*/
@Tag(name = "认证")
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authAppService: AuthAppService,
private val storeContextHolder: StoreContextHolder,
) {
@Operation(summary = "登录,返回 access token / refresh token 与用户信息")
@PostMapping("/login")
fun login(@Valid @RequestBody request: LoginRequest): ApiResult<LoginResponse> =
ApiResult.ok(authAppService.login(request))
/** 免认证:调它的时候 access token 恰好已经过期,要求带有效 token 就成了死锁。 */
@Operation(summary = "用 refresh token 换新的 access token(轮换)")
@PostMapping("/refresh")
fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<TokenResponse> =
ApiResult.ok(authAppService.refresh(request.refreshToken))
@Operation(summary = "登出,撤销该 refresh token")
@PostMapping("/logout")
fun logout(@Valid @RequestBody request: RefreshTokenRequest): ApiResult<Unit> {
authAppService.logout(request.refreshToken)
return ApiResult.ok()
}
/** 客户端冷启动第一个调用的接口,必须轻量且只读。 */
@Operation(summary = "当前登录用户与所在门店")
@GetMapping("/me")
fun me(): ApiResult<MeResponse> =
ApiResult.ok(authAppService.me(storeContextHolder.currentUserId()))
}
@@ -0,0 +1,34 @@
package com.continental.retailapp.identitystore.api
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.application.StoreAppService
import com.continental.retailapp.platform.web.ApiResult
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "门店")
@RestController
@RequestMapping("/api/v1/stores")
class StoreController(
private val storeAppService: StoreAppService,
) {
@Operation(summary = "查询当前用户可访问的门店列表")
@GetMapping("/accessible")
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
ApiResult.ok(storeAppService.listAccessibleStores())
/**
* 这是少数几个"门店 id 来自路径参数"的接口之一,
* 所以 `switchStore` 内部**第一件事就是校验可访问性**04-security-auth.md 越权规则第 2 条)。
*/
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
@PostMapping("/{storeId}/switch")
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
ApiResult.ok(storeAppService.switchStore(storeId))
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.identitystore.api.request
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
/**
* 登录请求。契约见 04-security-auth.md 的端点表——客户端已按此实现,后端对齐客户端。
*
* `password` 只在这里出现一次,不会进日志:`logback-spring.xml` 里配了兜底脱敏,
* 但真正的防线是"永远不打印整个请求体"08-observability.md)。
*/
data class LoginRequest(
@field:NotBlank(message = "用户名不能为空")
@field:Size(max = 64, message = "用户名过长")
val username: String,
@field:NotBlank(message = "密码不能为空")
@field:Size(max = 128, message = "密码过长")
val password: String,
)
@@ -0,0 +1,9 @@
package com.continental.retailapp.identitystore.api.request
import jakarta.validation.constraints.NotBlank
/** `POST /api/v1/auth/refresh` 与 `POST /api/v1/auth/logout` 共用同一个请求体。 */
data class RefreshTokenRequest(
@field:NotBlank(message = "refreshToken 不能为空")
val refreshToken: String,
)
@@ -0,0 +1,30 @@
package com.continental.retailapp.identitystore.api.response
/** 用户对外视图。不含 `passwordHash`、`failedAttempts` 这些内部字段。 */
data class UserResponse(
val id: Long,
val username: String,
val displayName: String,
)
/** `POST /api/v1/auth/login` 的响应:`{ accessToken, refreshToken, user }`。 */
data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val user: UserResponse,
)
/** `POST /api/v1/auth/refresh` 的响应:`{ accessToken, refreshToken }`。 */
data class TokenResponse(
val accessToken: String,
val refreshToken: String,
)
/**
* `GET /api/v1/auth/me` 的响应:`{ user, currentStoreId }`。
* 客户端冷启动会调它恢复会话,所以它必须是只读的轻量查询。
*/
data class MeResponse(
val user: UserResponse,
val currentStoreId: Long?,
)
@@ -0,0 +1,17 @@
package com.continental.retailapp.identitystore.api.response
/**
* 切店返回的完整门店上下文(04-security-auth.md)。
*
* 返回的是上下文而不是空 body`accessToken` 里的 `storeId` 决定了后续所有请求的数据范围,
* 只改客户端本地状态不换 token,等于门店没真的切过去。
*
* `menus` 只是 UI 便利——**它不构成安全边界**,真正的边界是各端点上的 `@PreAuthorize`。
* 菜单权限模型 04 文档自己标了"待补充",这里先留一个按角色推导的最小实现。
*/
data class StoreContextResponse(
val accessToken: String,
val store: StoreResponse,
val roles: List<String>,
val menus: List<String>,
)
@@ -0,0 +1,8 @@
package com.continental.retailapp.identitystore.api.response
/** 门店对外视图,字段与客户端已实现的契约一致(06-api-design.md)。 */
data class StoreResponse(
val id: Long,
val name: String,
val code: String,
)
@@ -0,0 +1,179 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.api.request.LoginRequest
import com.continental.retailapp.identitystore.api.response.LoginResponse
import com.continental.retailapp.identitystore.api.response.MeResponse
import com.continental.retailapp.identitystore.api.response.TokenResponse
import com.continental.retailapp.identitystore.api.response.UserResponse
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStatus
import com.continental.retailapp.platform.observability.audit.Audited
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import io.micrometer.core.instrument.MeterRegistry
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Duration
/**
* 登录 / 刷新 / 登出 / me,见 04-security-auth.md。
*
* 两条贯穿本类的规则:
*
* 1. **失败响应必须无差别**。"用户不存在""密码错误""账号被锁"对外都是同一句
* "用户名或密码错误"、同一个错误码。区分开来等于送给攻击方一个用户名枚举接口。
* 2. **密码和令牌不进日志**。日志里只出现 `userId`,不出现 `username`PII)、更不出现密码。
*/
@Service
class AuthAppService(
private val userJpaRepository: UserJpaRepository,
private val storeRepository: StoreRepository,
private val passwordEncoder: PasswordEncoder,
private val accessTokenIssuer: AccessTokenIssuer,
private val refreshTokenService: RefreshTokenService,
private val meterRegistry: MeterRegistry,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Audited(action = "LOGIN")
@Transactional
fun login(request: LoginRequest): LoginResponse {
val now = clock.instant()
val user = userJpaRepository.findByUsername(request.username)
// 用户不存在也要走一次 BCrypt:否则"不存在"的响应会明显更快,
// 时间差本身就是一个可用的用户名枚举信号。
if (user == null) {
passwordEncoder.matches(request.password, DUMMY_HASH)
return failLogin("USER_NOT_FOUND")
}
if (user.status != UserStatus.ACTIVE) {
return failLogin("DISABLED")
}
if (user.isLocked(now)) {
return failLogin("LOCKED")
}
if (!passwordEncoder.matches(request.password, user.passwordHash)) {
registerFailure(user)
return failLogin("BAD_PASSWORD")
}
user.failedAttempts = 0
user.lockedUntil = null
val storeId = resolveCurrentStore(user)
val roles = roleNamesOf(user.id!!, storeId)
user.currentStoreId = storeId
countLogin("success")
return LoginResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roles),
refreshToken = refreshTokenService.issue(user.id!!),
user = user.toResponse(),
)
}
/**
* 刷新。轮换只换 refresh token 和 access token**不换门店**——
* 门店取自 `user.current_store_id`,否则每次刷新都会把用户弹回默认门店。
*/
@Transactional
fun refresh(rawRefreshToken: String): TokenResponse {
val rotated = refreshTokenService.rotate(rawRefreshToken)
val user = userJpaRepository.findById(rotated.userId).orElseThrow { InvalidRefreshTokenException() }
if (user.status != UserStatus.ACTIVE) {
// 账号在这期间被停用:连带把该用户所有令牌作废,不给它继续刷的机会
refreshTokenService.revokeAllByUser(user.id!!)
throw InvalidRefreshTokenException()
}
val storeId = resolveCurrentStore(user)
user.currentStoreId = storeId
return TokenResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roleNamesOf(user.id!!, storeId)),
refreshToken = rotated.refreshToken,
)
}
@Audited(action = "LOGOUT")
@Transactional
fun logout(rawRefreshToken: String) = refreshTokenService.revoke(rawRefreshToken)
/** 客户端每次冷启动都会调,必须是只读的轻量查询——不要在这里做任何写操作。 */
@Transactional(readOnly = true)
fun me(userId: Long): MeResponse {
val user = userJpaRepository.findById(userId).orElseThrow {
BusinessException(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录", HttpStatus.UNAUTHORIZED)
}
return MeResponse(user = user.toResponse(), currentStoreId = user.currentStoreId)
}
/**
* 选门店:优先沿用上次的,但**必须重新校验它现在还可访问**——
* 授权可能在两次登录之间被收回,直接信任 `current_store_id` 就是越权。
*/
private fun resolveCurrentStore(user: UserEntity): Long {
val userId = user.id!!
user.currentStoreId
?.let { storeRepository.findAccessibleStore(userId, it) }
?.let { return it.id }
return storeRepository.findStoresByUserId(userId).firstOrNull()?.id
?: throw BusinessException(
ErrorCode.NO_STORE_PERMISSION,
"当前账号未被授权任何门店,请联系管理员",
HttpStatus.FORBIDDEN,
)
}
private fun roleNamesOf(userId: Long, storeId: Long): List<String> =
storeRepository.findRoleNames(userId, storeId)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
private fun registerFailure(user: UserEntity) {
user.failedAttempts += 1
if (user.failedAttempts >= MAX_FAILED_ATTEMPTS) {
user.lockedUntil = clock.instant().plus(LOCK_DURATION)
user.failedAttempts = 0
log.warn("用户 {} 连续登录失败 {} 次,已锁定 {} 分钟", user.id, MAX_FAILED_ATTEMPTS, LOCK_DURATION.toMinutes())
}
}
/** 所有失败分支收敛到这一个出口,保证对外文案与错误码完全一致。 */
private fun failLogin(internalReason: String): Nothing {
countLogin("failure", internalReason)
throw BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误", HttpStatus.UNAUTHORIZED)
}
/**
* 登录成功率埋点(08-observability.md)。
* `reason` 只有有限几个取值,不会把标签基数打爆。
*/
private fun countLogin(result: String, reason: String = "none") {
meterRegistry.counter("auth.login", "result", result, "reason", reason).increment()
}
private fun UserEntity.toResponse() = UserResponse(id = id!!, username = username, displayName = displayName)
private companion object {
const val MAX_FAILED_ATTEMPTS = 5
val LOCK_DURATION: Duration = Duration.ofMinutes(15)
/**
* 用户不存在时拿来空跑一次 BCrypt 的假哈希(明文是随机的,没人知道)。
* 目的只是消掉时间差,它永远不会匹配成功。
*/
const val DUMMY_HASH = "{bcrypt}\$2a\$12\$C6UzMDM.H6dfI/f/IKcEe.3XkkkkkkkkkkkkkkkkkkkkkkkkkkkkO"
}
}
@@ -0,0 +1,18 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.http.HttpStatus
/**
* refresh token 不可用的统一出口。
*
* 对外只有一句"登录状态已失效"——不区分"没见过这个 token""已经被轮换掉了""过期了"。
* 这三种情况在服务端走完全不同的分支(见 [RefreshTokenService.rotate]),
* 但对外必须长得一模一样,否则调用方可以拿响应差异来探测令牌是否存在。
*/
class InvalidRefreshTokenException : BusinessException(
code = ErrorCode.UNAUTHORIZED,
message = "登录状态已失效,请重新登录",
httpStatus = HttpStatus.UNAUTHORIZED,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.application
/**
* 角色 → 菜单的最小映射。
*
* **菜单不是安全边界**:它只决定客户端画不画那个入口。真正的边界是各端点上的
* `@PreAuthorize`——即便菜单里没有,直接调接口也必须被拦住(04-security-auth.md)。
* 完整的菜单权限模型 04 文档标了"待补充",这里先给一个够跑通链路的实现。
*/
object MenuCatalog {
private val BY_ROLE = mapOf(
"STORE_MANAGER" to listOf("workbench", "procurement", "warranty", "report", "staff"),
"STAFF" to listOf("workbench", "warranty"),
)
fun menusOf(roles: List<String>): List<String> =
roles.flatMap { BY_ROLE[it].orEmpty() }.distinct()
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
/**
* 清理过期的 refresh token12-concurrency-and-scheduling.md)。
*
* 定时任务放 `application` 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
*
* - `@SchedulerLock` 不能省:`@Scheduled` 在每个 Pod 上都会独立执行,2 个副本就跑 2 遍。
* 删除本身是幂等的,但保持"所有定时任务都加锁"这条规则比逐个判断更可靠。
* - cron 用 3:17 而不是 3:00:整点是全世界定时任务的尖峰,错开几分钟没有业务代价。
* - 自己兜住异常:`@Scheduled` 抛异常只会被 Spring 记一条日志,不会重试,也不会告警。
*/
@Component
class RefreshTokenCleanupJob(
private val repository: RefreshTokenJpaRepository,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(cron = "0 17 3 * * *")
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
@Transactional
fun cleanup() {
runCatching { repository.deleteExpiredBefore(clock.instant()) }
.onSuccess { log.info("清理过期 refresh token,删除 {} 条", it) }
.onFailure { log.error("清理过期 refresh token 失败", it) }
}
}
@@ -0,0 +1,103 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import com.continental.retailapp.platform.security.JwtProperties
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.HexFormat
/** 轮换结果:明文 refresh token 只在这里往外传一次,之后就只剩 hash 了。 */
data class RotatedTokens(val userId: Long, val refreshToken: String)
/**
* refresh token 的签发、轮换、撤销,逐条按 04-security-auth.md。
*
* refresh token 是**不透明随机串**而不是 JWT:它需要能被立即撤销,
* 而自包含的 JWT 在过期前撤不掉。库里只存 SHA-256 摘要。
*
* [Clock] 走构造注入而不是 `Instant.now()`,测试里才能用 `Clock.fixed` 精确控制过期边界(10-testing.md)。
*/
@Service
class RefreshTokenService(
private val repository: RefreshTokenJpaRepository,
private val props: JwtProperties,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val random = SecureRandom()
@Transactional
fun issue(userId: Long): String {
val rawToken = generateOpaqueToken()
repository.save(
RefreshTokenEntity(
userId = userId,
tokenHash = sha256Hex(rawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
return rawToken // 返回明文给客户端,库里只有 hash
}
@Transactional
fun rotate(rawToken: String): RotatedTokens {
val existing = repository.findByTokenHash(sha256Hex(rawToken))
?: throw InvalidRefreshTokenException() // 从来不存在的 token
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
//
// "从来没见过的 token" 和 "已撤销的 token" 必须分成两个分支,
// 合成一个 `?: throw` 会让重放检测形同虚设。
if (existing.revokedAt != null) {
repository.revokeAllByUserId(existing.userId, clock.instant())
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
throw InvalidRefreshTokenException()
}
if (existing.isExpired(clock.instant())) {
throw InvalidRefreshTokenException()
}
val newRawToken = generateOpaqueToken()
val rotated = repository.save(
RefreshTokenEntity(
userId = existing.userId,
tokenHash = sha256Hex(newRawToken),
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
),
)
existing.revokedAt = clock.instant()
existing.replacedByTokenId = rotated.id
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken)
}
@Transactional
fun revoke(rawToken: String) {
repository.findByTokenHash(sha256Hex(rawToken))
?.takeIf { it.revokedAt == null }
?.let { it.revokedAt = clock.instant() }
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
}
/** 强制下线(改密码、检测到异常登录时用)。 */
@Transactional
fun revokeAllByUser(userId: Long): Int = repository.revokeAllByUserId(userId, clock.instant())
/** 256 bit 随机 + URL-safe base64,够长到不可枚举。 */
private fun generateOpaqueToken(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun sha256Hex(raw: String): String =
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
}
@@ -0,0 +1,75 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.api.response.StoreContextResponse
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.application.mapper.StoreMapper
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.platform.observability.audit.Audited
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
/**
* 门店列表与切店。
*
* 命名说明:11-cross-domain-collaboration.md 里这段逻辑叫 `StoreSwitchAppService`
* 04-security-auth.md 里叫 `StoreAppService`。骨架统一用后者——
* "看得到哪些店"和"切到哪家店"本来就是一组用例,拆成两个 service 只会让上下文来回传。
*/
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val storeMapper: StoreMapper,
private val userJpaRepository: UserJpaRepository,
private val accessTokenIssuer: AccessTokenIssuer,
private val storeContextHolder: StoreContextHolder,
private val events: ApplicationEventPublisher,
private val clock: Clock,
) {
@Transactional(readOnly = true)
fun listAccessibleStores(): List<StoreResponse> =
storeMapper.toResponseList(storeRepository.findStoresByUserId(storeContextHolder.currentUserId()))
@Audited(action = "SWITCH_STORE")
@Transactional
fun switchStore(targetStoreId: Long): StoreContextResponse {
val userId = storeContextHolder.currentUserId()
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店。
// 校验合进查询条件("查不到"即"没权限"),而不是查出来再判断。
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
val roles = storeRepository.findRoleNames(userId, targetStoreId)
?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }
.orEmpty()
val fromStoreId = storeContextHolder.storeId
userJpaRepository.findById(userId).ifPresent { it.currentStoreId = targetStoreId }
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
// 只改客户端本地状态不换 token,等于门店没真的切过去。
// refresh token 不跟着换:它绑定的是身份,不是门店。
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
// 3. 切店会使旧门店下的 WebView 票据失效。走领域事件,
// 不由 identity-store 直接改 webview-ticket 的表(11-cross-domain-collaboration.md)。
events.publishEvent(StoreSwitchedEvent(userId, fromStoreId, targetStoreId, clock.instant()))
return StoreContextResponse(
accessToken = accessToken,
store = storeMapper.toResponse(store),
roles = roles,
menus = MenuCatalog.menusOf(roles),
)
}
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.contract.StoreInfo
import com.continental.retailapp.identitystore.contract.StoreQueryService
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreRepository
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 契约实现:别的 domain 想知道门店信息,只能通过这个接口,
* 不能直接注入 identity-store 的 repository11-cross-domain-collaboration.md)。
*
* 返回的是契约模型 [StoreInfo] 而不是 Entity/投影——契约模型是独立的数据结构,
* 让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺。
*/
@Service
class StoreQueryServiceImpl(
private val storeRepository: StoreRepository,
) : StoreQueryService {
@Transactional(readOnly = true)
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
/**
* 门店基础信息读多写少、变更不敏感,适合本地缓存(12-concurrency-and-scheduling.md)。
*
* 注意对比:**用户对门店的访问权限绝不能缓存**——权限收回必须立刻生效,
* 所以 [listStoresByUserId] 和 `findAccessibleStore` 上都没有 `@Cacheable`。
*/
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
@Transactional(readOnly = true)
override fun findStore(storeId: Long): StoreInfo? =
storeRepository.findStoreById(storeId)?.let { StoreInfo(it.id, it.name, it.code) }
}
@@ -0,0 +1,19 @@
package com.continental.retailapp.identitystore.application.mapper
import com.continental.retailapp.identitystore.api.response.StoreResponse
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreView
import org.mapstruct.Mapper
/**
* 投影 → Response 的转换,编译期由 MapStruct 生成实现(06-api-design.md)。
*
* 放在 `application` 层而不是 `api/mapper/`:它的入参是 `infrastructure` 里的投影类型,
* 放进 `api` 就等于让 `api` 依赖 `infrastructure`,会被 ArchUnit 判红。
*/
@Mapper(componentModel = "spring")
interface StoreMapper {
fun toResponse(view: StoreView): StoreResponse
fun toResponseList(views: List<StoreView>): List<StoreResponse>
}
@@ -0,0 +1,77 @@
package com.continental.retailapp.identitystore.infrastructure.config
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.StoreJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserJpaRepository
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.UserStoreJpaRepository
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.transaction.annotation.Transactional
/**
* 本地开发用的种子数据:一个 demo 用户 + 两家门店。
*
* **刻意不写进 Flyway 脚本**:迁移脚本在 dev/uat/prod 也会跑,
* 一个带已知密码的账号进到线上库就是一个后门。放在 `@Profile("local")` 的 runner 里,
* 跑在别的 profile 上时它根本不会被实例化。
*
* 账号:`demo` / `demo1234`(仅 local)。
*/
@Configuration
@Profile("local")
class LocalSeedDataConfig {
@Bean
fun localSeedDataRunner(seeder: LocalSeedDataSeeder) = ApplicationRunner { seeder.seed() }
}
@Configuration
@Profile("local")
class LocalSeedDataSeeder(
private val userJpaRepository: UserJpaRepository,
private val storeJpaRepository: StoreJpaRepository,
private val userStoreJpaRepository: UserStoreJpaRepository,
private val passwordEncoder: PasswordEncoder,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional
fun seed() {
if (userJpaRepository.findByUsername(SEED_USERNAME) != null) {
return
}
val stores = storeJpaRepository.saveAll(
listOf(
StoreEntity(name = "上海徐汇店", code = "SH-XH-001"),
StoreEntity(name = "上海浦东店", code = "SH-PD-002"),
),
)
val user = userJpaRepository.save(
UserEntity(
username = SEED_USERNAME,
passwordHash = requireNotNull(passwordEncoder.encode(SEED_PASSWORD)),
displayName = "演示店长",
currentStoreId = stores.first().id,
),
)
userStoreJpaRepository.saveAll(
listOf(
UserStoreEntity(userId = user.id!!, storeId = stores[0].id!!, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.id!!, storeId = stores[1].id!!, roles = "STAFF"),
),
)
log.info("已写入本地种子数据:用户 {} / 门店 {} 家", SEED_USERNAME, stores.size)
}
private companion object {
const val SEED_USERNAME = "demo"
const val SEED_PASSWORD = "demo1234"
}
}
@@ -0,0 +1,46 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
/**
* 刷新令牌,见 04-security-auth.md。
*
* 只存 SHA-256 十六进制摘要,**永远不存明文**:库被拖走也换不出 access token。
* 摘要长度固定 64,所以列类型是 `char(64)` 而不是 varchar。
*
* `revokedAt` + `replacedByTokenId` 一起支撑轮换与重放检测:
* 一条已经 revoke 的令牌再次被使用,说明它被人截获重放了,
* 此时把该用户名下所有令牌全部作废(见 [RefreshTokenService.rotate])。
*/
@Entity
@Table(name = "refresh_token", schema = "identity_store")
class RefreshTokenEntity(
@Column(name = "user_id", nullable = false)
var userId: Long,
@Column(name = "token_hash", nullable = false, length = 64, unique = true)
var tokenHash: String,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by_token_id")
var replacedByTokenId: Long? = null,
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
}
@@ -0,0 +1,34 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface RefreshTokenJpaRepository : JpaRepository<RefreshTokenEntity, Long> {
fun findByTokenHash(tokenHash: String): RefreshTokenEntity?
/**
* 检测到重放时一次性作废该用户的所有令牌。
*
* 用批量 update 而不是"查出来逐条改":重放场景下要的是尽快关门,
* 而且一个用户的令牌可能有几十条(多端登录 + 轮换历史)。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update RefreshTokenEntity t set t.revokedAt = :now
where t.userId = :userId and t.revokedAt is null
""",
)
fun revokeAllByUserId(@Param("userId") userId: Long, @Param("now") now: Instant): Int
/** 清理任务用:过期且已经没有保留价值的令牌直接删(12-concurrency-and-scheduling.md)。 */
@Modifying
@Query("delete from RefreshTokenEntity t where t.expiresAt < :before")
fun deleteExpiredBefore(@Param("before") before: Instant): Int
}
@@ -0,0 +1,33 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
enum class StoreStatus { ACTIVE, CLOSED }
/** 门店。 */
@Entity
@Table(name = "store", schema = "identity_store")
class StoreEntity(
@Column(name = "name", nullable = false, length = 128)
var name: String,
@Column(name = "code", nullable = false, length = 32)
var code: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: StoreStatus = StoreStatus.ACTIVE,
) : VersionedEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,38 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
order by s.id
""",
)
override fun findStoresByUserId(@Param("userId") userId: Long): List<StoreView>
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId and s.id = :storeId
and s.status = com.continental.retailapp.identitystore.infrastructure.persistence.StoreStatus.ACTIVE
""",
)
override fun findAccessibleStore(@Param("userId") userId: Long, @Param("storeId") storeId: Long): StoreView?
@Query("select s.id as id, s.name as name, s.code as code from StoreEntity s where s.id = :storeId")
override fun findStoreById(@Param("storeId") storeId: Long): StoreView?
@Query("select us.roles from UserStoreEntity us where us.userId = :userId and us.storeId = :storeId")
override fun findRoleNames(@Param("userId") userId: Long, @Param("storeId") storeId: Long): String?
}
@@ -0,0 +1,25 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
/**
* 门店查询口径,`application` 依赖的是这个接口而不是 [StoreJpaRepository]02-layering.md)。
*
* [findAccessibleStore] 把权限校验合进了查询条件——"查不到"就是"没权限"
* 而不是先查出来再判断。后者很容易漏判,见 04-security-auth.md 的越权规则第 2 条。
*/
interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreView>
/** 目标门店对该用户可访问才返回;不可访问返回 null。切店前必须过这一关。 */
fun findAccessibleStore(userId: Long, storeId: Long): StoreView?
fun findStoreById(storeId: Long): StoreView?
/**
* 返回逗号分隔的原始角色串,解析交给 `application`。
*
* 对 04 文档的小改名:文档里叫 `findRoles` 并直接返回 `List<String>`
* 但角色在库里就是一个逗号串,拆分是业务转换、不该藏在 repository 的方法名背后。
*/
fun findRoleNames(userId: Long, storeId: Long): String?
}
@@ -0,0 +1,14 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
/**
* Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
*
* 用它而不是直接返回 [StoreEntity],是为了让 `application` 拿到的东西不带 Entity 的
* 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
* 见 02-layering.md。
*/
interface StoreView {
val id: Long
val name: String
val code: String
}
@@ -0,0 +1,55 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
enum class UserStatus { ACTIVE, DISABLED }
/**
* 用户,见 04-security-auth.md。
*
* 几个约定:
* - 密码只存 BCrypt 哈希,字段名叫 `passwordHash` 而不是 `password`,减少误打日志的概率;
* - `failedAttempts` / `lockedUntil` 实现"连续失败 5 次锁定 15 分钟";
* - `currentStoreId` 记住用户上次选的门店,刷新 token 时用它重签,不至于一刷新就掉回默认门店。
*/
@Entity
@Table(name = "user", schema = "identity_store")
class UserEntity(
@Column(name = "username", nullable = false, length = 64)
var username: String,
@Column(name = "password_hash", nullable = false, length = 100)
var passwordHash: String,
@Column(name = "display_name", nullable = false, length = 64)
var displayName: String,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: UserStatus = UserStatus.ACTIVE,
@Column(name = "current_store_id")
var currentStoreId: Long? = null,
@Column(name = "failed_attempts", nullable = false)
var failedAttempts: Int = 0,
@Column(name = "locked_until")
var lockedUntil: Instant? = null,
) : VersionedEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
fun isLocked(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
@@ -0,0 +1,10 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface UserJpaRepository : JpaRepository<UserEntity, Long> {
fun findByUsername(username: String): UserEntity?
}
@@ -0,0 +1,37 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
/**
* 用户 ↔ 门店的授权关系。这张表是"能不能看这家店"的唯一事实来源。
*
* 刻意用 `userId` / `storeId` 裸字段而不是 `@ManyToOne`
* 授权判定全是按 id 的存在性查询,建关联只会让 Hibernate 多拉两张表。
*/
@Entity
@Table(name = "user_store", schema = "identity_store")
class UserStoreEntity(
@Column(name = "user_id", nullable = false)
var userId: Long,
@Column(name = "store_id", nullable = false)
var storeId: Long,
/**
* 逗号分隔,写进 JWT 的 `roles` claim。
* 角色挂在"用户 × 门店"上而不是用户上:同一个人在 A 店是店长、在 B 店可能只是接待。
*/
@Column(name = "roles", nullable = false, length = 255)
var roles: String = "STAFF",
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,11 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* `user_store` 在授权查询里只作为 join 出现,本身不需要仓储;
* 建它是为了写入方(本地种子数据、后续的授权管理用例)有一个正常的入口。
*/
@Repository
interface UserStoreJpaRepository : JpaRepository<UserStoreEntity, Long>
@@ -0,0 +1,64 @@
-- identity_store 库初始化:用户、门店、用户↔门店授权关系。
--
-- 约定(03-persistence.md):
-- * 时间列一律 datetime(6),存 UTC;应用侧对应 Instant。
-- * 每张表都带 BaseEntity 的四个审计列;需要乐观锁的再加 version。
-- * 字符集 utf8mb4 + utf8mb4_0900_ai_ci,跟 MySQL 8.4 默认保持一致。
--
-- 种子数据不写在这里,见 infrastructure/config/LocalSeedDataRunner(只在 local profile 生效)。
create table `user`
(
id bigint not null auto_increment,
username varchar(64) not null,
password_hash varchar(100) not null comment 'BCrypt(strength=12),永远不存明文',
display_name varchar(64) not null,
status varchar(16) not null default 'ACTIVE',
current_store_id bigint null comment '上次选中的门店,刷新 token 时据此重签',
failed_attempts int not null default 0,
locked_until datetime(6) null comment '连续失败 5 次后锁定 15 分钟',
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_user_username (username)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '用户';
create table store
(
id bigint not null auto_increment,
name varchar(128) not null,
code varchar(32) not null,
status varchar(16) not null default 'ACTIVE',
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_store_code (code)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '门店';
create table user_store
(
id bigint not null auto_increment,
user_id bigint not null,
store_id bigint not null,
roles varchar(255) not null default 'STAFF' comment '逗号分隔;角色是"用户×门店"维度的',
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
-- 唯一约束而不是靠应用去查重:并发授权时数据库才是最后一道闸
unique key uk_user_store (user_id, store_id),
key idx_user_store_store_id (store_id)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '用户可访问的门店(授权的唯一事实来源)';
@@ -0,0 +1,26 @@
-- refresh tokenDDL 逐字取自 04-security-auth.md。
--
-- token_hash 是 char(64) 而不是 varcharSHA-256 十六进制摘要长度固定,
-- 定长列在唯一索引上更省空间、比较也更快。库里没有明文,拖库换不出 access token。
create table refresh_token
(
id bigint not null auto_increment,
user_id bigint not null,
token_hash char(64) not null,
expires_at datetime(6) not null,
revoked_at datetime(6),
replaced_by_token_id bigint,
-- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64),
updated_by varchar(64),
primary key (id),
unique key uk_refresh_token_hash (token_hash),
key idx_refresh_token_user_id (user_id),
key idx_refresh_token_expires_at (expires_at),
constraint fk_refresh_token_user foreign key (user_id) references `user` (id)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci;
@@ -0,0 +1,11 @@
package com.continental.retailapp.identitystore
import org.springframework.boot.autoconfigure.SpringBootApplication
/**
* `@DataJpaTest` 需要在包层级上能找到一个 `@SpringBootConfiguration`。
* 真正的入口类在 `bootstrap` 模块,而 domain 模块不许反向依赖 bootstrap01-project-structure.md),
* 所以这里给测试单独放一个最小入口,只覆盖本模块的实体与仓储。
*/
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp.identitystore"])
class IdentityStoreTestApplication
@@ -0,0 +1,155 @@
package com.continental.retailapp.identitystore.application
import com.continental.retailapp.identitystore.fixtures.aJwtProperties
import com.continental.retailapp.identitystore.fixtures.aRefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenJpaRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.MessageDigest
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.HexFormat
/**
* 04-security-auth.md 里 refresh token 的三条分支:正常轮换、重放检测、过期。
*
* 重放检测那条是这套设计的关键——"从来没见过的 token"和"已撤销的 token"
* 必须走不同分支,合并成一个 `?: throw` 就等于没做重放检测。
*/
class RefreshTokenServiceTest {
private val now = Instant.parse("2026-01-01T00:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private val repository = mockk<RefreshTokenJpaRepository>()
private val service = RefreshTokenService(repository, aJwtProperties(), clock)
private fun sha256Hex(raw: String): String =
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()))
@Test
fun `签发时库里只存摘要,明文只返回给调用方`() {
val saved = slot<RefreshTokenEntity>()
every { repository.save(capture(saved)) } answers { firstArg() }
val rawToken = service.issue(userId = 7L)
assertEquals(sha256Hex(rawToken), saved.captured.tokenHash)
assertNotEquals(rawToken, saved.captured.tokenHash)
assertEquals(7L, saved.captured.userId)
// TTL 30 天,来自 JwtProperties.refreshTokenTtlDays
assertEquals(now.plusSeconds(30 * 24 * 3600), saved.captured.expiresAt)
}
@Test
fun `轮换后旧令牌被撤销并指向新令牌`() {
val rawToken = "old-raw-token"
val existing = aRefreshTokenEntity(id = 1L, userId = 7L, tokenHash = sha256Hex(rawToken))
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
every { repository.save(any()) } answers { firstArg<RefreshTokenEntity>().also { it.id = 2L } }
val rotated = service.rotate(rawToken)
assertEquals(7L, rotated.userId)
assertNotEquals(rawToken, rotated.refreshToken)
assertEquals(now, existing.revokedAt)
assertEquals(2L, existing.replacedByTokenId)
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `从未存在过的令牌直接拒绝,不牵连该用户其他令牌`() {
every { repository.findByTokenHash(any()) } returns null
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate("never-existed") }
// 关键:随便伪造一个串就能把别人全部踢下线的话,这里就成了 DoS 入口
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `重放已撤销的令牌会撤销该用户的全部令牌`() {
val rawToken = "leaked-raw-token"
val revoked = aRefreshTokenEntity(
userId = 7L,
tokenHash = sha256Hex(rawToken),
revokedAt = now.minusSeconds(60),
)
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns revoked
every { repository.revokeAllByUserId(7L, now) } returns 3
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
verify(exactly = 1) { repository.revokeAllByUserId(7L, now) }
verify(exactly = 0) { repository.save(any()) }
}
@Test
fun `过期的令牌拒绝轮换`() {
val rawToken = "expired-raw-token"
val expired = aRefreshTokenEntity(
userId = 7L,
tokenHash = sha256Hex(rawToken),
// 边界:expiresAt 恰好等于 now 就算过期
expiresAt = now,
)
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns expired
assertThrows(InvalidRefreshTokenException::class.java) { service.rotate(rawToken) }
verify(exactly = 0) { repository.save(any()) }
// 过期不是安全事件,只是自然到期,不该连坐该用户其他令牌
verify(exactly = 0) { repository.revokeAllByUserId(any(), any()) }
}
@Test
fun `登出时撤销令牌`() {
val rawToken = "active-raw-token"
val existing = aRefreshTokenEntity(userId = 7L, tokenHash = sha256Hex(rawToken))
every { repository.findByTokenHash(sha256Hex(rawToken)) } returns existing
service.revoke(rawToken)
assertEquals(now, existing.revokedAt)
}
@Test
fun `登出一个不存在的令牌不报错`() {
every { repository.findByTokenHash(any()) } returns null
// 不抛异常本身就是断言:登出接口不能泄露"这个 token 存不存在"
service.revoke("whatever")
}
@Test
fun `登出已撤销的令牌不会覆盖原撤销时间`() {
val firstRevokedAt = now.minusSeconds(600)
val existing = aRefreshTokenEntity(userId = 7L, revokedAt = firstRevokedAt)
every { repository.findByTokenHash(any()) } returns existing
service.revoke("already-revoked")
assertEquals(firstRevokedAt, existing.revokedAt)
}
@Test
fun `签发的明文令牌足够长且不重复`() {
every { repository.save(any()) } answers { firstArg() }
val tokens = (1..50).map { service.issue(userId = 7L) }
assertEquals(50, tokens.toSet().size)
// 32 字节 URL-safe base64 去 padding = 43 个字符
assertTrue(tokens.all { it.length == 43 }) { "令牌长度异常:${tokens.first().length}" }
assertNull(tokens.firstOrNull { it.contains('+') || it.contains('/') })
}
}
@@ -0,0 +1,39 @@
package com.continental.retailapp.identitystore.fixtures
import com.continental.retailapp.identitystore.infrastructure.persistence.RefreshTokenEntity
import com.continental.retailapp.platform.security.JwtProperties
import java.time.Instant
import java.util.Base64
/** 32 字节,刚好过 [JwtProperties.validate] 的下限——测试里不要用更短的串。 */
const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!"
fun testJwtSecretBase64(raw: String = TEST_JWT_SECRET_RAW): String =
Base64.getEncoder().encodeToString(raw.toByteArray())
fun aJwtProperties(
activeKeyId: String = "v1",
keys: Map<String, String> = mapOf("v1" to testJwtSecretBase64()),
accessTokenTtlMinutes: Long = 30,
refreshTokenTtlDays: Long = 30,
) = JwtProperties(
activeKeyId = activeKeyId,
keys = keys,
accessTokenTtlMinutes = accessTokenTtlMinutes,
refreshTokenTtlDays = refreshTokenTtlDays,
)
fun aRefreshTokenEntity(
id: Long? = 1L,
userId: Long = 1L,
tokenHash: String = "0".repeat(64),
expiresAt: Instant = Instant.parse("2026-02-01T00:00:00Z"),
revokedAt: Instant? = null,
replacedByTokenId: Long? = null,
) = RefreshTokenEntity(
userId = userId,
tokenHash = tokenHash,
expiresAt = expiresAt,
revokedAt = revokedAt,
replacedByTokenId = replacedByTokenId,
).also { it.id = id }
@@ -0,0 +1,125 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.springframework.test.context.TestPropertySource
import org.testcontainers.mysql.MySQLContainer
/**
* 仓储层的集成测试,见 10-testing.md
* **仓储不 mock**。JPQL 写错、`@Query` 投影字段对不上、跨表 join 的门店过滤漏掉,
* 这些全都只有真数据库能发现,H2 的方言差异反而会给出假的绿灯。
*
* 打了 `@Tag("integration")`:本地快速验证用 `./gradlew build -PexcludeIntegration` 跳过,
* CI 上跑完整 `./gradlew test`Runner 需要能起 Docker)。
*/
@Tag("integration")
@DataJpaTest
// 必须显式关掉"用内存库替换数据源",否则 Boot 会把 Testcontainers 顶掉,测试悄悄跑在 H2 上
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(
properties = [
// 表结构由 Flyway 建,和 dev/uat/prod 完全同一套脚本;ddl-auto 只做校验。
// 这样"实体和迁移脚本对不上"会在测试里就暴露,而不是等到部署时 validate 失败。
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:db/migration/identity_store",
"spring.flyway.schemas=identity_store",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.jdbc.time_zone=UTC",
],
)
class StoreJpaRepositoryTest {
@Autowired
private lateinit var storeJpaRepository: StoreJpaRepository
@Autowired
private lateinit var userStoreJpaRepository: UserStoreJpaRepository
private var activeStoreId: Long = 0
private var otherStoreId: Long = 0
private var closedStoreId: Long = 0
@BeforeEach
fun seed() {
activeStoreId = storeJpaRepository.save(
StoreEntity(name = "静安门店", code = "SH-JA-001", status = StoreStatus.ACTIVE),
).id!!
otherStoreId = storeJpaRepository.save(
StoreEntity(name = "徐汇门店", code = "SH-XH-001", status = StoreStatus.ACTIVE),
).id!!
closedStoreId = storeJpaRepository.save(
StoreEntity(name = "已关停门店", code = "SH-XX-999", status = StoreStatus.CLOSED),
).id!!
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = activeStoreId, roles = "STORE_MANAGER"))
userStoreJpaRepository.save(UserStoreEntity(userId = 1L, storeId = closedStoreId, roles = "STAFF"))
// 用户 2 只能看徐汇:越权查询的对照组
userStoreJpaRepository.save(UserStoreEntity(userId = 2L, storeId = otherStoreId, roles = "STAFF"))
}
@Test
fun `只返回该用户有权限且在营的门店`() {
val stores = storeJpaRepository.findStoresByUserId(1L)
assertEquals(listOf(activeStoreId), stores.map { it.id })
assertEquals("静安门店", stores.single().name)
}
@Test
fun `没有任何授权的用户拿到空列表`() {
assertEquals(emptyList<Long>(), storeJpaRepository.findStoresByUserId(999L).map { it.id })
}
@Test
fun `查询自己有权限的门店能查到`() {
assertNotNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = activeStoreId))
}
@Test
fun `查询别人的门店查不到`() {
// 切店接口的安全性完全押在这个查询上:查不到就返回 STORE_NOT_ACCESSIBLE
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = otherStoreId))
}
@Test
fun `已关停门店即使有授权也查不到`() {
assertNull(storeJpaRepository.findAccessibleStore(userId = 1L, storeId = closedStoreId))
}
@Test
fun `角色按用户与门店的组合返回`() {
assertEquals("STORE_MANAGER", storeJpaRepository.findRoleNames(1L, activeStoreId))
assertEquals("STAFF", storeJpaRepository.findRoleNames(1L, closedStoreId))
// 同一个人在不同门店可以是不同角色,所以 roles 挂在 user_store 上而不是 user 上
assertNull(storeJpaRepository.findRoleNames(1L, otherStoreId))
}
@Test
fun `按 id 查门店不做权限过滤`() {
// 这个方法给跨域契约 StoreQueryService 用,只查基础信息;
// 权限过滤是调用方的责任,不能靠它兜底
assertEquals("徐汇门店", storeJpaRepository.findStoreById(otherStoreId)?.name)
}
@TestConfiguration
class ContainerConfig {
/** `@ServiceConnection` 自动把容器的 jdbc url / 账号密码接到 DataSource 上,不用手写 DynamicPropertySource。 */
@Bean
@ServiceConnection
fun mysqlContainer(): MySQLContainer =
MySQLContainer("mysql:8.4")
// 库名要和实体上的 schema = "identity_store" 对上
.withDatabaseName("identity_store")
}
}
+11
View File
@@ -0,0 +1,11 @@
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
// 跨域只走契约模块:这里需要的是 StoreSwitchedEvent,不是 identity-store 的内部类型
implementation project(':domains:identity-store-contract')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.api
import com.continental.retailapp.platform.web.ApiResult
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.application.WebviewTicketAppService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "WebView 票据")
@RestController
@RequestMapping("/api/v1/webview")
class WebviewTicketController(
private val webviewTicketAppService: WebviewTicketAppService,
) {
/** 端点上不接受 storeId 参数:门店来自 token,见 [WebviewTicketAppService]。 */
@Operation(summary = "换取一次性 WebView 票据")
@PostMapping("/tickets")
fun issueTicket(): ApiResult<WebviewTicketResponse> =
ApiResult.ok(webviewTicketAppService.issueTicket())
}
@@ -0,0 +1,13 @@
package com.continental.retailapp.webviewticket.api.response
import java.time.Instant
/**
* 换票响应。
* `expiresAt` 是 [Instant],序列化成 ISO-8601 UTC`2026-01-01T03:00:00Z`),
* 时区转换由客户端做——服务端一律 UTC06-api-design.md)。
*/
data class WebviewTicketResponse(
val ticketId: String,
val expiresAt: Instant,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.identitystore.contract.StoreSwitchedEvent
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
/**
* 切店后作废旧门店下的 WebView 票据(11-cross-domain-collaboration.md)。
*
* 三个写法各解决一个具体问题,缺一个都会出事:
*
* - `@TransactionalEventListener(AFTER_COMMIT)`:普通 `@EventListener` 是同步、在同一事务内执行的,
* 会出现"切店事务回滚了但票据已经作废"。`AFTER_COMMIT` 保证只在主事务真正提交后才触发。
* - `@Transactional(REQUIRES_NEW)``AFTER_COMMIT` 阶段原事务已提交,此时**没有活跃事务**,
* 不开新事务的话这里的写操作行为不可控。
* - `runCatching` + 记日志:监听器抛异常**不会**回滚主事务(它已经提交了),
* 只会让这次副作用静默丢失。必须显式捕获并留下能排查的日志。
*/
@Component
class StoreSwitchedListener(
private val ticketRepository: WebviewTicketRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun onStoreSwitched(event: StoreSwitchedEvent) {
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
.onSuccess { log.info("切店后作废 webview 票据 userId={} count={}", event.userId, it) }
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
}
}
@@ -0,0 +1,29 @@
package com.continental.retailapp.webviewticket.application
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.webviewticket.api.response.WebviewTicketResponse
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 换票用例。业务规则在 domain 层,这里只负责事务边界、上下文取值和 → Response 的转换。
*
* 门店取自 [StoreContextHolder](也就是签名过的 JWT claim),
* **不接受调用方传进来的 storeId**——请求参数可以被任意篡改,token 里的不行。
*/
@Service
class WebviewTicketAppService(
private val issueWebviewTicketService: IssueWebviewTicketService,
private val storeContextHolder: StoreContextHolder,
) {
@Transactional
fun issueTicket(): WebviewTicketResponse {
val ticket = issueWebviewTicketService.issue(
userId = storeContextHolder.currentUserId(),
storeId = storeContextHolder.currentStoreId(),
)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.webviewticket.domain.model
import java.time.Instant
/**
* WebView 票据(领域模型)。
*
* 注意这是一个纯 Kotlin 类:没有 `@Entity`、没有 Spring 注解。
* 换票的规则(复用/过期/作废)能脱离 Spring 和数据库单独编译和测试,
* 这是 `webview-ticket` 保留 domain 层的全部理由(02-layering.md)。
*/
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 }
@@ -0,0 +1,17 @@
package com.continental.retailapp.webviewticket.domain.repository
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
/**
* 仓储接口定义在 domain 层、实现在 infrastructure 层——依赖倒置在这里的体现:
* 领域规则不认识 JPA,只认识"能拿到票据、能存票据"。
*/
interface WebviewTicketRepository {
fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket?
fun save(ticket: WebviewTicket)
/** 切店后作废该用户名下所有仍然有效的票据,返回受影响条数。 */
fun revokeActiveTickets(userId: Long): Int
}
@@ -0,0 +1,45 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import java.time.Clock
import java.time.Duration
import java.util.UUID
/**
* 换票规则,见 02-layering.md。
*
* **没有 `@Service` 注解**domain 层不依赖 Spring(有一条 ArchUnit 规则盯着)。
* 它变成 bean 的方式是 `infrastructure/config/DomainServiceConfig` 里显式 `@Bean`。
*
* [Clock] 构造注入而不是 `Instant.now()`:否则"过期判断"只能靠 `Thread.sleep` 硬等着测。
*/
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(TICKET_TTL),
)
ticketRepository.save(ticket)
return ticket
}
private companion object {
/** 票据只用来完成一次跳转,5 分钟足够,短 TTL 本身就是一种防护。 */
val TICKET_TTL: Duration = Duration.ofMinutes(5)
}
}
@@ -0,0 +1,21 @@
package com.continental.retailapp.webviewticket.infrastructure.config
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.domain.service.IssueWebviewTicketService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
/**
* domain 层的类不带 Spring 注解,成为 bean 的方式是在这里显式声明(02-layering.md)。
*
* 多写这几行换来的是:领域规则那一层可以脱离 Spring 单独编译和测试。
* 模块内部的 `@Configuration` 一律放在 `infrastructure/config/`。
*/
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
@@ -0,0 +1,59 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
@Entity
@Table(name = "webview_ticket", schema = "webview_ticket")
class WebviewTicketEntity(
@Column(name = "ticket_id", nullable = false, length = 36, unique = true)
var ticketId: String,
@Column(name = "store_id", nullable = false)
var storeId: Long,
@Column(name = "user_id", nullable = false)
var userId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: TicketStatus,
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
) : BaseEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
/**
* Entity ↔ 领域模型的转换只在 `infrastructure` 里发生。
* Entity 到 [WebviewTicketRepositoryImpl] 为止,不会出现在它的返回值里(02-layering.md)。
*/
fun WebviewTicketEntity.toDomain() = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
fun WebviewTicket.toEntity() = WebviewTicketEntity(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
@@ -0,0 +1,37 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface WebviewTicketJpaRepository : JpaRepository<WebviewTicketEntity, Long> {
fun findByUserIdAndStoreIdAndStatus(
userId: Long,
storeId: Long,
status: TicketStatus,
): WebviewTicketEntity?
/**
* 切店后作废票据。用批量 update 而不是查出来逐条改:
* 作废必须立即生效(票据状态是绝对不能缓存的东西),走一条 SQL 最直接。
* 枚举在 JPQL 里写全限定名,避免依赖参数默认值这种在 Spring Data 里不稳的写法。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update WebviewTicketEntity t
set t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.INVALIDATED
where t.userId = :userId
and t.status = com.continental.retailapp.webviewticket.domain.model.TicketStatus.ISSUED
""",
)
fun invalidateActiveTickets(@Param("userId") userId: Long): Int
fun deleteByExpiresAtBefore(before: Instant): Int
}
@@ -0,0 +1,23 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import org.springframework.stereotype.Repository
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
override fun save(ticket: WebviewTicket) {
jpaRepository.save(ticket.toEntity())
}
override fun revokeActiveTickets(userId: Long): Int =
jpaRepository.invalidateActiveTickets(userId)
}
@@ -0,0 +1,24 @@
-- webview_ticket 库初始化。
--
-- 票据是"短命 + 一次性"的:TTL 5 分钟,用完置 CONSUMED,切店置 INVALIDATED。
-- status 上有索引是因为"查该用户当前有效票据"是最热的查询。
create table webview_ticket
(
id bigint not null auto_increment,
ticket_id varchar(36) not null comment '对外暴露的票据标识(UUID),不暴露自增主键',
store_id bigint not null,
user_id bigint not null,
status varchar(16) not null,
expires_at datetime(6) not null,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_webview_ticket_id (ticket_id),
key idx_webview_ticket_user_status (user_id, status),
key idx_webview_ticket_expires_at (expires_at)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment 'WebView 一次性票据';
@@ -0,0 +1,84 @@
package com.continental.retailapp.webviewticket.domain.service
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.repository.WebviewTicketRepository
import com.continental.retailapp.webviewticket.fixtures.aWebviewTicket
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
/**
* 领域服务的单测:不起 Spring,不连数据库,毫秒级跑完。
* 这正是 `webview-ticket` 保留 domain 层换来的东西(02-layering.md / 10-testing.md)。
*/
class IssueWebviewTicketServiceTest {
private val now = Instant.parse("2026-01-01T00:00:00Z")
private val clock = Clock.fixed(now, ZoneOffset.UTC)
private val repository = mockk<WebviewTicketRepository>()
private val service = IssueWebviewTicketService(repository, clock)
@Test
fun `已有未过期票据时直接复用,不重复签发`() {
val existing = aWebviewTicket(expiresAt = now.plusSeconds(60))
every { repository.findActiveTicket(1L, 100L) } returns existing
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(existing, result)
verify(exactly = 0) { repository.save(any()) }
}
@Test
fun `票据已过期时签发新票据`() {
val expired = aWebviewTicket(ticketId = "old", expiresAt = now.minusSeconds(1))
every { repository.findActiveTicket(1L, 100L) } returns expired
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
// 边界:expiresAt 恰好等于 now 也算过期(isAfter 是严格大于)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `票据已被作废时签发新票据`() {
val invalidated = aWebviewTicket(
ticketId = "old",
status = TicketStatus.INVALIDATED,
// 故意让它还没到期:这里要验证的是"状态不对"这一条,而不是过期那条
expiresAt = now.plusSeconds(60),
)
every { repository.findActiveTicket(1L, 100L) } returns invalidated
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertNotEquals("old", result.ticketId)
assertEquals(TicketStatus.ISSUED, result.status)
verify(exactly = 1) { repository.save(result) }
}
@Test
fun `没有任何票据时签发新票据`() {
every { repository.findActiveTicket(1L, 100L) } returns null
every { repository.save(any()) } returns Unit
val result = service.issue(userId = 1L, storeId = 100L)
assertEquals(1L, result.userId)
assertEquals(100L, result.storeId)
assertEquals(TicketStatus.ISSUED, result.status)
assertEquals(now.plusSeconds(300), result.expiresAt)
verify(exactly = 1) { repository.save(result) }
}
}
@@ -0,0 +1,24 @@
package com.continental.retailapp.webviewticket.fixtures
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import java.time.Instant
/**
* 测试数据统一走构建器函数,见 10-testing.md
* 每个测试只覆盖自己关心的那一两个字段,其余给默认值。
* 这样以后 [WebviewTicket] 加字段,只需要改这一处。
*/
fun aWebviewTicket(
ticketId: String = "ticket-1",
storeId: Long = 100L,
userId: Long = 1L,
status: TicketStatus = TicketStatus.ISSUED,
expiresAt: Instant = Instant.parse("2026-01-01T00:05:00Z"),
) = WebviewTicket(
ticketId = ticketId,
storeId = storeId,
userId = userId,
status = status,
expiresAt = expiresAt,
)
+16
View File
@@ -0,0 +1,16 @@
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
implementation project(':platform:platform-persistence')
implementation project(':platform:platform-integration')
implementation project(':integration:mini-clients')
// 11-cross-domain-collaboration.md 的 WorkbenchAppService 示例同时用到 F6ApiClient
// 所以比 01-project-structure.md 的示例多一条 integration/* 依赖(规则本身允许)
implementation project(':integration:f6-adapter')
implementation project(':domains:identity-store-contract')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.micrometer:context-propagation'
}
@@ -0,0 +1,22 @@
package com.continental.retailapp.workbench.api
import com.continental.retailapp.platform.web.ApiResult
import com.continental.retailapp.workbench.api.response.HomepageResponse
import com.continental.retailapp.workbench.application.WorkbenchAppService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@Tag(name = "工作台")
@RestController
@RequestMapping("/api/v1/workbench")
class WorkbenchController(
private val workbenchAppService: WorkbenchAppService,
) {
@Operation(summary = "首页聚合:门店上下文 + 采购 + 保修,单个 tile 失败降级不影响整体")
@GetMapping("/homepage")
fun homepage(): ApiResult<HomepageResponse> =
ApiResult.ok(workbenchAppService.loadHomepage())
}
@@ -0,0 +1,17 @@
package com.continental.retailapp.workbench.api.response
import com.continental.retailapp.identitystore.contract.StoreInfo
import com.continental.retailapp.integration.f6.ProcurementList
import com.continental.retailapp.integration.mini.WarrantySummary
/**
* 首页聚合响应。
*
* 规则:**只要主数据(门店上下文)拿到了,首页接口就返回 `code: 0`**
* 个别 tile 降级不会让整个接口失败——一个外部系统抖动不该让用户连首页都打不开。
*/
data class HomepageResponse(
val store: Tile<StoreInfo>,
val procurement: Tile<ProcurementList>,
val warranty: Tile<WarrantySummary>,
)
@@ -0,0 +1,19 @@
package com.continental.retailapp.workbench.api.response
/**
* 首页每一块(tile)的包装(11-cross-domain-collaboration.md)。
*
* 降级必须**对客户端可见**,不能悄悄返回空数据:客户端要能区分
* "这块真的没数据"和"这块没拉到",才能决定显示空态还是"加载失败,点击重试"。
*/
data class Tile<T>(
val data: T?,
val status: TileStatus,
) {
companion object {
fun <T> ok(data: T) = Tile(data, TileStatus.OK)
fun <T> degraded() = Tile<T>(null, TileStatus.DEGRADED)
}
}
enum class TileStatus { OK, DEGRADED }
@@ -0,0 +1,80 @@
package com.continental.retailapp.workbench.application
import com.continental.retailapp.identitystore.contract.StoreQueryService
import com.continental.retailapp.integration.f6.F6ApiClient
import com.continental.retailapp.integration.mini.O2OClient
import com.continental.retailapp.platform.security.StoreContextHolder
import com.continental.retailapp.workbench.api.response.HomepageResponse
import com.continental.retailapp.workbench.api.response.Tile
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import java.time.Duration
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
/**
* 首页并行聚合(11-cross-domain-collaboration.md)。
*
* **总预算不等于各下游超时之和**:三个下游各配 2 秒,并行最坏 2 秒——但那是单次调用,
* 叠上 `@Retry`(3 次)之后单个 tile 最坏可能到 6 秒。所以必须有一个独立于下游配置的
* 接口级总预算,到点就把还没回来的 tile 全部降级返回。没有这道闸,
* 首页最坏耗时是由下游配置的乘积决定的,不可控。
*
* 跨域读走 [StoreQueryService] 这个契约接口,不直接 import identity-store 的内部类型。
*/
@Service
class WorkbenchAppService(
@Qualifier("workbenchExecutor") private val executor: Executor,
private val f6ApiClient: F6ApiClient,
private val o2oClient: O2OClient,
private val storeQueryService: StoreQueryService,
private val storeContextHolder: StoreContextHolder,
) {
private val log = LoggerFactory.getLogger(javaClass)
fun loadHomepage(): HomepageResponse {
// 门店取自 token,不接受请求参数——请求参数可以被篡改
val storeId = storeContextHolder.currentStoreId()
val deadline = System.nanoTime() + TOTAL_BUDGET.toNanos()
val store = supply("store") { storeQueryService.findStore(storeId) }
val procurement = supply("procurement") { f6ApiClient.fetchProcurementList(storeId) }
val warranty = supply("warranty") { o2oClient.fetchWarrantySummary(storeId) }
return HomepageResponse(
store = await(store, deadline, "store"),
procurement = await(procurement, deadline, "procurement"),
warranty = await(warranty, deadline, "warranty"),
)
}
private fun <T : Any> supply(tile: String, block: () -> T?): CompletableFuture<Tile<T>> =
CompletableFuture.supplyAsync(
{
runCatching(block)
.map { if (it == null) Tile.degraded() else Tile.ok(it) }
.getOrElse {
log.warn("tile={} 加载失败,降级", tile, it)
Tile.degraded()
}
},
executor,
)
private fun <T : Any> await(future: CompletableFuture<Tile<T>>, deadlineNanos: Long, tile: String): Tile<T> {
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) return Tile.degraded()
return runCatching { future.get(remaining, TimeUnit.NANOSECONDS) }
.getOrElse {
log.warn("tile={} 超出总预算,降级", tile)
Tile.degraded()
}
}
private companion object {
/** 整个首页接口的总预算。 */
val TOTAL_BUDGET: Duration = Duration.ofSeconds(3)
}
}
@@ -0,0 +1,56 @@
package com.continental.retailapp.workbench.infrastructure.config
import io.micrometer.context.ContextSnapshotFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.task.TaskDecorator
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.ThreadPoolExecutor
/**
* 首页并行聚合的专用线程池(11-cross-domain-collaboration.md)。
*
* 模块内的 `@Configuration` 一律放 `infrastructure/config/`,不要散在模块根包下——
* 那样它既不属于任何一层,ArchUnit 的分层规则也管不到它。
*
* 四个必须这么写的点:
*
* 1. **必须是专用池**,不能用公共 `@Async` 默认池。共用一个池时,一个下游变慢会把池占满、
* 波及所有异步任务——这正是舱壁要防的事。
* 2. **队列必须有界**。`queueCapacity` 默认是 `Integer.MAX_VALUE`:任务会全部堆进队列,
* 线程数永远不会从 core 涨到 max,然后在某次高峰把堆吃光。
* 3. **`CallerRunsPolicy`**:池满时任务退回 Tomcat 线程自己跑,是一种天然背压——
* 聚合变慢,但不丢请求、不抛 `RejectedExecutionException`。
* 4. **上下文传播装饰器**:把 MDC 里的 traceId 和 `@RequestScope` 的门店上下文搬到子线程。
* 没有它,子线程日志全部断链、`StoreContextHolder` 直接取不到值——
* 这是同步栈下并行聚合最典型的翻车方式。
*/
@Configuration
class WorkbenchExecutorConfig {
@Bean("workbenchExecutor")
fun workbenchExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
corePoolSize = 8
maxPoolSize = 16
queueCapacity = 32
setThreadNamePrefix("workbench-")
setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
setTaskDecorator(contextPropagatingTaskDecorator())
setWaitForTasksToCompleteOnShutdown(true)
setAwaitTerminationSeconds(20) // 配合 09 的优雅停机
initialize()
}
/**
* 11 文档里写的是 Micrometer 的 `ContextPropagatingTaskDecorator`。
* 这里用 `ContextSnapshotFactory` 手写等价实现,是为了不额外引一个只用一个类的依赖——
* 语义完全一致:抓当前线程的上下文快照,在子线程里 restore、执行完再关掉。
*/
private fun contextPropagatingTaskDecorator(): TaskDecorator {
val snapshotFactory = ContextSnapshotFactory.builder().build()
return TaskDecorator { runnable ->
val snapshot = snapshotFactory.captureAll()
Runnable { snapshot.wrap(runnable).run() }
}
}
}
@@ -0,0 +1,23 @@
-- workbench 库初始化。
--
-- 工作台目前的数据全部来自实时聚合(F6 / O2O / identity-store),本身没有持久化需求。
-- 这张表是占位:Flyway 的 location 不能是空目录,否则 DomainFlywayConfig 起不来。
-- 它也确实有用——首页卡片的排序/开关将来要落在这里,不必再加一次迁移基线。
create table workbench_tile
(
id bigint not null auto_increment,
store_id bigint not null,
tile_key varchar(64) not null comment 'procurement / warranty / ...',
sort_order int not null default 0,
enabled tinyint(1) not null default 1,
version bigint not null default 0,
created_at datetime(6) not null,
updated_at datetime(6) not null,
created_by varchar(64) null,
updated_by varchar(64) null,
primary key (id),
unique key uk_workbench_tile (store_id, tile_key)
) engine = InnoDB
default charset = utf8mb4
collate = utf8mb4_0900_ai_ci comment '工作台卡片配置';
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+7
View File
@@ -0,0 +1,7 @@
dependencies {
api project(':platform:platform-integration')
implementation project(':platform:platform-web')
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
testImplementation 'com.ninja-squad:springmockk:5.0.1'
}
@@ -0,0 +1,42 @@
package com.continental.retailapp.integration.f6
import io.github.resilience4j.bulkhead.annotation.Bulkhead
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import io.github.resilience4j.retry.annotation.Retry
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
/**
* F6 采购接口,见 05-integration-layer.md。
*
* 三个注解的叠加顺序由 `application.yml` 里的 `*-aspect-order` 决定,
* 配的是 bulkhead(1) < circuitbreaker(2) < retry(3),也就是
* **Retry 在最外层**:每次重试都会被熔断器单独统计,连续失败能更快打开熔断。
* 方向容易记反,所以有一条 `ResilienceAspectOrderTest` 实测它(见 10-testing.md)。
*
* 没有 `@TimeLimiter`:同步栈下超时由 HTTP 客户端本身控制(见 RestClientFactory),
* 加 TimeLimiter 反而会引入一个额外线程池。
*/
@Component
class F6ApiClient(private val f6RestClient: RestClient) {
private val log = LoggerFactory.getLogger(javaClass)
@Bulkhead(name = "f6-api")
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
@Retry(name = "f6-api")
fun fetchProcurementList(storeId: Long): ProcurementList =
f6RestClient.get()
.uri("/f6/procurement/list?storeId={storeId}", storeId)
.retrieve()
.body(ProcurementList::class.java)
?: ProcurementList.degraded(storeId)
/** fallback 的签名必须是"原方法参数 + Throwable",少一个参数 Resilience4j 就找不到它。 */
@Suppress("unused")
private fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementList {
log.warn("F6 采购列表降级 storeId={} cause={}", storeId, ex.javaClass.simpleName)
return ProcurementList.degraded(storeId)
}
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.integration.f6
import com.continental.retailapp.platform.integration.IntegrationException
import com.continental.retailapp.platform.web.ErrorCode
/**
* F6 侧的失败分两类,见 05-integration-layer.md——这个区分直接决定了要不要重试:
*
* - [F6ServerException]5xx / 网络层):对方的问题,**可以重试**,计入熔断统计;
* - [F6ClientException]4xx):我们请求本身有问题,重试一百次也是同样结果,
* 所以它在 `retry-exceptions` 之外、在 `ignore-exceptions` 之内。
*
* 构造参数里的 message 只进日志,对外文案是父类里那句用户能看懂的话。
*/
class F6ServerException(detail: String) :
IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试") {
init {
// detail 只在日志里出现,不进响应体
F6_LOG.warn("F6 服务端错误: {}", detail)
}
companion object {
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ServerException::class.java)
}
}
class F6ClientException(detail: String) :
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝") {
init {
F6_LOG.warn("F6 客户端错误: {}", detail)
}
companion object {
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ClientException::class.java)
}
}
@@ -0,0 +1,31 @@
package com.continental.retailapp.integration.f6
import com.continental.retailapp.platform.integration.IntegrationClientProperties
import com.continental.retailapp.platform.integration.RestClientFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
/**
* F6 的 [RestClient],见 05-integration-layer.md(以及那里对文档偏离 #3 的说明)。
*
* statusHandler 在这里而不是 platform-integration:只有 f6-adapter 知道 F6 的错误该映射成什么异常,
* 平台层不应该反过来 import 适配层的类型。
*/
@Configuration
class F6RestClientConfig {
@Bean
fun f6RestClient(
factory: RestClientFactory,
props: IntegrationClientProperties,
): RestClient = factory.build(props.require("f6")) { builder ->
builder
.defaultStatusHandler({ it.is5xxServerError }) { _, response ->
throw F6ServerException("status=${response.statusCode}")
}
.defaultStatusHandler({ it.is4xxClientError }) { _, response ->
throw F6ClientException("status=${response.statusCode}")
}
}
}

Some files were not shown because too many files have changed in this diff Show More