Skip to content

Commit c05c908

Browse files
authored
refactor: migrate to jdbi (#241)
迁移到 JDBI,原因如下: 1. AI 非常擅长编写 SQL,更不擅长 ktorm 这种 DSL 2. sql 更加灵活自由,可以方便地使用PG自身能力,配合freemarker可以有不错的灵活性 3. 逻辑安全性由单元测试保证,已引入 embedded pg 用于测试 ## Summary by Sourcery 将持久化层从 Ktorm 迁移到 Jdbi,引入由 Flyway 管理的数据库 schema,以及基于嵌入式 Postgres 的集成测试,并修复核心服务和任务中的若干查询与分页边界情况。 New Features: - 引入基于 Jdbi 的仓库与数据库访问配置,替代 Ktorm Bug Fixes: - 修复 CopilotScoreRefreshTask 的分页问题,避免在刷新热门分数时出现跳页情况 - 确保用户名搜索会对 LIKE 通配符进行转义,使 % 和 _ 被视为字面字符 Enhancements: - 重构服务层(copilot、comments、user、rating、follow、site message、segment、ark level),使其使用新的 Jdbi 仓库而非 Ktorm - 简化多处工具函数和配置函数,以提升可读性与一致性 - 调整 Copilot 分页的 hasNext 逻辑,更清晰地区分聚合与非聚合场景 Build: - 将构建迁移为使用 Jdbi 依赖而不是 Ktorm,添加 Flyway 和嵌入式 Postgres 以支持测试,并提升 Kotlin 和 Gradle 版本 Documentation: - 在 README 中记录基于 Flyway 的 schema 管理及基线策略 - 添加 AGENTS.md,描述架构以及数据库/测试环境的设置 Tests: - 使用 zonky 嵌入式 Postgres 与 Flyway 迁移,为 Jdbi 仓库与评分服务增加大量集成测试 - 为数据库基础设施和 LocalDateTime 绑定添加冒烟测试 Chores: - 引入 Flyway 迁移脚本 V1__init.sql 定义核心表结构,使 docker 初始化 schema 与代码预期保持一致 <details> <summary>Original summary in English</summary> ## Summary by Sourcery Migrate the persistence layer from Ktorm to Jdbi, introduce Flyway-managed schema and embedded Postgres-backed integration tests, and fix several query and pagination edge cases in core services and tasks. New Features: - Introduce Jdbi-based repositories and configuration for database access, replacing Ktorm Bug Fixes: - Fix CopilotScoreRefreshTask pagination bug that could skip pages when refreshing hot scores - Ensure user name search escapes LIKE wildcards so % and _ are treated literally Enhancements: - Refactor service layer (copilot, comments, user, rating, follow, site message, segment, ark level) to use new Jdbi repositories instead of Ktorm - Simplify multiple utility and config functions for better readability and consistency - Adjust Copilot pagination hasNext logic to more clearly reflect aggregation vs non-aggregation cases Build: - Migrate build to use Jdbi dependencies instead of Ktorm, add Flyway and embedded Postgres for tests, and bump Kotlin and Gradle versions Documentation: - Document Flyway-based schema management and baseline strategy in README - Add AGENTS.md describing architecture and database/testing setup Tests: - Add extensive integration tests for Jdbi repositories and rating service using zonky embedded Postgres and Flyway migrations - Add smoke tests for database infrastructure and LocalDateTime binding Chores: - Introduce Flyway V1__init.sql migration to define core tables, aligning docker init schema with code expectations </details>
2 parents ec41d62 + e89f361 commit c05c908

96 files changed

Lines changed: 8444 additions & 2088 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
HELP.md
2+
.pi
3+
4+
# binary
25
target/
36
build/
47
!.mvn/wrapper/maven-wrapper.jar

AGENTS.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# ZootPlusBackend
2+
3+
## 项目概览
4+
5+
- 技术栈:Spring Boot 4 / Kotlin 2.4 / Java 25 / Gradle 9 / PostgreSQL 18
6+
- 持久层:Jdbi 3(SQL Freemarker 模板 + SqlObject DAO);测试用 zonky embedded-postgres
7+
- Schema:Flyway 11(`src/main/resources/db/migration/`),只追加不修改已发布版本
8+
- 改动后必须跑 `./gradlew ktlintFormat`
9+
10+
## 数据库访问
11+
12+
### 实体映射
13+
14+
- 实体为 Kotlin `data class`,KotlinMapper 默认 snake_case aware(`user_id``userId`
15+
- 枚举(`CopilotType`/`CopilotSetStatus`/`CommentStatus`/`RatingType`/`SiteMessageType`):PG text 列存枚举 `name`,Jdbi 默认按 name 绑定/映射,无需自定义工厂
16+
- jsonb 列(`copilot_set.copilot_ids: List<Long>`):须自定义 `ColumnMapperFactory` + `ArgumentFactory`(见 `CopilotSetRepository.kt``CopilotIdsColumnMapperFactory`)。坑:Kotlin `List<Long>` 反射出精确与协变两种 Java 形态,只匹配一种会致 PG 数组解码抛 `ArrayIndexOutOfBoundsException`
17+
- `Instant` 列(如 `user.pwd_update_time` 为 timestamp(3))须列映射器
18+
19+
### SqlObject
20+
21+
- 插入:`@SqlUpdate` + `@BindKotlin` 整对象绑定 + `@GetGeneratedKeys("id")`;自增 id 不在 INSERT 列清单时方法须加 `@AllowUnusedBindings`
22+
- `jdbi.onDemand(XxxDao::class.java)` 每次方法调用独立开/关 handle:`jdbi.useTransaction` 事务内调用 onDemand DAO 方法**不会**加入该事务;需要原子性时须在同一 handle 上执行(`handle.attach(XxxDao::class.java)` 或直接用该 handle 的语句对象)
23+
- `jdbi.withHandle<R, X>``X` 仅出现在 throws 子句时 Kotlin 无法推断类型参数,须显式写全两个类型参数(如 `jdbi.withHandle<Long, Exception> { ... }`
24+
- `@Define` 只提供模板变量不产生绑定:freemarker `<#if xxx??>` 判断用 `@Define`,SQL 里 `:xxx` 绑定须同一参数叠加 `@Bind("xxx")`;条件不成立时绑定不被引用,靠 `@AllowUnusedBindings` 容忍
25+
- 动态条件 SQL 用 `@UseFreemarkerEngine` + `<#if>`,仅标注在需要的方法上(全局启用会使普通 SQL 的 `<` 与 FTL 冲突)
26+
27+
### SQL 实践
28+
29+
- 分页查询必须带 `ORDER BY`
30+
- PG 保留字 `"delete"``"user"` 须加双引号
31+
- 空集合 `IN ()`:DAO 层防御性返回空列表
32+
- jsonb 包含查询:`col @> :jsonText::jsonb`,绑定 JSON 数组文本
33+
34+
## 测试
35+
36+
- 基类 `TestDbSupport.kt`:JVM 级单例 embedded PG(多类共享),Flyway 建表(与生产共用 `db/migration`),`@BeforeEach` TRUNCATE 全部业务表 `RESTART IDENTITY CASCADE`
37+
- 不依赖 Spring 上下文(无 `@SpringBootTest`),repository 测试直接 `XxxRepository(jdbi)` 构造
38+
- mockk mock 服务依赖(如 `ArkLevelService`),DB 用真实库
39+
- 测试库选 zonky embedded-postgres(纯 JVM 嵌入式 PG)而非 testcontainers:开发机为 WSL 无 docker
40+
41+
## Flyway
42+
43+
- 迁移文件 `V{n}__描述.sql`,只追加不修改已发布版本,优先幂等安全,有问题先反问开发者
44+
- 生产由 Spring Boot 自动配置执行(`spring.flyway.enabled: true`);测试在 `TestDbSupport` 手动 `migrate()`
45+
46+
## 遗留事项(基线行为,改动需谨慎)
47+
48+
- `updateEntity` 两派语义:rating / copilot 用脏检查快照复刻 Ktorm `flushChanges`(读回时存快照,updateEntity 与快照 diff 只 SET 变化列;无快照时退化全列 SET),其余(comments_area / copilot_set / site_message / user)全列 SET;ark_level 已移除 `updateEntity`(无 production 调用方),save/saveAll 统一走 INSERT ... ON CONFLICT (id) DO UPDATE 全列 upsert
49+
- `save()` 显式指定不存在的 id 时按给定值插入且不推进自增序列(Ktorm 基线)
50+
- LIKE 通配符不转义:`ArkLevelRepository.findByLevelIdFuzzy` / `CopilotSetService.query` 的 keyword 的 `%``_` 按 PG LIKE 通配符解释(基线行为,非 SQL 注入风险,参数化绑定);`UserService.search` 已转义 `%`/`_`,搜索词按字面匹配(LIKE ... ESCAPE `\\`
51+
- 事务:jdbi3-spring 依赖在 classpath 但 `SpringTransactionPlugin` **未安装**`@Transactional` 不覆盖 Jdbi),repository 内事务由 `jdbi.useTransaction` 自管

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,38 @@
1414
## 本地开发指南
1515

1616
1. 你需要一个有 Valkey (或Redis) 和 PostgreSQL 的环境,如果你是windows用户,可以从 [redis-windows](https://github.com/redis-windows/redis-windows/releases) 中下载Redis使用。 您也可以直接使用 [](./dev-docker/docker-compose.yml) 来启动 docker 服务
17-
2. 通过 [](docker/init.sql) 初始化数据库
17+
2. 无需手动初始化数据库:应用首次启动时 Flyway 会根据 `src/main/resources/db/migration` 自动建表
1818
3. 使用你喜欢的 IDE 导入此项目,复制 [](/src/main/resources/application-template.yml) 到同目录下,命名为 `application-dev.yml`,修改数据库配置以符合你自己配置的环境。
1919
4. 下载安装 JDK 25 或者以上版本的 JDK, 可以考虑从 [zuluJDK](https://www.azul.com/downloads/?version=java-25-lts&package=jdk) 或者 [libreicaJDK](https://bell-sw.com/pages/downloads/#jdk-25-lts) 下载安装。 Jetbrains Idea 可以使用自带的 JDK 管理器进行下载
2020
5. 运行 `./gradlew bootRun`, windows 环境为 `./gradlew.bat bootRun`
2121
6. 首次运行建议修改配置文件中的 `maa-copilot.task-cron.ark-level` 配置,这样可以将明日方舟中的关卡数据同步到你本地的
2222
数据库中,为了防止反复调用造成调试的麻烦,建议首次运行同步成功后再将配置修改回去
2323
7. 本项目使用 [ScalaR](https://github.com/ScalaR/ScalaR) 作为 OpenAPI 展示工具,本地启动时可通过 http://127.0.0.1:8848/scalar 调试
2424

25+
## 数据库迁移(Flyway)
26+
27+
本项目使用 [Flyway](https://flywaydb.org/) 管理数据库 Schema,迁移脚本位于 `src/main/resources/db/migration/`,首次接入由 `V1__init.sql` 建表。Spring Boot 会在应用启动时自动执行迁移(`spring.flyway.enabled: true`)。
28+
29+
### 已有库表的老项目接入
30+
31+
如果你的数据库已经建好全部表(例如旧版本中使用过已移除的 `docker/init.sql`),**首次启动接入 Flyway 前必须先建立 baseline**,否则 Flyway 会在「非空 schema 且无 `flyway_schema_history` 表」时直接报错,导致应用启动失败。
32+
33+
操作方法:在 `application.yml`(或你的 `application-prod.yml` / `application-dev.yml`)中打开以下两项注释:
34+
35+
```yaml
36+
spring:
37+
flyway:
38+
enabled: true
39+
baseline-on-migrate: true
40+
baseline-version: 1 # V1__init.sql 视为已应用并跳过,不会重建/破坏现有表
41+
```
42+
43+
首次启动后 Flyway 会创建 `flyway_schema_history` 并写入 baseline(版本 1),`V1__init.sql` 因版本号 ≤ baseline 被跳过,后续 `V2__...` 迁移才会真正执行。baseline 成功后即可把这两项重新注释掉。
44+
45+
> 注意:baseline 跳过的 `V1__init.sql` 并不保证与线上实际结构完全一致(存在个别默认值差异),后续新增迁移若依赖「V1 = 当前线上结构」这一假设时请另行核对。
46+
47+
全新空库无需此操作,`V1__init.sql` 会正常建表。
48+
2549
## 项目结构
2650

2751
- config # 存放 spring 配置

build.gradle.kts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ plugins {
1111
id("com.gorylenko.gradle-git-properties") version "3.0.2"
1212
id("io.freefair.aspectj.post-compile-weaving") version "9.5.0"
1313

14-
val ktVersion = "2.4.0"
14+
val ktVersion = "2.4.10"
1515
kotlin("jvm") version ktVersion
1616
kotlin("plugin.spring") version ktVersion
1717
kotlin("plugin.serialization") version ktVersion
@@ -51,7 +51,6 @@ repositories {
5151
}
5252

5353
dependencies {
54-
val ktormVersion = "4.2.1"
5554
val hutoolVersion = "5.8.47"
5655
val mapstructVersion = "1.6.3"
5756

@@ -60,6 +59,16 @@ dependencies {
6059
testImplementation("io.mockk:mockk:1.14.11")
6160
testImplementation("org.springframework.boot:spring-boot-starter-test")
6261

62+
// 平台 binary 按当前构建机自动选择(zonkyBinaryArtifact,见文件底部函数),不固定平台:
63+
// 默认传递依赖会引入全部平台 jar,这里排除后只引入当前系统对应的一个
64+
testImplementation("io.zonky.test:embedded-postgres:2.2.2") {
65+
exclude(group = "io.zonky.test.postgres")
66+
}
67+
testImplementation(zonkyBinaryArtifact())
68+
69+
implementation("org.flywaydb:flyway-core")
70+
implementation("org.flywaydb:flyway-database-postgresql")
71+
6372
implementation("org.aspectj:aspectjrt:1.9.25.1")
6473
implementation("org.springframework:spring-aspects")
6574
implementation("org.springframework.boot:spring-boot-starter-web")
@@ -85,10 +94,17 @@ dependencies {
8594
// kotlin-logging
8695
implementation("io.github.oshai:kotlin-logging-jvm:8.0.4")
8796

88-
// ktorm connect with spring-jdbc
8997
implementation("org.springframework.boot:spring-boot-starter-jdbc")
90-
implementation("org.ktorm:ktorm-core:$ktormVersion")
91-
implementation("org.ktorm:ktorm-support-postgresql:$ktormVersion")
98+
implementation(platform("org.jdbi:jdbi3-bom:3.54.0"))
99+
implementation("org.jdbi:jdbi3-core")
100+
implementation("org.jdbi:jdbi3-sqlobject")
101+
implementation("org.jdbi:jdbi3-kotlin")
102+
implementation("org.jdbi:jdbi3-kotlin-sqlobject")
103+
implementation("org.jdbi:jdbi3-postgres")
104+
implementation("org.jdbi:jdbi3-spring")
105+
// 动态 SQL 解析结果由 jdbi3-caffeine-cache 缓存
106+
implementation("org.jdbi:jdbi3-freemarker")
107+
implementation("org.jdbi:jdbi3-caffeine-cache")
92108
implementation("org.postgresql:postgresql:42.7.13")
93109
// hutool 的邮箱工具类依赖
94110
implementation("com.sun.mail:javax.mail:1.6.2")
@@ -109,6 +125,7 @@ dependencies {
109125
implementation("com.networknt:json-schema-validator:1.5.8")
110126

111127
implementation("com.belerweb:pinyin4j:2.5.1")
128+
testImplementation(kotlin("test"))
112129
}
113130

114131
val swaggerOutputDir = layout.buildDirectory.dir("docs")
@@ -125,22 +142,18 @@ val swaggerInputFile = swaggerOutputDir.get().file(swaggerOutputName)
125142
val clientDir = layout.buildDirectory.dir("clients")
126143

127144
// Helper: register an OpenAPI code-gen task using the official plugin's GenerateTask
128-
fun TaskContainer.registerOpenApiGen(
129-
name: String,
130-
language: String,
131-
configFilePath: String,
132-
outputSubDir: String,
133-
) = register<GenerateTask>("generateSwaggerCode$name") {
134-
group = "swagger"
135-
description = "Generate $name client code from OpenAPI spec"
136-
137-
dependsOn("generateOpenApiDocs")
138-
139-
generatorName.set(language)
140-
inputSpec.set(swaggerInputFile.asFile.absolutePath)
141-
outputDir.set(clientDir.map { it.dir(outputSubDir) }.get().asFile.absolutePath)
142-
configFile.set(file(configFilePath))
143-
}
145+
fun TaskContainer.registerOpenApiGen(name: String, language: String, configFilePath: String, outputSubDir: String) =
146+
register<GenerateTask>("generateSwaggerCode$name") {
147+
group = "swagger"
148+
description = "Generate $name client code from OpenAPI spec"
149+
150+
dependsOn("generateOpenApiDocs")
151+
152+
generatorName.set(language)
153+
inputSpec.set(swaggerInputFile.asFile.absolutePath)
154+
outputDir.set(clientDir.map { it.dir(outputSubDir) }.get().asFile.absolutePath)
155+
configFile.set(file(configFilePath))
156+
}
144157

145158
tasks {
146159
registerOpenApiGen("TsFetch", "typescript-fetch", "client-config/ts-fetch.json", "ts-fetch-client")
@@ -178,3 +191,26 @@ ktlint {
178191
reporter(ReporterType.PLAIN)
179192
}
180193
}
194+
195+
/**
196+
* 使用构建平台的pg二进制
197+
*
198+
* zonky 的二进制命名规律:`embedded-postgres-binaries-os-arch`
199+
* os ∈ {linux, darwin, windows},arch ∈ {amd64, arm64v8, i386, ppc64le}
200+
*/
201+
fun zonkyBinaryArtifact(): String {
202+
val os = System.getProperty("os.name").lowercase()
203+
val arch = System.getProperty("os.arch").lowercase()
204+
val platform = when {
205+
os.contains("linux") && (arch.contains("amd64") || arch.contains("x86_64")) -> "linux-amd64"
206+
os.contains("linux") && (arch.contains("aarch64") || arch.contains("arm64")) -> "linux-arm64v8"
207+
os.contains("linux") && arch.contains("86") -> "linux-i386"
208+
os.contains("linux") && arch.contains("ppc64") -> "linux-ppc64le"
209+
os.contains("mac") && (arch.contains("amd64") || arch.contains("x86_64")) -> "darwin-amd64"
210+
os.contains("mac") && (arch.contains("aarch64") || arch.contains("arm64")) -> "darwin-arm64v8"
211+
os.contains("win") && (arch.contains("amd64") || arch.contains("x86_64")) -> "windows-amd64"
212+
os.contains("win") && arch.contains("86") -> "windows-i386"
213+
else -> error("不支持的平台(zonky embedded-postgres):os.name=${System.getProperty("os.name")}, os.arch=${System.getProperty("os.arch")}")
214+
}
215+
return "io.zonky.test.postgres:embedded-postgres-binaries-$platform:18.4.0"
216+
}

docker/docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ services:
1515
- "5432:5432"
1616
volumes:
1717
- .././data/:/var/lib/postgresql/data/
18-
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
1918
environment:
2019
POSTGRES_PASSWORD: 1234
2120
zootplusbackend:

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
44
networkTimeout=10000
55
retries=0
66
retryBackOffMs=500

src/main/kotlin/plus/maa/backend/common/controller/Extensions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ package plus.maa.backend.common.controller
22

33
import org.springframework.data.domain.Page
44

5-
fun <T: Any> Page<T>.toDto() = PagedDTO(hasNext(), pageable.pageNumber + 1, totalElements, content)
5+
fun <T : Any> Page<T>.toDto() = PagedDTO(hasNext(), pageable.pageNumber + 1, totalElements, content)

src/main/kotlin/plus/maa/backend/common/extensions/KtormExtensions.kt

Lines changed: 0 additions & 100 deletions
This file was deleted.

src/main/kotlin/plus/maa/backend/common/utils/converter/ArkLevelEntityConverter.kt

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@ import plus.maa.backend.repository.entity.ArkLevelEntity
88
class ArkLevelEntityConverter {
99

1010
fun convertToEntityWithAutoId(arkLevel: ArkLevel): ArkLevelEntity {
11-
return ArkLevelEntity {
12-
this.levelId = arkLevel.levelId
13-
this.stageId = arkLevel.stageId
14-
this.sha = arkLevel.sha
15-
this.catOne = arkLevel.catOne
16-
this.catTwo = arkLevel.catTwo
17-
this.catThree = arkLevel.catThree
18-
this.name = arkLevel.name
19-
this.width = arkLevel.width
20-
this.height = arkLevel.height
21-
this.isOpen = arkLevel.isOpen
22-
this.closeTime = arkLevel.closeTime
23-
}
11+
// id 不设置(默认 0),由 repository insert 回填自增主键
12+
return ArkLevelEntity(
13+
levelId = arkLevel.levelId,
14+
stageId = arkLevel.stageId,
15+
sha = arkLevel.sha,
16+
catOne = arkLevel.catOne,
17+
catTwo = arkLevel.catTwo,
18+
catThree = arkLevel.catThree,
19+
name = arkLevel.name,
20+
width = arkLevel.width,
21+
height = arkLevel.height,
22+
isOpen = arkLevel.isOpen,
23+
closeTime = arkLevel.closeTime,
24+
)
2425
}
2526

2627
fun convertFromEntity(entity: ArkLevelEntity): ArkLevel {

0 commit comments

Comments
 (0)