feat(persistence)!: V1.4.0 退场 legacy 槽位存储,persistNextStates 成为唯一写路径 (ACM2-29 P4)

- V1.4.0 迁移:DROP FLIGHT_SCHD 全部 77 个槽位/里程碑/紧凑航路列(V1.3.0 起
  零写入、P2-2 起零读取)+ 三个与 PK 前缀冗余的 (flid) 单列索引;回退窗口关闭
  (对拍留档:P2-1 FS7 v2 双读差异记录)
- 接口收敛:FlightSchdRepository 删除 upsertSnapshotBatch/upsertIncremental,
  删除废弃 FlightStateRepository 及 Jdbc/Stub 适配器;仓储与装配器删除全部
  legacy flatten/slot/milestone/route 视图代码,ALL_COLUMNS 收敛为无损承载列
- FS7 对拍工具移除 diffDualRead(legacy 槽位基准退场);diff() 对 legacy
  FLTR JSON 比对保留;FlightStoreDualReadTest 随工具移除
- 测试种子统一走 v2 写路径(support/SeedHelpers),GuardedFlightSchd 简化
- docs/decision-flight-state.md:头部复核入口更新 + 新增 §10 v2 落地终态索引,
  §1–§9 保留为历史记录

验证:MSGX_PG_PORT=5433 真实 PG 全量 100 用例 0 失败 0 跳过;无环境变量
Testcontainers 全新容器 V1.0.0→V1.4.0 迁移链 18/18 全绿
This commit is contained in:
windyboy
2026-09-08 17:44:20 +08:00
parent 107be5d74a
commit 7c2d22e8f1
15 changed files with 142 additions and 746 deletions
@@ -101,11 +101,7 @@ interface FlightSchdRepository {
val updatedAt: Instant = Instant.now(),
)
/** 快照全量写入(DNLD):强行声明/更新 FDAY 归属,批处理写入,航班字段集整体替换。 */
fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant = Instant.now())
/** 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行保留原 FDAY;字段级合并。 */
fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant = Instant.now())
/** v2P4 退场):legacy upsertSnapshotBatch/upsertIncremental 已删除——唯一写路径是 persistNextStates。 */
/** 按代差删域化:仅删除 FDAY = day 且在 delFlids 中的记录(ADFT 与跨代已迁移行受保护)。 */
fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int
@@ -256,17 +252,6 @@ interface PumpJobRepository {
fun markFailed(jobId: Long, lastError: String)
}
/**
* 历史占位接口(已被 ACM2-28 之 FlightSchdRepository 取代,保留供兼容与过渡)。
*/
@Deprecated("Replaced by FlightSchdRepository in ACM2-28", ReplaceWith("FlightSchdRepository"))
interface FlightStateRepository {
/** 阶段 B 权威;replaceDay = 单事务删差集+写新代+版本提升。 */
fun replaceDay(day: String, flights: List<Pair<String, FlightFields>>)
fun findByDay(day: String): List<Pair<String, FlightFields>>
}
/**
* 共享信箱 CMINMSGS 访问(ACM2-12:库属他人系统,本系统不建表)。
*
@@ -1,15 +1,17 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
/**
* FLIGHT_SCHD 读侧视图组装(v2 双读对拍 FS7)。
* FLIGHT_SCHD 读侧视图组装(v2 权威读,P4 退场后唯一读路径)。
*
* - [assembleMerged]:生产读路径,明细表优先、槽位列回退(与 legacy flightInfo hash 同构)。
* - [assembleDetailOnly]:仅标量 + 明细表集合(v2 权威读)。
* - [assembleLegacySlotsOnly]:仅标量 + V1.2.0 槽位/里程碑/航路列重建(legacy 回退读)。
* - 标量列直读 + 无界集合文本列(SRVT/VIPF/MAFL
* - FDIV/FRET/FLAB 由主表前缀标量列重建对象
* - 10 类重复集合由明细表重建(保序、保源序号),legacy 槽位/里程碑列已随 V1.4.0 退场
*
* 过渡期三条读路径(assembleMerged/assembleLegacySlotsOnly)与 FS7 双读对拍
* 已随回退窗口关闭一并移除(V1.4.0,ACM2-29 P4)。
*/
object FlightSchdReadAssembler {
@@ -28,82 +30,14 @@ object FlightSchdReadAssembler {
"FLAB_ARES", "FLAB_RSN",
)
data class SlotCollection(
val key: String,
val seqAttr: String,
val dedupeAttr: String,
val slots: List<Map<String, String>>,
)
private fun slotCollection(
key: String,
seqAttr: String,
dedupeAttr: String,
attrs: List<String>,
count: Int,
renames: Map<String, String> = emptyMap(),
): SlotCollection = SlotCollection(
key = key,
seqAttr = seqAttr,
dedupeAttr = dedupeAttr,
slots = (1..count).map { i -> attrs.associateWith { attr -> (renames[attr] ?: attr) + i } },
)
val OCCURRENCE_COLLECTIONS: List<SlotCollection> = listOf(
slotCollection("GTDT", "GTNO", "GATE", listOf("GATE", "PGOT", "PGCT", "GOTM", "GCTM", "GTYP"), 2),
slotCollection(
"CKDT", "CKNO", "CHKC",
listOf("CHKC", "CCLS", "PCOT", "PCCT", "COTM", "CCTM", "CTYP"), 3,
),
slotCollection(
"CLDT", "CLNO", "BELT",
listOf("BELT", "BCLS", "PCOT", "PCCT", "FBAG", "LBAG", "BTYP"), 2,
renames = mapOf("PCOT" to "BPCOT", "PCCT" to "BPCCT"),
),
slotCollection("PSDT", "PSNO", "PSST", listOf("PSST", "STST", "STET"), 2),
slotCollection(
"CHDT", "CHNO", "CHUT",
listOf("CHUT", "CCLS", "PCBT", "PCET", "CBTM", "CETM", "CTYP"), 2,
renames = mapOf("CCLS" to "CHCLS", "CTYP" to "CHTYP"),
),
)
val MILESTONE_COLUMNS: List<String> = listOf(
"DELY_CODE", "DELY_STRT", "DELY_DURA", "DELY_REMC",
"ABTM_A", "ABTM_D", "CHOT_ON", "CHOT_OFF",
)
val PATH_COLUMNS: List<String> = listOf("ROUT_PATH", "ERUT_PATH")
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS +
OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map<String, String>::values) } +
MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS
/** 库列名全集(读侧 SELECT 稳定顺序;与 V1.4.0 后表结构一致)。 */
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
private val mapper = ObjectMapper()
/** 生产读路径:明细表优先,缺失时由槽位/里程碑列回退重建。 */
fun assembleMerged(
flid: String,
row: Map<String, String?>,
detailCollections: Map<String, String>,
): FlightFields {
val fields = assembleScalarsAndExceptions(flid, row)
val detailKeys = detailCollections.keys
if ("DELY" !in detailKeys) delayView(row)?.let { fields["DELY"] = it }
if ("ABTM" !in detailKeys) airbridgeView(row)?.let { fields["ABTM"] = it }
if ("CHOT" !in detailKeys) chocksView(row)?.let { fields["CHOT"] = it }
if ("ROUT" !in detailKeys) row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) }
if ("ERUT" !in detailKeys) row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) }
OCCURRENCE_COLLECTIONS.forEach { c ->
if (c.key !in detailKeys) slotView(c, row)?.let { fields[c.key] = it }
}
detailCollections.forEach { (key, value) -> fields[key] = value }
return fields
}
/** v2 权威读:标量 + 明细表集合,不读槽位/里程碑列。 */
/** v2 权威读:标量 + 异常 + 文本列 + 明细表集合。 */
fun assembleDetailOnly(
flid: String,
row: Map<String, String?>,
@@ -114,18 +48,6 @@ object FlightSchdReadAssembler {
return fields
}
/** legacy 回退读:标量 + 槽位/里程碑/航路列重建,不叠加明细表。 */
fun assembleLegacySlotsOnly(flid: String, row: Map<String, String?>): FlightFields {
val fields = assembleScalarsAndExceptions(flid, row)
delayView(row)?.let { fields["DELY"] = it }
airbridgeView(row)?.let { fields["ABTM"] = it }
chocksView(row)?.let { fields["CHOT"] = it }
row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) }
row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) }
OCCURRENCE_COLLECTIONS.forEach { c -> slotView(c, row)?.let { fields[c.key] = it } }
return fields
}
private fun assembleScalarsAndExceptions(flid: String, row: Map<String, String?>): LinkedHashMap<String, String> {
val fields = linkedMapOf<String, String>()
fields["FLID"] = flid
@@ -145,56 +67,4 @@ object FlightSchdReadAssembler {
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
return if (node.size() == 0) null else mapper.writeValueAsString(node)
}
private fun delayView(row: Map<String, String?>): String? {
val node = mapper.createObjectNode()
listOf("CODE" to "DELY_CODE", "STRT" to "DELY_STRT", "DURA" to "DELY_DURA", "REMC" to "DELY_REMC")
.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
return if (node.size() == 0) null else mapper.writeValueAsString(arrayOf(node))
}
private fun airbridgeView(row: Map<String, String?>): String? {
val items = mutableListOf<ObjectNode>()
row["ABTM_A"]?.let {
items += mapper.createObjectNode().apply { put("ASNO", "1"); put("ABOP", "A"); put("AOTM", it) }
}
row["ABTM_D"]?.let {
items += mapper.createObjectNode().apply { put("ASNO", "2"); put("ABOP", "D"); put("AOTM", it) }
}
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
}
private fun chocksView(row: Map<String, String?>): String? {
val items = mutableListOf<ObjectNode>()
row["CHOT_ON"]?.let {
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "ON"); put("CHTM", it) }
}
row["CHOT_OFF"]?.let {
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "OFF"); put("CHTM", it) }
}
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
}
private fun routeView(path: String): String {
val items = path.split(",").mapIndexed { index, segment ->
val parts = segment.split("/", limit = 3)
mapper.createObjectNode().apply {
put("RTNO", (index + 1).toString())
listOf("APCD", "SCAT", "SCDT").forEachIndexed { i, attr ->
if (i < parts.size && parts[i].isNotEmpty()) put(attr, parts[i])
}
}
}
return mapper.writeValueAsString(items)
}
private fun slotView(c: SlotCollection, row: Map<String, String?>): String? {
val items = c.slots.mapIndexed { index, slot ->
mapper.createObjectNode().apply {
put(c.seqAttr, (index + 1).toString())
slot.forEach { (attr, column) -> row[column]?.let { put(attr, it) } }
}
}.filterNot { it.size() <= 1 }
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
}
}
@@ -12,7 +12,6 @@ import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
@@ -412,76 +411,16 @@ class JdbcFlightSchdRepository(
"FLAB_ARES", "FLAB_RSN",
)
/**
* 平铺出现槽位集合(ACM2-29 零子表定案):槽位序号即报文序号属性(GTNO/CKNO/...),
* 列名 = legacy 属性名 + 槽位号;跨集合同名属性按 V1.2.0 注释加专属前缀
* (转盘时刻 BPCOT/BPCCT、通道等级/类型 CHCLS/CHTYP)。槽位数按现场地服
* 规律定界(登机门 2、值机柜台 3、转盘 2、计划机位 2、离港通道 2),
* 超界条目按定案丢弃,不再为 XSD maxOccurs=99 的理论上限设计。
*/
private data class SlotCollection(
val key: String,
val seqAttr: String,
val dedupeAttr: String,
val slots: List<Map<String, String>>, // 报文属性 → 库列名
)
private fun slotCollection(
key: String,
seqAttr: String,
dedupeAttr: String,
attrs: List<String>,
count: Int,
renames: Map<String, String> = emptyMap(),
): SlotCollection = SlotCollection(
key = key,
seqAttr = seqAttr,
dedupeAttr = dedupeAttr,
slots = (1..count).map { i -> attrs.associateWith { attr -> (renames[attr] ?: attr) + i } },
)
private val OCCURRENCE_COLLECTIONS: List<SlotCollection> = listOf(
slotCollection("GTDT", "GTNO", "GATE", listOf("GATE", "PGOT", "PGCT", "GOTM", "GCTM", "GTYP"), 2),
slotCollection(
"CKDT", "CKNO", "CHKC",
listOf("CHKC", "CCLS", "PCOT", "PCCT", "COTM", "CCTM", "CTYP"), 3,
),
slotCollection(
"CLDT", "CLNO", "BELT",
listOf("BELT", "BCLS", "PCOT", "PCCT", "FBAG", "LBAG", "BTYP"), 2,
renames = mapOf("PCOT" to "BPCOT", "PCCT" to "BPCCT"),
),
slotCollection("PSDT", "PSNO", "PSST", listOf("PSST", "STST", "STET"), 2),
slotCollection(
"CHDT", "CHNO", "CHUT",
listOf("CHUT", "CCLS", "PCBT", "PCET", "CBTM", "CETM", "CTYP"), 2,
renames = mapOf("CCLS" to "CHCLS", "CTYP" to "CHTYP"),
),
)
/** 里程碑/单值集合列(延误单有效覆盖语义、靠撤桥/轮挡时刻)。 */
private val MILESTONE_COLUMNS: List<String> = listOf(
"DELY_CODE", "DELY_STRT", "DELY_DURA", "DELY_REMC",
"ABTM_A", "ABTM_D", "CHOT_ON", "CHOT_OFF",
)
/** 紧凑航路字串列("APCD/SCAT/SCDT,...",≤7 站)。 */
private val PATH_COLUMNS: List<String> = listOf("ROUT_PATH", "ERUT_PATH")
/** 无界集合紧凑 JSON 数组字串列(键名去 _TEXT 后缀即 legacy 视图键,直存直取)。 */
private val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
/** v2 主表写入列:标量 + 异常 + 无界文本;集合由明细表承载,不经槽位/里程碑。 */
/** v2 主表写入列:标量 + 异常 + 无界文本;集合由明细表承载P4 起槽位/里程碑/航路列已退场)。 */
private val SCALAR_WRITE_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
/** 库列名全集(读侧 SELECT 与视图重建的稳定顺序,与 V1.2.0 迁移一致)。 */
/** 库列名全集(读侧 SELECT 稳定顺序P4 退场后 = 无损承载列,与 V1.4.0 后表结构一致)。 */
private val ALL_COLUMNS: List<String> = FlightSchdReadAssembler.ALL_COLUMNS
private val SCALAR_KEY_SET: Set<String> = SCALAR_COLUMNS.toSet()
/** 16 个集合视图键:写侧消费平铺、读侧重建,视图与 legacy flightInfo hash 同构。 */
private val COLLECTION_KEYS: Set<String> = OCCURRENCE_COLLECTIONS.map { it.key }.toSet() +
setOf("DELY", "ABTM", "CHOT", "ROUT", "ERUT", "SRVT", "VIPF", "MAFL", "FDIV", "FRET", "FLAB")
}
private val mapper = ObjectMapper()
@@ -489,37 +428,6 @@ class JdbcFlightSchdRepository(
private fun toSqlDate(day: String): java.sql.Date =
java.sql.Date.valueOf(day.trim().take(10))
/** 集合键平铺 + 标量键校验 → (列名, 值);未知键拒绝写入(fail fast,经处理边界落 FAILED(INFRA))。
* FLID 即主键列:写侧忽略该键(主键已承载),读侧合成返回。 */
private fun storagePairs(fields: FlightFields): List<Pair<String, String?>> =
flattenForStorage(fields).mapNotNull { (key, value) ->
if (key == "FLID") null else key to value
}
/** 字段集 → 列名键值(标量键同名直取;16 个集合键消费为平铺列/紧凑字串列;
* 未知键 fail fast)。值为 null 表示显式清空(单资源集合级全量快照替换语义)。 */
private fun flattenForStorage(fields: FlightFields): Map<String, String?> {
val out = mutableMapOf<String, String?>()
fields.forEach { (key, value) ->
when {
key in SCALAR_KEY_SET -> out[key] = value
key == "FLID" || key in COLLECTION_KEYS -> Unit // 主键跳过;集合键由下方平铺消费
else -> throw IllegalArgumentException("unknown flight field: $key")
}
}
flattenExceptions(fields, out)
OCCURRENCE_COLLECTIONS.forEach { c -> flattenSlots(c, fields[c.key], out) }
flattenDelay(fields["DELY"], out)
flattenAirbridge(fields["ABTM"], out)
flattenChocks(fields["CHOT"], out)
fields["ROUT"]?.let { out["ROUT_PATH"] = routeToPath(it) }
fields["ERUT"]?.let { out["ERUT_PATH"] = routeToPath(it) }
fields["SRVT"]?.let { out["SRVT_TEXT"] = it }
fields["VIPF"]?.let { out["VIPF_TEXT"] = it }
fields["MAFL"]?.let { out["MAFL_TEXT"] = it }
return out
}
/** v2:仅标量/异常/文本列 flatten;集合键由明细表读写,不经过槽位列。 */
private fun flattenScalarsOnly(scalars: Map<String, String>): Map<String, String?> {
val out = mutableMapOf<String, String?>()
@@ -552,170 +460,6 @@ class JdbcFlightSchdRepository(
flatten("FLAB", mapOf("ARES" to "FLAB_ARES"), "FLAB_RSN")
}
/** 出现槽位集合 → 槽位序号列。序号属性 "0" = 显式删除标记;按资源号抹平复用冗余;
* 超出现场定界槽位数的条目按定案丢弃;集合键出现但条目为空 = 全槽位清空。 */
private fun flattenSlots(c: SlotCollection, raw: String?, out: MutableMap<String, String?>) {
if (raw == null) return
val seen = mutableSetOf<String>()
val items = collectionItems(raw)
.filterNot { it[c.seqAttr]?.takeUnless(JsonNode::isNull)?.asText() == "0" }
.filter { item ->
val dedupeKey = item[c.dedupeAttr]?.takeUnless(JsonNode::isNull)?.asText()
dedupeKey == null || seen.add(dedupeKey)
}
.take(c.slots.size)
c.slots.forEachIndexed { index, slot ->
val item = items.getOrNull(index)
slot.forEach { (attr, column) ->
out[column] = item?.get(attr)?.takeUnless(JsonNode::isNull)?.asText()
}
}
}
/** 延误:业务上任意时刻仅 1 个有效延误(覆盖语义)→ 首条延误平铺。 */
private fun flattenDelay(raw: String?, out: MutableMap<String, String?>) {
if (raw == null) return
val first = collectionItems(raw).firstOrNull()
out["DELY_CODE"] = first?.get("CODE")?.takeUnless(JsonNode::isNull)?.asText()
out["DELY_STRT"] = first?.get("STRT")?.takeUnless(JsonNode::isNull)?.asText()
out["DELY_DURA"] = first?.get("DURA")?.takeUnless(JsonNode::isNull)?.asText()
out["DELY_REMC"] = first?.let { f ->
listOf("REMC", "value", "#text").asSequence()
.mapNotNull { name -> f[name]?.takeUnless(JsonNode::isNull)?.asText() }
.firstOrNull()
}
}
/** 靠撤桥:按 ABOP 取靠桥(A)/撤桥(D) 时刻;桥号沿用派生列 ABDG。 */
private fun flattenAirbridge(raw: String?, out: MutableMap<String, String?>) {
if (raw == null) return
val items = collectionItems(raw)
fun timeOf(op: String): String? = items.firstOrNull { it["ABOP"]?.asText() == op }
?.get("AOTM")?.takeUnless(JsonNode::isNull)?.asText()
out["ABTM_A"] = timeOf("A")
out["ABTM_D"] = timeOf("D")
}
/** 轮挡:按 CHID 取上轮挡(ON)/下轮挡(OFF) 时刻;机位沿用派生列 STND。 */
private fun flattenChocks(raw: String?, out: MutableMap<String, String?>) {
if (raw == null) return
val items = collectionItems(raw)
fun timeOf(id: String): String? = items.firstOrNull { it["CHID"]?.asText() == id }
?.get("CHTM")?.takeUnless(JsonNode::isNull)?.asText()
out["CHOT_ON"] = timeOf("ON")
out["CHOT_OFF"] = timeOf("OFF")
}
/** 航路集合 → 紧凑字串 "APCD/SCAT/SCDT,..."(空属性保留空槽位;空集合 → NULL = 显式清空)。 */
private fun routeToPath(raw: String): String? {
val items = collectionItems(raw)
if (items.isEmpty()) return null
return items.joinToString(",") { item ->
listOf("APCD", "SCAT", "SCDT").joinToString("/") { attr ->
item[attr]?.takeUnless(JsonNode::isNull)?.asText() ?: ""
}
}
}
/** 集合值统一形状:JSON 数组(多值)/对象(单值)/null(清空);其余形状 fail fast。 */
private fun collectionItems(raw: String): List<JsonNode> {
val node = mapper.readTree(raw)
return when {
node.isNull -> emptyList()
node.isArray -> node.toList()
node.isObject -> listOf(node)
else -> throw IllegalArgumentException("collection value must be a JSON array or object")
}
}
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant) {
if (flights.isEmpty()) return
// 快照 = 航班字段整体替换:缺省字段置 NULL,批量 SQL 形状对全部行一致
val sql = buildString {
append("INSERT INTO flight_schd (flid, fday, ")
append(ALL_COLUMNS.joinToString(", "))
append(", created_at, updated_at) VALUES (?, ?, ")
append(ALL_COLUMNS.joinToString(", ") { "?" })
append(", ?, ?) ON CONFLICT (flid) DO UPDATE SET ")
append((listOf("fday") + ALL_COLUMNS + listOf("updated_at")).joinToString(", ") { "$it = EXCLUDED.$it" })
}
val sqlDate = toSqlDate(day)
val sqlTimestamp = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
conn.prepareStatement(sql).use { ps ->
var count = 0
for ((flid, fields) in flights) {
val stored = flattenForStorage(fields)
var i = 1
ps.setString(i++, flid)
ps.setDate(i++, sqlDate)
for (column in ALL_COLUMNS) {
ps.setString(i++, stored[column])
}
ps.setTimestamp(i++, sqlTimestamp)
ps.setTimestamp(i++, sqlTimestamp)
ps.addBatch()
count++
if (count % 200 == 0) {
ps.executeBatch()
}
}
if (count % 200 != 0) {
ps.executeBatch()
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
if (changes.isEmpty()) return
val sqlTimestamp = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
// 父行:新插 FDAY=NULL;已有行保留原 FDAY,仅推进 updated_at
conn.prepareStatement(
"INSERT INTO flight_schd (flid, created_at, updated_at) VALUES (?, ?, ?) " +
"ON CONFLICT (flid) DO UPDATE SET updated_at = EXCLUDED.updated_at",
).use { ps ->
var count = 0
for (c in changes) {
ps.setString(1, c.flid)
ps.setTimestamp(2, sqlTimestamp)
ps.setTimestamp(3, sqlTimestamp)
ps.addBatch()
count++
if (count % 200 == 0) {
ps.executeBatch()
}
}
if (count % 200 != 0) {
ps.executeBatch()
}
}
// 字段列更新(legacy flightInfo hash 同语义:仅新增/覆盖,不删除缺失字段;
// 集合键出现 = 单资源集合级全量快照替换,缺槽位置 NULL)
for (c in changes) {
val columns = storagePairs(c.fields)
if (columns.isNotEmpty()) {
val sql = "UPDATE flight_schd SET " +
columns.joinToString(", ") { "${it.first} = ?" } +
", updated_at = ? WHERE flid = ?"
ds.update(sql) { ps ->
var i = 1
columns.forEach { (_, value) -> ps.setString(i++, value) }
ps.setTimestamp(i++, sqlTimestamp)
ps.setString(i, c.flid)
}
}
}
} finally {
conn.releaseIfNotInTransaction()
}
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int {
if (delFlids.isEmpty()) return 0
FlightDetailTables.deleteForFlids(ds, delFlids)
@@ -1131,15 +875,3 @@ class JdbcReqTrackRepository(
}
}
@Singleton
@Requires(property = "msgx.stubs", notEquals = "true")
@Requires(property = "datasources.default.enabled", value = "true")
class JdbcFlightStateRepository(
private val flightSchd: FlightSchdRepository,
) : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, FlightFields>>) =
flightSchd.upsertSnapshotBatch(day, flights)
override fun findByDay(day: String): List<Pair<String, FlightFields>> =
flightSchd.findByDay(day)
}
@@ -10,7 +10,6 @@ import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
@@ -220,32 +219,6 @@ class StubFlightSchd : FlightSchdRepository {
gens.clear()
}
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant) {
for ((flid, fields) in flights) {
val existing = records[flid]
records[flid] = Record(
flid = flid,
fday = day,
fields = fields,
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
}
}
override fun upsertIncremental(changes: List<com.gzzn.omms.msgexchange.domain.FlightChange>, now: Instant) {
for (c in changes) {
val existing = records[c.flid]
records[c.flid] = Record(
flid = c.flid,
fday = existing?.fday, // preserve existing fday; null if new
fields = (existing?.fields ?: emptyMap()) + c.fields, // 字段级合并
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
}
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int {
if (delFlids.isEmpty()) return 0
val delSet = delFlids.toSet()
@@ -421,18 +394,6 @@ class StubReqTrack : ReqTrackRepository {
override fun markDone(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "DONE") } }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubFlightState(
private val flightSchd: FlightSchdRepository,
) : FlightStateRepository {
override fun replaceDay(day: String, flights: List<Pair<String, FlightFields>>) =
flightSchd.upsertSnapshotBatch(day, flights)
override fun findByDay(day: String): List<Pair<String, FlightFields>> =
flightSchd.findByDay(day)
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubBackfillTodo : BackfillTodoRepository {
@@ -38,10 +38,6 @@ class FlightStoreDiffTool(
MISSING_IN_PG,
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
UNEXPECTED_EXTRA_IN_PG,
/** v2 双读:明细表读路径缺失该航班 */
MISSING_IN_DETAIL,
/** v2 双读:legacy 槽位读路径缺失该航班 */
MISSING_IN_LEGACY,
}
data class Deviation(
@@ -92,60 +88,9 @@ class FlightStoreDiffTool(
/**
* FS7 v2 双读对拍:明细表组装视图 vs legacy 槽位/里程碑列组装视图。
* 严格逐字段比对,禁止通过忽略 GTNO/第三资源/ABDG 等规则洗绿
* FS7 v2 双读对拍已随回退窗口关闭移除(V1.4.0ACM2-29 P4):
* legacy 槽位/里程碑列退场后无对比基准,损失性差异已由 P2-1 对拍记录留档
*/
fun diffDualRead(
detailViews: Map<String, FlightFields>,
legacySlotViews: Map<String, FlightFields>,
): DiffReport = diffFlightFieldMaps(detailViews, legacySlotViews, missingInPrimary = DeviationKind.MISSING_IN_DETAIL, missingInReference = DeviationKind.MISSING_IN_LEGACY)
/** 两侧均为 FlightFields 的严格逐字段比对(供双读对拍与单测复用)。 */
fun diffFlightFieldMaps(
primaryFlights: Map<String, FlightFields>,
referenceFlights: Map<String, FlightFields>,
missingInPrimary: DeviationKind = DeviationKind.MISSING_IN_PG,
missingInReference: DeviationKind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
): DiffReport {
val allFlids = (primaryFlights.keys + referenceFlights.keys).toSortedSet()
var matched = 0
val unexpected = mutableListOf<Deviation>()
for (flid in allFlids) {
val primary = primaryFlights[flid]
val reference = referenceFlights[flid]
if (primary == null) {
unexpected += Deviation(
flid = flid,
kind = missingInPrimary,
legacyValue = reference,
detail = "Flight present in reference view but missing in primary view",
)
continue
}
if (reference == null) {
unexpected += Deviation(
flid = flid,
kind = missingInReference,
pgValue = primary,
detail = "Flight present in primary view but missing in reference view",
)
continue
}
val mismatches = compareFlightFields(flid, primary, reference)
if (mismatches.isEmpty()) matched++ else unexpected.addAll(mismatches)
}
return DiffReport(
totalPg = primaryFlights.size,
totalLegacy = referenceFlights.size,
matchedCount = matched,
knownDeviations = emptyList(),
unexpectedDeviations = unexpected,
)
}
/** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */
internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List<Deviation> {
@@ -0,0 +1,45 @@
-- flight-state-design-v2 §7 步骤 5 (ACM2-29 P4): 旧损失性结构退场。
-- v2 无损明细表已是唯一权威存储(V1.3.0 起),槽位/里程碑/紧凑航路列自 V1.3.0 起零写入、
-- P2-2 起零读取;对拍(FS7 v2 双读 + 全结构回环矩阵)与升级链验证通过后关闭回退窗口,
-- 本迁移物理删除这些列与其冗余索引。SRVT/VIPF/MAFL_TEXT 与异常前缀列为无损承载,保留。
ALTER TABLE flight_schd
DROP COLUMN IF EXISTS gate1, DROP COLUMN IF EXISTS pgot1, DROP COLUMN IF EXISTS pgct1,
DROP COLUMN IF EXISTS gotm1, DROP COLUMN IF EXISTS gctm1, DROP COLUMN IF EXISTS gtyp1,
DROP COLUMN IF EXISTS gate2, DROP COLUMN IF EXISTS pgot2, DROP COLUMN IF EXISTS pgct2,
DROP COLUMN IF EXISTS gotm2, DROP COLUMN IF EXISTS gctm2, DROP COLUMN IF EXISTS gtyp2,
DROP COLUMN IF EXISTS chkc1, DROP COLUMN IF EXISTS ccls1, DROP COLUMN IF EXISTS pcot1,
DROP COLUMN IF EXISTS pcct1, DROP COLUMN IF EXISTS cotm1, DROP COLUMN IF EXISTS cctm1,
DROP COLUMN IF EXISTS ctyp1, DROP COLUMN IF EXISTS chkc2, DROP COLUMN IF EXISTS ccls2,
DROP COLUMN IF EXISTS pcot2, DROP COLUMN IF EXISTS pcct2, DROP COLUMN IF EXISTS cotm2,
DROP COLUMN IF EXISTS cctm2, DROP COLUMN IF EXISTS ctyp2, DROP COLUMN IF EXISTS chkc3,
DROP COLUMN IF EXISTS ccls3, DROP COLUMN IF EXISTS pcot3, DROP COLUMN IF EXISTS pcct3,
DROP COLUMN IF EXISTS cotm3, DROP COLUMN IF EXISTS cctm3, DROP COLUMN IF EXISTS ctyp3,
DROP COLUMN IF EXISTS belt1, DROP COLUMN IF EXISTS bcls1, DROP COLUMN IF EXISTS bpcot1,
DROP COLUMN IF EXISTS bpcct1, DROP COLUMN IF EXISTS fbag1, DROP COLUMN IF EXISTS lbag1,
DROP COLUMN IF EXISTS btyp1, DROP COLUMN IF EXISTS belt2, DROP COLUMN IF EXISTS bcls2,
DROP COLUMN IF EXISTS bpcot2, DROP COLUMN IF EXISTS bpcct2, DROP COLUMN IF EXISTS fbag2,
DROP COLUMN IF EXISTS lbag2, DROP COLUMN IF EXISTS btyp2,
DROP COLUMN IF EXISTS psst1, DROP COLUMN IF EXISTS stst1, DROP COLUMN IF EXISTS stet1,
DROP COLUMN IF EXISTS psst2, DROP COLUMN IF EXISTS stst2, DROP COLUMN IF EXISTS stet2,
DROP COLUMN IF EXISTS chut1, DROP COLUMN IF EXISTS chcls1, DROP COLUMN IF EXISTS pcbt1,
DROP COLUMN IF EXISTS pcet1, DROP COLUMN IF EXISTS cbtm1, DROP COLUMN IF EXISTS cetm1,
DROP COLUMN IF EXISTS chtyp1, DROP COLUMN IF EXISTS chut2, DROP COLUMN IF EXISTS chcls2,
DROP COLUMN IF EXISTS pcbt2, DROP COLUMN IF EXISTS pcet2, DROP COLUMN IF EXISTS cbtm2,
DROP COLUMN IF EXISTS cetm2, DROP COLUMN IF EXISTS chtyp2,
DROP COLUMN IF EXISTS dely_code, DROP COLUMN IF EXISTS dely_strt,
DROP COLUMN IF EXISTS dely_dura, DROP COLUMN IF EXISTS dely_remc,
DROP COLUMN IF EXISTS abtm_a, DROP COLUMN IF EXISTS abtm_d,
DROP COLUMN IF EXISTS chot_on, DROP COLUMN IF EXISTS chot_off,
DROP COLUMN IF EXISTS rout_path, DROP COLUMN IF EXISTS erut_path;
-- V1.3.0 的三个 (flid) 单列索引与 PK (flid, ordinal) 前缀冗余(L2),一并清理
DROP INDEX IF EXISTS idx_flight_gate_flid;
DROP INDEX IF EXISTS idx_flight_checkin_flid;
DROP INDEX IF EXISTS idx_flight_belt_flid;
@@ -231,7 +231,6 @@ class FlightSchdJdbcPgTest {
)
assertNull(repo.findByFlid(flid)!!["GTDT"])
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertLegacyCollectionColumnsAllNull(flid)
}
@Test
@@ -533,7 +532,6 @@ class FlightSchdJdbcPgTest {
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertLegacyCollectionColumnsAllNull(flid)
val roundtrip = repo.findNextStateByFlid(flid)!!
assertEquals(1L, roundtrip.stateVersion)
@@ -585,7 +583,6 @@ class FlightSchdJdbcPgTest {
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertLegacyCollectionColumnsAllNull(flid)
assertEquals("MU123", repo.findByFlid(flid)!!["FLNO"])
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
@@ -794,16 +791,4 @@ class FlightSchdJdbcPgTest {
if (e != null && a != null) assertEquals(e, a, path) else assertEquals(expected, actual, path)
}
private fun assertLegacyCollectionColumnsAllNull(flid: String) {
val columnList = com.gzzn.omms.msgexchange.support.LEGACY_COLLECTION_STORAGE_COLUMNS.joinToString(", ")
ds.queryOne(
"SELECT $columnList FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs ->
com.gzzn.omms.msgexchange.support.LEGACY_COLLECTION_STORAGE_COLUMNS.forEach { column ->
assertNull(rs.getString(column), "legacy column $column must be null for v2 persistNextStates (flid=$flid)")
}
true
}
}
}
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -44,7 +45,7 @@ class HistorySweepJobTest {
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3")))
// 模拟判史:F1, F2, F3 全部为历史候选
@@ -72,7 +73,7 @@ class HistorySweepJobTest {
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
@@ -95,7 +96,7 @@ class HistorySweepJobTest {
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it }
// 模拟 ES 全部写入失败
@@ -114,7 +115,7 @@ class HistorySweepJobTest {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
)
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1
val esUpserted = mutableSetOf<String>()
@@ -151,7 +152,7 @@ class HistorySweepJobTest {
@Test
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
val flights = listOf("F1" to mapOf("FLID" to "F1"))
flightSchd.upsertSnapshotBatch("2026-09-01", flights)
seedSnapshot(flightSchd, "2026-09-01", flights)
// 首次删除
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
@@ -29,6 +29,8 @@ 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 com.gzzn.omms.msgexchange.support.seedIncremental
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -269,10 +271,10 @@ class FlightSchdInvariantTest {
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", fields = mapOf("FLID" to "ADFT_888", "FLTY" to "ADFT"))
flightSchd.upsertIncremental(listOf(adftChange))
seedIncremental(flightSchd, listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
flightSchd.upsertSnapshotBatch(day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
seedSnapshot(flightSchd, day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("REG_OLD")))
// 3. 执行下一轮快照 DNLD,新代仅包含 REG_NEW(REG_OLD 不在新代中,属于待删差集;ADFT 也不在新代中)
@@ -308,11 +310,11 @@ class FlightSchdInvariantTest {
val dayNew = "2026-09-08"
// 初始属旧代
flightSchd.upsertSnapshotBatch(dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "07")))
seedSnapshot(flightSchd, dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "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 mapOf("FLID" to "FL_MIG", "REMC" to "08")))
seedSnapshot(flightSchd, dayNew, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "08")))
// 此时 09-07 再次执行差删(差集中包含 FL_MIG)
val deleted = flightSchd.deleteDiffByDay(dayOld, listOf("FL_MIG"))
@@ -334,7 +336,7 @@ class FlightSchdInvariantTest {
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 mapOf("FLID" to "TZ_01")), now)
seedSnapshot(flightSchd, "2026-09-07", listOf("TZ_01" to mapOf("FLID" to "TZ_01")), now)
flightSchd.putGenIfVersion("2026-09-07", 0L, FlightSchdRepository.GenMeta("2026-09-07", 1L, setOf("TZ_01")), now)
val gen = flightSchd.getGen("2026-09-07")
@@ -156,13 +156,6 @@ class MessageProcessorTest {
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, 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.fields }
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
override fun findByFlid(flid: String): FlightFields? = flights[flid]
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> =
@@ -70,8 +70,6 @@ class V2PipelineWritePathContractTest {
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
assertEquals(0, flightSchd.legacySnapshotCalls)
assertEquals(0, flightSchd.legacyIncrementalCalls)
}
@Test
@@ -109,7 +107,5 @@ class V2PipelineWritePathContractTest {
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
assertEquals(0, flightSchd.legacySnapshotCalls)
assertEquals(0, flightSchd.legacyIncrementalCalls)
}
}
@@ -0,0 +1,44 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import java.time.Instant
/** 测试种子助手:v2 写路径(persistNextStates)等价替代已退场的 legacy upsertP4)。 */
fun seedSnapshot(
repo: FlightSchdRepository,
day: String,
flights: List<Pair<String, FlightFields>>,
now: Instant = Instant.now(),
) {
repo.persistNextStates(
day,
flights.map { (flid, fields) ->
FlightStateEngine.apply(
null,
FlightStateEngine.commandsFromFields(flid, fields, snapshotReplace = true),
"seed-$day",
bumpVersion = false,
).copy(stateVersion = 1L)
},
snapshotReplace = true,
now = now,
)
}
fun seedIncremental(repo: FlightSchdRepository, changes: List<FlightChange>, messageId: String = "seed-inc") {
repo.persistNextStates(
null,
changes.map { change ->
FlightStateEngine.apply(
repo.findNextStateByFlid(change.flid),
FlightStateEngine.commandsFromFields(change.flid, change.fields, snapshotReplace = false),
messageId,
bumpVersion = true,
)
},
snapshotReplace = false,
)
}
@@ -1,46 +1,17 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import java.time.Instant
/** V1.2.0 宽表集合存储列;v2 persistNextStates 不得写入。 */
val LEGACY_COLLECTION_STORAGE_COLUMNS: List<String> = listOf(
"gate1", "pgot1", "pgct1", "gotm1", "gctm1", "gtyp1",
"gate2", "pgot2", "pgct2", "gotm2", "gctm2", "gtyp2",
"chkc1", "ccls1", "pcot1", "pcct1", "cotm1", "cctm1", "ctyp1",
"chkc2", "ccls2", "pcot2", "pcct2", "cotm2", "cctm2", "ctyp2",
"chkc3", "ccls3", "pcot3", "pcct3", "cotm3", "cctm3", "ctyp3",
"belt1", "bcls1", "bpcot1", "bpcct1", "fbag1", "lbag1", "btyp1",
"belt2", "bcls2", "bpcot2", "bpcct2", "fbag2", "lbag2", "btyp2",
"psst1", "stst1", "stet1", "psst2", "stst2", "stet2",
"chut1", "chcls1", "pcbt1", "pcet1", "cbtm1", "cetm1", "chtyp1",
"chut2", "chcls2", "pcbt2", "pcet2", "cbtm2", "cetm2", "chtyp2",
"dely_code", "dely_strt", "dely_dura", "dely_remc",
"abtm_a", "abtm_d", "chot_on", "chot_off",
"rout_path", "erut_path",
)
/** v2 写路径守卫(P4 退场后):persistNextStates 是唯一写路径,legacy upsert 入口已从接口删除。 */
class GuardedFlightSchd(
private val inner: FlightSchdRepository = StubFlightSchd(),
) : FlightSchdRepository {
var legacySnapshotCalls = 0
var legacyIncrementalCalls = 0
var persistNextStatesCalls = 0
override fun upsertSnapshotBatch(day: String, flights: List<Pair<String, FlightFields>>, now: Instant) {
legacySnapshotCalls++
throw AssertionError("v2 path must not call upsertSnapshotBatch")
}
override fun upsertIncremental(changes: List<FlightChange>, now: Instant) {
legacyIncrementalCalls++
throw AssertionError("v2 path must not call upsertIncremental")
}
override fun persistNextStates(day: String?, states: List<FlightNextState>, snapshotReplace: Boolean, now: Instant) {
persistNextStatesCalls++
inner.persistNextStates(day, states, snapshotReplace, now)
@@ -1,156 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightSchdReadAssembler
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
/**
* FS7 v2 双读对拍:明细表组装 vs legacy 槽位列组装。
* 反例来自 review-flight-state-2026-09-08 §12.1 / SIS §3.34.3G28/G33/G23)。
*/
class FlightStoreDualReadTest {
private val tool = FlightStoreDiffTool()
private val flid = "TEST_DUAL_READ"
private fun detailView(vararg collections: Pair<String, String>) = mapOf(
flid to linkedMapOf(
"FLID" to flid,
*collections,
),
)
private fun legacyView(vararg collections: Pair<String, String>) = mapOf(
flid to linkedMapOf(
"FLID" to flid,
*collections,
),
)
@Test
fun `Dual read GREEN when detail and legacy slot views agree within slot capacity`() {
val gtdt = """[{"GTNO":"1","GATE":"G28","PGOT":"07SEP260800"},{"GTNO":"2","GATE":"G33"}]"""
val detail = detailView("GTDT" to gtdt)
val legacy = legacyView("GTDT" to gtdt)
val report = tool.diffDualRead(detail, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
}
@Test
fun `Dual read RED for SIS three-gate sample G28 G33 G23 - third gate must not be ignored`() {
val detailGtdt = """[
{"GTNO":"1","GATE":"G28"},
{"GTNO":"2","GATE":"G33"},
{"GTNO":"3","GATE":"G23"}
]""".replace("\n", "").replace(" ", "")
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"}]"""
val report = tool.diffDualRead(
detailView("GTDT" to detailGtdt),
legacyView("GTDT" to legacyGtdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "GTDT" })
}
@Test
fun `Dual read RED for non-contiguous GTNO=3 - must not rewrite to GTNO=1`() {
val detailGtdt = """[{"GTNO":"3","GATE":"G28"}]"""
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"}]"""
val report = tool.diffDualRead(
detailView("GTDT" to detailGtdt),
legacyView("GTDT" to legacyGtdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path?.startsWith("GTDT") == true })
}
@Test
fun `Dual read RED for duplicate CHKC different cabin classes - legacy dedupe loses row`() {
val detailCkdt = """[
{"CKNO":"1","CHKC":"01","CCLS":"F"},
{"CKNO":"2","CHKC":"01","CCLS":"Y"}
]""".replace("\n", "").replace(" ", "")
val legacyCkdt = """[{"CKNO":"1","CHKC":"01","CCLS":"F"}]"""
val report = tool.diffDualRead(
detailView("CKDT" to detailCkdt),
legacyView("CKDT" to legacyCkdt),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "CKDT" })
}
@Test
fun `Dual read RED for two bridge ops with ABDG - legacy milestone view drops bridge id`() {
val detailAbtm = """[
{"ASNO":"1","ABDG":"B12","ABOP":"A","AOTM":"07SEP260730"},
{"ASNO":"2","ABDG":"B15","ABOP":"D","AOTM":"07SEP261200"}
]""".replace("\n", "").replace(" ", "")
val legacyAbtm = """[
{"ASNO":"1","ABOP":"A","AOTM":"07SEP260730"},
{"ASNO":"2","ABOP":"D","AOTM":"07SEP261200"}
]""".replace("\n", "").replace(" ", "")
val report = tool.diffDualRead(
detailView("ABTM" to detailAbtm),
legacyView("ABTM" to legacyAbtm),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path?.contains("ABDG") == true || it.path?.startsWith("ABTM") == true })
}
@Test
fun `Dual read RED for two DELY reasons - legacy keeps only first`() {
val detailDely = """[
{"DLNO":"1","CODE":"01","STRT":"07SEP260800","DURA":"30"},
{"DLNO":"2","CODE":"02","STRT":"07SEP260900","DURA":"15"}
]""".replace("\n", "").replace(" ", "")
val legacyDely = """[{"CODE":"01","STRT":"07SEP260800","DURA":"30"}]"""
val report = tool.diffDualRead(
detailView("DELY" to detailDely),
legacyView("DELY" to legacyDely),
)
assertFalse(report.isGreen)
assertTrue(report.unexpectedDeviations.any { it.path == "DELY" })
}
@Test
fun `FlightSchdReadAssembler exposes lossy legacy slot view for three-gate SIS sample`() {
val row = mapOf(
"GATE1" to "G28",
"GATE2" to "G33",
)
val detailCollections = mapOf(
"GTDT" to """[
{"GTNO":"1","GATE":"G28"},
{"GTNO":"2","GATE":"G33"},
{"GTNO":"3","GATE":"G23"}
]""".replace("\n", "").replace(" ", ""),
)
val detailView = FlightSchdReadAssembler.assembleDetailOnly(flid, row, detailCollections)
val legacyView = FlightSchdReadAssembler.assembleLegacySlotsOnly(flid, row)
val report = tool.diffDualRead(mapOf(flid to detailView), mapOf(flid to legacyView))
assertFalse(report.isGreen)
assertTrue(legacyView["GTDT"]!!.contains("G23").not())
assertTrue(detailView["GTDT"]!!.contains("G23"))
}
@Test
fun `FlightSchdReadAssembler merged view follows detail table when present`() {
val row = mapOf("GATE1" to "OLD")
val detailCollections = mapOf(
"GTDT" to """[{"GTNO":"1","GATE":"G28"}]""",
)
val merged = FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections)
assertEquals(detailCollections["GTDT"], merged["GTDT"])
}
}