155 lines
6.3 KiB
Markdown
155 lines
6.3 KiB
Markdown
# 03. 持久层方案
|
||||
|
|
|
|||
|
|
## 决策
|
|||
|
|
|
|||
|
|
Spring Data JPA + Hibernate 作为默认 ORM,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 只处理复杂查询,两者不互斥。
|
|||
|
|
|
|||
|
|
## 结构约定
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
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)
|