backend scaffold
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# 镜像只需要 bootstrap/build/libs/*.jar 这一个文件(Dockerfile 不在镜像里编译),
|
||||
# 其余一律排除,避免把源码、Gradle 缓存、本地配置塞进构建上下文。
|
||||
*
|
||||
!bootstrap/build/libs/*.jar
|
||||
@@ -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
@@ -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
@@ -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 Registry(ACR)
|
||||
|
||||
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 Cluster(API 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
@@ -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/ ./
|
||||
|
||||
# MaxRAMPercentage:JVM 在容器里默认只用可用内存的 25% 做堆,配 2Gi 的 Pod 堆只有 512Mi。
|
||||
# ExitOnOutOfMemoryError:OOM 直接结束进程交给 K8s 重启,而不是留一个探针还返回健康的半死 Pod。
|
||||
ENTRYPOINT ["java", \
|
||||
"-XX:MaxRAMPercentage=75.0", \
|
||||
"-XX:+ExitOnOutOfMemoryError", \
|
||||
"org.springframework.boot.loader.launch.JarLauncher"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# conti-backend
|
||||
|
||||
Continental 门店零售 App 的后端服务。**模块化单体**:14 个 Gradle 模块,一个可部署 jar,
|
||||
模块边界由 Gradle 依赖图 + ArchUnit 测试守着,将来要拆微服务时按模块切即可。
|
||||
|
||||
架构决策全部写在 [`conti-docs/backend/`](../conti-docs/backend/) 的 12 篇文档里,
|
||||
本仓库是那些决策的落地。**文档是唯一事实来源**——代码和文档不一致时,改代码,不是改文档,
|
||||
也不是把 ArchUnit 规则调松(规则调松了这套结构就白建了)。
|
||||
|
||||
## 版本基线
|
||||
|
||||
| | 版本 |
|
||||
| --- | --- |
|
||||
| Java | 21(toolchain) |
|
||||
| Kotlin | 2.4.10 |
|
||||
| Spring Boot | 4.1.0 |
|
||||
| Spring Cloud | 2025.1.2 |
|
||||
| Gradle | 9.5.1(wrapper) |
|
||||
| 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 F6(Bulkhead + 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 / WireMock(CI 跑这个)
|
||||
```
|
||||
|
||||
需要真实 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
|
||||
——那是越权查询的标准入口。
|
||||
@@ -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')
|
||||
}
|
||||
+166
@@ -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..")
|
||||
// 对文档的有意偏离 #4:10-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.md:api 层不 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("统一用 Instant,UTC 存储,见 03-persistence.md")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `禁止字段注入`() {
|
||||
noFields().should().beAnnotatedWith(Autowired::class.java)
|
||||
.because("统一用构造器注入,可测试且不可变")
|
||||
.check(classes)
|
||||
}
|
||||
}
|
||||
+43
@@ -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")}" }
|
||||
}
|
||||
}
|
||||
@@ -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 # 定期轮询 ConfigMap(event 模式需要 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 Secret;base64 解码后必须 ≥ 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
|
||||
@@ -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 launcher,starter-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 test(Runner 需能起 Docker,见 10-testing.md)
|
||||
if (project.hasProperty('excludeIntegration')) {
|
||||
excludeTags 'integration'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 一个 database,Flyway 起来之前这几个库得先存在(见 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:
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 本地环境建库。一个 domain 一个 database(MySQL 里 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;
|
||||
@@ -0,0 +1,304 @@
|
||||
# 01. 工程结构(后端)
|
||||
|
||||
## 技术栈
|
||||
|
||||
Kotlin + Spring Boot + Gradle(Groovy 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 4;8.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 Center:F6 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/)
|
||||
@@ -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 Architecture(Uncle 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)
|
||||
@@ -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 / use,database 由 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)
|
||||
@@ -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 / NimbusJwtDecoder(HS256)
|
||||
AccessTokenIssuer # 签发 access token(claims 结构见下)
|
||||
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 TTL(30 分钟)后,从 `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)
|
||||
@@ -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)
|
||||
@@ -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.gradle(Kotlin 项目用 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/)
|
||||
@@ -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 Alibaba(Nacos)的原因:部署环境已经是 K8s,K8s 本身自带 Service(服务发现)、ConfigMap/Secret(配置)、Deployment 滚动更新(发布),再引入 Nacos 意味着多维护一套集群、多一套"配置到底以谁为准"的心智负担。除非未来有 Nacos 才能提供、K8s 原生方案覆盖不了的能力(比如更细粒度的灰度配置推送),否则先用 K8s 原生的就够。
|
||||
|
||||
## 结构约定
|
||||
|
||||
- **非敏感配置**(菜单开关、超时参数、日志级别等)放 ConfigMap,挂载成 `application-{profile}.yml` 或环境变量。
|
||||
- **敏感配置**(DB 密码、JWT secret、F6 API key)放 Secret,都不进代码库、不进镜像。
|
||||
- 各环境(dev/uat/prod)对应各自 namespace 下的 ConfigMap/Secret + Spring profile,`bootstrap` 按 `SPRING_PROFILES_ACTIVE` 加载对应配置。
|
||||
|
||||
## ConfigMap / Secret 示例
|
||||
|
||||
```yaml
|
||||
# k8s/configmap-workbench-uat.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: conti-backend-config
|
||||
namespace: retailapp-uat
|
||||
data:
|
||||
application-uat.yml: |
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
f6-api:
|
||||
failure-rate-threshold: 50
|
||||
workbench:
|
||||
degrade-message: "部分数据暂时无法显示,请稍后刷新"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# k8s/secret-uat.yaml(实际值由 CI/CD 从密钥管理服务注入,不手写明文提交)
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: conti-backend-secret
|
||||
namespace: retailapp-uat
|
||||
type: Opaque
|
||||
stringData:
|
||||
SECURITY_JWT_SECRET: "__injected_by_pipeline__"
|
||||
DB_PASSWORD: "__injected_by_pipeline__"
|
||||
F6_API_KEY: "__injected_by_pipeline__"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# k8s/deployment-uat.yaml(节选)
|
||||
spec:
|
||||
containers:
|
||||
- name: conti-backend
|
||||
image: registry.example.com/conti-backend:__TAG__
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: conti-backend-secret
|
||||
env:
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
value: uat
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /app/config
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: conti-backend-config
|
||||
```
|
||||
|
||||
## 启用 Spring Cloud Kubernetes 配置热更新
|
||||
|
||||
```groovy
|
||||
// bootstrap/build.gradle —— 版本由根工程的 spring-cloud-dependencies BOM(2025.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 / Prod(Azure 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/)
|
||||
@@ -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 的一等公民,这些点框架都已经处理好,而且将来真要接 APM(Jaeger/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)
|
||||
@@ -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/Prod(Azure 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 Endpoint,Dev 环境的 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 Registry(ACR)
|
||||
|
||||
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 两次发布。已经执行过的迁移脚本不可修改。
|
||||
- 容器以非 root(UID 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/)
|
||||
@@ -0,0 +1,550 @@
|
||||
# 10. 测试策略
|
||||
|
||||
## 决策
|
||||
|
||||
JUnit 5 + MockK 做单元测试,Testcontainers(MySQL)做集成测试,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 + MockK,mock 掉 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.md:api 层不 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("统一用 Instant,UTC 存储,见 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-Docker(dind),托管 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)
|
||||
@@ -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())
|
||||
// 关键:把 MDC(traceId)和 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)
|
||||
@@ -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)
|
||||
@@ -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/infrastructure,Entity 边界) |
|
||||
| [03-persistence.md](./03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate + MySQL,Flyway 多实例) |
|
||||
| [04-security-auth.md](./04-security-auth.md) | 安全与认证(Spring Security + JWT,refresh 轮换,门店上下文与越权隔离) |
|
||||
| [05-integration-layer.md](./05-integration-layer.md) | 集成层设计(同步 RestClient + Resilience4j,F6 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/)(Flutter,01-14)
|
||||
- **架构图与 PRD**:[`conti-docs/Architecture-Diagram/`](../../conti-docs/Architecture-Diagram/)
|
||||
- **未决阻塞项**(错误码表未定等):见 [`conti-docs/README.md`](../../conti-docs/README.md) 的「跨文档的阻塞项」
|
||||
@@ -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`)。
|
||||
@@ -0,0 +1,4 @@
|
||||
// 骨架阶段这个模块只占位,见 README.md
|
||||
dependencies {
|
||||
implementation project(':platform:platform-web')
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 契约模块:只放接口、传输模型和事件,不携带任何技术栈。
|
||||
// ArchUnit 有一条规则盯着它不依赖 Spring Web / JPA(见 10-testing.md)。
|
||||
dependencies {
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+19
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
+56
@@ -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()))
|
||||
}
|
||||
+34
@@ -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))
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+9
@@ -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,
|
||||
)
|
||||
+30
@@ -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?,
|
||||
)
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+8
@@ -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,
|
||||
)
|
||||
+179
@@ -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"
|
||||
}
|
||||
}
|
||||
+18
@@ -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,
|
||||
)
|
||||
+19
@@ -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()
|
||||
}
|
||||
+36
@@ -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 token(12-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) }
|
||||
}
|
||||
}
|
||||
+103
@@ -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()))
|
||||
}
|
||||
+75
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -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 的 repository(11-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) }
|
||||
}
|
||||
+19
@@ -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>
|
||||
}
|
||||
+77
@@ -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"
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+38
@@ -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?
|
||||
}
|
||||
+25
@@ -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?
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
+10
@@ -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?
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+11
@@ -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 '用户可访问的门店(授权的唯一事实来源)';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- refresh token,DDL 逐字取自 04-security-auth.md。
|
||||
--
|
||||
-- token_hash 是 char(64) 而不是 varchar:SHA-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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.continental.retailapp.identitystore
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
|
||||
/**
|
||||
* `@DataJpaTest` 需要在包层级上能找到一个 `@SpringBootConfiguration`。
|
||||
* 真正的入口类在 `bootstrap` 模块,而 domain 模块不许反向依赖 bootstrap(01-project-structure.md),
|
||||
* 所以这里给测试单独放一个最小入口,只覆盖本模块的实体与仓储。
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp.identitystore"])
|
||||
class IdentityStoreTestApplication
|
||||
+155
@@ -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('/') })
|
||||
}
|
||||
}
|
||||
+39
@@ -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 }
|
||||
+125
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+23
@@ -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())
|
||||
}
|
||||
+13
@@ -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`),
|
||||
* 时区转换由客户端做——服务端一律 UTC(06-api-design.md)。
|
||||
*/
|
||||
data class WebviewTicketResponse(
|
||||
val ticketId: String,
|
||||
val expiresAt: Instant,
|
||||
)
|
||||
+37
@@ -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) }
|
||||
}
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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 }
|
||||
+17
@@ -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
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
+59
@@ -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,
|
||||
)
|
||||
+37
@@ -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
|
||||
}
|
||||
+23
@@ -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 一次性票据';
|
||||
+84
@@ -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) }
|
||||
}
|
||||
}
|
||||
+24
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
+22
@@ -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())
|
||||
}
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+19
@@ -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 }
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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 '工作台卡片配置';
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -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
|
||||
@@ -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
@@ -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%
|
||||
@@ -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'
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+31
@@ -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
Reference in New Issue
Block a user