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

6.3 KiB
Raw Blame History

03. 持久层方案

决策

Spring Data JPA + Hibernate 作为默认 ORMFlyway 做 schema 迁移。

选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:

  • Kotlin + Spring Boot 生态里 JPA 是最主流、文档和踩坑资料最多的组合,团队上手成本低。
  • 大部分 domain 模块(identity-storewebview-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 示例

// 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 里的 StoreContextHolder,避免每个 domain 各写一份"当前操作人是谁"的逻辑。

Entity + Repository + Migration 示例(identity-store 里的门店表)

// 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>
}
-- 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 版本号(V1V2…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。

跨 domain 数据访问规则

每个 domain 独立 schema:即使同一个数据库实例,各 domains/* 的表也归属各自 schema,不允许跨 domain 直接 join 表——需要数据时通过对方模块暴露的 application 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分)。

// 错误示范: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 拆开:workbenchDataSource 配置的默认 schema 是 workbench,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。

代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。

待补充

  • 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
  • 复杂查询是否引入 QueryDSL/jOOQSpecification 不够用时再决定)。
  • 各 domain 的实际表结构,等开发到对应模块时再补。

参考链接