commit 84fc2c067781e944de6692ff2fcab454c9920cf0 Author: Guangfei.Zhao Date: Mon Aug 17 15:31:27 2026 +0800 backend scaffold diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2df8b76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# 镜像只需要 bootstrap/build/libs/*.jar 这一个文件(Dockerfile 不在镜像里编译), +# 其余一律排除,避免把源码、Gradle 缓存、本地配置塞进构建上下文。 +* +!bootstrap/build/libs/*.jar diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..28ca5ed --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc1406f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..97952a1 --- /dev/null +++ b/.gitlab-ci.yml @@ -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「数据库迁移与回滚的协同」)。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9cc846d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a272ef2 --- /dev/null +++ b/README.md @@ -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:(`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 `(先连 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 + ——那是越权查询的标准入口。 diff --git a/architecture-test/build.gradle b/architecture-test/build.gradle new file mode 100644 index 0000000..946a5ed --- /dev/null +++ b/architecture-test/build.gradle @@ -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') +} diff --git a/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/ArchitectureRulesTest.kt b/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/ArchitectureRulesTest.kt new file mode 100644 index 0000000..cf43418 --- /dev/null +++ b/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/ArchitectureRulesTest.kt @@ -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`)。 + // 按原文写会把 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) + } +} diff --git a/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/NativeQueryScanTest.kt b/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/NativeQueryScanTest.kt new file mode 100644 index 0000000..0b3d3da --- /dev/null +++ b/architecture-test/src/test/kotlin/com/continental/retailapp/architecture/NativeQueryScanTest.kt @@ -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")}" } + } +} diff --git a/bootstrap/build.gradle b/bootstrap/build.gradle new file mode 100644 index 0000000..2264a20 --- /dev/null +++ b/bootstrap/build.gradle @@ -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' +} diff --git a/bootstrap/src/main/kotlin/com/continental/retailapp/BootstrapApplication.kt b/bootstrap/src/main/kotlin/com/continental/retailapp/BootstrapApplication.kt new file mode 100644 index 0000000..28cb0ed --- /dev/null +++ b/bootstrap/src/main/kotlin/com/continental/retailapp/BootstrapApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/bootstrap/src/main/resources/application-dev.yml b/bootstrap/src/main/resources/application-dev.yml new file mode 100644 index 0000000..4e8e909 --- /dev/null +++ b/bootstrap/src/main/resources/application-dev.yml @@ -0,0 +1,11 @@ +# dev:只放与其他环境的差异,其余走 application.yml + ConfigMap。 + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + +logging: + level: + com.continental.retailapp: DEBUG diff --git a/bootstrap/src/main/resources/application-local.yml b/bootstrap/src/main/resources/application-local.yml new file mode 100644 index 0000000..3bc57a6 --- /dev/null +++ b/bootstrap/src/main/resources/application-local.yml @@ -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 diff --git a/bootstrap/src/main/resources/application-prod.yml b/bootstrap/src/main/resources/application-prod.yml new file mode 100644 index 0000000..18b6cf2 --- /dev/null +++ b/bootstrap/src/main/resources/application-prod.yml @@ -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 diff --git a/bootstrap/src/main/resources/application-uat.yml b/bootstrap/src/main/resources/application-uat.yml new file mode 100644 index 0000000..9256552 --- /dev/null +++ b/bootstrap/src/main/resources/application-uat.yml @@ -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 diff --git a/bootstrap/src/main/resources/application.yml b/bootstrap/src/main/resources/application.yml new file mode 100644 index 0000000..9316e9b --- /dev/null +++ b/bootstrap/src/main/resources/application.yml @@ -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 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3d117b8 --- /dev/null +++ b/build.gradle @@ -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' + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6898825 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/mysql/init/01-create-databases.sql b/docker/mysql/init/01-create-databases.sql new file mode 100644 index 0000000..1cd39aa --- /dev/null +++ b/docker/mysql/init/01-create-databases.sql @@ -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; diff --git a/docs/01-project-structure.md b/docs/01-project-structure.md new file mode 100644 index 0000000..d6658b7 --- /dev/null +++ b/docs/01-project-structure.md @@ -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.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) { + runApplication(*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/) diff --git a/docs/02-layering.md b/docs/02-layering.md new file mode 100644 index 0000000..576359a --- /dev/null +++ b/docs/02-layering.md @@ -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 +} + +@Repository +interface StoreJpaRepository : JpaRepository, 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 +} + +// 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 = + 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) diff --git a/docs/03-persistence.md b/docs/03-persistence.md new file mode 100644 index 0000000..360ae3e --- /dev/null +++ b/docs/03-persistence.md @@ -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 # 统一分页返回封装 + JpaAuditingConfig # 开启 Spring Data JPA Auditing + DomainFlywayConfig # 按 domain database 分别建 Flyway 实例 + +domains/xxx/ + src/main/kotlin/.../xxx/infrastructure/persistence/ + XxxEntity # JPA entity + XxxJpaRepository # : JpaRepository + 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, +) { + @Bean + fun auditorAware(): AuditorAware = 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 { + fun findByCode(code: String): StoreEntity? + fun findByStatus(status: StoreStatus): List +} +``` + +```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 { + 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` 返回(字段名和 APP 侧约定见 [06-api-design.md](./06-api-design.md)),不直接把 Spring 的 `Page` 序列化给前端——`Page` 的 JSON 结构由 Spring 版本决定,升级 Boot 时会变,属于把框架内部结构写进 API 契约。 + +```kotlin +data class PageResult( + val list: List, + 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) diff --git a/docs/04-security-auth.md b/docs/04-security-auth.md new file mode 100644 index 0000000..945014d --- /dev/null +++ b/docs/04-security-auth.md @@ -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`。撤销该 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 = 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 { + 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 = 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, +) : 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("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 { + val userId = storeContextHolder.currentUserId() + val storeId = storeContextHolder.currentStoreId() + // ... + } +} +``` + +## `SecurityConfig` + +```kotlin +@Configuration +@EnableWebSecurity +@EnableMethodSecurity // 开启 @PreAuthorize +class SecurityConfig( + private val storeContextHolder: ObjectFactory, + 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 { + 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 = + 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) diff --git a/docs/05-integration-layer.md b/docs/05-integration-layer.md new file mode 100644 index 0000000..c661110 --- /dev/null +++ b/docs/05-integration-layer.md @@ -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..-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) diff --git a/docs/06-api-design.md b/docs/06-api-design.md new file mode 100644 index 0000000..b9a3cd2 --- /dev/null +++ b/docs/06-api-design.md @@ -0,0 +1,300 @@ +# 06. API 设计规范 + +## 决策 + +REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 domain 的返回标准化成 APP 需要的形态。 + +## 结构约定 + +``` +platform-web/ + ApiResult # { 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( + val code: Int, // 0 = 成功;非 0 见下面的错误码分段 + val message: String, + val data: T?, + val traceId: String, +) { + companion object { + fun ok(data: T): ApiResult = + ApiResult(ErrorCode.OK, "success", data, currentTraceId()) + + fun error(code: Int, message: String): ApiResult = + 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> { + 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> = + ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常")) + + @ExceptionHandler(ObjectOptimisticLockingFailureException::class) + fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity> = + ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试")) + + @ExceptionHandler(Exception::class) + fun handleUnexpected(ex: Exception): ResponseEntity> { + // 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 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> = + ApiResult.ok(storeAppService.listAccessibleStores()) + + @Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token") + @PostMapping("/{storeId}/switch") + fun switchStore(@PathVariable storeId: Long): ApiResult = + 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): List +} +``` + +**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 ` | 除免认证端点外 | 见 [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`): + +```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/) diff --git a/docs/07-config-governance.md b/docs/07-config-governance.md new file mode 100644 index 0000000..a532d4d --- /dev/null +++ b/docs/07-config-governance.md @@ -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 不落地明文** + +
+展开查看(AKS 需装 `azure-keyvault-secrets-provider` 插件 + 配置 Managed Identity,运维成本更高,暂不采用) + +```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: "" + keyvaultName: "conti-backend-kv" + tenantId: "" + 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 执行上下文",再切换到这条路径,现阶段先用方式一。 + +
+ +两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。 + +## 配置绑定与启动期校验 + +**所有配置一律绑定到 `@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-` | `retailapp-uat` | +| ConfigMap | `conti-backend-config` | 每个环境一份,同名不同 namespace | +| Secret | `conti-backend-secret` | 同上 | +| ConfigMap 里的 key | `application-.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/) diff --git a/docs/08-observability.md b/docs/08-observability.md new file mode 100644 index 0000000..61c7864 --- /dev/null +++ b/docs/08-observability.md @@ -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 + + + + + traceId + spanId + {"app":"conti-backend","env":"${SPRING_PROFILES_ACTIVE:-local}"} + + + + password + accessToken + refreshToken + authorization + + + + + + + +``` + +```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) { + 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) diff --git a/docs/09-build-deploy.md b/docs/09-build-deploy.md new file mode 100644 index 0000000..264406f --- /dev/null +++ b/docs/09-build-deploy.md @@ -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 +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/) diff --git a/docs/10-testing.md b/docs/10-testing.md new file mode 100644 index 0000000..2ed380b --- /dev/null +++ b/docs/10-testing.md @@ -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() + 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) diff --git a/docs/11-cross-domain-collaboration.md b/docs/11-cross-domain-collaboration.md new file mode 100644 index 0000000..6847a11 --- /dev/null +++ b/docs/11-cross-domain-collaboration.md @@ -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 + 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 = + 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 supply(tile: String, block: () -> T): CompletableFuture> = + CompletableFuture.supplyAsync({ + runCatching(block) + .map { Tile.ok(it) } + .getOrElse { log.warn("tile={} 加载失败,降级", tile, it); Tile.degraded() } + }, executor) + + private fun await(future: CompletableFuture>, deadlineNanos: Long, tile: String): Tile { + 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( + val data: T?, + val status: TileStatus, // OK / DEGRADED +) { + companion object { + fun ok(data: T) = Tile(data, TileStatus.OK) + fun degraded() = Tile(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) diff --git a/docs/12-concurrency-and-scheduling.md b/docs/12-concurrency-and-scheduling.md new file mode 100644 index 0000000..70ce2a7 --- /dev/null +++ b/docs/12-concurrency-and-scheduling.md @@ -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) { + 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 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) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f4840ec --- /dev/null +++ b/docs/README.md @@ -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) 的「跨文档的阻塞项」 diff --git a/domains/bff-orchestration/README.md b/domains/bff-orchestration/README.md new file mode 100644 index 0000000..291a9f0 --- /dev/null +++ b/domains/bff-orchestration/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`)。 diff --git a/domains/bff-orchestration/build.gradle b/domains/bff-orchestration/build.gradle new file mode 100644 index 0000000..93212c1 --- /dev/null +++ b/domains/bff-orchestration/build.gradle @@ -0,0 +1,4 @@ +// 骨架阶段这个模块只占位,见 README.md +dependencies { + implementation project(':platform:platform-web') +} diff --git a/domains/identity-store-contract/build.gradle b/domains/identity-store-contract/build.gradle new file mode 100644 index 0000000..f3153d2 --- /dev/null +++ b/domains/identity-store-contract/build.gradle @@ -0,0 +1,4 @@ +// 契约模块:只放接口、传输模型和事件,不携带任何技术栈。 +// ArchUnit 有一条规则盯着它不依赖 Spring Web / JPA(见 10-testing.md)。 +dependencies { +} diff --git a/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreQueryService.kt b/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreQueryService.kt new file mode 100644 index 0000000..79b8703 --- /dev/null +++ b/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreQueryService.kt @@ -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 + + fun findStore(storeId: Long): StoreInfo? +} + +data class StoreInfo( + val storeId: Long, + val name: String, + val code: String, +) diff --git a/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreSwitchedEvent.kt b/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreSwitchedEvent.kt new file mode 100644 index 0000000..bf695db --- /dev/null +++ b/domains/identity-store-contract/src/main/kotlin/com/continental/retailapp/identitystore/contract/StoreSwitchedEvent.kt @@ -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, +) diff --git a/domains/identity-store/build.gradle b/domains/identity-store/build.gradle new file mode 100644 index 0000000..1f44496 --- /dev/null +++ b/domains/identity-store/build.gradle @@ -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' +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/AuthController.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/AuthController.kt new file mode 100644 index 0000000..f72f535 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/AuthController.kt @@ -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 = + ApiResult.ok(authAppService.login(request)) + + /** 免认证:调它的时候 access token 恰好已经过期,要求带有效 token 就成了死锁。 */ + @Operation(summary = "用 refresh token 换新的 access token(轮换)") + @PostMapping("/refresh") + fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ApiResult = + ApiResult.ok(authAppService.refresh(request.refreshToken)) + + @Operation(summary = "登出,撤销该 refresh token") + @PostMapping("/logout") + fun logout(@Valid @RequestBody request: RefreshTokenRequest): ApiResult { + authAppService.logout(request.refreshToken) + return ApiResult.ok() + } + + /** 客户端冷启动第一个调用的接口,必须轻量且只读。 */ + @Operation(summary = "当前登录用户与所在门店") + @GetMapping("/me") + fun me(): ApiResult = + ApiResult.ok(authAppService.me(storeContextHolder.currentUserId())) +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/StoreController.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/StoreController.kt new file mode 100644 index 0000000..38a3bb3 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/StoreController.kt @@ -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> = + ApiResult.ok(storeAppService.listAccessibleStores()) + + /** + * 这是少数几个"门店 id 来自路径参数"的接口之一, + * 所以 `switchStore` 内部**第一件事就是校验可访问性**(04-security-auth.md 越权规则第 2 条)。 + */ + @Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token") + @PostMapping("/{storeId}/switch") + fun switchStore(@PathVariable storeId: Long): ApiResult = + ApiResult.ok(storeAppService.switchStore(storeId)) +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/LoginRequest.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/LoginRequest.kt new file mode 100644 index 0000000..d4d3912 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/LoginRequest.kt @@ -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, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/RefreshTokenRequest.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/RefreshTokenRequest.kt new file mode 100644 index 0000000..36dfdcc --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/request/RefreshTokenRequest.kt @@ -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, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/AuthResponses.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/AuthResponses.kt new file mode 100644 index 0000000..b5e87d0 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/AuthResponses.kt @@ -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?, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreContextResponse.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreContextResponse.kt new file mode 100644 index 0000000..ff6f744 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreContextResponse.kt @@ -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, + val menus: List, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreResponse.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreResponse.kt new file mode 100644 index 0000000..8b3265d --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/api/response/StoreResponse.kt @@ -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, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/AuthAppService.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/AuthAppService.kt new file mode 100644 index 0000000..10f79ef --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/AuthAppService.kt @@ -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 = + 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" + } +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/InvalidRefreshTokenException.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/InvalidRefreshTokenException.kt new file mode 100644 index 0000000..f81d855 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/InvalidRefreshTokenException.kt @@ -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, +) diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/MenuCatalog.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/MenuCatalog.kt new file mode 100644 index 0000000..ad063e6 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/MenuCatalog.kt @@ -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): List = + roles.flatMap { BY_ROLE[it].orEmpty() }.distinct() +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenCleanupJob.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenCleanupJob.kt new file mode 100644 index 0000000..b3d00c5 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenCleanupJob.kt @@ -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) } + } +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenService.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenService.kt new file mode 100644 index 0000000..fe034ee --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenService.kt @@ -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())) +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreAppService.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreAppService.kt new file mode 100644 index 0000000..86321f9 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreAppService.kt @@ -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 = + 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), + ) + } +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreQueryServiceImpl.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreQueryServiceImpl.kt new file mode 100644 index 0000000..557a771 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/StoreQueryServiceImpl.kt @@ -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 = + 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) } +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/mapper/StoreMapper.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/mapper/StoreMapper.kt new file mode 100644 index 0000000..b1a38c5 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/application/mapper/StoreMapper.kt @@ -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): List +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/config/LocalSeedDataConfig.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/config/LocalSeedDataConfig.kt new file mode 100644 index 0000000..c895f5a --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/config/LocalSeedDataConfig.kt @@ -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" + } +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenEntity.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenEntity.kt new file mode 100644 index 0000000..6078cd1 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenEntity.kt @@ -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) +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenJpaRepository.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenJpaRepository.kt new file mode 100644 index 0000000..3fcb88c --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/RefreshTokenJpaRepository.kt @@ -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 { + + 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 +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreEntity.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreEntity.kt new file mode 100644 index 0000000..0e6fbfa --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreEntity.kt @@ -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 +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepository.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepository.kt new file mode 100644 index 0000000..21da1f4 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepository.kt @@ -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, 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 + + @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? +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreRepository.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreRepository.kt new file mode 100644 index 0000000..57f1f61 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreRepository.kt @@ -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 + + /** 目标门店对该用户可访问才返回;不可访问返回 null。切店前必须过这一关。 */ + fun findAccessibleStore(userId: Long, storeId: Long): StoreView? + + fun findStoreById(storeId: Long): StoreView? + + /** + * 返回逗号分隔的原始角色串,解析交给 `application`。 + * + * 对 04 文档的小改名:文档里叫 `findRoles` 并直接返回 `List`, + * 但角色在库里就是一个逗号串,拆分是业务转换、不该藏在 repository 的方法名背后。 + */ + fun findRoleNames(userId: Long, storeId: Long): String? +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreView.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreView.kt new file mode 100644 index 0000000..e47401b --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreView.kt @@ -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 +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserEntity.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserEntity.kt new file mode 100644 index 0000000..d0b5330 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserEntity.kt @@ -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 +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserJpaRepository.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserJpaRepository.kt new file mode 100644 index 0000000..27a8e88 --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserJpaRepository.kt @@ -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 { + + fun findByUsername(username: String): UserEntity? +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreEntity.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreEntity.kt new file mode 100644 index 0000000..db7db4a --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreEntity.kt @@ -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 +} diff --git a/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreJpaRepository.kt b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreJpaRepository.kt new file mode 100644 index 0000000..c86850e --- /dev/null +++ b/domains/identity-store/src/main/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/UserStoreJpaRepository.kt @@ -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 diff --git a/domains/identity-store/src/main/resources/db/migration/identity_store/V1__init.sql b/domains/identity-store/src/main/resources/db/migration/identity_store/V1__init.sql new file mode 100644 index 0000000..4ed9c14 --- /dev/null +++ b/domains/identity-store/src/main/resources/db/migration/identity_store/V1__init.sql @@ -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 '用户可访问的门店(授权的唯一事实来源)'; diff --git a/domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql b/domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql new file mode 100644 index 0000000..3927fce --- /dev/null +++ b/domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql @@ -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; diff --git a/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/IdentityStoreTestApplication.kt b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/IdentityStoreTestApplication.kt new file mode 100644 index 0000000..404d5b7 --- /dev/null +++ b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/IdentityStoreTestApplication.kt @@ -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 diff --git a/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenServiceTest.kt b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenServiceTest.kt new file mode 100644 index 0000000..8995b25 --- /dev/null +++ b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/application/RefreshTokenServiceTest.kt @@ -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() + 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() + 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().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('/') }) + } +} diff --git a/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/fixtures/IdentityStoreFixtures.kt b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/fixtures/IdentityStoreFixtures.kt new file mode 100644 index 0000000..54c464b --- /dev/null +++ b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/fixtures/IdentityStoreFixtures.kt @@ -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 = 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 } diff --git a/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepositoryTest.kt b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepositoryTest.kt new file mode 100644 index 0000000..6722f97 --- /dev/null +++ b/domains/identity-store/src/test/kotlin/com/continental/retailapp/identitystore/infrastructure/persistence/StoreJpaRepositoryTest.kt @@ -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(), 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") + } +} diff --git a/domains/webview-ticket/build.gradle b/domains/webview-ticket/build.gradle new file mode 100644 index 0000000..49611bf --- /dev/null +++ b/domains/webview-ticket/build.gradle @@ -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' +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/WebviewTicketController.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/WebviewTicketController.kt new file mode 100644 index 0000000..6370dcb --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/WebviewTicketController.kt @@ -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 = + ApiResult.ok(webviewTicketAppService.issueTicket()) +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/response/WebviewTicketResponse.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/response/WebviewTicketResponse.kt new file mode 100644 index 0000000..cbf7a4c --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/api/response/WebviewTicketResponse.kt @@ -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, +) diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/StoreSwitchedListener.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/StoreSwitchedListener.kt new file mode 100644 index 0000000..0dad3a2 --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/StoreSwitchedListener.kt @@ -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) } + } +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/WebviewTicketAppService.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/WebviewTicketAppService.kt new file mode 100644 index 0000000..c96e262 --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/application/WebviewTicketAppService.kt @@ -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) + } +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/model/WebviewTicket.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/model/WebviewTicket.kt new file mode 100644 index 0000000..e38666d --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/model/WebviewTicket.kt @@ -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 } diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/repository/WebviewTicketRepository.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/repository/WebviewTicketRepository.kt new file mode 100644 index 0000000..6b999de --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/repository/WebviewTicketRepository.kt @@ -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 +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketService.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketService.kt new file mode 100644 index 0000000..292d9ec --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketService.kt @@ -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) + } +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/config/DomainServiceConfig.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/config/DomainServiceConfig.kt new file mode 100644 index 0000000..528196c --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/config/DomainServiceConfig.kt @@ -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) +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketEntity.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketEntity.kt new file mode 100644 index 0000000..2d42ac3 --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketEntity.kt @@ -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, +) diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketJpaRepository.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketJpaRepository.kt new file mode 100644 index 0000000..628f0d5 --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketJpaRepository.kt @@ -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 { + + 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 +} diff --git a/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketRepositoryImpl.kt b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketRepositoryImpl.kt new file mode 100644 index 0000000..cedb72c --- /dev/null +++ b/domains/webview-ticket/src/main/kotlin/com/continental/retailapp/webviewticket/infrastructure/persistence/WebviewTicketRepositoryImpl.kt @@ -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) +} diff --git a/domains/webview-ticket/src/main/resources/db/migration/webview_ticket/V1__init.sql b/domains/webview-ticket/src/main/resources/db/migration/webview_ticket/V1__init.sql new file mode 100644 index 0000000..cfd8b4e --- /dev/null +++ b/domains/webview-ticket/src/main/resources/db/migration/webview_ticket/V1__init.sql @@ -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 一次性票据'; diff --git a/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketServiceTest.kt b/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketServiceTest.kt new file mode 100644 index 0000000..768156f --- /dev/null +++ b/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/domain/service/IssueWebviewTicketServiceTest.kt @@ -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() + 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) } + } +} diff --git a/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/fixtures/WebviewTicketFixtures.kt b/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/fixtures/WebviewTicketFixtures.kt new file mode 100644 index 0000000..aad6183 --- /dev/null +++ b/domains/webview-ticket/src/test/kotlin/com/continental/retailapp/webviewticket/fixtures/WebviewTicketFixtures.kt @@ -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, +) diff --git a/domains/workbench/build.gradle b/domains/workbench/build.gradle new file mode 100644 index 0000000..d04a995 --- /dev/null +++ b/domains/workbench/build.gradle @@ -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' +} diff --git a/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/WorkbenchController.kt b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/WorkbenchController.kt new file mode 100644 index 0000000..c4c51ff --- /dev/null +++ b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/WorkbenchController.kt @@ -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 = + ApiResult.ok(workbenchAppService.loadHomepage()) +} diff --git a/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/HomepageResponse.kt b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/HomepageResponse.kt new file mode 100644 index 0000000..aa06e12 --- /dev/null +++ b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/HomepageResponse.kt @@ -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, + val procurement: Tile, + val warranty: Tile, +) diff --git a/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/Tile.kt b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/Tile.kt new file mode 100644 index 0000000..ae7ebd2 --- /dev/null +++ b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/api/response/Tile.kt @@ -0,0 +1,19 @@ +package com.continental.retailapp.workbench.api.response + +/** + * 首页每一块(tile)的包装(11-cross-domain-collaboration.md)。 + * + * 降级必须**对客户端可见**,不能悄悄返回空数据:客户端要能区分 + * "这块真的没数据"和"这块没拉到",才能决定显示空态还是"加载失败,点击重试"。 + */ +data class Tile( + val data: T?, + val status: TileStatus, +) { + companion object { + fun ok(data: T) = Tile(data, TileStatus.OK) + fun degraded() = Tile(null, TileStatus.DEGRADED) + } +} + +enum class TileStatus { OK, DEGRADED } diff --git a/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/application/WorkbenchAppService.kt b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/application/WorkbenchAppService.kt new file mode 100644 index 0000000..6f7e0b8 --- /dev/null +++ b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/application/WorkbenchAppService.kt @@ -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 supply(tile: String, block: () -> T?): CompletableFuture> = + 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 await(future: CompletableFuture>, deadlineNanos: Long, tile: String): Tile { + 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) + } +} diff --git a/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/infrastructure/config/WorkbenchExecutorConfig.kt b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/infrastructure/config/WorkbenchExecutorConfig.kt new file mode 100644 index 0000000..0732308 --- /dev/null +++ b/domains/workbench/src/main/kotlin/com/continental/retailapp/workbench/infrastructure/config/WorkbenchExecutorConfig.kt @@ -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() } + } + } +} diff --git a/domains/workbench/src/main/resources/db/migration/workbench/V1__init.sql b/domains/workbench/src/main/resources/db/migration/workbench/V1__init.sql new file mode 100644 index 0000000..cb49fc0 --- /dev/null +++ b/domains/workbench/src/main/resources/db/migration/workbench/V1__init.sql @@ -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 '工作台卡片配置'; diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/gradlew.bat @@ -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% diff --git a/integration/f6-adapter/build.gradle b/integration/f6-adapter/build.gradle new file mode 100644 index 0000000..9649f82 --- /dev/null +++ b/integration/f6-adapter/build.gradle @@ -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' +} diff --git a/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6ApiClient.kt b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6ApiClient.kt new file mode 100644 index 0000000..43a5114 --- /dev/null +++ b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6ApiClient.kt @@ -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) + } +} diff --git a/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6Exceptions.kt b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6Exceptions.kt new file mode 100644 index 0000000..1e828fe --- /dev/null +++ b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6Exceptions.kt @@ -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) + } +} diff --git a/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6RestClientConfig.kt b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6RestClientConfig.kt new file mode 100644 index 0000000..25f5341 --- /dev/null +++ b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/F6RestClientConfig.kt @@ -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}") + } + } +} diff --git a/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/ProcurementList.kt b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/ProcurementList.kt new file mode 100644 index 0000000..72ecc30 --- /dev/null +++ b/integration/f6-adapter/src/main/kotlin/com/continental/retailapp/integration/f6/ProcurementList.kt @@ -0,0 +1,15 @@ +package com.continental.retailapp.integration.f6 + +/** + * F6 采购列表的传输模型。`degraded` 让调用方能区分"真的没数据"和"没拉到", + * 见 11-cross-domain-collaboration.md 的局部降级约定。 + */ +data class ProcurementList( + val storeId: Long, + val pendingCount: Int, + val degraded: Boolean = false, +) { + companion object { + fun degraded(storeId: Long) = ProcurementList(storeId, pendingCount = 0, degraded = true) + } +} diff --git a/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6ApiClientResilienceTest.kt b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6ApiClientResilienceTest.kt new file mode 100644 index 0000000..c2c68eb --- /dev/null +++ b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6ApiClientResilienceTest.kt @@ -0,0 +1,139 @@ +package com.continental.retailapp.integration.f6 + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +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.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource + +/** + * 降级路径的行为测试,见 10-testing.md:**外部依赖挂掉时本服务不能跟着挂**。 + * + * 用 WireMock 而不是 mock 掉 `RestClient`:要验的是超时、状态码映射、fallback 这条真实链路, + * 把 RestClient mock 掉就等于把要测的东西测掉了。 + */ +@Tag("integration") +@SpringBootTest( + classes = [F6TestApplication::class], + properties = [ + "integration.clients.f6.connect-timeout-ms=300", + "integration.clients.f6.read-timeout-ms=500", + "integration.clients.f6.connection-request-timeout-ms=200", + "resilience4j.retry.retry-aspect-order=3", + "resilience4j.retry.instances.f6-api.max-attempts=2", + "resilience4j.retry.instances.f6-api.wait-duration=10ms", + "resilience4j.retry.instances.f6-api.retry-exceptions[0]=" + + "org.springframework.web.client.ResourceAccessException", + "resilience4j.retry.instances.f6-api.retry-exceptions[1]=" + + "com.continental.retailapp.integration.f6.F6ServerException", + "resilience4j.retry.instances.f6-api.ignore-exceptions[0]=" + + "com.continental.retailapp.integration.f6.F6ClientException", + "resilience4j.circuitbreaker.circuit-breaker-aspect-order=2", + // 样本门槛调高,避免熔断在测试中途打开,把"降级"和"熔断"两件事混在一起 + "resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100", + "resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" + + "com.continental.retailapp.integration.f6.F6ServerException", + "resilience4j.bulkhead.bulkhead-aspect-order=1", + "resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20", + "resilience4j.bulkhead.instances.f6-api.max-wait-duration=0", + ], +) +class F6ApiClientResilienceTest { + + @Autowired + private lateinit var client: F6ApiClient + + @BeforeEach + fun resetStubs() { + wireMock.resetAll() + } + + @Test + fun `F6 返回 5xx 时降级而不是抛给调用方`() { + wireMock.stubFor( + get(urlPathEqualTo("/f6/procurement/list")) + .willReturn(aResponse().withStatus(500)), + ) + + val result = client.fetchProcurementList(storeId = 100L) + + assertTrue(result.degraded) + assertEquals(100L, result.storeId) + assertEquals(0, result.pendingCount) + } + + @Test + fun `F6 响应超时时降级`() { + wireMock.stubFor( + get(urlPathEqualTo("/f6/procurement/list")) + .willReturn(aResponse().withStatus(200).withFixedDelay(2_000)), + ) + + val result = client.fetchProcurementList(storeId = 100L) + + // read-timeout 500ms < 固定延迟 2000ms,超时由 HTTP 客户端本身兜住, + // 这也是不需要 @TimeLimiter 的原因(见 05-integration-layer.md) + assertTrue(result.degraded) + } + + @Test + fun `F6 正常返回时不降级`() { + wireMock.stubFor( + get(urlPathEqualTo("/f6/procurement/list")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("""{"storeId":100,"pendingCount":7}"""), + ), + ) + + val result = client.fetchProcurementList(storeId = 100L) + + assertFalse(result.degraded) + assertEquals(7, result.pendingCount) + } + + @Test + fun `4xx 不重试`() { + wireMock.stubFor( + get(urlPathEqualTo("/f6/procurement/list")) + .willReturn(aResponse().withStatus(400)), + ) + + val result = client.fetchProcurementList(storeId = 100L) + + assertTrue(result.degraded) + // 参数错了再打一次还是错,重试只会放大下游压力:F6ClientException 在 ignore-exceptions 里 + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/f6/procurement/list"))) + } + + companion object { + private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort()) + + @JvmStatic + @DynamicPropertySource + fun registerBaseUrl(registry: DynamicPropertyRegistry) { + if (!wireMock.isRunning) wireMock.start() + registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" } + } + + @JvmStatic + @AfterAll + fun stopWireMock() { + if (wireMock.isRunning) wireMock.stop() + } + } +} diff --git a/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6TestApplication.kt b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6TestApplication.kt new file mode 100644 index 0000000..32fdc0d --- /dev/null +++ b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/F6TestApplication.kt @@ -0,0 +1,18 @@ +package com.continental.retailapp.integration.f6 + +import org.springframework.boot.autoconfigure.SpringBootApplication + +/** + * 只给 f6-adapter 的集成测试用的最小上下文。 + * + * 扫描范围刻意收窄到 f6-adapter + platform-integration 两个包: + * 真实的 `BootstrapApplication` 会把 JPA、Security、Flyway 全拉起来, + * 而这里要测的只是 RestClient 加三层韧性注解之后的行为。 + */ +@SpringBootApplication( + scanBasePackages = [ + "com.continental.retailapp.integration.f6", + "com.continental.retailapp.platform.integration", + ], +) +class F6TestApplication diff --git a/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/ResilienceAspectOrderTest.kt b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/ResilienceAspectOrderTest.kt new file mode 100644 index 0000000..1cb0cee --- /dev/null +++ b/integration/f6-adapter/src/test/kotlin/com/continental/retailapp/integration/f6/ResilienceAspectOrderTest.kt @@ -0,0 +1,106 @@ +package com.continental.retailapp.integration.f6 + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.get +import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +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.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource + +/** + * 三个韧性注解的叠加顺序是**配出来的**,不是写注解的先后顺序决定的, + * 而且方向极容易记反——所以 10-testing.md 要求实测它。 + * + * 当前配置是 bulkhead(1) < circuitbreaker(2) < retry(3),即 Retry 在最外层: + * + * ``` + * Retry( CircuitBreaker( Bulkhead( 真实调用 ))) + * ``` + * + * 可观察的差别就是本测试断言的那一条:**每一次重试都会被熔断器单独记一笔失败**。 + * 如果哪天有人把 order 改反了(Retry 在熔断器内层),一次业务调用只会给熔断器留下 1 笔失败, + * 连续故障时熔断打开会慢得多——这条测试会立刻变红。 + */ +@Tag("integration") +@SpringBootTest( + classes = [F6TestApplication::class], + properties = [ + "integration.clients.f6.connect-timeout-ms=300", + "integration.clients.f6.read-timeout-ms=500", + "integration.clients.f6.connection-request-timeout-ms=200", + "resilience4j.retry.retry-aspect-order=3", + "resilience4j.retry.instances.f6-api.max-attempts=2", + "resilience4j.retry.instances.f6-api.wait-duration=10ms", + "resilience4j.retry.instances.f6-api.retry-exceptions[0]=" + + "com.continental.retailapp.integration.f6.F6ServerException", + "resilience4j.circuitbreaker.circuit-breaker-aspect-order=2", + "resilience4j.circuitbreaker.instances.f6-api.sliding-window-type=COUNT_BASED", + "resilience4j.circuitbreaker.instances.f6-api.sliding-window-size=20", + // 门槛抬高到测试内不可能触发,否则熔断打开后调用不再落到 WireMock,计数就没意义了 + "resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100", + "resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" + + "com.continental.retailapp.integration.f6.F6ServerException", + "resilience4j.bulkhead.bulkhead-aspect-order=1", + "resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20", + "resilience4j.bulkhead.instances.f6-api.max-wait-duration=0", + ], +) +class ResilienceAspectOrderTest { + + @Autowired + private lateinit var client: F6ApiClient + + @Autowired + private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry + + @BeforeEach + fun reset() { + wireMock.resetAll() + circuitBreakerRegistry.circuitBreaker("f6-api").reset() + } + + @Test + fun `Retry 在熔断器外层,每次重试都被单独统计`() { + wireMock.stubFor( + get(urlPathEqualTo("/f6/procurement/list")) + .willReturn(aResponse().withStatus(500)), + ) + + client.fetchProcurementList(storeId = 100L) + + // max-attempts=2 → 一次业务调用打了 2 个 HTTP 请求 + wireMock.verify(2, getRequestedFor(urlPathEqualTo("/f6/procurement/list"))) + + // 关键断言:熔断器看到的是 2 笔失败而不是 1 笔。 + // Retry 若在熔断器内层,这里会是 1。 + val metrics = circuitBreakerRegistry.circuitBreaker("f6-api").metrics + assertEquals(2, metrics.numberOfFailedCalls) + } + + companion object { + private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort()) + + @JvmStatic + @DynamicPropertySource + fun registerBaseUrl(registry: DynamicPropertyRegistry) { + if (!wireMock.isRunning) wireMock.start() + registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" } + } + + @JvmStatic + @AfterAll + fun stopWireMock() { + if (wireMock.isRunning) wireMock.stop() + } + } +} diff --git a/integration/mini-clients/build.gradle b/integration/mini-clients/build.gradle new file mode 100644 index 0000000..10e8927 --- /dev/null +++ b/integration/mini-clients/build.gradle @@ -0,0 +1,4 @@ +dependencies { + api project(':platform:platform-integration') + implementation project(':platform:platform-web') +} diff --git a/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/MiniRestClientConfig.kt b/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/MiniRestClientConfig.kt new file mode 100644 index 0000000..87c9189 --- /dev/null +++ b/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/MiniRestClientConfig.kt @@ -0,0 +1,31 @@ +package com.continental.retailapp.integration.mini + +import com.continental.retailapp.platform.integration.IntegrationClientProperties +import com.continental.retailapp.platform.integration.IntegrationException +import com.continental.retailapp.platform.integration.RestClientFactory +import com.continental.retailapp.platform.web.ErrorCode +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +/** Mini 侧(O2O 等自建服务)的 [RestClient],构建方式同 F6,见 05-integration-layer.md。 */ +@Configuration +class MiniRestClientConfig { + + @Bean + fun miniRestClient( + factory: RestClientFactory, + props: IntegrationClientProperties, + ): RestClient = factory.build(props.require("mini")) { builder -> + builder.defaultStatusHandler({ it.isError }) { _, response -> + throw MiniUnavailableException("status=${response.statusCode}") + } + } +} + +class MiniUnavailableException(detail: String) : + IntegrationException(ErrorCode.MINI_UNAVAILABLE, "服务暂不可用,请稍后重试") { + init { + org.slf4j.LoggerFactory.getLogger(javaClass).warn("Mini 服务错误: {}", detail) + } +} diff --git a/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/O2OClient.kt b/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/O2OClient.kt new file mode 100644 index 0000000..2b8daa5 --- /dev/null +++ b/integration/mini-clients/src/main/kotlin/com/continental/retailapp/integration/mini/O2OClient.kt @@ -0,0 +1,43 @@ +package com.continental.retailapp.integration.mini + +import io.github.resilience4j.bulkhead.annotation.Bulkhead +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +/** 保修概要,`degraded` 的含义同 F6 侧。 */ +data class WarrantySummary( + val storeId: Long, + val openCount: Int, + val degraded: Boolean = false, +) { + companion object { + fun degraded(storeId: Long) = WarrantySummary(storeId, openCount = 0, degraded = true) + } +} + +/** + * O2O 客户端,见 05-integration-layer.md。 + * + * 这里只用舱壁不用熔断:这是自建服务,抖动通常是短暂的, + * 熔断打开反而会在恢复后多压 10 秒不可用。真正要防的是它把 Tomcat 线程全占住, + * 那正是 `@Bulkhead` 的职责(`max-wait-duration: 0` —— 拿不到许可立刻失败,不排队)。 + */ +@Component +class O2OClient(private val miniRestClient: RestClient) { + + private val log = LoggerFactory.getLogger(javaClass) + + @Bulkhead(name = "mini-o2o") + fun fetchWarrantySummary(storeId: Long): WarrantySummary = + try { + miniRestClient.get() + .uri("/o2o/warranty/summary?storeId={storeId}", storeId) + .retrieve() + .body(WarrantySummary::class.java) + ?: WarrantySummary.degraded(storeId) + } catch (ex: Exception) { + log.warn("O2O 保修概要降级 storeId={} cause={}", storeId, ex.javaClass.simpleName) + WarrantySummary.degraded(storeId) + } +} diff --git a/k8s/configmap-dev.yaml b/k8s/configmap-dev.yaml new file mode 100644 index 0000000..ed58a42 --- /dev/null +++ b/k8s/configmap-dev.yaml @@ -0,0 +1,35 @@ +# Dev 的配置,由 scripts/deploy-dev.sh apply 到内网 k3s 集群的 retailapp-dev namespace。 +# Dev 的数据库跑在同一台 Ubuntu 机器上(不是 Azure Flexible Server),所以连的是集群内的 Service。 +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-env + namespace: retailapp-dev +data: + DB_HOST: "mysql.retailapp-dev.svc.cluster.local" + DB_USERNAME: "conti" + SECURITY_JWT_ACTIVE_KEY_ID: "v1" + # Dev 环境暂时没有可用的 F6 / O2O 联调地址,先指向不存在的地址, + # 走的就是降级路径——这本身也是对 fallback 的一次真实验证(见 05-integration-layer.md) + F6_BASE_URL: "http://f6-not-available.retailapp-dev.svc.cluster.local" + MINI_BASE_URL: "http://mini-not-available.retailapp-dev.svc.cluster.local" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-config + namespace: retailapp-dev +data: + application-dev.yml: | + # Dev 上放开到 DEBUG,方便团队排查;改这一项是热更新的典型用例, + # 改完 15 秒生效,不用重新部署 + logging: + level: + com.continental.retailapp: DEBUG + + resilience4j: + circuitbreaker: + instances: + f6-api: + failure-rate-threshold: 50 + slow-call-duration-threshold: 1500ms diff --git a/k8s/configmap-prod.yaml b/k8s/configmap-prod.yaml new file mode 100644 index 0000000..b504b8c --- /dev/null +++ b/k8s/configmap-prod.yaml @@ -0,0 +1,37 @@ +# Prod 的配置。结构与 configmap-uat.yaml 完全一致,只有取值不同—— +# 环境差异全部收敛在这里,Deployment 之间只差 namespace 和 profile。 +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-env + namespace: retailapp-prod +data: + # UAT 和 Prod 是两个**独立的** Flexible Server 实例,不是同一实例上的两个 database—— + # 共用实例意味着 UAT 的一次压测能直接影响生产(见 09-build-deploy.md 阶段五) + DB_HOST: "conti-mysql-prod.mysql.database.azure.com" + DB_USERNAME: "conti_app" + SECURITY_JWT_ACTIVE_KEY_ID: "v1" + F6_BASE_URL: "https://f6.internal.example.com" + MINI_BASE_URL: "https://mini.internal.example.com" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-config + namespace: retailapp-prod +data: + application-prod.yml: | + logging: + level: + root: INFO + com.continental.retailapp: INFO + # 生产绝不打开 SQL 日志:绑定参数里会带 PII(见 08-observability.md) + org.hibernate.SQL: WARN + + # 同 UAT:改了要走发布才生效,不是热更新 + resilience4j: + circuitbreaker: + instances: + f6-api: + failure-rate-threshold: 50 + slow-call-duration-threshold: 1500ms diff --git a/k8s/configmap-uat.yaml b/k8s/configmap-uat.yaml new file mode 100644 index 0000000..6d311d6 --- /dev/null +++ b/k8s/configmap-uat.yaml @@ -0,0 +1,44 @@ +# UAT 的配置。两个 ConfigMap,用途不同,别合并: +# +# - conti-backend-env:以**环境变量**注入(Deployment 的 envFrom),application.yml 里 +# ${DB_HOST} 这类占位符从这里取值。改了必须重启 Pod 才生效。 +# - conti-backend-config:由 spring-cloud-kubernetes 直接读 K8s API 拿到(不挂卷), +# 15 秒轮询一次,改了能热更新——但只对 @RefreshScope bean 生效,见下面的注释。 +# +# 敏感值一个都不在这里,全在 Secret 里(见 secret-uat.yaml)。 +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-env + namespace: retailapp-uat +data: + # Azure Database for MySQL Flexible Server,通过 Private Endpoint 接入 AKS 所在 VNet。 + # 连接串里的 sslMode=REQUIRED 写在 application.yml 中,Flexible Server 默认要求 TLS。 + DB_HOST: "conti-mysql-uat.mysql.database.azure.com" + # 运行时账号,只有 DML 权限;迁移用的 DDL 账号是另一个(见 09-build-deploy.md) + DB_USERNAME: "conti_app" + SECURITY_JWT_ACTIVE_KEY_ID: "v1" + F6_BASE_URL: "https://f6-uat.internal.example.com" + MINI_BASE_URL: "https://mini-uat.internal.example.com" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: conti-backend-config + namespace: retailapp-uat +data: + application-uat.yml: | + # 这一段是**能**热更新的:改完 15 秒内通过 /actuator/loggers 同样的机制生效 + logging: + level: + com.continental.retailapp: INFO + + # 这一段是**不能**热更新的,放在这里只是为了让阈值和代码分离、改的时候不用重新构建镜像。 + # resilience4j 的实例在启动时创建,普通 refresh 刷不到它们——改完要走一次发布 + # (或者 strategy: restart_context,等于一次内部重启)。见 07-config-governance.md 的表格。 + resilience4j: + circuitbreaker: + instances: + f6-api: + failure-rate-threshold: 50 + slow-call-duration-threshold: 1500ms diff --git a/k8s/deployment-dev.yaml b/k8s/deployment-dev.yaml new file mode 100644 index 0000000..465fc9b --- /dev/null +++ b/k8s/deployment-dev.yaml @@ -0,0 +1,104 @@ +# Dev:跑在公司内网一台 Ubuntu 服务器的 k3s 上,不接 CI/CD,由 scripts/deploy-dev.sh 手动 apply +# (见 09-build-deploy.md「环境层级」)。 +# +# 跟 UAT/Prod 的两处有意差别: +# 1. replicas: 1 —— 单节点 k3s,起两个副本没有可用性收益,只是多占内存; +# 代价是滚动更新期间会有短暂不可用,Dev 环境可以接受,所以也不配 PDB。 +# 2. requests 调小 —— 这台机器上还跑着别的东西。 +# 其余(探针、优雅停机、securityContext)与另外两份保持一致, +# 这正是 Dev 用真 K8s 而不是 docker compose 的意义:验证的是真实部署行为。 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: conti-backend + namespace: retailapp-dev + labels: + app: conti-backend +spec: + replicas: 1 + selector: + matchLabels: + app: conti-backend + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + template: + metadata: + labels: + app: conti-backend + spec: + serviceAccountName: conti-backend + terminationGracePeriodSeconds: 45 + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: conti-backend + image: registry.example.com/conti-backend:__TAG__ + ports: + - name: http + containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: dev + envFrom: + - configMapRef: + name: conti-backend-env + # Dev 的 Secret 是写死的假值,由 scripts/deploy-dev.sh 创建,不接 Key Vault + - secretRef: + name: conti-backend-secret + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: "250m" + memory: "768Mi" + limits: + memory: "1536Mi" + startupProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + periodSeconds: 5 + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 10"] + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: conti-backend + namespace: retailapp-dev + labels: + app: conti-backend +spec: + selector: + app: conti-backend + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/k8s/deployment-prod.yaml b/k8s/deployment-prod.yaml new file mode 100644 index 0000000..f4f1af2 --- /dev/null +++ b/k8s/deployment-prod.yaml @@ -0,0 +1,97 @@ +# Prod。与 deployment-uat.yaml 逐字一致,只差 namespace 和 SPRING_PROFILES_ACTIVE +# ——两个环境跑的是同一个镜像,差异只在外部注入的配置上(见 09-build-deploy.md 附录)。 +# 改动这份时请同步改另外两份,别让三份漂移。 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: conti-backend + namespace: retailapp-prod + labels: + app: conti-backend +spec: + replicas: 2 + selector: + matchLabels: + app: conti-backend + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + template: + metadata: + labels: + app: conti-backend + spec: + serviceAccountName: conti-backend + terminationGracePeriodSeconds: 45 + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: conti-backend + image: registry.example.com/conti-backend:__TAG__ + ports: + - name: http + containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: prod + envFrom: + - configMapRef: + name: conti-backend-env + - secretRef: + name: conti-backend-secret + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + memory: "2Gi" + startupProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + periodSeconds: 5 + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 10"] + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: conti-backend + namespace: retailapp-prod + labels: + app: conti-backend +spec: + selector: + app: conti-backend + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/k8s/deployment-uat.yaml b/k8s/deployment-uat.yaml new file mode 100644 index 0000000..11a7c77 --- /dev/null +++ b/k8s/deployment-uat.yaml @@ -0,0 +1,113 @@ +# UAT 环境的部署清单。这是三个环境里的"基准版本"—— +# deployment-dev.yaml / deployment-prod.yaml 与它的差别只有 namespace、副本数和镜像 tag, +# 其余(探针、优雅停机、securityContext、资源)三份完全一致, +# 避免出现"UAT 好好的、生产漏配了一项"这类问题(见 07-config-governance.md)。 +# +# 内容来源:09-build-deploy.md「Deployment 清单」+ 08-observability.md「K8s 探针配置」 +# + 07-config-governance.md「ConfigMap / Secret 示例」,三处合并成同一份可 apply 的清单。 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: conti-backend + namespace: retailapp-uat + labels: + app: conti-backend +spec: + replicas: 2 + selector: + matchLabels: + app: conti-backend + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 # 更新期间不允许可用副本数低于 replicas,先起新的再停旧的 + maxSurge: 1 # 同时只有一个新 Pod 启动,配合 Flyway 表级锁,不会并发迁移 + template: + metadata: + labels: + app: conti-backend + spec: + # Pod 要能读所在 namespace 的 ConfigMap(spring-cloud-kubernetes 走 K8s API), + # 权限见同目录的 rbac.yaml + serviceAccountName: conti-backend + terminationGracePeriodSeconds: 45 # 必须 > preStop 等待(10) + 应用 graceful 超时(25) + securityContext: + runAsNonRoot: true # 镜像里 USER 写的是数字 10001,这条才校验得过 + seccompProfile: + type: RuntimeDefault + containers: + - name: conti-backend + # 初始占位;实际版本由流水线的 `kubectl set image` 覆盖, + # 部署的永远是 cut-release 打出来的那个不可变 tag(见 09-build-deploy.md 阶段三) + image: registry.example.com/conti-backend:__TAG__ + ports: + - name: http + containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: uat + envFrom: + # 非敏感的环境变量(DB_HOST / DB_USERNAME / F6_BASE_URL ...) + - configMapRef: + name: conti-backend-env + # 敏感值(DB_PASSWORD / SECURITY_JWT_SECRET),由流水线从 Key Vault 现取现渲染 + - secretRef: + name: conti-backend-secret + # 注:07 文档的示例里还把 conti-backend-config 挂成了 /app/config 卷。 + # 这里没有挂——应用已经通过 spring.cloud.kubernetes.config.sources 直接读同一个 + # ConfigMap,两条路都开着等于同一份配置有两个来源,出问题时说不清以谁为准; + # 而且热更新只有走 spring-cloud-kubernetes 这条路才生效。 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + memory: "2Gi" # 只限内存,不限 CPU:cfs 限流会让延迟毫无规律地抖动 + 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 + lifecycle: + preStop: + exec: + # 把 SIGTERM 推迟 10 秒,等 Endpoints 摘除在各节点生效, + # 否则滚动更新期间会有零星 502(见 09-build-deploy.md) + command: ["sh", "-c", "sleep 10"] + volumeMounts: + - name: tmp + mountPath: /tmp # readOnlyRootFilesystem 的必需配套:内嵌 Tomcat 要可写的临时目录 + volumes: + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: conti-backend + namespace: retailapp-uat + labels: + app: conti-backend +spec: + selector: + app: conti-backend + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/k8s/pdb.yaml b/k8s/pdb.yaml new file mode 100644 index 0000000..4ead7da --- /dev/null +++ b/k8s/pdb.yaml @@ -0,0 +1,28 @@ +# PodDisruptionBudget:保证节点维护、AKS 节点池升级这类**自愿中断**时至少留一个副本在跑。 +# 没有它,一次节点池升级可能把两个副本同时驱逐,造成一次没人预料到的短暂全站不可用。 +# +# 注意 PDB 只管自愿中断(drain/evict),管不了节点宕机、OOMKilled 这类非自愿中断—— +# 那是 replicas + 探针的事。 +# +# Dev 没有 PDB:单节点 k3s 上 replicas=1,minAvailable: 1 会让节点永远 drain 不掉。 +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: conti-backend-pdb + namespace: retailapp-uat +spec: + minAvailable: 1 + selector: + matchLabels: + app: conti-backend +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: conti-backend-pdb + namespace: retailapp-prod +spec: + minAvailable: 1 + selector: + matchLabels: + app: conti-backend diff --git a/k8s/rbac.yaml b/k8s/rbac.yaml new file mode 100644 index 0000000..d9e985a --- /dev/null +++ b/k8s/rbac.yaml @@ -0,0 +1,100 @@ +# spring-cloud-kubernetes 是在 Pod 里调 K8s API 去读 ConfigMap 的, +# 默认的 default ServiceAccount 没有这个权限——漏了这份清单,应用启动时读配置会 403, +# 而且 reload 的轮询会每 15 秒报一次错(见 07-config-governance.md「关键规则」)。 +# +# 权限严格收敛到"读所在 namespace 的 ConfigMap"这一件事: +# - 不给 secrets 的 get/list:Secret 是通过 envFrom 由 kubelet 注入的,应用自己不需要读; +# 给了反而等于让一次 RCE 直接拿到 JWT 签名密钥。 +# - 用 Role 而不是 ClusterRole:跨 namespace 读不到,UAT 的 Pod 看不见 Prod 的配置。 +# - reload 用的是 mode: polling 而不是 event,所以不需要 watch 权限。 +# +# 三个 namespace 各一份,内容相同。 +apiVersion: v1 +kind: ServiceAccount +metadata: + name: conti-backend + namespace: retailapp-dev +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: conti-backend-config-reader + namespace: retailapp-dev +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: conti-backend-config-reader + namespace: retailapp-dev +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: conti-backend-config-reader +subjects: + - kind: ServiceAccount + name: conti-backend + namespace: retailapp-dev +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: conti-backend + namespace: retailapp-uat +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: conti-backend-config-reader + namespace: retailapp-uat +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: conti-backend-config-reader + namespace: retailapp-uat +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: conti-backend-config-reader +subjects: + - kind: ServiceAccount + name: conti-backend + namespace: retailapp-uat +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: conti-backend + namespace: retailapp-prod +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: conti-backend-config-reader + namespace: retailapp-prod +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: conti-backend-config-reader + namespace: retailapp-prod +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: conti-backend-config-reader +subjects: + - kind: ServiceAccount + name: conti-backend + namespace: retailapp-prod diff --git a/k8s/secret-uat.yaml b/k8s/secret-uat.yaml new file mode 100644 index 0000000..76185cf --- /dev/null +++ b/k8s/secret-uat.yaml @@ -0,0 +1,27 @@ +# 这份文件**只是形状说明**,不是拿来 apply 的。 +# +# 真实值由 deploy-uat job 在流水线里从 Azure Key Vault 现取现渲染: +# +# JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 ...) +# kubectl create secret generic conti-backend-secret -n retailapp-uat \ +# --from-literal=SECURITY_JWT_SECRET="$JWT_KEY" ... | kubectl apply -f - +# +# 密钥只在那个 job 的执行过程中短暂存在,不落盘、不进镜像、不进 git。 +# 谁要是把真值填进这个文件并提交,即使之后再删掉,也已经永久留在 git 历史里了—— +# 那时候唯一正确的处理是轮换密钥,而不是改一次提交。 +apiVersion: v1 +kind: Secret +metadata: + name: conti-backend-secret + namespace: retailapp-uat +type: Opaque +stringData: + # HS256 签名密钥,base64 编码;解码后必须 ≥ 32 字节,否则 JwtProperties 在启动时就拒绝 + # (见 04-security-auth.md)。轮换时先加 SECURITY_JWT_KEYS_V2,再切 ACTIVE_KEY_ID, + # 最后一步才移除 V1——三步之间要隔开,否则已签发的 token 会集体失效。 + SECURITY_JWT_SECRET: "__injected_by_pipeline__" + # 运行时账号的密码(只有 DML 权限),迁移账号的密码是另一个 + DB_PASSWORD: "__injected_by_pipeline__" + # F6 的调用凭证。骨架里 IntegrationClientProperties.ClientConfig 还没有 apiKey 字段, + # F6ApiClient 也还没往请求头上挂它——接入 F6 真实鉴权时补上这两处,这个 key 就有人用了。 + F6_API_KEY: "__injected_by_pipeline__" diff --git a/platform/platform-integration/build.gradle b/platform/platform-integration/build.gradle new file mode 100644 index 0000000..8b7b669 --- /dev/null +++ b/platform/platform-integration/build.gradle @@ -0,0 +1,10 @@ +dependencies { + api project(':platform:platform-web') + + api 'org.springframework.boot:spring-boot-starter-web' + api 'org.apache.httpcomponents.client5:httpclient5' + // Boot 4 / Spring Framework 7 对应的 Resilience4j starter 是 -spring-boot4 + api 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-aspectj' +} diff --git a/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationClientProperties.kt b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationClientProperties.kt new file mode 100644 index 0000000..288da6e --- /dev/null +++ b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationClientProperties.kt @@ -0,0 +1,34 @@ +package com.continental.retailapp.platform.integration + +import jakarta.validation.Valid +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +/** + * 外部依赖的连接参数,见 05-integration-layer.md。 + * 全部走 `@ConfigurationProperties` + `@Validated`,不用 `@Value`——配置错了要在启动时就炸, + * 而不是等第一次调用外部系统时才发现(见 07-config-governance.md)。 + */ +@ConfigurationProperties(prefix = "integration") +@Validated +data class IntegrationClientProperties( + val clients: Map = emptyMap(), +) { + data class ClientConfig( + @field:NotBlank val baseUrl: String, + @field:Min(100) val connectTimeoutMs: Long = 1000, + @field:Min(100) val readTimeoutMs: Long = 2000, + /** + * 最容易漏配的一项:从连接池拿连接的等待超时。 + * 不配的话池满时线程会一直排队,connect/read 超时全都管不到它。 + */ + @field:Min(50) val connectionRequestTimeoutMs: Long = 500, + @field:Min(1) val maxConnections: Int = 60, + @field:Min(1) val maxConnectionsPerRoute: Int = 30, + ) + + fun require(name: String): ClientConfig = + clients[name] ?: error("缺少 integration.clients.$name 配置") +} diff --git a/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationException.kt b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationException.kt new file mode 100644 index 0000000..e9f2ce6 --- /dev/null +++ b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/IntegrationException.kt @@ -0,0 +1,17 @@ +package com.continental.retailapp.platform.integration + +import com.continental.retailapp.platform.web.BusinessException +import org.springframework.http.HttpStatus + +/** + * 外部依赖失败的统一父类,见 05-integration-layer.md。 + * 默认 502:调用方(APP)需要知道"不是你的请求有问题,是我依赖的系统挂了"。 + * + * 注意 message 是**给用户看的文案**,绝不能把下游的原始报错透出去 + * (可能含内网地址、SQL、堆栈)——原始信息只进日志。 + */ +open class IntegrationException( + code: Int, + message: String, + httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY, +) : BusinessException(code, message, httpStatus) diff --git a/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/RestClientFactory.kt b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/RestClientFactory.kt new file mode 100644 index 0000000..b6b11ca --- /dev/null +++ b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/RestClientFactory.kt @@ -0,0 +1,68 @@ +package com.continental.retailapp.platform.integration + +import org.apache.hc.client5.http.config.ConnectionConfig +import org.apache.hc.client5.http.config.RequestConfig +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder +import org.apache.hc.core5.util.TimeValue +import org.apache.hc.core5.util.Timeout +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory +import org.springframework.web.client.RestClient + +/** + * 统一构建 [RestClient]:超时、连接池、trace 透传都收口在这里,见 05-integration-layer.md。 + * + * 对文档的有意偏离 #3:05 文档把 `f6RestClient` 的 `@Bean` 放在 platform-integration, + * 但它的 statusHandler 抛的是 f6-adapter 里的 `F6ServerException`——平台层反过来依赖适配层, + * 方向是反的。这里把它拆成"平台提供工厂、各适配器自己声明 bean 并给自己的 statusHandler"。 + * + * 用同步 `RestClient` + Apache HttpClient 5 而不是 WebClient:整条链路是同步阻塞的 Servlet 栈, + * 引入 Reactor 只会带来 `.block()` 和难排查的线程模型。 + */ +@Configuration +@EnableConfigurationProperties(IntegrationClientProperties::class) +class RestClientFactory( + private val tracePropagationInterceptor: TracePropagationInterceptor, +) { + + fun build( + config: IntegrationClientProperties.ClientConfig, + customizer: (RestClient.Builder) -> Unit = {}, + ): RestClient = RestClient.builder() + .baseUrl(config.baseUrl) + .requestFactory(requestFactory(config)) + .requestInterceptor(tracePropagationInterceptor) + .apply(customizer) + .build() + + private fun requestFactory( + config: IntegrationClientProperties.ClientConfig, + ): HttpComponentsClientHttpRequestFactory { + 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) + } +} diff --git a/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/TracePropagationInterceptor.kt b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/TracePropagationInterceptor.kt new file mode 100644 index 0000000..71e3cec --- /dev/null +++ b/platform/platform-integration/src/main/kotlin/com/continental/retailapp/platform/integration/TracePropagationInterceptor.kt @@ -0,0 +1,25 @@ +package com.continental.retailapp.platform.integration + +import org.slf4j.MDC +import org.springframework.http.HttpRequest +import org.springframework.http.client.ClientHttpRequestExecution +import org.springframework.http.client.ClientHttpRequestInterceptor +import org.springframework.http.client.ClientHttpResponse +import org.springframework.stereotype.Component + +/** + * 把当前 traceId 透传给下游,见 05-integration-layer.md / 08-observability.md。 + * 下游出问题时能用同一个 ID 把两边日志串起来。 + */ +@Component +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) + } +} diff --git a/platform/platform-observability/build.gradle b/platform/platform-observability/build.gradle new file mode 100644 index 0000000..f756d4b --- /dev/null +++ b/platform/platform-observability/build.gradle @@ -0,0 +1,12 @@ +dependencies { + api project(':platform:platform-web') + + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'io.micrometer:micrometer-core' + implementation 'org.springframework.boot:spring-boot-starter-aspectj' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'net.logstash.logback:logstash-logback-encoder:9.0' + implementation 'io.github.resilience4j:resilience4j-micrometer:2.4.0' +} diff --git a/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/ClientTraceIdBridgeFilter.kt b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/ClientTraceIdBridgeFilter.kt new file mode 100644 index 0000000..5016f32 --- /dev/null +++ b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/ClientTraceIdBridgeFilter.kt @@ -0,0 +1,76 @@ +package com.continental.retailapp.platform.observability + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpServletRequestWrapper +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.security.SecureRandom +import java.util.Enumeration +import java.util.Collections + +/** + * 把客户端传来的 `X-Trace-Id` 桥接成 W3C `traceparent`,让 Micrometer Tracing 直接续上, + * 见 08-observability.md。必须跑在所有 filter 最前面,否则 tracing 已经自己生成了 traceId。 + * + * 安全边界:`X-Trace-Id` 是**外部输入**,会进日志。不校验就等于给了日志注入的口子, + * 所以只接受 32 位小写十六进制、且拒绝全零(W3C 规定的无效值)。 + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +class ClientTraceIdBridgeFilter : OncePerRequestFilter() { + + companion object { + private val TRACE_ID = Regex("^[0-9a-f]{32}$") + private const val INVALID = "00000000000000000000000000000000" + private val RANDOM = SecureRandom() + } + + 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) + return + } + val traceparent = "00-$clientTraceId-${randomSpanId()}-01" + chain.doFilter(TraceparentRequestWrapper(request, traceparent), response) + } + + private fun randomSpanId(): String { + val bytes = ByteArray(8) + RANDOM.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } +} + +/** 只覆盖 `traceparent` 这一个请求头,其余原样透传。 */ +class TraceparentRequestWrapper( + request: HttpServletRequest, + private val traceparent: String, +) : HttpServletRequestWrapper(request) { + + override fun getHeader(name: String): String? = + if (name.equals("traceparent", ignoreCase = true)) traceparent else super.getHeader(name) + + override fun getHeaders(name: String): Enumeration = + if (name.equals("traceparent", ignoreCase = true)) { + Collections.enumeration(listOf(traceparent)) + } else { + super.getHeaders(name) + } + + override fun getHeaderNames(): Enumeration { + val names = super.getHeaderNames().toList().toMutableList() + if (names.none { it.equals("traceparent", ignoreCase = true) }) { + names.add("traceparent") + } + return Collections.enumeration(names) + } +} diff --git a/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/MetricsConfig.kt b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/MetricsConfig.kt new file mode 100644 index 0000000..5945c15 --- /dev/null +++ b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/MetricsConfig.kt @@ -0,0 +1,30 @@ +package com.continental.retailapp.platform.observability + +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.config.MeterFilter +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 指标公共标签,见 08-observability.md。 + * + * `maximumAllowableTags` 是防基数爆炸的闸:URI 里如果混进了 storeId/userId 这类高基数值, + * Prometheus 的时间序列会失控。这里给 http 请求指标压一个上限,超了直接丢,不拖垮监控。 + * + * 注:Boot 4 把 `MeterRegistryCustomizer` 从 `org.springframework.boot.actuate.autoconfigure.metrics` + * 挪到了 `org.springframework.boot.micrometer.metrics.autoconfigure`。 + */ +@Configuration +class MetricsConfig { + + @Bean + fun commonTags( + @Value("\${spring.application.name:conti-backend}") application: String, + ): MeterRegistryCustomizer = MeterRegistryCustomizer { registry -> + registry.config() + .commonTags("application", application) + .meterFilter(MeterFilter.maximumAllowableTags("http.server.requests", "uri", 200, MeterFilter.deny())) + } +} diff --git a/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/TraceResponseFilter.kt b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/TraceResponseFilter.kt new file mode 100644 index 0000000..359035c --- /dev/null +++ b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/TraceResponseFilter.kt @@ -0,0 +1,25 @@ +package com.continental.retailapp.platform.observability + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +/** + * 把服务端最终使用的 traceId 回写到响应头,见 08-observability.md。 + * 客户端拿到它就能在报障时直接给出一个可检索的 ID。 + */ +@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) + } +} diff --git a/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/audit/AuditLogAspect.kt b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/audit/AuditLogAspect.kt new file mode 100644 index 0000000..b456d1d --- /dev/null +++ b/platform/platform-observability/src/main/kotlin/com/continental/retailapp/platform/observability/audit/AuditLogAspect.kt @@ -0,0 +1,46 @@ +package com.continental.retailapp.platform.observability.audit + +import com.continental.retailapp.platform.web.AuditContext +import com.continental.retailapp.platform.web.currentTraceId +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.stereotype.Component + +/** + * 审计日志,见 08-observability.md。加在敏感操作(登录、切店、改配置)的 application 方法上。 + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Audited(val action: String) + +/** + * 只记"谁在哪个门店做了什么、成没成功",**不记参数、不记返回值**—— + * 这两处最容易把密码、令牌、手机号带进日志(见 08-observability.md 的脱敏红线)。 + * + * 注入 [AuditContext] 而不是 platform-security 的 StoreContextHolder:对文档的有意偏离 #1。 + */ +@Aspect +@Component +class AuditLogAspect(private val auditContext: ObjectProvider) { + + private val auditLog = LoggerFactory.getLogger("AUDIT") + + @Around("@annotation(audited)") + fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? { + val result = runCatching { joinPoint.proceed() } + // 定时任务等非 HTTP 场景取不到 @RequestScope bean,兜住,别让审计把业务打挂 + val ctx = runCatching { auditContext.getObject() }.getOrNull() + auditLog.info( + "action={} userId={} storeId={} traceId={} success={}", + audited.action, + ctx?.userId, + ctx?.storeId, + currentTraceId(), + result.isSuccess, + ) + return result.getOrThrow() + } +} diff --git a/platform/platform-observability/src/main/resources/logback-spring.xml b/platform/platform-observability/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..6bdeac6 --- /dev/null +++ b/platform/platform-observability/src/main/resources/logback-spring.xml @@ -0,0 +1,37 @@ + + + + + + + + + traceId + spanId + {"app":"${appName}","env":"${SPRING_PROFILES_ACTIVE:-local}"} + + + password + accessToken + refreshToken + authorization + + + + + + + + + + + + + + diff --git a/platform/platform-persistence/build.gradle b/platform/platform-persistence/build.gradle new file mode 100644 index 0000000..6ee8728 --- /dev/null +++ b/platform/platform-persistence/build.gradle @@ -0,0 +1,13 @@ +dependencies { + // 只依赖 platform-web 里的 AuditContext(见根 README 的"对文档的偏离 #1") + api project(':platform:platform-web') + + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'net.javacrumbs.shedlock:shedlock-spring:6.9.2' + implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2' + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.flywaydb:flyway-core' + runtimeOnly 'org.flywaydb:flyway-mysql' + runtimeOnly 'com.mysql:mysql-connector-j' +} diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/BaseEntity.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/BaseEntity.kt new file mode 100644 index 0000000..2576ad8 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/BaseEntity.kt @@ -0,0 +1,44 @@ +package com.continental.retailapp.platform.persistence + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.Version +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedBy +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Instant + +/** + * 审计字段基类,见 03-persistence.md。 + * 时间统一 UTC 的 [Instant],落库 `datetime(6)`——不要用 LocalDateTime,它不带时区信息。 + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant = Instant.EPOCH + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.EPOCH + + @CreatedBy + @Column(name = "created_by", updatable = false, length = 64) + var createdBy: String? = null + + @LastModifiedBy + @Column(name = "updated_by", length = 64) + var updatedBy: String? = null +} + +/** 需要乐观锁的表继承它,见 12-concurrency-and-scheduling.md。 */ +@MappedSuperclass +abstract class VersionedEntity : BaseEntity() { + @Version + @Column(name = "version", nullable = false) + var version: Long = 0 +} diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/PageResult.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/PageResult.kt new file mode 100644 index 0000000..6a81779 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/PageResult.kt @@ -0,0 +1,13 @@ +package com.continental.retailapp.platform.persistence + +/** + * 统一分页响应,见 03-persistence.md / 06-api-design.md。 + * `pageNum` 从 1 开始,与客户端约定一致。 + */ +data class PageResult( + val list: List, + val pageNum: Int, + val pageSize: Int, + val total: Long, + val hasMore: Boolean, +) diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/cache/CacheConfig.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/cache/CacheConfig.kt new file mode 100644 index 0000000..ee251b8 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/cache/CacheConfig.kt @@ -0,0 +1,29 @@ +package com.continental.retailapp.platform.persistence.cache + +import com.github.benmanes.caffeine.cache.Caffeine +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Duration + +/** + * 本地缓存,见 12-concurrency-and-scheduling.md。只用 Caffeine,暂不引 Redis。 + * + * 代价要如实认识:多副本下各自一份缓存,失效时机不一致。所以 + * **用户→门店的访问权限、WebView 票据状态一律不缓存**(必须能立即撤销)。 + */ +@Configuration +@EnableCaching +class CacheConfig { + + @Bean + fun cacheManager(): CacheManager = CaffeineCacheManager().apply { + setCaffeine( + Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterWrite(Duration.ofMinutes(10)), + ) + } +} diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/ClockConfig.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/ClockConfig.kt new file mode 100644 index 0000000..dcbfcda --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/ClockConfig.kt @@ -0,0 +1,16 @@ +package com.continental.retailapp.platform.persistence.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +/** + * 全局 [Clock],见 10-testing.md。 + * 所有涉及时间的业务代码一律构造注入它,不要写 `Instant.now()`—— + * 否则"过期判断"这类逻辑只能靠 `Thread.sleep` 测,那是慢测试和随机失败的主要来源。 + */ +@Configuration +class ClockConfig { + @Bean + fun clock(): Clock = Clock.systemUTC() +} diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/DomainFlywayConfig.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/DomainFlywayConfig.kt new file mode 100644 index 0000000..b9c575f --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/DomainFlywayConfig.kt @@ -0,0 +1,50 @@ +package com.continental.retailapp.platform.persistence.config + +import org.flywaydb.core.Flyway +import org.springframework.boot.jpa.autoconfigure.EntityManagerFactoryDependsOnPostProcessor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.sql.DataSource + +/** + * 一个 domain 一个 database(MySQL 里 schema ≡ database),所以要跑多个 Flyway 实例, + * 见 03-persistence.md。Boot 自带的单实例 Flyway 用 `spring.flyway.enabled=false` 关掉。 + * + * `EntityManagerFactoryDependsOnPostProcessor` 保证迁移在 Hibernate 做 `ddl-auto: validate` 之前跑完, + * 否则新表还没建,校验直接失败。 + * + * 注:Boot 4 把这个类从 `org.springframework.boot.autoconfigure.orm.jpa` 挪到了 + * `org.springframework.boot.jpa.autoconfigure`(autoconfigure 模块拆分的结果)。 + * + * `platform` 库不在 03 文档的清单里,是按 12-concurrency-and-scheduling.md 补上的 + * (`idempotency_record` 和 `shedlock` 两张公共表)。 + */ +@Configuration +class DomainFlywayConfig { + + companion object { + val DOMAIN_SCHEMAS = listOf("platform", "identity_store", "workbench", "webview_ticket") + } + + @Bean + fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations { + DOMAIN_SCHEMAS.forEach { schema -> + Flyway.configure() + .dataSource(dataSource) + .schemas(schema) + .defaultSchema(schema) + .table("flyway_schema_history") + .locations("classpath:db/migration/$schema") + .load() + .migrate() + } + return DomainFlywayMigrations + } + + @Bean + fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor = + object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {} +} + +/** 只是一个可被依赖的标记 bean,让 EntityManagerFactory 有东西可以 depends-on。 */ +object DomainFlywayMigrations diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/JpaAuditingConfig.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/JpaAuditingConfig.kt new file mode 100644 index 0000000..c599352 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/config/JpaAuditingConfig.kt @@ -0,0 +1,30 @@ +package com.continental.retailapp.platform.persistence.config + +import com.continental.retailapp.platform.web.AuditContext +import org.springframework.beans.factory.ObjectProvider +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.domain.AuditorAware +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import java.util.Optional + +/** + * JPA 审计,见 03-persistence.md。 + * + * 注入的是 platform-web 的 [AuditContext] 接口而不是 platform-security 的 StoreContextHolder + * (对文档的有意偏离 #1,理由见 AuditContext 的注释)。 + * + * `runCatching` 是必要的:定时任务、启动期的种子数据这些场景没有 HTTP 请求, + * `@RequestScope` 的 bean 取不到,不兜住会直接把写操作打挂。 + */ +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +class JpaAuditingConfig(private val auditContext: ObjectProvider) { + + @Bean + fun auditorAware(): AuditorAware = AuditorAware { + runCatching { auditContext.getObject().userId?.toString() } + .getOrNull() + .let { Optional.ofNullable(it) } + } +} diff --git a/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/scheduling/SchedulingConfig.kt b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/scheduling/SchedulingConfig.kt new file mode 100644 index 0000000..5990199 --- /dev/null +++ b/platform/platform-persistence/src/main/kotlin/com/continental/retailapp/platform/persistence/scheduling/SchedulingConfig.kt @@ -0,0 +1,31 @@ +package com.continental.retailapp.platform.persistence.scheduling + +import net.javacrumbs.shedlock.core.LockProvider +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.scheduling.annotation.EnableScheduling +import javax.sql.DataSource + +/** + * 定时任务 + 分布式锁,见 12-concurrency-and-scheduling.md。 + * + * 多副本下 `@Scheduled` 会在每个 Pod 上各跑一次,ShedLock 保证同一时刻只有一个副本真正执行。 + * `usingDbTime()` 是关键:用数据库时间而不是各 Pod 的本地时间判断锁,避免时钟漂移导致锁形同虚设。 + */ +@Configuration +@EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") +class SchedulingConfig { + + @Bean + fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider( + JdbcTemplateLockProvider.Configuration.builder() + .withJdbcTemplate(JdbcTemplate(dataSource)) + .withTableName("platform.shedlock") + .usingDbTime() + .build(), + ) +} diff --git a/platform/platform-persistence/src/main/resources/db/migration/platform/V1__init.sql b/platform/platform-persistence/src/main/resources/db/migration/platform/V1__init.sql new file mode 100644 index 0000000..1a7dd04 --- /dev/null +++ b/platform/platform-persistence/src/main/resources/db/migration/platform/V1__init.sql @@ -0,0 +1,27 @@ +-- platform 公共库:幂等记录与 ShedLock 锁表(见 12-concurrency-and-scheduling.md) +-- 这两张表不属于任何业务域,所以单独放一个 database。 + +create table idempotency_record +( + id bigint not null auto_increment, + idem_key varchar(64) not null comment '客户端传入的幂等键', + user_id bigint not null, + response text not null comment '首次执行的响应快照,重放时原样返回', + created_at datetime(6) not null, + primary key (id), + unique key uk_idem_key_user (idem_key, user_id), + key idx_idempotency_record_created_at (created_at) +) engine = InnoDB + default charset = utf8mb4 + collate = utf8mb4_0900_ai_ci comment '幂等记录'; + +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 comment '定时任务分布式锁'; diff --git a/platform/platform-security/build.gradle b/platform/platform-security/build.gradle new file mode 100644 index 0000000..dc1c7fe --- /dev/null +++ b/platform/platform-security/build.gradle @@ -0,0 +1,9 @@ +dependencies { + // 01-project-structure.md 认可的唯一 platform-* 之间的依赖:platform-security -> platform-web + api project(':platform:platform-web') + + api 'org.springframework.boot:spring-boot-starter-security' + api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuer.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuer.kt new file mode 100644 index 0000000..f5b1a68 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuer.kt @@ -0,0 +1,36 @@ +package com.continental.retailapp.platform.security + +import org.springframework.security.oauth2.jose.jws.MacAlgorithm +import org.springframework.security.oauth2.jwt.JwsHeader +import org.springframework.security.oauth2.jwt.JwtClaimsSet +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtEncoderParameters +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.temporal.ChronoUnit +import java.util.UUID + +/** + * 签发 access token,见 04-security-auth.md。 + * 门店维度写进 `storeId` claim——**请求头 X-Store-Id 不构成身份,只有签名过的 claim 算数**。 + */ +@Component +class AccessTokenIssuer( + private val jwtEncoder: JwtEncoder, + private val props: JwtProperties, + private val clock: Clock, +) { + fun issue(userId: Long, storeId: Long, roles: List): String { + val now = clock.instant() + val claims = JwtClaimsSet.builder() + .subject(userId.toString()) + .claim("storeId", storeId) + .claim("roles", roles) + .id(UUID.randomUUID().toString()) + .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 + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/ApiResultSecurityHandlers.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/ApiResultSecurityHandlers.kt new file mode 100644 index 0000000..ded2d11 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/ApiResultSecurityHandlers.kt @@ -0,0 +1,53 @@ +package com.continental.retailapp.platform.security + +import com.continental.retailapp.platform.web.ApiResult +import com.continental.retailapp.platform.web.ErrorCode +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import tools.jackson.databind.ObjectMapper + +/** + * 认证失败(401)也要返回统一的 [ApiResult] 结构,见 04-security-auth.md。 + * 不接管的话 Spring Security 会直接吐一个空 body 的 401,客户端的统一解析会崩。 + */ +@Component +class ApiResultAuthenticationEntryPoint(private val objectMapper: ObjectMapper) : AuthenticationEntryPoint { + override fun commence( + req: HttpServletRequest, + resp: HttpServletResponse, + ex: AuthenticationException, + ) { + resp.status = HttpStatus.UNAUTHORIZED.value() + resp.contentType = MediaType.APPLICATION_JSON_VALUE + resp.characterEncoding = Charsets.UTF_8.name() + objectMapper.writeValue( + resp.outputStream, + ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"), + ) + } +} + +/** 鉴权失败(403)同上。 */ +@Component +class ApiResultAccessDeniedHandler(private val objectMapper: ObjectMapper) : AccessDeniedHandler { + override fun handle( + req: HttpServletRequest, + resp: HttpServletResponse, + ex: AccessDeniedException, + ) { + resp.status = HttpStatus.FORBIDDEN.value() + resp.contentType = MediaType.APPLICATION_JSON_VALUE + resp.characterEncoding = Charsets.UTF_8.name() + objectMapper.writeValue( + resp.outputStream, + ApiResult.error(ErrorCode.FORBIDDEN, "没有权限执行该操作"), + ) + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtEncoderConfig.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtEncoderConfig.kt new file mode 100644 index 0000000..d09f941 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtEncoderConfig.kt @@ -0,0 +1,60 @@ +package com.continental.retailapp.platform.security + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.OctetSequenceKey +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.proc.JWSVerificationKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.DefaultJWTProcessor +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * JWT 编解码器,见 04-security-auth.md。用 Spring Security 自带的 Nimbus 实现而不是 jjwt—— + * jjwt 的 Jackson 集成还在 Jackson 2,与 Boot 4 的 Jackson 3 会打架。 + * + * 对文档的有意偏离 #2:文档正文要求"轮换期间新旧 token 都能验过",但示例里的 + * `NimbusJwtDecoder.withSecretKey(secretKey(activeKeyId))` 只装了一把密钥,做不到这件事。 + * 这里改成把 `keys` 里所有密钥装进 JWKSet,由 `JWSVerificationKeySelector` 按 header 的 kid 选, + * 这样才对得上文档写的轮换三步走(先加新密钥 → 再切 activeKeyId → 最后删旧密钥)。 + */ +@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") + + private fun jwkSource(): ImmutableJWKSet { + val jwkSet = JWKSet( + props.keys.keys.map { kid -> + OctetSequenceKey.Builder(secretKey(kid)) + .keyID(kid) + .algorithm(JWSAlgorithm.HS256) + .build() + }, + ) + return ImmutableJWKSet(jwkSet) + } + + @Bean + fun jwtEncoder(): JwtEncoder = NimbusJwtEncoder(jwkSource()) + + @Bean + fun jwtDecoder(): JwtDecoder { + val processor = DefaultJWTProcessor().apply { + jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.HS256, jwkSource()) + // 只签发/校验自家 token,不需要 Nimbus 默认的 claims 校验器之外的东西 + } + return NimbusJwtDecoder(processor) + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtProperties.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtProperties.kt new file mode 100644 index 0000000..f2ae48b --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/JwtProperties.kt @@ -0,0 +1,32 @@ +package com.continental.retailapp.platform.security + +import jakarta.annotation.PostConstruct +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated +import java.util.Base64 + +/** + * JWT 配置,见 04-security-auth.md。 + * + * `keys` 是 kid -> base64(密钥) 的映射,支持多把密钥同时在用(轮换期新旧 token 都要能验过)。 + * 真实密钥来自 K8s Secret(见 07-config-governance.md),仓库和镜像里只有 local profile 的假密钥。 + */ +@ConfigurationProperties(prefix = "security.jwt") +@Validated +data class JwtProperties( + @field:NotBlank val activeKeyId: String, + val keys: Map = emptyMap(), + @field:Min(5) val accessTokenTtlMinutes: Long = 30, + @field:Min(1) val refreshTokenTtlDays: Long = 30, +) { + /** 启动即失败:弱密钥或缺 activeKeyId 不允许把服务拉起来。 */ + @PostConstruct + fun validate() { + val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥") + require(Base64.getDecoder().decode(key).size >= 32) { + "HS256 密钥长度必须 ≥ 32 字节,当前配置不满足" + } + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/SecurityConfig.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/SecurityConfig.kt new file mode 100644 index 0000000..b6a9141 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/SecurityConfig.kt @@ -0,0 +1,79 @@ +package com.continental.retailapp.platform.security + +import org.springframework.beans.factory.ObjectFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.env.Environment +import org.springframework.core.env.Profiles +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter +import org.springframework.security.web.SecurityFilterChain + +/** + * 安全配置,见 04-security-auth.md。 + * + * 无状态资源服务器:没有 session、没有 CSRF token,认证完全靠 Bearer JWT。 + * swagger 只在非 prod 放开——生产环境暴露接口文档等于把攻击面清单送出去。 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +class SecurityConfig( + private val storeContextHolder: ObjectFactory, + 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() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { auth -> + auth.requestMatchers("/actuator/health/**").permitAll() + auth.requestMatchers("/api/v1/auth/login", "/api/v1/auth/refresh").permitAll() + if (!isProd) { + auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + } + auth.anyRequest().authenticated() + } + .oauth2ResourceServer { oauth2 -> + oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) } + oauth2.authenticationEntryPoint(entryPoint) + } + .exceptionHandling { + it.authenticationEntryPoint(entryPoint) + it.accessDeniedHandler(accessDeniedHandler) + } + .addFilterAfter( + StoreContextFilter(storeContextHolder), + BearerTokenAuthenticationFilter::class.java, + ) + + return http.build() + } + + /** BCrypt strength 12,见 04-security-auth.md;用 Delegating 是为了将来换算法时老哈希还能验。 */ + @Bean + fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + + private fun rolesConverter(): JwtAuthenticationConverter { + val authorities = JwtGrantedAuthoritiesConverter().apply { + setAuthoritiesClaimName("roles") + setAuthorityPrefix("ROLE_") + } + return JwtAuthenticationConverter().apply { + setJwtGrantedAuthoritiesConverter(authorities) + } + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextFilter.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextFilter.kt new file mode 100644 index 0000000..1203172 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextFilter.kt @@ -0,0 +1,39 @@ +package com.continental.retailapp.platform.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.ObjectFactory +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import org.springframework.web.filter.OncePerRequestFilter + +/** + * 把 JWT 里的身份/门店信息搬进 [StoreContextHolder],见 04-security-auth.md。 + * 注册位置在 `BearerTokenAuthenticationFilter` 之后(见 SecurityConfig),保证此时认证已经完成。 + */ +class StoreContextFilter( + private val storeContextHolder: ObjectFactory, +) : 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?.toLongOrNull() + ctx.storeId = jwt.getClaim("storeId")?.toLong() + ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList() + // X-Store-Id 只用于对账排查,**永远以 token 里的 storeId 为准** + 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) + } +} diff --git a/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextHolder.kt b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextHolder.kt new file mode 100644 index 0000000..55abce7 --- /dev/null +++ b/platform/platform-security/src/main/kotlin/com/continental/retailapp/platform/security/StoreContextHolder.kt @@ -0,0 +1,26 @@ +package com.continental.retailapp.platform.security + +import com.continental.retailapp.platform.web.AuditContext +import org.springframework.stereotype.Component +import org.springframework.web.context.annotation.RequestScope + +/** + * 当前请求的门店上下文,见 04-security-auth.md。 + * + * 用 `@RequestScope` bean 而不是 ThreadLocal:容器负责在请求结束时清理, + * 不会出现忘记 remove 导致的线程池串味。并行聚合的子线程靠 + * `ContextPropagatingTaskDecorator` 传播(见 11-cross-domain-collaboration.md)。 + * + * 实现 [AuditContext] 是为了让 platform-persistence / platform-observability 不用反向依赖本模块。 + */ +@Component +@RequestScope +class StoreContextHolder : AuditContext { + override var userId: Long? = null + override var storeId: Long? = null + var roles: List = emptyList() + + fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里") + + fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里") +} diff --git a/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuerTest.kt b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuerTest.kt new file mode 100644 index 0000000..d6771f9 --- /dev/null +++ b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/AccessTokenIssuerTest.kt @@ -0,0 +1,127 @@ +package com.continental.retailapp.platform.security + +import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW +import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW_V2 +import com.continental.retailapp.platform.security.fixtures.aJwtProperties +import com.continental.retailapp.platform.security.fixtures.base64Of +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.jwt.JwtException +import java.time.Clock +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit + +/** + * 签发 → 解码 round-trip:claims 和 kid 都要对得上(04-security-auth.md)。 + * + * 直接 new 出 [JwtEncoderConfig],不起 Spring 容器——这一层没有任何需要容器才能装配的东西。 + */ +class AccessTokenIssuerTest { + + // 签发端的时钟可以固定,**解码端的不行**:`exp` 校验发生在 Nimbus 的 + // DefaultJWTProcessor 里,用的是真实系统时间,注不进去。 + // 所以基准点取真实当下(截断到秒,因为 JWT 的 iat/exp 只有秒精度), + // 再由各测试用例围绕它做加减——单个用例内部仍然是确定的。 + private val now = Clock.systemUTC().instant().truncatedTo(ChronoUnit.SECONDS) + private val clock = Clock.fixed(now, ZoneOffset.UTC) + + private fun issuerFor(props: JwtProperties): Pair { + val config = JwtEncoderConfig(props) + return AccessTokenIssuer(config.jwtEncoder(), props, clock) to config + } + + @Test + fun `签发的 token 能被解回原样的 claims`() { + val props = aJwtProperties() + val (issuer, config) = issuerFor(props) + + val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STORE_MANAGER", "STAFF")) + val decoded = config.jwtDecoder().decode(token) + + assertEquals("42", decoded.subject) + assertEquals(100L, decoded.getClaim("storeId")) + assertEquals(listOf("STORE_MANAGER", "STAFF"), decoded.getClaimAsStringList("roles")) + assertEquals(now, decoded.issuedAt) + assertEquals(now.plusSeconds(30 * 60), decoded.expiresAt) + assertNotNull(decoded.id) // jti,审计日志里用来串联同一次会话 + } + + @Test + fun `token 头里带 activeKeyId 作为 kid`() { + val props = aJwtProperties( + activeKeyId = "v2", + keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)), + ) + val (issuer, config) = issuerFor(props) + + val decoded = config.jwtDecoder().decode(issuer.issue(1L, 100L, listOf("STAFF"))) + + assertEquals("v2", decoded.headers["kid"]) + assertEquals("HS256", decoded.headers["alg"].toString()) + } + + @Test + fun `轮换期间旧 kid 签发的 token 仍然能验过`() { + // 轮换第一步:只加密钥,activeKeyId 还指向 v1,此时签出来的是 v1 的 token + val beforeSwitch = aJwtProperties( + activeKeyId = "v1", + keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)), + ) + val (oldIssuer, _) = issuerFor(beforeSwitch) + val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF")) + + // 轮换第二步:activeKeyId 切到 v2,两把密钥都还在 + val afterSwitch = aJwtProperties( + activeKeyId = "v2", + keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)), + ) + val decoder = JwtEncoderConfig(afterSwitch).jwtDecoder() + + // 这条就是对文档偏离 #2 的理由:单密钥的 withSecretKey 解码器在这里会直接失败, + // 已经发出去的 access token 会全体作废 + assertEquals("1", decoder.decode(oldToken).subject) + } + + @Test + fun `轮换第三步移除旧密钥后,旧 token 不再被接受`() { + val beforeSwitch = aJwtProperties( + activeKeyId = "v1", + keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)), + ) + val (oldIssuer, _) = issuerFor(beforeSwitch) + val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF")) + + val onlyV2 = aJwtProperties(activeKeyId = "v2", keys = mapOf("v2" to base64Of(TEST_JWT_SECRET_RAW_V2))) + val decoder = JwtEncoderConfig(onlyV2).jwtDecoder() + + assertThrows(JwtException::class.java) { decoder.decode(oldToken) } + } + + @Test + fun `被篡改的 token 验签失败`() { + val props = aJwtProperties() + val (issuer, config) = issuerFor(props) + val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STAFF")) + + // 只动 payload 不动签名:storeId 造假是这套鉴权最直接的攻击面 + val parts = token.split(".") + val tampered = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}" + + assertThrows(JwtException::class.java) { config.jwtDecoder().decode(tampered) } + } + + @Test + fun `过期的 token 解码失败`() { + val props = aJwtProperties(accessTokenTtlMinutes = 5) + val expiredIssuer = AccessTokenIssuer( + JwtEncoderConfig(props).jwtEncoder(), + props, + Clock.fixed(now.minusSeconds(3600), ZoneOffset.UTC), + ) + val token = expiredIssuer.issue(1L, 100L, listOf("STAFF")) + + assertThrows(JwtException::class.java) { JwtEncoderConfig(props).jwtDecoder().decode(token) } + } +} diff --git a/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/JwtPropertiesTest.kt b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/JwtPropertiesTest.kt new file mode 100644 index 0000000..0ed956a --- /dev/null +++ b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/JwtPropertiesTest.kt @@ -0,0 +1,88 @@ +package com.continental.retailapp.platform.security + +import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW +import com.continental.retailapp.platform.security.fixtures.base64Of +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtEncoder + +/** + * 密钥强度是**启动期**的事,不是运行期的事:配错了就不该让服务活着接流量。 + * 所以这里用 [ApplicationContextRunner] 起一个真的上下文,验证 `@PostConstruct` 确实把它拦下来了, + * 而不是直接调 `validate()` ——后者只能证明方法本身好使,证明不了它会被触发。 + */ +class JwtPropertiesTest { + + private val runner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration::class.java)) + .withUserConfiguration(JwtEncoderConfig::class.java) + + @Test + fun `密钥合规时正常启动并装配好编解码器`() { + runner + .withPropertyValues( + "security.jwt.active-key-id=v1", + "security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}", + ) + .run { context -> + assertNull(context.startupFailure) { "期望启动成功,实际:${context.startupFailure}" } + assertNotNull(context.getBean(JwtEncoder::class.java)) + assertNotNull(context.getBean(JwtDecoder::class.java)) + } + } + + @Test + fun `密钥不足 32 字节时启动失败`() { + runner + .withPropertyValues( + "security.jwt.active-key-id=v1", + "security.jwt.keys.v1=${base64Of("too-short-16byte")}", + ) + .run { context -> + val failure = context.startupFailure + assertNotNull(failure) { "弱密钥必须让启动失败,不能带病上线" } + assertTrue(rootCauseMessageOf(failure!!).contains("32")) { + "错误信息里要写清楚是长度问题:${rootCauseMessageOf(failure)}" + } + } + } + + @Test + fun `activeKeyId 在 keys 里找不到对应密钥时启动失败`() { + runner + .withPropertyValues( + "security.jwt.active-key-id=v9", + "security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}", + ) + .run { context -> + val failure = context.startupFailure + assertNotNull(failure) { "activeKeyId 指向不存在的密钥必须启动失败" } + assertTrue(rootCauseMessageOf(failure!!).contains("activeKeyId")) { + rootCauseMessageOf(failure) + } + } + } + + @Test + fun `activeKeyId 为空时校验不通过`() { + runner + .withPropertyValues("security.jwt.active-key-id=", "security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}") + .run { context -> + assertNotNull(context.startupFailure) { "空 activeKeyId 必须启动失败" } + } + } + + private fun rootCauseMessageOf(throwable: Throwable): String { + var current = throwable + while (current.cause != null && current.cause !== current) { + current = current.cause!! + } + return current.message.orEmpty() + } +} diff --git a/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/fixtures/JwtFixtures.kt b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/fixtures/JwtFixtures.kt new file mode 100644 index 0000000..7437984 --- /dev/null +++ b/platform/platform-security/src/test/kotlin/com/continental/retailapp/platform/security/fixtures/JwtFixtures.kt @@ -0,0 +1,24 @@ +package com.continental.retailapp.platform.security.fixtures + +import com.continental.retailapp.platform.security.JwtProperties +import java.util.Base64 + +/** 32 字节,刚好过 [JwtProperties.validate] 的下限。 */ +const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!" + +/** 第二把密钥,用来验证轮换期间新旧 kid 都能验过。 */ +const val TEST_JWT_SECRET_RAW_V2 = "conti-test-secret-key-32bytes#v2" + +fun base64Of(raw: String): String = Base64.getEncoder().encodeToString(raw.toByteArray()) + +fun aJwtProperties( + activeKeyId: String = "v1", + keys: Map = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW)), + accessTokenTtlMinutes: Long = 30, + refreshTokenTtlDays: Long = 30, +) = JwtProperties( + activeKeyId = activeKeyId, + keys = keys, + accessTokenTtlMinutes = accessTokenTtlMinutes, + refreshTokenTtlDays = refreshTokenTtlDays, +) diff --git a/platform/platform-web/build.gradle b/platform/platform-web/build.gradle new file mode 100644 index 0000000..f4f3ca4 --- /dev/null +++ b/platform/platform-web/build.gradle @@ -0,0 +1,9 @@ +dependencies { + api 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-validation' + // 幂等记录表(见 12-concurrency-and-scheduling.md)落在 platform 库里,所以这里需要 JPA + api 'org.springframework.boot:spring-boot-starter-data-jpa' + // 只要注解,不要 springdoc 的 UI —— Controller 上的 @Operation/@Tag 各 domain 都要用, + // 收口在这里比每个 domain 各写一遍好。UI 只在 bootstrap 里装(见 06-api-design.md)。 + api 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.30' +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ApiResult.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ApiResult.kt new file mode 100644 index 0000000..cec08fa --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ApiResult.kt @@ -0,0 +1,21 @@ +package com.continental.retailapp.platform.web + +/** + * 统一响应结构,见 06-api-design.md。 + * 所有 Controller 端点方法的返回类型都必须是它(ArchUnit 有规则盯着,见 10-testing.md)。 + */ +data class ApiResult( + val code: Int, + val message: String, + val data: T?, + val traceId: String, +) { + companion object { + fun ok(data: T): ApiResult = ApiResult(ErrorCode.OK, "success", data, currentTraceId()) + + fun ok(): ApiResult = ApiResult(ErrorCode.OK, "success", Unit, currentTraceId()) + + fun error(code: Int, message: String): ApiResult = + ApiResult(code, message, null, currentTraceId()) + } +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/AuditContext.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/AuditContext.kt new file mode 100644 index 0000000..e4b5ed6 --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/AuditContext.kt @@ -0,0 +1,20 @@ +package com.continental.retailapp.platform.web + +/** + * 审计上下文的最小契约。 + * + * 对文档的有意偏离 #1:03-persistence.md 的 `JpaAuditingConfig` 和 08-observability.md 的 + * `AuditLogAspect` 都直接注入了 platform-security 的 `StoreContextHolder`,那会造成 + * `platform-persistence -> platform-security`、`platform-observability -> platform-security` + * 两条依赖,与 01-project-structure.md「platform-* 之间尽量不互相依赖」冲突。 + * + * 折中:把"当前是谁、在哪个门店"这一点点信息抽成本接口放在 platform-web(公共基座, + * `platform-security -> platform-web` 本来就是文档认可的例外),`StoreContextHolder` 实现它, + * persistence / observability 只注入 `ObjectProvider`。 + * + * 收敛后的规则:**platform-web 是公共基座,其他 platform-* 只能依赖它,彼此之间不再有依赖。** + */ +interface AuditContext { + val userId: Long? + val storeId: Long? +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/BusinessException.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/BusinessException.kt new file mode 100644 index 0000000..d56d3f0 --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/BusinessException.kt @@ -0,0 +1,13 @@ +package com.continental.retailapp.platform.web + +import org.springframework.http.HttpStatus + +/** + * 业务异常基类,见 06-api-design.md。 + * 子类只负责携带错误码和给用户看的文案,HTTP 状态码由 [GlobalExceptionHandler] 统一落地。 + */ +open class BusinessException( + val code: Int, + override val message: String, + val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST, +) : RuntimeException(message) diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ErrorCode.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ErrorCode.kt new file mode 100644 index 0000000..07e67af --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/ErrorCode.kt @@ -0,0 +1,24 @@ +package com.continental.retailapp.platform.web + +/** + * 业务错误码,见 06-api-design.md。 + * 分段约定:1xxxx 通用、11xxx 门店/权限、3xxxx 外部系统。 + * 各 domain 自己的错误码段在骨架阶段尚未分配(交付说明里已列为留白项)。 + */ +object ErrorCode { + const val OK = 0 + + const val INVALID_PARAM = 10001 + const val UNAUTHORIZED = 10401 + const val FORBIDDEN = 10403 + const val NOT_FOUND = 10404 + const val CONFLICT = 10409 + const val INTERNAL_ERROR = 10500 + + const val STORE_NOT_ACCESSIBLE = 11001 + const val NO_STORE_PERMISSION = 11002 + + const val F6_UNAVAILABLE = 30001 + const val F6_BUSINESS_ERROR = 30002 + const val MINI_UNAVAILABLE = 31001 +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/GlobalExceptionHandler.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/GlobalExceptionHandler.kt new file mode 100644 index 0000000..3218521 --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/GlobalExceptionHandler.kt @@ -0,0 +1,44 @@ +package com.continental.retailapp.platform.web + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.orm.ObjectOptimisticLockingFailureException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +/** + * 全局异常处理,见 06-api-design.md。 + * 所有异常最终都以 [ApiResult] 的结构返回,客户端只需要解析一种响应体。 + */ +@RestControllerAdvice +class GlobalExceptionHandler { + + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity> { + 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> = + ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message)) + + @ExceptionHandler(ObjectOptimisticLockingFailureException::class) + fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity> { + log.warn("乐观锁冲突", ex) + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试")) + } + + @ExceptionHandler(Exception::class) + fun handleUnexpected(ex: Exception): ResponseEntity> { + // 只记异常本身,绝不打印请求体(可能含密码/令牌,见 08-observability.md 的脱敏要求) + log.error("未处理异常", ex) + return ResponseEntity.internalServerError() + .body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试")) + } +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/TraceIdSupport.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/TraceIdSupport.kt new file mode 100644 index 0000000..726162b --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/TraceIdSupport.kt @@ -0,0 +1,9 @@ +package com.continental.retailapp.platform.web + +import org.slf4j.MDC + +/** + * 从 MDC 里取当前 traceId,见 08-observability.md。 + * traceId 由 Micrometer Tracing 写进 MDC;请求头 `X-Trace-Id` 的桥接在 platform-observability 里做。 + */ +fun currentTraceId(): String = MDC.get("traceId") ?: "unknown" diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyGuard.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyGuard.kt new file mode 100644 index 0000000..e154126 --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyGuard.kt @@ -0,0 +1,49 @@ +package com.continental.retailapp.platform.web.idempotency + +import com.continental.retailapp.platform.web.BusinessException +import com.continental.retailapp.platform.web.ErrorCode +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import tools.jackson.databind.ObjectMapper +import java.time.Clock + +/** + * 幂等守卫,见 12-concurrency-and-scheduling.md。 + * + * 真正的兜底是 `uk_idem_key_user` 这个唯一索引——"先查再写"之间存在竞态窗口, + * 两个并发请求可能都查不到记录然后都执行 block,所以必须捕获唯一键冲突。 + * + * 与文档示例的一点补充:反序列化需要目标类型,所以签名上多了一个 `responseType`。 + */ +@Service +class IdempotencyGuard( + private val recordRepository: IdempotencyRecordRepository, + private val objectMapper: ObjectMapper, + private val clock: Clock, +) { + + @Transactional + fun execute(key: String, userId: Long, responseType: Class, block: () -> T): T { + recordRepository.findByIdemKeyAndUserId(key, userId)?.let { + return objectMapper.readValue(it.response, responseType) + } + val result = block() + try { + recordRepository.saveAndFlush( + IdempotencyRecordEntity( + idemKey = key, + userId = userId, + response = objectMapper.writeValueAsString(result), + createdAt = clock.instant(), + ), + ) + } catch (ex: DataIntegrityViolationException) { + // 并发窗口内另一个请求先落库了:这次请求的副作用可能已经重复执行, + // 明确报冲突让客户端重试,比静默返回一个可能不一致的结果安全 + throw BusinessException(ErrorCode.CONFLICT, "请求正在处理中,请稍后重试", HttpStatus.CONFLICT) + } + return result + } +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordEntity.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordEntity.kt new file mode 100644 index 0000000..3505396 --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordEntity.kt @@ -0,0 +1,36 @@ +package com.continental.retailapp.platform.web.idempotency + +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 + +/** + * 幂等记录,见 12-concurrency-and-scheduling.md。 + * 表落在 platform 共用库里,DDL 见 platform-persistence 的 `db/migration/platform/V1__init.sql`。 + * + * 注意它不继承 platform-persistence 的 `BaseEntity`:platform-web 是公共基座,不反过来依赖 + * platform-persistence(见 AuditContext 里对偏离 #1 的说明),所以这里自带 createdAt。 + */ +@Entity +@Table(name = "idempotency_record", schema = "platform") +class IdempotencyRecordEntity( + @Column(name = "idem_key", nullable = false, updatable = false, length = 64) + var idemKey: String, + + @Column(name = "user_id", nullable = false, updatable = false) + var userId: Long, + + @Column(name = "response", nullable = false, columnDefinition = "text") + var response: String, + + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant = Instant.EPOCH, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordRepository.kt b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordRepository.kt new file mode 100644 index 0000000..6ed890f --- /dev/null +++ b/platform/platform-web/src/main/kotlin/com/continental/retailapp/platform/web/idempotency/IdempotencyRecordRepository.kt @@ -0,0 +1,7 @@ +package com.continental.retailapp.platform.web.idempotency + +import org.springframework.data.jpa.repository.JpaRepository + +interface IdempotencyRecordRepository : JpaRepository { + fun findByIdemKeyAndUserId(idemKey: String, userId: Long): IdempotencyRecordEntity? +} diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh new file mode 100644 index 0000000..c930978 --- /dev/null +++ b/scripts/deploy-dev.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# scripts/deploy-dev.sh +# +# 把某个已经推到 ACR 的镜像部署到 Dev 环境(公司内网 Ubuntu 服务器上的 k3s 集群)。 +# 见 09-build-deploy.md「Dev 环境怎么部署」。 +# +# 用法: +# ./scripts/deploy-dev.sh +# +# 前提: +# - 已连上公司 VPN +# - KUBECONFIG 指向那台机器(k3s 默认生成在 /etc/rancher/k3s/k3s.yaml, +# 里面的 https://127.0.0.1:6443 要换成机器的内网 IP) +# - 镜像已经由 main 分支的 docker-build-push job 推到 ACR +# +# 和 UAT/Prod 那条链路的三处区别: +# 1. 不接 CI/CD,谁要验证谁自己跑; +# 2. 不用 Key Vault——这台机器到不了 Key Vault 的 Private Endpoint, +# Dev 的 Secret 就是下面这两个写死的假值(跟 local profile 同一个思路)。 +# Dev 环境本来也不该碰生产密钥; +# 3. 镜像可以是任意 commit-sha,不需要等打 release tag—— +# "Build once, promote across environments" 这条只针对 UAT/Prod。 +set -euo pipefail + +IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"} +# 09 文档的脚本里直接用了 $CI_REGISTRY_IMAGE,但这个变量只在 GitLab job 里存在, +# 本机手动执行时是空的(set -u 下会直接报错)。这里改成可覆盖的显式变量。 +REGISTRY_IMAGE=${REGISTRY_IMAGE:-"conti.azurecr.io/conti-backend"} +NAMESPACE=${NAMESPACE:-"retailapp-dev"} + +echo ">> 目标:${REGISTRY_IMAGE}:${IMAGE_TAG} → ${NAMESPACE}" + +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + +# Dev 的假密钥。key 名和 application.yml 里的 ${SECURITY_JWT_SECRET}/${DB_PASSWORD} 对齐—— +# 09 文档的脚本写的是 SECURITY_JWT_KEYS_V1,与 07 文档的 Secret 示例(SECURITY_JWT_SECRET) +# 不一致,这里按应用实际读的那个名字来。 +# JWT 密钥是 base64,解码后必须 ≥ 32 字节,否则 JwtProperties 启动时就拒绝。 +kubectl create secret generic conti-backend-secret -n "$NAMESPACE" \ + --from-literal=SECURITY_JWT_SECRET="ZGV2LW9ubHktZmFrZS1zZWNyZXQtYXQtbGVhc3QtMzItYnl0ZXMtbG9uZw==" \ + --from-literal=DB_PASSWORD="dev-only-fake-password" \ + --from-literal=F6_API_KEY="dev-only-fake-api-key" \ + --dry-run=client -o yaml | kubectl apply -f - + +kubectl apply -f k8s/rbac.yaml +kubectl apply -f k8s/configmap-dev.yaml -n "$NAMESPACE" + +# 用 sed 把镜像替换掉再 apply,而不是"apply 之后再 kubectl set image"—— +# 后者每跑一次都会先把镜像重置成 __TAG__ 占位符(触发一次 ImagePullBackOff)再改回来。 +sed "s|registry.example.com/conti-backend:__TAG__|${REGISTRY_IMAGE}:${IMAGE_TAG}|" \ + k8s/deployment-dev.yaml | kubectl apply -f - + +# 迁移失败会让新 Pod 起不来、这里超时退出,但线上(Dev)服务不受影响—— +# maxUnavailable: 0 保证旧 Pod 还在跑。这个失败模式是刻意选的, +# 比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。 +kubectl rollout status deployment/conti-backend -n "$NAMESPACE" --timeout=180s + +echo ">> 完成。查看日志:kubectl logs -f deployment/conti-backend -n ${NAMESPACE}" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..24fd283 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,19 @@ +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'