backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码。
// bootstrap 里全是 implementation 依赖,只会进 runtimeClasspath——扫描够用了。
testImplementation project(':bootstrap')
// 规则里要按类型引用 ApiResult / @RestController / @RequestMapping(比字符串匹配安全,
// 类改名了编译期就报错),这几个得显式进 compileClasspath。
testImplementation project(':platform:platform-web')
}
@@ -0,0 +1,166 @@
package com.continental.retailapp.architecture
import com.continental.retailapp.platform.web.ApiResult
import com.tngtech.archunit.base.DescribedPredicate
import com.tngtech.archunit.core.domain.JavaClass
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.core.importer.ImportOption
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
/**
* 把 01-project-structure.md / 02-layering.md 里定的规则变成可执行测试。
*
* 这个模块依赖所有其他模块,是唯一能看到全代码库字节码的地方。
*
* 两个使用上的注意点:
* 1. `ImportOption.DoNotIncludeTests()` 不能省——测试代码里 mock、构造 Entity、跨层引用都是正常的。
* 2. 匹配不到任何类的规则默认会失败,还没建起来的包要配 `.allowEmptyShould(true)`
* 而不是把整条规则删掉。
*/
class ArchitectureRulesTest {
private val classes = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeTests())
.importPackages("com.continental.retailapp")
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
// —— 规则组一:模块边界(01-project-structure.md)——
@Test
fun `domain 之间只能通过 contract 包互相依赖`() {
domains.forEach { source ->
domains.filter { it != source }.forEach { target ->
noClasses()
.that().resideInAPackage("..retailapp.$source..")
.should().dependOnClassesThat(
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
.and(
DescribedPredicate.not(
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract.."),
),
),
)
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
// bff-orchestration 目前还是空模块,规则对它空转
.allowEmptyShould(true)
.check(classes)
}
}
}
@Test
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
noClasses()
.that().resideInAPackage("..retailapp.*.contract..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
.check(classes)
}
// —— 规则组二:层内方向(02-layering.md)——
@Test
fun `层依赖方向`() {
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("api").definedBy("..retailapp.*.api..")
.layer("application").definedBy("..retailapp.*.application..")
.layer("domain").definedBy("..retailapp.*.domain..")
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
// 对文档的有意偏离 #410-testing.md 原文是 api 层 mayNotBeAccessedByAnyLayer()。
// 但 02-layering.md 和 06-api-design.md 都规定 Response DTO 定义在 api 层、
// 而 Entity/投影 → Response 的转换(含 MapStruct mapper)必须发生在 application 层——
// 那么 application 返回 api 层的 Response 类型就是文档自己示例里就有的依赖
// `StoreAppService.listAccessibleStores(): List<StoreResponse>`)。
// 按原文写会把 02 自己的示例判红,所以放开 application → api 这一条。
// 真正的红线是下面单独一条"api 不能碰 infrastructure",它保持严格。
// 这和文档已经为 infrastructure 做过的同类调整是一个道理。
.whereLayer("api").mayOnlyBeAccessedByLayers("application")
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
.check(classes)
}
@Test
fun `api 层不能依赖 infrastructure 层`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.because("02-layering.mdapi 层不 import infrastructure 包下的任何类型(包括 Entity)")
.check(classes)
}
@Test
fun `domain 层不能依赖 Spring 或 JPA`() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.check(classes)
}
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
@Test
fun `Entity 只能待在 infrastructure 包里`() {
classes()
.that().haveSimpleNameEndingWith("Entity")
// platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
// 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
// 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
.and().resideOutsideOfPackage("..retailapp.platform..")
.should().resideInAPackage("..infrastructure..")
.check(classes)
}
@Test
fun `api 层不能触碰 Entity`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
.check(classes)
}
// —— 规则组四:编码约定 ——
@Test
fun `Controller 的端点方法必须返回 ApiResult`() {
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
// 用 metaAnnotatedWith 而不是 arePublic@GetMapping/@PostMapping 都是 @RequestMapping 的
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
.and().areMetaAnnotatedWith(RequestMapping::class.java)
.should().haveRawReturnType(ApiResult::class.java)
.because("统一响应结构,见 06-api-design.md")
.check(classes)
}
@Test
fun `禁止使用 java 时间类型的老 API`() {
noClasses()
.should().dependOnClassesThat()
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
.because("统一用 InstantUTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
}
@@ -0,0 +1,43 @@
package com.continental.retailapp.architecture
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.streams.toList
/**
* 03-persistence.md 承诺的那条扫描测试。
*
* MySQL 里 schema ≡ database**跨 database join 只要账号有权限就是合法的**,
* 编译器和 ArchUnit 都拦不住——它们看的是字节码,而库名是字符串。
* 所以额外加一条源码扫描作为软约束的第二道防线。
*/
class NativeQueryScanTest {
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
private val ownerByModule = mapOf(
"identity-store" to "identity_store",
"workbench" to "workbench",
"webview-ticket" to "webview_ticket",
)
private val allDatabases = ownerByModule.values.toSet()
@Test
fun `原生 SQL 里不能出现其他 domain 的库名`() {
val violations = Files.walk(Path.of("../domains"))
.filter { it.toString().endsWith(".kt") }
.toList()
.flatMap { file ->
val path = file.toString().replace('\\', '/')
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
val text = Files.readString(file)
(allDatabases - setOfNotNull(owner))
.filter { text.contains("$it.") }
.map { "$path 引用了 $it" }
}
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
}
}