Files
conti-backend/README.md
T
Guangfei.ZhaoandClaude Code aca7487be2 feat: Kotlin 语法约束——只当语法糖用,不当新范式用
新增 docs/13-kotlin-style.md,并把它落成可执行的构建门禁,而不是一份只能靠
自觉遵守的建议。两条准入标准:只当语法糖(不引入新的思维模型)、每条规则必须
说得出它挡住的具体故障。

- KotlinSyntaxScanTest(源码扫描):禁 !! / lateinit / suspend / kotlinx.coroutines
  / RxJava / Reactor / data class Entity。走源码文本而非 ArchUnit,是因为这些在
  字节码上看不出来——!! 编译成 Intrinsics.checkNotNull(...) 调用,和其他 null
  检查混在一起。
- ArchitectureRulesTest 加「禁止工具类」:只禁 Utils/Util/Helper/Common 四个
  后缀,不禁 XxxManager——Spring 自身大量使用这个命名,一刀切会误伤。
- PersistedEntity + requiredId:把「从 repository 读出来的实体必有 id」变成
  一个有名字的类型,替掉 AuthAppService / LocalSeedDataConfig 里 7 处 .id!!。
- 5 个 Entity 实现 PersistedEntity。

两条故意不自动强制的写进了文档:companion object 靠 review 判断(现存 14 处
用法均合法,硬规则只会误伤);Entity 可被 Hibernate 代理目前没守住——未 apply
kotlin("plugin.jpa"),Entity 是 final 类,fetch = LAZY 会静默降级为 eager,现
因为零懒加载关联而未暴露。

docs/01-project-structure.md 顺带补了 Gradle 版本行的双向约束说明;README 修正
了 12 篇→13 篇文档、10 条→11 条规则,并补上工具类禁名。

验证:./gradlew build -PexcludeIntegration 通过。ArchitectureRulesTest 11 项、
KotlinSyntaxScanTest、NativeQueryScanTest 全绿;两条新规则均以「植入一个故意
违规、确认它变红」的方式验证过不是空转。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 16:01:22 +08:00

7.9 KiB
Raw Blame History

conti-backend

Continental 门店零售 App 的后端服务。模块化单体:14 个 Gradle 模块,一个可部署 jar, 模块边界由 Gradle 依赖图 + ArchUnit 测试守着,将来要拆微服务时按模块切即可。

架构决策全部写在 conti-docs/backend/ 的 13 篇文档里, 本仓库是那些决策的落地。文档是唯一事实来源——代码和文档不一致时,改代码,不是改文档, 也不是把 ArchUnit 规则调松(规则调松了这套结构就白建了)。

版本基线

版本
Java 21(toolchain)
Kotlin 2.4.10
Spring Boot 4.1.0
Spring Cloud 2025.1.2
Gradle 9.5.0(wrapper)——双向约束:Boot 4.1 插件要 8.14+ 或 9.x,KGP 2.4.10 的完全支持上限是 9.5.0,升任一边都要重取交集
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)

具体 11 条规则见 architecture-test/.../ArchitectureRulesTest.kt, 每条规则的注释里都写了它对应文档的哪一节。几条最容易踩的:

  • Entity 只能待在 infrastructure 包里,api 层碰不到 Entity;
  • domain 层不许出现 Spring / JPA 的任何引用,它的 bean 由 infrastructure/config/ 显式 @Bean 装配;
  • Controller 的端点方法必须返回 ApiResult;
  • 不许字段注入(@Autowired 到字段上);
  • 不许用 java.util.Date / Calendar / SimpleDateFormat,时间一律 Instant(UTC);
  • 不许 XxxUtils / XxxUtil / XxxHelper / XxxCommon 这类工具类(无状态逻辑用顶层函数,让文件名表达归属;有状态的用 @Component)。注意不禁 XxxManager——Spring 自身大量用这个后缀。

本地怎么跑

只有 MySQL 跑在 Docker 里,应用本身用 Gradle 跑,断点和热重载都还在:

