feat(processing): 实现自有 PostgreSQL 运营航班权威存储与单事务闭环 (ACM2-28)

- FS1: 增加 Flyway 迁移 V1.1.0__flight_schd.sql,创建 FLIGHT_SCHD 与 SCHD_GEN
- FS2: 实现 FlightSchdRepository 接口及 JdbcFlightSchdRepository 与 StubFlightSchd,增强 JdbcOps 事务管理
- FS3: 扩展 MessageProcessor 事务 2 与按 FLID 点查视图,合并变更、事件与终态入单事务提交
- FS4: SnapshotFlow SQL 化(批处理 upsert、域内差删、SQL CAS 推进与熔断保护),JobExecutor 接入 PG 清场删除
- FS5: 彻底退役 Redis 权威与写路径,移除 FlightRedisClient、Lua 脚本、健康指示器与配置残留
- FS6: 补齐 U09/U29 不变量门禁(崩溃幂等、CAS 防并发、ADFT 存活保障、非 UTC JVM/会话时区无漂移)与 FlywayMigrationTest
- FS7: 交付影子对拍比较内核 FlightStoreDiffTool 与单元测试
- FS8: 全面回改 decision-flight-state、architecture、design、user-stories 权威文档与规范
This commit is contained in:
windyboy
2026-09-07 16:12:00 +08:00
parent dd4bbc86ca
commit 7b7b61f100
37 changed files with 2037 additions and 382 deletions
@@ -71,7 +71,6 @@ class DispatcherTickTest {
}
override fun indexFlightHts(payloadJson: String) = Unit
override fun projectRedis(payloadJson: String) = Unit
}
private val clock = MutableClock(MutableClock.BASE)
@@ -184,23 +183,4 @@ class DispatcherTickTest {
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
}
@Test
fun `delete events always carry legal JSON refs - null and non-null partitionKey`() {
val props = PipelineProps().apply { phase = PipelineProps.Phase.B }
val repo = FakeRepo()
val port = FakePort()
val d = dispatcher(repo, port, props)
repo.enqueue(ev(1, Targets.ES_FLIGHT_HTS, "F1", """{"h":1}"""))
repo.enqueue(ev(2, Targets.ES_FLIGHT_HTS, null, """{"h":2}"""))
d.tick(); d.tick() // 每 target 每 tick 仅出队一条:两条 ES 事件需两个 tick
val deletes = repo.syncInserted.filter { it.target == Targets.REDIS_FLIGHT_INFO }
assertEquals(2, deletes.size)
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
val parsed = deletes.map { mapper.readTree(it.payloadJson) } // readTree 即合法性断言
assertEquals(listOf("delete", "delete"), parsed.map { it.get("op").asText() })
assertEquals("F1", parsed[0].get("refs").asText()) // 非空 → 带引号字符串
assertTrue(parsed[1].get("refs").isNull) // null → null 字面量
}
}
@@ -1,8 +1,6 @@
package com.gzzn.omms.msgexchange.infra.health
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import io.micronaut.health.HealthStatus
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -13,40 +11,18 @@ import org.junit.jupiter.api.Test
*/
class HealthIndicatorsTest {
private class FakeRedis(private val pingResult: Boolean) : FlightRedisClient {
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
override fun ping(): Boolean = pingResult
}
private class ThrowingRedis : FlightRedisClient {
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
override fun ping(): Boolean = throw RuntimeException("connection refused")
}
private class FakePort(private val pingResult: Boolean) : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit
override fun projectRedis(payloadJson: String) = Unit
override fun ping(): Boolean = pingResult
}
private class ThrowingPort : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit
override fun projectRedis(payloadJson: String) = Unit
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
}
@Test
fun `redis up only when ping succeeds`() {
assertEquals(HealthStatus.UP, redisHealth(FakeRedis(true)).status)
assertEquals(HealthStatus.DOWN, redisHealth(FakeRedis(false)).status)
assertEquals(HealthStatus.DOWN, redisHealth(ThrowingRedis()).status)
assertEquals(HealthStatus.DOWN, redisHealth(null).status) // bean 缺失
}
@Test
fun `kafka up only when ping succeeds`() {
assertEquals(HealthStatus.UP, kafkaHealth(FakePort(true)).status)
@@ -0,0 +1,289 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import com.zaxxer.hikari.HikariDataSource
import java.sql.DriverManager
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* ACM2-28 FS6PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository
* 验证:
* 1. DNLD 快照批处理写入(JDBC batch200 批次);
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
* 3. 域化差删(按 FDAY 严格隔离);
* 4. SCHD_GEN SQL CAS(防并发断言);
* 5. 单事务原子回滚(崩溃无残留);
* 6. ES 历史清场 deleteByFlids 幂等删除。
*/
class FlightSchdJdbcPgTest {
private lateinit var ds: HikariDataSource
private lateinit var repo: JdbcFlightSchdRepository
@BeforeEach
fun setUp() {
val url = "jdbc:postgresql://localhost:5432/msgx"
val user = "msgx_dev"
val pass = "msgx_dev_pass"
// 仅当本地 PostgreSQL 容器可用时执行
val canConnect = try {
DriverManager.getConnection(url, user, pass).use { true }
} catch (_: Exception) {
false
}
assumeTrue(canConnect, "Local PostgreSQL on port 5432 is not accessible, skipping PG dialect integration tests")
ds = HikariDataSource().apply {
jdbcUrl = url
username = user
this.password = pass
driverClassName = "org.postgresql.Driver"
maximumPoolSize = 2
}
repo = JdbcFlightSchdRepository(ds)
cleanup()
}
@AfterEach
fun tearDown() {
if (::ds.isInitialized) {
cleanup()
ds.close()
}
}
private fun cleanup() {
try {
ds.update("DELETE FROM flight_schd WHERE flid LIKE 'TEST_%'") {}
ds.update("DELETE FROM schd_gen WHERE fday >= '2026-09-01'") {}
} catch (_: Exception) {
}
}
@Test
fun `PG dialect - snapshot batch upsert, find, and domain diff delete`() {
val day = "2026-09-07"
// 构造 250 条记录跨越 batchSize 200 门限
val flights = (1..250).map { i ->
"TEST_FL_$i" to """{"FLID":"TEST_FL_$i","air":"CA$i"}"""
}
repo.upsertSnapshotBatch(day, flights)
// 验证全量写入成功
val byDay = repo.findByDay(day)
assertEquals(250, byDay.size)
// 点查测试
val f1 = repo.findByFlid("TEST_FL_1")
assertNotNull(f1)
assertTrue(f1!!.contains("CA1"))
// 多键点查测试(分批查)
val subset = (1..50).map { "TEST_FL_$it" }
val map = repo.findByFlids(subset)
assertEquals(50, map.size)
// 域内差删:删除 1..50
val deleted = repo.deleteDiffByDay(day, subset)
assertEquals(50, deleted)
assertEquals(200, repo.findByDay(day).size)
assertNull(repo.findByFlid("TEST_FL_1"))
}
@Test
fun `PG dialect - incremental upsert preserves existing FDAY and sets NULL for new`() {
val day = "2026-09-07"
// 1. 快照写入 REG_01FDAY 为 2026-09-07
repo.upsertSnapshotBatch(day, listOf("TEST_REG_01" to """{"FLID":"TEST_REG_01","v":1}"""))
// 2. 增量更新 REG_01(已有行)与 ADFT_01(新行)
val changes = listOf(
FlightChange("TEST_REG_01", """{"FLID":"TEST_REG_01","v":2}"""),
FlightChange("TEST_ADFT_01", """{"FLID":"TEST_ADFT_01","v":1}"""),
)
repo.upsertIncremental(changes)
// 3. 验证 REG_01 内容更新但 FDAY 依然保留为 2026-09-07
val reg01 = ds.queryOne("SELECT fday, fltr_json FROM flight_schd WHERE flid = 'TEST_REG_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("fltr_json")
}
assertNotNull(reg01)
assertEquals("2026-09-07", reg01!!.first)
assertTrue(reg01.second.contains("\"v\": 2") || reg01.second.contains("\"v\":2"))
// 4. 验证 ADFT_01 新插入行 FDAY 恒为 NULL
val adft01 = ds.queryOne("SELECT fday, fltr_json FROM flight_schd WHERE flid = 'TEST_ADFT_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("fltr_json")
}
assertNotNull(adft01)
assertNull(adft01!!.first)
// 5. 执行 2026-09-07 的差删,ADFT_01 绝不被误删
val deleted = repo.deleteDiffByDay(day, listOf("TEST_ADFT_01"))
assertEquals(0, deleted)
assertNotNull(repo.findByFlid("TEST_ADFT_01"))
}
@Test
fun `PG dialect - SCHD_GEN SQL CAS version advance and conflict rejection`() {
val day = "2026-09-07"
// 初始 CAS: expected = 0,版本推进至 1
val gen1 = FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_A", "TEST_B"))
val ok1 = repo.putGenIfVersion(day, 0L, gen1)
assertTrue(ok1)
val stored1 = repo.getGen(day)
assertNotNull(stored1)
assertEquals(1L, stored1!!.version)
assertEquals(setOf("TEST_A", "TEST_B"), stored1.flids)
// 成功 CAS: expected = 1,版本推进至 2
val gen2 = FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_A", "TEST_C"))
val ok2 = repo.putGenIfVersion(day, 1L, gen2)
assertTrue(ok2)
assertEquals(2L, repo.getGen(day)!!.version)
// 冲突 CAS: expected 依然传 1(已过期的旧版本),应被拒并返回 false
val genConflict = FlightSchdRepository.GenMeta(day, 3L, setOf("TEST_CONFLICT"))
val conflictResult = repo.putGenIfVersion(day, 1L, genConflict)
assertFalse(conflictResult)
// 确认版本未被非法篡改,仍为 2
assertEquals(2L, repo.getGen(day)!!.version)
}
@Test
fun `PG dialect - Transaction rollback leaves zero partial rows on failure`() {
val day = "2026-09-07"
try {
ds.withTransaction {
repo.upsertSnapshotBatch(day, listOf("TEST_TX_01" to """{"tx":1}"""))
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TX_01")))
// 模拟事务内部抛出异常
throw IllegalStateException("forced-abort-for-rollback-test")
}
} catch (_: IllegalStateException) {
// 异常捕获
}
// 断言:由于事务原子回滚,数据库中零残留
assertNull(repo.findByFlid("TEST_TX_01"))
assertNull(repo.getGen(day))
}
@Test
fun `PG dialect - ES history sweep deleteByFlids idempotent batch execution`() {
val day = "2026-09-07"
val flids = (1..50).map { "TEST_SWEEP_$it" }
repo.upsertSnapshotBatch(day, flids.map { it to """{"flid":"$it"}""" })
assertEquals(50, repo.findByFlids(flids).size)
// 第一次分批删除
val deleted1 = repo.deleteByFlids(flids.toSet())
assertEquals(50, deleted1)
assertEquals(0, repo.findByFlids(flids).size)
// 重放删除:安全返回 0,无副作用
val deleted2 = repo.deleteByFlids(flids.toSet())
assertEquals(0, deleted2)
}
@Test
fun `PG dialect - non-UTC JVM default timezone preserves exact Instant timestamps without drift`() {
val originalTz = TimeZone.getDefault()
try {
// 1. 设置 JVM 默认时区为东京 (UTC+09:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val day = "2026-09-07"
val fixedInstant = Instant.parse("2026-09-07T08:30:15.123456Z")
repo.upsertSnapshotBatch(day, listOf("TEST_TZ_01" to """{"tz":1}"""), fixedInstant)
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TZ_01")), fixedInstant)
// 东京时区下回读
val genTokyo = repo.getGen(day)
assertNotNull(genTokyo)
assertEquals(fixedInstant, genTokyo!!.updatedAt)
// 2. 切换 JVM 默认时区为纽约 (UTC-05:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
// 纽约时区下再次回读同一条记录
val genNy = repo.getGen(day)
assertNotNull(genNy)
// 断言:TIMESTAMPTZ 映射到 Instant 完全相等,绝无 JVM 时区偏移
assertEquals(fixedInstant, genNy!!.updatedAt)
val flightNy = ds.queryOne("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_TZ_01'", {}) { rs ->
rs.getInstant("created_at") to rs.getInstant("updated_at")
}
assertNotNull(flightNy)
assertEquals(fixedInstant, flightNy!!.first)
assertEquals(fixedInstant, flightNy.second)
} finally {
TimeZone.setDefault(originalTz)
}
}
@Test
fun `PG dialect - non-UTC database session timezone produces zero Instant drift across sessions`() {
val day = "2026-09-07"
val writeInstant = Instant.parse("2026-09-07T14:45:00.654321Z")
// 1. 在会话 1 中显式设置会话时区为 'Asia/Shanghai' (+08:00) 并写入数据
ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'Asia/Shanghai'") }
conn.prepareStatement(
"""
INSERT INTO flight_schd (flid, fday, fltr_json, created_at, updated_at)
VALUES ('TEST_SESS_01', '2026-09-07', '{"session":"shanghai"}'::jsonb, ?, ?)
""".trimIndent(),
).use { ps ->
ps.setTimestamp(1, writeInstant.toSqlTimestamp())
ps.setTimestamp(2, writeInstant.toSqlTimestamp())
ps.executeUpdate()
}
}
// 2. 在会话 2 中显式设置会话时区为 'America/Chicago' (-05:00) 并回读数据
val instantChicago = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'America/Chicago'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 3. 在会话 3 中显式设置会话时区为 'UTC' 并回读数据
val instantUtc = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'UTC'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 断言:跨不同数据库会话时区(+08:00 vs -05:00 vs UTC),读取出的 Instant 与写入的 UTC Instant 完全一致!
assertEquals(writeInstant, instantChicago)
assertEquals(writeInstant, instantUtc)
assertEquals(instantChicago, instantUtc)
}
}
@@ -0,0 +1,74 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import java.sql.DriverManager
/**
* P2-1 验证测试:Flyway 迁移引擎在真实 PostgreSQL 上的端到端自动迁移验证。
* 断言:
* 1. Flyway 自动执行 V1.0.0__own_pg_pipeline.sql 与 V1.1.0__flight_schd.sql
* 2. 真实落库 flyway_schema_history 元数据表,且 success = true
* 3. 运营航班表 FLIGHT_SCHD 与计划代 SCHD_GEN 结构完整就绪。
*/
class FlywayMigrationTest {
@Test
fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() {
val url = "jdbc:postgresql://localhost:5432/msgx"
val user = "msgx_dev"
val pass = "msgx_dev_pass"
val canConnect = try {
DriverManager.getConnection(url, user, pass).use { true }
} catch (_: Exception) {
false
}
assumeTrue(canConnect, "PostgreSQL on port 5432 not accessible, skipping Flyway test")
val flyway = Flyway.configure()
.dataSource(url, user, pass)
.locations("classpath:db/migration")
.load()
val migrateResult = flyway.migrate()
assertTrue(migrateResult.success, "Flyway migration must succeed")
// 验证 flyway_schema_history 元数据表与执行记录
DriverManager.getConnection(url, user, pass).use { conn ->
conn.createStatement().use { stmt ->
stmt.executeQuery(
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
).use { rs ->
val records = mutableListOf<Triple<String, String, Boolean>>()
while (rs.next()) {
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
}
assertTrue(records.size >= 2, "At least 2 migrations must be recorded in flyway_schema_history")
assertEquals("1.0.0", records[0].first)
assertEquals("V1.0.0__own_pg_pipeline.sql", records[0].second)
assertTrue(records[0].third)
assertEquals("1.1.0", records[1].first)
assertEquals("V1.1.0__flight_schd.sql", records[1].second)
assertTrue(records[1].third)
}
// 验证 FLIGHT_SCHD 与 SCHD_GEN 存在
stmt.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('flight_schd', 'schd_gen')",
).use { rs ->
val tables = mutableSetOf<String>()
while (rs.next()) {
tables.add(rs.getString("table_name"))
}
assertTrue(tables.contains("flight_schd"))
assertTrue(tables.contains("schd_gen"))
}
}
}
}
}
@@ -0,0 +1,167 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* ACM2-28 FS6 历史清场测试:
* 覆盖 ES 全成功、部分成功、全部失败、ES 成功后 PG 删除前崩溃、PG 删除重放五种场景;
* 断言失败项不丢、成功项最终删除、重复执行无副作用。
*/
class HistorySweepJobTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var sweepJob: HistorySweepJob
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
sweepJob = HistorySweepJob(flightSchd)
HistorySweepJob.historyPicker = null
HistorySweepJob.esArchiver = null
HistorySweepJob.cutoffProvider = null
}
@AfterEach
fun tearDown() {
HistorySweepJob.historyPicker = null
HistorySweepJob.esArchiver = null
HistorySweepJob.cutoffProvider = null
}
// 1. 场景一:ES 全成功
@Test
fun `Scenario 1 - ES All Success removes all confirmed flights and cleans old gens`() {
val flights = listOf(
"F1" to """{"FLID":"F1"}""",
"F2" to """{"FLID":"F2"}""",
"F3" to """{"FLID":"F3"}""",
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3")))
// 模拟判史:F1, F2, F3 全部为历史候选
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:全部成功返回
HistorySweepJob.esArchiver = { it.keys }
// 模拟历史代清理:清理 2026-09-02 之前的代
HistorySweepJob.cutoffProvider = { "2026-09-02" }
sweepJob.run()
// 断言:PG 中 F1, F2, F3 全部被删除
assertNull(flightSchd.findByFlid("F1"))
assertNull(flightSchd.findByFlid("F2"))
assertNull(flightSchd.findByFlid("F3"))
// 断言:过期代元数据被清理
assertNull(flightSchd.getGen("2026-09-01"))
}
// 2. 场景二:ES 部分成功
@Test
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() {
val flights = listOf(
"F1" to """{"FLID":"F1"}""",
"F2" to """{"FLID":"F2"}""",
"F3" to """{"FLID":"F3"}""",
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
HistorySweepJob.esArchiver = { setOf("F1", "F2") }
sweepJob.run()
// 断言:成功项最终删除
assertNull(flightSchd.findByFlid("F1"))
assertNull(flightSchd.findByFlid("F2"))
// 断言:失败项 F3 绝不丢失,继续保留在 PG 中供下次重试
assertNotNull(flightSchd.findByFlid("F3"))
}
// 3. 场景三:ES 全部失败
@Test
fun `Scenario 3 - ES All Failure deletes nothing and keeps all candidates in PG`() {
val flights = listOf(
"F1" to """{"FLID":"F1"}""",
"F2" to """{"FLID":"F2"}""",
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 全部写入失败
HistorySweepJob.esArchiver = { emptySet() }
sweepJob.run()
// 断言:PG 未执行任何删除,全部候选均安全保留
assertNotNull(flightSchd.findByFlid("F1"))
assertNotNull(flightSchd.findByFlid("F2"))
}
// 4. 场景四:ES 成功后 PG 删除前崩溃重放
@Test
fun `Scenario 4 - Crash after ES success before PG delete recovers idempotently on next run`() {
val flights = listOf(
"F1" to """{"FLID":"F1"}""",
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1
val esUpserted = mutableSetOf<String>()
var crashed = false
HistorySweepJob.historyPicker = { it }
HistorySweepJob.esArchiver = {
esUpserted.addAll(it.keys)
if (!crashed) {
crashed = true
throw RuntimeException("crash-before-pg-delete")
}
it.keys
}
try {
sweepJob.run()
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:ES 已有数据,但 PG 中 F1 仍然存在作为可恢复事实
assertTrue(esUpserted.contains("F1"))
assertNotNull(flightSchd.findByFlid("F1"))
// 下一次作业重试:
sweepJob.run()
// 断言:重试成功,PG 中 F1 最终完成删除
assertNull(flightSchd.findByFlid("F1"))
}
// 5. 场景五:PG 删除重复执行(无副作用与幂等性)
@Test
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
val flights = listOf("F1" to """{"FLID":"F1"}""")
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
// 首次删除
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
assertEquals(1, deletedCount1)
assertNull(flightSchd.findByFlid("F1"))
// 重复执行删除(重放)
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
assertEquals(0, deletedCount2)
assertNull(flightSchd.findByFlid("F1"))
}
}
@@ -0,0 +1,353 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* U09 / U29 不变量门禁测试(ACM2-28 FS6 核心验收门槛):
* 1. I2 不变量:航班状态变更、MSG_EVENT 插入、PROC_STATE 终态在单事务原子提交;失败整体回滚重放;
* 2. 门槛 1(崩溃幂等无自增):重放相同快照报文后 SCHD_GEN.VERSION 绝不发生二次自增;
* 3. 门槛 2(CAS 防并发):伪造版本过期模拟并发快照,准确拦截 CAS 冲突并转入 FAILED(INFRA) 与退避;
* 4. 门槛 3ADFT 存活保障):增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删;跨代迁移保护;
* 5. UTC 方言测试:非 UTC JVM 时区下读写与幂等判据无漂移。
*/
class FlightSchdInvariantTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var procState: StubProcState
private lateinit var msgEvents: StubMsgEvents
private lateinit var reqTrack: StubReqTrack
private lateinit var txManager: StubPipelineTransactionManager
private lateinit var inbox: FakeInbox
private lateinit var clock: MutableClock
private lateinit var scheduler: FailureScheduler
private lateinit var procFailure: ProcFailure
private lateinit var props: PipelineProps
class FakeInbox : CminmsgInboxRepository {
val raws = mutableMapOf<Long, String>()
val backfilled = mutableListOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled.add(cminmsgsId)
}
}
class SimpleCodec(private val decodedMsg: DecodedMessage) : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decodedMsg)
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
procState = StubProcState()
msgEvents = StubMsgEvents()
reqTrack = StubReqTrack()
txManager = StubPipelineTransactionManager()
inbox = FakeInbox()
clock = MutableClock(MutableClock.BASE)
props = PipelineProps()
scheduler = FailureScheduler(props, clock)
procFailure = ProcFailure(procState, scheduler)
SnapshotFlow.StageResult.parser = null
}
@AfterEach
fun tearDown() {
SnapshotFlow.StageResult.parser = null
}
// =========================================================================
// I2 不变量测试:普通报文事务 2 扩展(变更 + 事件 + SUCCEEDED 原子提交)
// =========================================================================
@Test
fun `I2 - MessageProcessor atomically commits flightChanges, msgEvents, and SUCCEEDED`() {
val headId = 101L
inbox.raws[headId] = "<MSG><FLID>F101</FLID></MSG>"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "FLOP", "DELY", 1L, 20260907120000L)
val decoded = DecodedMessage(meta, MsgKind.Flop("DELY"), "<MSG><FLID>F101</FLID></MSG>")
val codecHolder = CodecHolder(SimpleCodec(decoded))
val handler = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, String>, msg: DecodedMessage): Decision {
return Decision(
flightChanges = listOf(FlightChange("F101", """{"FLID":"F101","status":"DELAYED"}""")),
msgNotifies = listOf(NotifyPayload("""{"flid":"F101","event":"DELAY"}""")),
schdPush = listOf(SchdPush("F101", """{"FLID":"F101","status":"DELAYED"}""")),
)
}
}
val handlerHolder = HandlerHolder(HandlerRegistry(listOf(handler)))
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents, codecHolder, handlerHolder,
flightSchd, snapshotFlow, procFailure, props, txManager,
)
val head = procState.headUnfinished()!!
processor.processOne(head)
// 断言:FLIGHT_SCHD 有更新
val fltr = flightSchd.findByFlid("F101")
assertNotNull(fltr)
assertTrue(fltr!!.contains("DELAYED"))
// 断言:MSG_EVENT 写入了 KAFKA_MSG 和 KAFKA_SCHD 两条事件
val evtMsg = msgEvents.headUnsent(Targets.KAFKA_MSG)
assertNotNull(evtMsg)
val evtSchd = msgEvents.headUnsent(Targets.KAFKA_SCHD)
assertNotNull(evtSchd)
// 断言:伴生状态更新为 SUCCEEDED
val currentHead = procState.headUnfinished()
assertNull(currentHead) // 无未完成队头,说明 101 已终态
// 断言:共享信箱 backfill 成功调用
assertTrue(inbox.backfilled.contains(headId))
}
// =========================================================================
// FS6 门槛 1:崩溃幂等无自增(重放相同快照报文,SCHD_GEN.VERSION 绝不发生二次自增)
// =========================================================================
@Test
fun `Threshold 1 - Crash idempotency without version re-increment`() {
val headId = 201L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 1L, 20260907030000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
// 配置 staging 解析模拟产出 2 条航班
val flights = listOf(
"FL_01" to """{"FLID":"FL_01","air":"CA1234"}""",
"FL_02" to """{"FLID":"FL_02","air":"MU5678"}""",
)
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 模拟快照单事务内崩溃注入(例如在提交前崩溃回滚)
var crashInjected = true
val rollbackTxManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T {
if (crashInjected) {
try {
block()
} finally {
// 事务回滚:清除未提交修改并恢复状态
flightSchd.clear()
procState.update(headId, ProcStatus.PENDING)
}
throw RuntimeException("crash-before-pg-commit")
}
return block()
}
}
val snapshotFlowCrashing = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, rollbackTxManager, inbox)
// 首次运行:事务内崩溃注入
val head1 = procState.headUnfinished()!!
try {
snapshotFlowCrashing.publishSnapshot(head1, decoded)
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:由于 PG 单事务回滚,SCHD_GEN 不存在半成品中间态,版本未推进
assertNull(flightSchd.getGen(day))
// 重放相同报文(正常完成)
crashInjected = false
val snapshotFlowNormal = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val headReplay = procState.headUnfinished()!!
snapshotFlowNormal.publishSnapshot(headReplay, decoded)
// 门槛 1 断言:版本绝不发生二次自增,版本号精确为 1L(根除旧 Redis 两阶段二次自增缺陷)
val genAfterReplay = flightSchd.getGen(day)
assertNotNull(genAfterReplay)
assertEquals(1L, genAfterReplay!!.version)
assertEquals(setOf("FL_01", "FL_02"), genAfterReplay.flids)
}
// =========================================================================
// FS6 门槛 2:CAS 防并发(伪造版本过期模拟并发快照,拦截 CAS 冲突并转入 FAILED(INFRA)
// =========================================================================
@Test
fun `Threshold 2 - CAS prevents concurrency and enters FAILED INFRA`() {
val headId = 202L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 2L, 20260907033000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flights = listOf("FL_01" to """{"FLID":"FL_01"}""")
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 先预置日代版本为 5L(模拟另一并发实例已经推进了版本)
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD")))
// 构造一个在读取版本后版本被篡改的场景(模拟读到 5L 后,外部并发变成了 6L)
val mockFlightSchd = object : FlightSchdRepository by flightSchd {
override fun getGen(day: String): FlightSchdRepository.GenMeta? {
// 模拟读取时返回版本 5L
return FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD"))
}
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
// 模拟 CAS 校验失败(数据库已被并发推进,expected 5 已过期)
return false
}
}
val snapshotFlow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 2 断言:CAS 冲突被拦截,状态转换为 FAILED(INFRA),带有退避与告警错误信息
val failedHead = procState.headUnfinished()!!
assertEquals(ProcStatus.FAILED, failedHead.state)
assertEquals(ErrorClass.INFRA, failedHead.errorClass)
assertTrue(failedHead.lastError?.contains("gen-cas-conflict") == true)
assertNotNull(failedHead.nextAttemptAt)
}
// =========================================================================
// FS6 门槛 3ADFT 存活保障(增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删)
// =========================================================================
@Test
fun `Threshold 3 - ADFT flights survive subsequent DNLD replacement`() {
val day = "2026-09-07"
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", payloadJson = """{"FLID":"ADFT_888","type":"ADFT"}""")
flightSchd.upsertIncremental(listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
flightSchd.upsertSnapshotBatch(day, listOf("REG_OLD" to """{"FLID":"REG_OLD","type":"REG"}"""))
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("REG_OLD")))
// 3. 执行下一轮快照 DNLD,新代仅包含 REG_NEW(REG_OLD 不在新代中,属于待删差集;ADFT 也不在新代中)
val headId = 203L
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 3L, 20260907040000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val newFlights = listOf("REG_NEW" to """{"FLID":"REG_NEW","type":"REG"}""")
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, newFlights) }
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 3 核心断言:
// ① 旧代航班 REG_OLD 被按代差删清除
assertNull(flightSchd.findByFlid("REG_OLD"))
// ② 新代航班 REG_NEW 成功写入
assertNotNull(flightSchd.findByFlid("REG_NEW"))
// ③ 增量 ADFT_888 航班由于 FDAY=NULL 天然存活、绝不被误删!
val adftRecord = flightSchd.findByFlid("ADFT_888")
assertNotNull(adftRecord)
assertTrue(adftRecord!!.contains("ADFT_888"))
}
@Test
fun `Threshold 3 - Migrated flight across days is protected from diff deletion of old day`() {
// 场景:航班 FL_MIG 原属 2026-09-07,后来迁移到了 2026-09-08FDAY 更新为 09-08
val dayOld = "2026-09-07"
val dayNew = "2026-09-08"
// 初始属旧代
flightSchd.upsertSnapshotBatch(dayOld, listOf("FL_MIG" to """{"FLID":"FL_MIG","day":"07"}"""))
flightSchd.putGenIfVersion(dayOld, 0L, FlightSchdRepository.GenMeta(dayOld, 1L, setOf("FL_MIG")))
// 随后 09-08 快照写入将 FDAY 更新为 2026-09-08
flightSchd.upsertSnapshotBatch(dayNew, listOf("FL_MIG" to """{"FLID":"FL_MIG","day":"08"}"""))
// 此时 09-07 再次执行差删(差集中包含 FL_MIG)
val deleted = flightSchd.deleteDiffByDay(dayOld, listOf("FL_MIG"))
// 断言:FL_MIG 虽在差集,但由于 FDAY 已迁移至 09-08,受到域化差删保护,删除数为 0,记录依然存活!
assertEquals(0, deleted)
assertNotNull(flightSchd.findByFlid("FL_MIG"))
}
// =========================================================================
// UTC 时区规范测试:非 UTC JVM 默认时区与会话下写入读取无漂移
// =========================================================================
@Test
fun `UTC Dialect - Timestamps and instant evaluations are immune to JVM timezone drift`() {
val originalTz = TimeZone.getDefault()
try {
// 切换 JVM 默认时区为非 UTC(东京 +09:00 与 纽约 -05:00
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val now = Instant.parse("2026-09-07T08:00:00.123456Z")
flightSchd.upsertSnapshotBatch("2026-09-07", listOf("TZ_01" to """{"test":true}"""), now)
flightSchd.putGenIfVersion("2026-09-07", 0L, FlightSchdRepository.GenMeta("2026-09-07", 1L, setOf("TZ_01")), now)
val gen = flightSchd.getGen("2026-09-07")
assertNotNull(gen)
assertEquals(now, gen!!.updatedAt)
// 再次切换到西五区
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
val genNy = flightSchd.getGen("2026-09-07")
assertNotNull(genNy)
assertEquals(now, genNy!!.updatedAt) // 绝无时区漂移
} finally {
TimeZone.setDefault(originalTz)
}
}
}
@@ -21,11 +21,9 @@ import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import org.junit.jupiter.api.Assertions.assertEquals
@@ -139,17 +137,48 @@ class MessageProcessorTest {
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
private object FakeRedis : FlightRedisClient {
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) = Unit
override fun hgetAllFlightInfo(): Map<String, String> = emptyMap()
override fun ping(): Boolean = true
private class FakeReqTrack : ReqTrackRepository {
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? = null
override fun forceExpireOpenOf(kind: String) = Unit
override fun insert(kind: String, paramsJson: String): Long = 1L
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
override fun markSent(reqId: Long, sentAt: Instant) = Unit
override fun expireIfWaiting(reqId: Long) = Unit
override fun markDone(reqId: Long) = Unit
}
private class FakeRefData : RefDataRepository {
override fun getGen(day: String): RefDataRepository.GenMeta? = null
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean = true
private class FakeFlightSchd : FlightSchdRepository {
val flights = mutableMapOf<String, String>()
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, String>>, now: Instant) {
this.flights.putAll(flights)
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
incrementalChanges.addAll(changes)
changes.forEach { flights[it.flid] = it.payloadJson }
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
override fun findByFlid(flid: String): String? = flights[flid]
override fun findByFlids(flids: Collection<String>): Map<String, String> =
flids.mapNotNull { f -> flights[f]?.let { f to it } }.toMap()
override fun findByDay(day: String): List<Pair<String, String>> = flights.map { it.key to it.value }
override fun findAll(): Map<String, String> = flights.toMap()
override fun deleteByFlids(flids: Set<String>): Int = 0
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = newGen
return true
}
override fun deleteGenBefore(cutoffDay: String): Int = 0
}
private class FakeTxManager : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
// ---------- helpers ----------
private val clock = MutableClock(MutableClock.BASE)
@@ -177,10 +206,11 @@ class MessageProcessorTest {
val props = PipelineProps()
val scheduler = FailureScheduler(props, clock)
val procFailure = ProcFailure(procState, scheduler)
val snapshot = SnapshotFlow(procState, FakeRefData(), FakeRedis, procFailure)
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), FakeRedis, snapshot, procFailure, props)
val flightSchd = FakeFlightSchd()
val txManager = FakeTxManager()
val snapshot = SnapshotFlow(procState, flightSchd, events, FakeReqTrack(), procFailure, txManager, inbox)
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), flightSchd, snapshot, procFailure, props, txManager)
}
// ---------- tests ----------
@Test
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
@@ -0,0 +1,87 @@
package com.gzzn.omms.msgexchange.tools
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FlightStoreDiffToolTest {
private val tool = FlightStoreDiffTool()
@Test
fun `AST comparison ignores key ordering differences`() {
val pg = mapOf(
"F1" to """{"flid":"F1","airline":"CA","flightNo":"123","status":"SCHD"}""",
)
val legacy = mapOf(
"F1" to """{"status":"SCHD","flightNo":"123","airline":"CA","flid":"F1"}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `AST comparison normalizes numeric precision and null equivalents`() {
val pg = mapOf(
"F1" to """{"flid":"F1","weight":100.0,"delay":0,"extra":null}""",
)
val legacy = mapOf(
"F1" to """{"flid":"F1","weight":100,"delay":0.0}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Recognizes known deviation 1 - Cross-day migrated flights protected in PG`() {
// PG 中包含跨日迁移未被误删的航班,Legacy Redis 中已被误删
val pg = mapOf(
"F_STABLE" to """{"flid":"F_STABLE"}""",
"F_MIGRATED" to """{"flid":"F_MIGRATED","day":"2026-09-08"}""",
)
val legacy = mapOf(
"F_STABLE" to """{"flid":"F_STABLE"}""",
// F_MIGRATED 在 legacy 中被按旧代差删误删
)
val report = tool.diff(pg, legacy, crossDayMigratedFlids = setOf("F_MIGRATED"))
assertTrue(report.isGreen) // 属于已知合法偏差,红绿灯依然为 GREEN
assertEquals(1, report.matchedCount)
assertEquals(1, report.knownDeviations.size)
assertEquals(FlightStoreDiffTool.DeviationKind.CROSS_DAY_PROTECTED, report.knownDeviations[0].kind)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Detects real field mismatch and missing flight as unexpected deviation`() {
val pg = mapOf(
"F1" to """{"flid":"F1","status":"BOARDING"}""",
"F_EXTRA" to """{"flid":"F_EXTRA"}""",
)
val legacy = mapOf(
"F1" to """{"flid":"F1","status":"DEPARTED"}""",
"F_MISSING" to """{"flid":"F_MISSING"}""",
)
val report = tool.diff(pg, legacy)
assertFalse(report.isGreen)
assertEquals(0, report.matchedCount)
assertEquals(3, report.unexpectedDeviations.size)
val kinds = report.unexpectedDeviations.map { it.kind }
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.FIELD_MISMATCH))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.MISSING_IN_PG))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.UNEXPECTED_EXTRA_IN_PG))
val summary = report.formatSummary()
assertTrue(summary.contains("RED (未通过)"))
assertTrue(summary.contains("F1"))
}
}
-3
View File
@@ -23,9 +23,6 @@ flyway:
default:
enabled: false
redis:
uri: redis://127.0.0.1:6379
kafka:
bootstrap:
servers: 127.0.0.1:9092