Files
conti-docs/backend/03-persistence.md
T
Guangfei.Zhao 1e0cbb86a2 feat: Add comprehensive documentation for integration layer, API design, config governance, observability, build/deploy, and testing strategies
- Introduced integration layer design with Resilience4j for external vendor calls.
- Established API design standards with unified response structures and global exception handling.
- Defined configuration and service governance using Kubernetes native solutions.
- Implemented observability practices including trace ID propagation and structured logging.
- Outlined build and multi-environment deployment strategies using Gradle and GitLab CI/CD.
- Specified testing strategies across different layers, utilizing JUnit, MockK, Testcontainers, and WireMock.
2026-08-12 18:23:11 +08:00

155 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03. 持久层方案
## 决策
Spring Data JPA + Hibernate 作为默认 ORMFlyway 做 schema 迁移。
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
- Kotlin + Spring Boot 生态里 JPA 是最主流、文档和踩坑资料最多的组合,团队上手成本低。
- 大部分 domain 模块(`identity-store``webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
## 结构约定
```
platform-persistence/
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
PageResult<T> # 统一分页返回封装
JpaAuditingConfig # 开启 Spring Data JPA Auditing
domains/xxx/
src/main/kotlin/.../xxx/infrastructure/persistence/
XxxEntity # JPA entity
XxxJpaRepository # : JpaRepository<XxxEntity, Long>
src/main/resources/db/migration/xxx/
V1__init.sql # Flyway migration,按 domain 分子目录
```
## `BaseEntity` 示例
```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/.../JpaAuditingConfig.kt
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
class JpaAuditingConfig {
@Bean
fun auditorAware(): AuditorAware<String> = AuditorAware {
Optional.ofNullable(StoreContextHolder.currentUserIdOrNull()?.toString())
}
}
```
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
## 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, unique = true, length = 32)
var code: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: StoreStatus,
) : BaseEntity()
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
fun findByCode(code: String): StoreEntity?
fun findByStatus(status: StoreStatus): List<StoreEntity>
}
```
```sql
-- src/main/resources/db/migration/identity_store/V1__init.sql
create schema if not exists identity_store;
create table identity_store.store (
id bigint generated always as identity primary key,
name varchar(128) not null,
code varchar(32) not null unique,
status varchar(16) not null,
created_at timestamp not null,
updated_at timestamp not null,
created_by varchar(64),
updated_by varchar(64)
);
```
Flyway 版本号(`V1``V2`…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。
## 跨 domain 数据访问规则
**每个 domain 独立 schema**:即使同一个数据库实例,各 `domains/*` 的表也归属各自 schema,不允许跨 domain 直接 `join` 表——需要数据时通过对方模块暴露的 `application` 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分)。
```kotlin
// 错误示范:workbench 直接 join identity_store 的表
@Query("""
select w from WorkbenchTileEntity w
join StoreEntity s on s.id = w.storeId -- 跨 schema 直接 join,禁止
""")
fun findTilesWithStoreInfo(): List<WorkbenchTileEntity>
// 正确做法:workbench 通过 identity-store 暴露的接口获取门店信息
@Service
class WorkbenchAppService(
private val storeQueryService: StoreQueryService, // identity-store 模块对外暴露的接口
private val tileRepository: WorkbenchTileRepository,
) {
fun listTiles(userId: Long): List<TileResponse> {
val stores = storeQueryService.listStoresByUserId(userId) // 走 application 层调用,不查表
val tiles = tileRepository.findByUserId(userId)
return buildTiles(tiles, stores)
}
}
```
## 附录:为什么坚持"每个 domain 独立 schema"
模块化单体最容易被破坏的地方就是数据库——代码层面 Gradle 依赖规则挡住了跨 domain import 类,但如果两个 domain 的表都在同一个 schema 下,写 SQL 的时候很容易"顺手 join 一下",这条规则完全不受编译器约束,只能靠约定。所以我们把 schema 拆开:`workbench``DataSource` 配置的默认 schema 是 `workbench`,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。
代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
## 待补充
- 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
- 复杂查询是否引入 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)