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> {