docker compose up -d mysql
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
  • 建库脚本在 docker/mysql/init/,表结构由 Flyway 建(本地和 dev/uat/prod 是同一套迁移脚本)。
  • local profile 关掉了 Spring Cloud Kubernetes,不需要任何 K8s 环境。
  • 种子数据由 LocalSeedDataConfig 在启动时写入(账号 demo / demo1234,两家门店), 只在 local profile 下存在——种子数据绝不写进 Flyway 脚本,那些脚本在 dev/uat/prod 也会跑。
  • Swagger UI:http://localhost:8080/swagger-ui.html(local/dev/uat 开,prod 关)。

数据库要重来一遍:

docker compose down -v && docker compose up -d mysql

构建与测试

./gradlew build -PexcludeIntegration   # 编译 + 单测 + ArchUnit,不需要 Docker,约 1 分钟
./gradlew build                        # 完整,含 Testcontainers / WireMock(CI 跑这个)

需要真实 MySQL 或 WireMock 的测试都打了 @Tag("integration"),-PexcludeIntegration 会跳过它们。 本地日常改代码用前者,提 MR 前跑一次后者。

部署

环境 跑在哪 走 CI/CD 怎么部署
local 自己电脑 否 见上面
dev 内网 Ubuntu 上的 k3s 否 ./scripts/deploy-dev.sh <commit-sha>(先连 VPN)
uat / prod Azure Private AKS 是 打 protected tag → GitLab 手动点 Promote
  • 镜像里不编译代码:Dockerfile 直接消费 CI validate 阶段产出的 jar, 保证部署的字节就是被测试验证过的字节。
  • 一个镜像走所有环境,环境差异只在 ConfigMap / Secret / SPRING_PROFILES_ACTIVE。
  • 真实密钥不在这个仓库里的任何一个文件中。k8s/secret-uat.yaml 里只有 __injected_by_pipeline__ 占位符,真值由流水线从 Azure Key Vault 现取现渲染。

k8s 清单在 k8s/,流水线在 .gitlab-ci.yml。 lint(ktlint/detekt)和覆盖率两个 job 目前以注释形式留着——插件还没接, 放开就是必红;文件里写了启用需要补什么。

新增一个 domain 要接线的地方

漏掉任何一处,症状都不明显(编译过、启动过,但 Flyway 不跑、ArchUnit 不检查),所以逐条对:

  1. settings.gradle 加 include 'domains:xxx',并写 domains/xxx/build.gradle ——只依赖 platform-* 和别的 domain 的 -contract,不许依赖别的 domain 本身。
  2. bootstrap/build.gradle 加 implementation project(':domains:xxx'), 否则这个模块根本不会被打进 jar。
  3. DomainFlywayConfig.DOMAIN_SCHEMAS 加库名,并建 domains/xxx/src/main/resources/db/migration/<库名>/V1__init.sql ——Flyway 的 location 不能是空目录;同时在 docker/mysql/init/01-create-databases.sql 里加建库语句。
  4. architecture-test:ArchitectureRulesTest.domains 加包名(如 xxx), NativeQueryScanTest.ownerByModule 加 "xxx" to "<库名>" ——不加的话跨域依赖和跨库 SQL 这两条规则对新模块是完全失效的。

前三步是"能跑起来",第四步是"边界还守得住"。

一些容易踩的坑

  • Boot 4 的包名搬过家:@DataJpaTest 在 org.springframework.boot.data.jpa.test.autoconfigure, @AutoConfigureTestDatabase 在 org.springframework.boot.jdbc.test.autoconfigure, 都要单独引 spring-boot-starter-data-jpa-test;spring-boot-starter-aop 改叫 spring-boot-starter-aspectj。
  • Gradle 9 不再自带 JUnit Platform launcher,根 build.gradle 里那行 testRuntimeOnly 'org.junit.platform:junit-platform-launcher' 删掉的话所有 test 任务会直接失败。
  • 已经执行过的 Flyway 脚本不可修改,改了 checksum 对不上,下次启动直接 Validate failed。 要改就新写一个版本号更大的脚本,这条对 Dev 环境同样适用。
  • 每个迁移脚本必须前向兼容:maxUnavailable: 0 意味着滚动更新期间新旧两版 Pod 连同一个库, 删列改列一律走 expand-contract 两次发布。
  • 门店维度的查询一律用 StoreContextHolder.currentStoreId(),绝不用请求参数里的 storeId ——那是越权查询的标准入口。