Author SHA1 Message Date
Guangfei.ZhaoandClaude Code d78fcfc202 chore: 加 CLAUDE.md,忽略 agent 工作目录
CLAUDE.md(英文)给后续 AI 用:文档是约束来源而非描述、构建与测试命令、
architecture-test 三个测试类的不同机制(含源码扫描测试路径写错时静默扫 0 个
文件报绿的坑)、以及版本基线的硬约束。

.gitignore 忽略 .claude / .agents。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 16:01:51 +08:00
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
Guangfei.ZhaoandClaude Code 54f3b35245 build: 钉 Gradle 9.5.0,Maven 依赖走阿里云镜像
- wrapper 9.5.1 → 9.5.0:Boot 4.1 插件要求 8.14+ 或 9.x,而 KGP 2.4.10 的完全
  支持上限是 9.5.0,9.5.1 已在 KGP 支持区间之外。
- settings.gradle 加 pluginManagement:插件走 Gradle Plugin Portal,不经过
  Maven Central,只配根 build.gradle 的 repositories 覆盖不到插件那一半。
- 两处均保留官方源兜底,镜像在前、官方源在后,镜像缺失时自动回落。
- 不加 google():那是 Android 构件的仓,后端项目用不到。
- 实测构件可下载,全项目 Gradle 弃用警告归零(url 用 `=` 赋值,非空格赋值)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 16:01:05 +08:00
19 changed files with 522 additions and 24 deletions
+5
View File
@@ -25,3 +25,8 @@ Thumbs.db
application-local-override.yml
.env
*.env.local
# Agents
.claude
.agents
+158
View File
@@ -0,0 +1,158 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Rule #0: the docs are the constraint, not a description
Architecture decisions live in **[`../conti-docs/backend/`](../conti-docs/backend/)** (01–13, the single
source of truth). This repo's `docs/` is a **copy** of it, so people don't have to switch repos.
- **Edit docs in `../conti-docs/backend/`, then sync into `docs/`** (sync commands — including two
cross-repo link fixups — are in `docs/README.md`). Editing only one side causes drift.
- **When code and docs disagree, change the code, not the doc.** In particular, **never loosen a rule in
`architecture-test` just to make code pass** — loosening the rules is exactly what makes this whole
structure worthless.
- The few places where the scaffolding deliberately deviates from the docs are commented in the code
itself.
## Commands
```bash
# Everyday check: compile + unit tests + ArchUnit. No Docker needed (~1 min).
./gradlew build -PexcludeIntegration
# Full check: includes Testcontainers / WireMock. Needs a working Docker daemon. Run before an MR.
./gradlew build
# Single test class / single test method
./gradlew :architecture-test:test --tests "*KotlinSyntaxScanTest*"
./gradlew :domains:identity-store:test --tests "*StoreJpaRepositoryTest*"
# Run locally (only MySQL is in Docker; the app runs via Gradle so breakpoints/hot-reload work)
docker compose up -d mysql
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun # Swagger: http://localhost:8080/swagger-ui.html
# Reset the database
docker compose down -v && docker compose up -d mysql
```
Tests tagged `integration` (real MySQL / WireMock) are skipped by `-PexcludeIntegration`. When you touch a
repository layer or SQL, **`-PexcludeIntegration` does not exercise what you changed** — run the full build.
## Architecture
**Modular monolith**: 14 Gradle modules, one deployable jar. Boundaries are enforced *physically* by the
Gradle dependency graph, and *statically* by `architecture-test`.
```
bootstrap the only module with @SpringBootApplication; the only one producing a jar;
the only one that applies the Boot plugin
platform/platform-* cross-cutting infrastructure (web / security / persistence / observability / integration)
domains/* business domains; identity-store is the only fully implemented vertical slice
integration/* adapters to external systems (f6-adapter / mini-clients)
architecture-test no production code; holds the architecture rules and source-scan tests
```
Dependency rules (enforced at compile time):
- **`domains/x` must never depend on `domains/y`** — only on `y-contract` (a read-only contract module
containing no Spring Web / JPA).
- `domains/*` may depend on `integration/*` and `platform-*`.
- Among `platform-*`, a module **may only depend on `platform-web`**.
- Layering: `api → application → domain`, with `infrastructure` depended on by both. The `domain` layer
**must not reference Spring or JPA at all** — its beans are wired explicitly via `@Bean` in
`infrastructure/config/`. Entities live only in the `infrastructure` package; the `api` layer never sees them.
**Adding a domain requires wiring 4 places.** Miss one and the symptom is subtle (it compiles and boots, but
Flyway doesn't run or the boundary is silently unguarded):
1. `settings.gradle`: add the `include`, and write the module's `build.gradle`.
2. `bootstrap/build.gradle`: add `implementation project(...)`, or it never lands in the jar.
3. `DomainFlywayConfig.DOMAIN_SCHEMAS`: add the schema name, create
`db/migration/<schema>/V1__init.sql` (a Flyway location must not be an empty dir), and add the
`CREATE DATABASE` to `docker/mysql/init/01-create-databases.sql`.
4. `ArchitectureRulesTest.domains`: add the package name; `NativeQueryScanTest.ownerByModule`: add
`"xxx" to "<schema>"`.
Package name is the directory name with hyphens stripped (`identity-store` → `identitystore`); the sole
exception is `bff-orchestration` → `bff`.
## architecture-test: three test classes, three mechanisms
Know what's in here before changing production code, or you'll hit a red build and not know why:
| Test class | Mechanism | What it catches |
| --- | --- | --- |
| `ArchitectureRulesTest` | ArchUnit over **bytecode** | layer directions, Entity placement, field injection, `java.util.Date`, util-class naming, etc. |
| `NativeQueryScanTest` | **reads source text** | a native SQL string naming another domain's schema — schema names are string literals, invisible in bytecode |
| `KotlinSyntaxScanTest` | **reads source text** | `!!` / `lateinit` / `suspend` / `kotlinx.coroutines` / RxJava / Reactor / `data class *Entity` |
**Two traps in the source-scan tests** (you only see them by reading the code):
- They locate sources with **relative paths** (`Path.of("../domains")`), relying on Gradle running them with
the module dir as working directory. **If you add a new top-level source root, you must add it to
`KotlinSyntaxScanTest.productionSources()`** — otherwise the new code is never checked.
- `productionSources()` contains `filter { Files.exists(it) }`: **if the path is wrong it silently scans
zero files and reports green.** When writing a test like this, confirm it actually reads files by
planting a deliberate violation and checking that it goes red — don't trust a green alone.
### Kotlin coding constraints (see `13-kotlin-style.md`)
`!!` / `lateinit` / coroutines / RxJava / Reactor / `data class` entities / any
`XxxUtils|Util|Helper|Common` class **are build gates** — violating them fails `test` and therefore
`build`. They are not suggestions.
Two things that are **deliberately not automated**:
- `companion object` restricted to legitimate uses — left to review (all 14 existing uses are legitimate;
a hard rule would only cause false positives).
- **Entities must be proxyable by Hibernate — currently NOT enforced (known gap)**: the root `build.gradle`
does **not** apply `kotlin("plugin.jpa")`, so entities are final, Hibernate cannot proxy them, and
`fetch = LAZY` **silently degrades to eager**. This hasn't blown up only because there are currently zero
lazy associations. Read the "已知缺口(2026-09)" (known gap) section of `13-kotlin-style.md` before
adding anything like `@ManyToOne(fetch = LAZY)`.
## Hard version constraints
| Component | Version | Constraint |
| --- | --- | --- |
| Gradle | `9.5.0` (wrapper) | **Two-sided**: the Boot 4.1 plugin needs 8.14+ or 9.x, while KGP 2.4.10's fully-supported ceiling is 9.5.0. Bumping either Boot or Kotlin means recomputing the intersection. |
| Kotlin | `2.4.10` | |
| Java | `21` (pinned via toolchain) | |
| Spring Boot / Cloud | `4.1.0` / `2025.1.2` | |
- Version alignment always goes through Gradle's native **`platform()` BOM; `io.spring.dependency-management`
is deliberately not used** (that plugin historically breaks on every Gradle major).
- **Gradle 9 no longer bundles the JUnit Platform launcher** — deleting
`testRuntimeOnly 'org.junit.platform:junit-platform-launcher'` from the root `build.gradle` makes every
`test` task fail outright.
- **Boot 4 moved packages**: `@DataJpaTest` → `org.springframework.boot.data.jpa.test.autoconfigure`,
`@AutoConfigureTestDatabase` → `org.springframework.boot.jdbc.test.autoconfigure` (both need
`spring-boot-starter-data-jpa-test`); `spring-boot-starter-aop` is now `spring-boot-starter-aspectj`.
## Database conventions
- One domain, one database (in MySQL, schema ≡ database), so `DomainFlywayConfig` starts one Flyway
instance per entry in `DOMAIN_SCHEMAS`.
- **An already-executed Flyway script must never be edited** (checksum mismatch → startup fails with
`Validate failed`; this applies to the dev environment too). To change something, add a higher-versioned
script.
- **Every migration must be forward-compatible**: `maxUnavailable: 0` means old and new pods share one
database during a rolling update, so dropping or altering a column requires expand-contract across two
releases.
- **Store-scoped queries must use `StoreContextHolder.currentStoreId()`, never a `storeId` request
parameter** — that parameter is the standard entry point for an authorization-bypass query.
- Seed data (`LocalSeedDataConfig`) **exists only under the `local` profile and must never be written into
a Flyway script**.
## Deployment and misc
- `local` on your own machine (Gradle); `dev` on internal k3s (`./scripts/deploy-dev.sh <sha>`, connect
VPN first); `uat`/`prod` on Azure AKS (protected tag → manual Promote in GitLab).
- **Images don't compile code**: the `Dockerfile` consumes the jar produced by the CI `validate` stage, so
the bytes deployed are the bytes that were tested.
- **Real secrets are in no file in this repo**; `k8s/secret-uat.yaml` holds only
`__injected_by_pipeline__` placeholders.
- The `lint` (ktlint/detekt) and coverage jobs are currently **left commented out** in `.gitlab-ci.yml` —
the plugins aren't wired up, so enabling them would go red immediately. `.editorconfig` already reserves
the `ij_kotlin_*` section.
+5 -4
View File
@@ -3,7 +3,7 @@
Continental 门店零售 App 的后端服务。**模块化单体**:14 个 Gradle 模块,一个可部署 jar,
模块边界由 Gradle 依赖图 + ArchUnit 测试守着,将来要拆微服务时按模块切即可。
架构决策全部写在 [`conti-docs/backend/`](../conti-docs/backend/) 的 12 篇文档里,
架构决策全部写在 [`conti-docs/backend/`](../conti-docs/backend/) 的 13 篇文档里,
本仓库是那些决策的落地。**文档是唯一事实来源**——代码和文档不一致时,改代码,不是改文档,
也不是把 ArchUnit 规则调松(规则调松了这套结构就白建了)。
@@ -15,7 +15,7 @@ Continental 门店零售 App 的后端服务。**模块化单体**:14 个 Grad
| Kotlin | 2.4.10 |
| Spring Boot | 4.1.0 |
| Spring Cloud | 2025.1.2 |
| Gradle | 9.5.1(wrapper) |
| 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` 插件。
@@ -56,7 +56,7 @@ domains/A ───> domains/B-contract 只读查询:允许
domains/A ~~~> ApplicationEvent 写操作/状态联动:@TransactionalEventListener(AFTER_COMMIT)
```
具体 10 条规则见
具体 11 条规则见
[`architecture-test/.../ArchitectureRulesTest.kt`](architecture-test/src/test/kotlin/com/continental/retailapp/architecture/ArchitectureRulesTest.kt),
每条规则的注释里都写了它对应文档的哪一节。几条最容易踩的:
@@ -64,7 +64,8 @@ domains/A ~~~> ApplicationEvent 写操作/状态联动:@TransactionalEventLi
- `domain` 层不许出现 Spring / JPA 的任何引用,它的 bean 由 `infrastructure/config/` 显式 `@Bean` 装配;
- Controller 的端点方法必须返回 `ApiResult`;
- 不许字段注入(`@Autowired` 到字段上);
- 不许用 `java.util.Date` / `Calendar` / `SimpleDateFormat`,时间一律 `Instant`(UTC)。
- 不许用 `java.util.Date` / `Calendar` / `SimpleDateFormat`,时间一律 `Instant`(UTC);
- 不许 `XxxUtils` / `XxxUtil` / `XxxHelper` / `XxxCommon` 这类工具类(无状态逻辑用顶层函数,让文件名表达归属;有状态的用 `@Component`)。注意**不禁 `XxxManager`**——Spring 自身大量用这个后缀。
## 本地怎么跑
@@ -163,4 +163,17 @@ class ArchitectureRulesTest {
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
@Test
fun `禁止工具类`() {
// 13-kotlin-style.md:无状态逻辑用顶层函数让文件名表达归属,有状态的用 @Component。
// 只禁这四个后缀,**不禁 XxxManager**——Spring 自身大量使用 *Manager 命名,一刀切会误伤。
noClasses()
.should().haveSimpleNameEndingWith("Utils")
.orShould().haveSimpleNameEndingWith("Util")
.orShould().haveSimpleNameEndingWith("Helper")
.orShould().haveSimpleNameEndingWith("Common")
.because("工具类是「我懒得想这东西属于谁」的标记,会变成什么都往里塞的垃圾桶")
.check(classes)
}
}
@@ -0,0 +1,109 @@
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
/**
* 13-kotlin-style.md 里那些"字节码上看不出来"的语法规则。
*
* ArchUnit 看的是字节码:强解包编译成 `Intrinsics.checkNotNull(...)` 调用,和其他 null 检查
* 混在一起分不出来;`data class` 在字节码里只是多了 `component1()`/`copy()`,也没法干净判定。
* 这些规则在源码里是确定的 token,所以用源码扫描——和 [NativeQueryScanTest] 同一个套路,
* 是 10-testing.md 那条"编译器和 ArchUnit 拦不住的就加一道源码扫描"的第二次应用。
*
* **只扫 `src/main`**:测试代码里强解包不违规。测试里 null 是立即可见的失败信号,
* 再包一层 `?: throw` 只是噪音(见 13-kotlin-style.md)。
*/
class KotlinSyntaxScanTest {
private data class Rule(val label: String, val token: String, val pattern: Regex)
private val rules = listOf(
Rule("强解包", "!!", Regex("!!")),
Rule("延迟初始化", "lateinit", Regex("\\blateinit\\b")),
Rule("协程", "suspend fun", Regex("\\bsuspend\\s+fun\\b")),
Rule("协程", "kotlinx.coroutines", Regex("kotlinx\\.coroutines")),
Rule("RxJava", "io.reactivex", Regex("io\\.reactivex")),
Rule("Reactor", "reactor.core", Regex("reactor\\.core")),
Rule("Entity 是 data class", "data class", Regex("\\bdata\\s+class\\s+\\w*Entity\\b")),
)
@Test
fun `生产代码不出现 13-kotlin-style 禁止的语法`() {
val violations = productionSources().flatMap { file ->
val path = file.toString().replace('\\', '/')
codeOnly(Files.readString(file)).lines().flatMapIndexed { index, line ->
rules.filter { it.pattern.containsMatchIn(line) }
.map { "${path}:${index + 1} 出现 ${it.token}(${it.label})" }
}
}
assertTrue(violations.isEmpty()) {
"违反 13-kotlin-style.md:\n${violations.joinToString("\n")}"
}
}
private fun productionSources(): List<Path> =
listOf("../domains", "../platform", "../integration", "../bootstrap")
.map(Path::of)
.filter { Files.exists(it) }
.flatMap { root ->
Files.walk(root).filter { it.isProductionKotlin() }.toList()
}
private fun Path.isProductionKotlin(): Boolean {
val path = toString().replace('\\', '/')
return path.endsWith(".kt") && path.contains("/src/main/")
}
/**
* 把注释和字符串字面量抹成空白,只留代码,且**保留换行**——报违规时行号要能对上。
*
* 这一步不能省:注释里为了讲清规则经常要写反例("不要写 `user.id!!` 这种"),
* 字符串里也可能出现同样的字符,直接对原文匹配会把它们全报成违规。
*/
private fun codeOnly(source: String): String {
val out = StringBuilder(source.length)
var i = 0
fun blank() {
out.append(if (source[i] == '\n') '\n' else ' ')
i++
}
while (i < source.length) {
when {
source.startsWith("//", i) ->
while (i < source.length && source[i] != '\n') blank()
source.startsWith("/*", i) -> {
while (i < source.length && !source.startsWith("*/", i)) blank()
repeat(2) { if (i < source.length) blank() }
}
// 三引号原始字符串要排在普通字符串前面判断:@Query 里的 SQL 都用它
source.startsWith("\"\"\"", i) -> {
while (i < source.length && !source.startsWith("\"\"\"", i)) blank()
repeat(3) { if (i < source.length) blank() }
}
source[i] == '"' -> {
blank()
while (i < source.length && source[i] != '"') {
if (source[i] == '\\') blank()
if (i < source.length) blank()
}
if (i < source.length) blank()
}
else -> {
out.append(source[i])
i++
}
}
}
return out.toString()
}
}
+3
View File
@@ -14,6 +14,9 @@ subprojects {
version = '0.1.0-SNAPSHOT'
repositories {
maven { url = 'https://maven.aliyun.com/repository/public' }
maven { url = 'https://maven.aliyun.com/repository/google' }
google()
mavenCentral()
}
+1
View File
@@ -12,6 +12,7 @@ Kotlin + Spring Boot + Gradle(Groovy DSL,`build.gradle`)。
| 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 锁定) | |
| Gradle | `9.5.0` | **双向约束**:Boot 4.1 的 Gradle 插件要求 8.14+ 或 9.x,而 KGP 2.4.10 的完全支持上限是 9.5.0。升 Boot 或升 Kotlin 都要重新取一次交集——只看一边会踩到另一边的上限 |
Spring Boot 4 连带升级了 Spring Framework 7 / Spring Security 7 / **Jackson 3**,第三方库必须选对应版本线,各篇文档里出现的坐标已按此对齐:
+161
View File
@@ -0,0 +1,161 @@
# 13. Kotlin 语法与编码约束
## 决策
**Kotlin 在本项目里只当 Java 的语法糖用,不当新范式用。**
判断一个新特性该不该用,只问一句话:它对 Java 是**缩短**,还是**替换**?
| 特性 | 性质 | 用不用 |
| --- | --- | --- |
| 空安全(`?` / `?:` / `takeIf`)、`val`/`var`、属性、`data class`、扩展函数、顶层函数 | 把 Java 写得更短,心智模型没变 | ✅ 用 |
| `when` 表达式、`sealed` 层次 | 把 Java 的 switch + 继承写得更短**且更严**(分支穷尽性由编译器保证) | ✅ 用 |
| 协程 / `suspend` / `Flow`、RxJava、Reactor | 换一套并发心智模型,且与本项目的阻塞式 JPA + ThreadLocal 上下文直接冲突 | ❌ 不用 |
| `companion object` 当 static 工具袋、`object XxxUtils` | 换一套"万物皆对象、没有 static"的世界观,收益是零 | ❌ 不用 |
| DSL builder、Monad Transformer、`inline` + `reified` 造框架 | 换一套抽象方式,在 CRUD 业务里只换来调试成本 | ❌ 不用 |
第二条准入标准:**本文档里每条规则都要能指出它挡住的具体故障。** 指不出来的不写——写下来也不会有人遵守,只会在 review 时引发无穷争论。下面 DON'T 清单里每一条都带了「挡住什么」,那就是它的准入证明。
这条标准本身是 [02-layering.md](./02-layering.md) 里那次论证的推广。那份文档解释了为什么规则不是更严的"Entity 永不跨出 infrastructure"——因为更严的版本会逼出无意义的样板代码,却换不来任何隔离收益。**语法规则同理:不能减少实际复杂度的规则,只是把复杂度从代码挪进了文档。**
## 使用规则
### DO
- **`data class`**:DTO / Request / Response / Value Object,以及 `domain` 层的领域模型(如 `WebviewTicket`)。纯数据、无生命周期、无懒加载。
- **`sealed interface` / `sealed class`**:有限且封闭的状态建模,`when` 上不用写 `else`,新增状态时编译期报错。状态不带附加数据时仍用 `enum`(现有 `ErrorCode`、`TicketStatus`、`UserStatus` 都是 enum,这是对的);当每个状态要携带不同的字段时再换 `sealed`。
- **`val` 优先**,`var` 只留给 JPA Entity 的持久化字段——而 Entity 只存在于 `infrastructure` 包([02-layering.md](./02-layering.md))。
- **无状态逻辑用顶层函数**,不要为了"整洁"造一个 `@Component` 的壳。全库目前只有 13 个顶层声明,说明这条没有被滥用:绝大多数逻辑都有它该归属的类。
- **扩展函数**用于同层内的轻量转换:`WebviewTicketEntity.toDomain()`、`UserEntity.toResponse()`。**跨层转换不用扩展函数**,放 `application/mapper/`([06-api-design.md](./06-api-design.md))。
### DON'T
#### ❌ 生产代码禁止 `!!`
**挡住什么**:`!!` 抛的是 `NullPointerException`,消息里只有 `null` 两个字——没有类名、没有字段名、没有业务上下文。线上日志里出现一个光秃秃的 NPE,定位成本远高于写一行 `?: throw`。
```kotlin
// ❌ 违反不变量时,只能得到一个没有上下文的 NPE
accessTokenIssuer.issue(user.id!!, storeId, roles)
// ✅ 不变量有名字,报错带类名
accessTokenIssuer.issue(user.requiredId, storeId, roles)
```
**根因不在语法,在数据来源。** 应用层拿到的 Entity 只有两种状态:`new` 出来的(id 必然是 null)和从 repository 读出来 / `save` 过的(id 一定有)。上面那个 `!!` 想表达的正是后者这条不变量——那就把它写成类型:`platform-persistence` 的 `PersistedEntity.requiredId`。
所以本项目的修法不是"把 `!!` 逐处换成 `?: throw`"(那只是把同样的假设抄了 7 遍,还没解决问题),而是**让不变量有一个名字**。
**`src/test` 里的 `!!` 不禁止**:测试里 null 是立即可见的失败信号,再包一层 `?: throw` 只是噪音。
#### ❌ 生产代码禁止 `lateinit var`
**挡住什么**:`lateinit` 是"我保证在使用前一定赋值"的口头承诺,编译器从此不再管你。它和 `!!` 是同一个洞的两半——`!!` 是**读**的时候不安全,`lateinit` 是**写**的时候不检查。
字段注入时代它还有存在理由;本项目已经禁止字段注入(`ArchitectureRulesTest` 里有规则),依赖一律走构造器参数,`lateinit` 就没有场景了。需要延迟初始化就用 `by lazy`(只读且线程安全)。
#### ❌ 禁止引入协程 / `suspend` / `Flow` / RxJava / Reactor
**挡住什么**:见下方「为什么不用协程,而用虚拟线程」。结论是**同步到底;需要并发就用虚拟线程,不引入任何异步框架**。
2026-09 落地时全库 0 处违规——这是条预防性规则,不是清理性规则。它的价值在半年后有人提出"我们用协程改造一下"时,能直接翻到下面那一节,而不是重新辩论一遍。
#### ❌ 禁止 `XxxUtils` / `XxxUtil` / `XxxHelper` / `XxxCommon` 类
**挡住什么**:这类类名是"我懒得想这东西属于谁"的标记。它们一旦出现就会变成什么都往里塞的垃圾桶,并且彼此依赖,最后没人说得清 `CommonUtils` 里到底有什么、改一行会炸哪里。
**替代**:无状态逻辑用顶层函数,让**文件名**表达归属(`ErrorCode.kt` 里的 `object ErrorCode`);有状态的用 `@Component` 由容器管。
`XxxManager` **不在这条规则里**:Spring 自身就有大量 `*Manager` 类名,一刀切会误伤,交给 review 判断。
#### ❌ JPA Entity 禁止 `data class`,且必须能被 Hibernate 代理
**挡住什么**,三条都是真事故:
1. `data class` 的 `equals`/`hashCode` 基于**可变**的 `id`。实体放进 `Set`/`Map` 后再 flush 拿到 id,哈希值就变了,那个元素再也找不回来。
2. 生成的 `toString` 会打印所有字段——只要有一个懒加载关联,就是一次意料之外的查询,甚至 `LazyInitializationException`。
3. `copy()` 造出的是**游离态新对象**,`save()` 会被当成新增而不是更新。
Entity 一律写成普通 `class` + `var` 持久化字段(现有 6 个 Entity 都是这么写的,保持住)。
**并且 Entity 必须是 `open` 的**,否则 Hibernate 造不出代理,`fetch = LAZY` 会**静默退化**成每次立即查库——测试里看不出来,只有 SQL 条数的差别。
> ⚠️ **已知缺口(2026-09)**:根 `build.gradle` 只 apply 了 `org.jetbrains.kotlin.plugin.spring`,没有 `plugin.jpa`,所以现有 Entity 都是 `final` 的。当前不出问题,是因为全库**一个懒加载关联都没有**(0 处 `@ManyToOne` / `@OneToMany`),Hibernate 压根不需要造代理。
>
> 哪天有人加第一个 `@ManyToOne(fetch = LAZY)`,上面第 3 条就会静默生效。修法是 apply `org.jetbrains.kotlin.plugin.jpa`(它给 `@Entity` / `@MappedSuperclass` 补 `open` 和 no-arg 构造器,见 [03-persistence.md](./03-persistence.md)),**改完必须跑一遍打了 `@Tag("integration")` 的仓储测试**——那是唯一能发现代理行为变化的地方。
## 为什么规则不是"禁止 companion object"
全库 14 处 `companion object`,逐一看过,**没有一处是该删的**:
| 位置 | 用途 | 改成顶层会怎样 |
| --- | --- | --- |
| `F6Exceptions.kt` 的两个异常类 | `private companion object { F6_LOG }` 拿**类级** logger category | 顶层 `private val` 会变成文件级 category(`F6ExceptionsKt`),日志分类直接退化 |
| `Tile.kt` 的 `ok()` / `degraded()` | 类的命名工厂 | 顶层只能叫 `tileOk()`,丢掉类型归属 |
| `AuthAppService.kt` | `private` 常量(失败次数上限、锁定时长、假哈希) | 常量失去"属于这个服务"的语义 |
| `ApiResult.kt` / `ErrorCode.kt` 等 | 同上的构造入口与常量 | 同上 |
所以这条规则被砍掉,理由有两条:
1. **它没有一个能自动检查的形态。** "禁止 companion object" 和 "禁止把 companion object 当 static 工具袋用" 在字节码上长得一模一样,只能靠人判断。而一条只能靠人判断、且现有 14 处全是合法用法的规则,实际效果是让每次 review 多花五分钟争论它算不算例外。
2. **它本来是性能论据,但性能论据是错的。** Kotlin 用 companion object 替代 `static` 是"概念一致性"的取舍,不是性能取舍:companion 是每个类装载器一个单例,`const val` 还会被编译期内联,不存在 per-call 分配。拿性能去反驳一个概念性决定,等于认错了对方的动机,打不到点上。
**真正该禁的是"垃圾桶类"**,那件事已经由上面那条 `XxxUtils` 规则接管了。`companion object` 的合法用途就三种:**类级 logger、类的命名工厂、类私有常量**。超出这三种,review 时问一句"这个方法为什么不属于某个类"就够了。
## 为什么不用协程,而用虚拟线程
**结论:同步到底;需要并发就用虚拟线程。**
先说一个容易被忽略的前提:**本项目的请求上下文全部挂在 ThreadLocal 上。**
| 上下文 | 承载方式 |
| --- | --- |
| 门店上下文 | `StoreContextHolder` / `StoreContextFilter` |
| 事务 | `@Transactional`(连接与事务绑定在 ThreadLocal 上) |
| 认证信息 | Spring Security 的 `SecurityContextHolder` |
| 审计 | `AuditContext` |
| 链路追踪 | Micrometer Tracing 的 current span |
协程一旦在 `Dispatchers.IO` 之类的地方发生**线程切换**,上面这些 ThreadLocal 会**静默丢失**——不报错,只是门店过滤条件没了、事务不在同一个连接上、审计记不到人。这不是"风格不统一",这是越权和数据错乱级别的 bug,而且极难复现。
所以不用协程的第一理由是**正确性**,不是"协程是银弹"这类口感判断。用口感当理由,半年后有人拿"协程写起来更优雅"就能把它推翻。
那并发需求怎么办?——**虚拟线程**,本项目 toolchain 已经是 Java 21([01-project-structure.md](./01-project-structure.md)):
| 做法 | 吞吐 | ThreadLocal / `@Transactional` | 调试 |
| --- | --- | --- | --- |
| 平台线程 + `CompletableFuture` | 受线程池大小限制 | 正常 | 栈完整 |
| **虚拟线程 + `CompletableFuture`** | 阻塞时自动让出载体线程,接近协程的吞吐 | **照旧正常** | **栈完整** |
| 协程 / `suspend` | 高 | **静默丢失** | 栈不可读,异常要靠 CoroutineExceptionHandler |
| WebFlux + Reactor | 高 | 需要额外桥接,且 JPA 是阻塞的 | 栈不可读,需 Reactor Debug Agent |
`workbench` 那个跨 F6 + Mini 的并行 fan-out([11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md))就是目标场景:把 `WorkbenchExecutorConfig` 的线程池换成 `Executors.newVirtualThreadPerTaskExecutor()`,**业务代码一行不用改**,拿到接近响应式的吞吐,而 Hibernate、事务、门店上下文全部照旧工作。
**为什么也不引 RxJava**:RxJava 正是上面那张"糖 vs 范式"表里被否掉的那一类——操作符爆炸、栈不可读、学习曲线陡。而且 Spring 生态的响应式是 Reactor(WebFlux),引 RxJava 等于在阻塞栈里塞进**第二套**并发模型,两套都要维护。真要异步流,答案也是虚拟线程,不是再引一个框架。
## 检查方式
| 规则 | 强制手段 | 状态 |
| --- | --- | --- |
| 禁 `!!` / `lateinit` / `suspend` / `kotlinx.coroutines` / RxJava / Reactor | `KotlinSyntaxScanTest`(源码扫描) | ✅ 自动 |
| Entity 禁 `data class` | `KotlinSyntaxScanTest` | ✅ 自动 |
| 禁 `XxxUtils` / `Util` / `Helper` / `Common` 类 | `ArchitectureRulesTest` | ✅ 自动 |
| 禁字段注入、禁 `java.util.Date` / `Calendar` | `ArchitectureRulesTest`(既有规则) | ✅ 自动 |
| `companion object` 只用于三种合法用途 | 无 | review 判断 |
| Entity 必须 `open` | 无 | ⚠️ 待 apply `plugin.jpa`(见上文已知缺口) |
**为什么 `!!` 用源码扫描而不是 ArchUnit**:`!!` 编译成 `Intrinsics.checkNotNull(...)` 调用,字节码上它和其他 null 检查混在一起,ArchUnit 分不出来;而源码里 `!!` 就是一个确定的 token。
这是 [10-testing.md](./10-testing.md) 那条思路的第二次应用——"编译器和 ArchUnit 拦不住的东西,加一条源码扫描作为第二道防线"。第一次是 `NativeQueryScanTest`(库名是字符串,字节码里看不见)。
**后续**:`.editorconfig` 里已经预留了 `ij_kotlin_*` 段,说明 ktlint 迟早要接;接的时候 `!!` / `lateinit` 这些可以改用 detekt 的 `UnsafeCallOnNullableType` 等现成规则。在那之前,源码扫描是零依赖、零构建改动的做法,先把它换成可执行的。
## 为什么不选另一个方案
| 方案 | 否决理由 |
| --- | --- |
| 全面拥抱协程 + Spring WebFlux | 本项目是阻塞式 JPA + MySQL([03-persistence.md](./03-persistence.md)),WebFlux 的收益要整条链路非阻塞才拿得到;混在阻塞 JDBC 上只会把线程从 Tomcat 挪到 Reactor 的调度器里,同时丢掉 ThreadLocal 上下文。虚拟线程拿到了同样的吞吐,代价是零。 |
| 用 RxJava 做异步流 | 见上一节结尾。而且 Spring 生态的响应式是 Reactor,引 RxJava 是引入第二套并发模型。 |
| 禁止所有 `companion object` | 见「为什么规则不是禁止 companion object」。14 处合法用法会被误伤,且规则无法自动检查。 |
| 把编码规范写成纯文档,靠 review 执行 | 这套文档被定义成"代码的约束来源",但一条不能被机器检查的规范,半年后就会和代码脱节。能上机器检查的一律上机器检查——这不是不信任团队,是省下 review 的注意力去做机器做不了的事。 |
| 现在就接 detekt + ktlint 全套 | 值得做,但不是这次。先零依赖地把规则变成可执行测试,等格式化类规则积累够了再一次性接工具链——避免为了 6 条规则先引一套构建插件。 |
+1
View File
@@ -19,6 +19,7 @@ Kotlin 2.4 + Spring Boot 4.1 + Java 21 + Gradle 多模块(模块化单体)
| [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、本地缓存) |
| [13-kotlin-style.md](./13-kotlin-style.md) | Kotlin 语法与编码约束(只当语法糖用不当新范式用;禁 `!!`/`lateinit`/协程,Entity 禁 data class) |
## 这份副本从哪来、怎么更新
@@ -10,6 +10,7 @@ import com.continental.retailapp.identitystore.infrastructure.persistence.UserEn
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.persistence.requiredId
import com.continental.retailapp.platform.security.AccessTokenIssuer
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
@@ -70,13 +71,13 @@ class AuthAppService(
user.lockedUntil = null
val storeId = resolveCurrentStore(user)
val roles = roleNamesOf(user.id!!, storeId)
val roles = roleNamesOf(user.requiredId, storeId)
user.currentStoreId = storeId
countLogin("success")
return LoginResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roles),
refreshToken = refreshTokenService.issue(user.id!!),
accessToken = accessTokenIssuer.issue(user.requiredId, storeId, roles),
refreshToken = refreshTokenService.issue(user.requiredId),
user = user.toResponse(),
)
}
@@ -91,14 +92,14 @@ class AuthAppService(
val user = userJpaRepository.findById(rotated.userId).orElseThrow { InvalidRefreshTokenException() }
if (user.status != UserStatus.ACTIVE) {
// 账号在这期间被停用:连带把该用户所有令牌作废,不给它继续刷的机会
refreshTokenService.revokeAllByUser(user.id!!)
refreshTokenService.revokeAllByUser(user.requiredId)
throw InvalidRefreshTokenException()
}
val storeId = resolveCurrentStore(user)
user.currentStoreId = storeId
return TokenResponse(
accessToken = accessTokenIssuer.issue(user.id!!, storeId, roleNamesOf(user.id!!, storeId)),
accessToken = accessTokenIssuer.issue(user.requiredId, storeId, roleNamesOf(user.requiredId, storeId)),
refreshToken = rotated.refreshToken,
)
}
@@ -121,7 +122,7 @@ class AuthAppService(
* 授权可能在两次登录之间被收回,直接信任 `current_store_id` 就是越权。
*/
private fun resolveCurrentStore(user: UserEntity): Long {
val userId = user.id!!
val userId = user.requiredId
user.currentStoreId
?.let { storeRepository.findAccessibleStore(userId, it) }
?.let { return it.id }
@@ -164,7 +165,7 @@ class AuthAppService(
meterRegistry.counter("auth.login", "result", result, "reason", reason).increment()
}
private fun UserEntity.toResponse() = UserResponse(id = id!!, username = username, displayName = displayName)
private fun UserEntity.toResponse() = UserResponse(id = requiredId, username = username, displayName = displayName)
private companion object {
const val MAX_FAILED_ATTEMPTS = 5
@@ -6,6 +6,7 @@ import com.continental.retailapp.identitystore.infrastructure.persistence.UserEn
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 com.continental.retailapp.platform.persistence.requiredId
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
@@ -63,8 +64,8 @@ class LocalSeedDataSeeder(
)
userStoreJpaRepository.saveAll(
listOf(
UserStoreEntity(userId = user.id!!, storeId = stores[0].id!!, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.id!!, storeId = stores[1].id!!, roles = "STAFF"),
UserStoreEntity(userId = user.requiredId, storeId = stores[0].requiredId, roles = "STORE_MANAGER"),
UserStoreEntity(userId = user.requiredId, storeId = stores[1].requiredId, roles = "STAFF"),
),
)
log.info("已写入本地种子数据:用户 {} / 门店 {} 家", SEED_USERNAME, stores.size)
@@ -1,6 +1,7 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
@@ -36,11 +37,11 @@ class RefreshTokenEntity(
@Column(name = "replaced_by_token_id")
var replacedByTokenId: Long? = null,
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
}
@@ -1,5 +1,6 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
@@ -25,9 +26,9 @@ class StoreEntity(
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
var status: StoreStatus = StoreStatus.ACTIVE,
) : VersionedEntity() {
) : VersionedEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
@@ -1,5 +1,6 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.platform.persistence.VersionedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
@@ -45,11 +46,11 @@ class UserEntity(
@Column(name = "locked_until")
var lockedUntil: Instant? = null,
) : VersionedEntity() {
) : VersionedEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
fun isLocked(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
@@ -1,6 +1,7 @@
package com.continental.retailapp.identitystore.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
@@ -29,9 +30,9 @@ class UserStoreEntity(
*/
@Column(name = "roles", nullable = false, length = 255)
var roles: String = "STAFF",
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
@@ -1,6 +1,7 @@
package com.continental.retailapp.webviewticket.infrastructure.persistence
import com.continental.retailapp.platform.persistence.BaseEntity
import com.continental.retailapp.platform.persistence.PersistedEntity
import com.continental.retailapp.webviewticket.domain.model.TicketStatus
import com.continental.retailapp.webviewticket.domain.model.WebviewTicket
import jakarta.persistence.Column
@@ -31,11 +32,11 @@ class WebviewTicketEntity(
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant,
) : BaseEntity() {
) : BaseEntity(), PersistedEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
override var id: Long? = null
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
@@ -0,0 +1,31 @@
package com.continental.retailapp.platform.persistence
/**
* 带自增主键的实体,见 13-kotlin-style.md。
*
* 存在的唯一理由:把"这个实体有 id"这条不变量写成类型,替掉应用层里散落的主键强解包。
*
* 实体的 id 只在两种情况为 null——`new` 出来还没 persist,或者没 save 过。
* 应用层拿到 Entity 时它必然已经落库:要么从 repository 读出来的,要么刚 save 过
* (`GenerationType.IDENTITY` 会在 save 时立刻发出 INSERT 拿回 id)。那里要的是
* [requiredId],不是可空的 `id`。
*
* 平台侧注意:`platform-web` 的 `IdempotencyRecordEntity` **不实现**这个接口——
* platform-web 是公共基座,不反过来依赖 platform-persistence,它自己声明 id 即可。
*/
interface PersistedEntity {
val id: Long?
}
/**
* 已落库实体的主键。
*
* 不满足不变量时抛 [IllegalStateException],报错里带类名——比 Kotlin 强解包
* 抛出的那个没有上下文的 [NullPointerException] 好定位得多。
*
* **不要**在刚 `new` 出来、还没 persist 的实体上用。
*/
val PersistedEntity.requiredId: Long
get() = id ?: throw IllegalStateException(
"${javaClass.simpleName} 还没有 id:它既没从 repository 读出来,也没 save 过",
)
+8
View File
@@ -1,3 +1,11 @@
pluginManagement {
repositories {
maven { url = 'https://maven.aliyun.com/repository/gradle-plugin' }
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = 'conti-backend'
include 'bootstrap'