feat(flight): UNMAPPED_FIELD、SRVT/VIPF 明细与白名单内未映射留档(ACM2-89/99/100)

V5 迁移;集合段出现才写;白名单 7 类内未落态写入 UNMAPPED_FIELD,不扩白名单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 15:54:55 +08:00
co-authored by Cursor
parent 54b383fa9d
commit 02b0b2f189
14 changed files with 464 additions and 13 deletions
@@ -6,12 +6,14 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry
/** Stable payload boundary used by processing. It is deliberately not an XML model. */
data class FlopPayload(
val flid: String,
val scalars: Map<String, String> = emptyMap(),
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
val unmapped: List<UnmappedFieldEntry> = emptyList(),
)
data class ScheduleBody(
@@ -20,10 +22,8 @@ data class ScheduleBody(
)
/**
* 只保留在 wire/domain、尚未映射到持久化明细的集合键(`[G-SRVT-VIPF]`)。
*
* 它们不参与合并、不进快照、不落库;保留的目的是不让入站事实在解码层被静默抹平,并让真实
* 流量里的出现情况可观测——决定"缺席是否等于删除"的 `Q2` 需要真实报文才能定案。
* `SRVT`/`VIPF` 集合键(`[G-SRVT-VIPF]`):用于入站出现次数指标;明细落库见 `FLIGHT_SRVT`/`FLIGHT_VIPF`
* 不参与合并层与 Redis 快照。
*/
private val UNPERSISTED_COLLECTION_KEYS: Set<String> = setOf("SRVT", "VIPF")
@@ -160,11 +160,30 @@ data class FlightRecordXml(
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "ROUT") val rout: List<RoutXml> = emptyList(),
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "ERUT") val erut: List<RoutXml> = emptyList(),
// SRVT/VIPF 用可空表达"段是否出现":null = 未出现;出现即为列表(空元素得到一行空行)。
// 两者都不落明细表、不参与合并,清空语义待 `Q2``[G-SRVT-VIPF]`
// 明细表见 `G-SRVT-VIPF`;缺席是否清除待 `Q2`,现行默认不删已有行
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "SRVT") val srvt: List<SrvtXml>? = null,
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "VIPF") val vipf: List<VipfXml>? = null,
@param:JacksonXmlProperty(localName = "FDIV") val fdiv: FdivXml? = null,
@param:JacksonXmlProperty(localName = "FRET") val fret: FretXml? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class FdivXml(
@param:JacksonXmlProperty(isAttribute = true, localName = "DDES") val ddes: String? = null,
@param:JacksonXmlProperty(isAttribute = true, localName = "DDIR") val ddir: String? = null,
) {
@field:JacksonXmlText
var text: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class FretXml(
@param:JacksonXmlProperty(isAttribute = true, localName = "REID") val reid: String? = null,
) {
@field:JacksonXmlText
var text: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
class FdelXml
@@ -1,6 +1,7 @@
package com.gzzn.omms.msgexchange.codec
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry
/** Converts the annotated wire DTOs into the stable payload types used by processing. */
internal object SisWireMapper {
@@ -11,7 +12,7 @@ internal object SisWireMapper {
fun flopPayload(xml: FlightRecordXml): FlopPayload? =
xml.flid?.trim()?.takeIf(String::isNotEmpty)?.let {
FlopPayload(it, xml.scalars(), xml.collections())
FlopPayload(it, xml.scalars(), xml.collections(), xml.unmappedFields())
}
private fun scheduleRecord(xml: FlightRecordXml): ScheduleRecord? =
@@ -58,6 +59,25 @@ internal object SisWireMapper {
return mapped
}
/** 尚未落入当前态的 FLOP 元素(`G-FLOP-UNMAPPED`);路径编码见 implementation.md「未映射字段」。 */
private fun FlightRecordXml.unmappedFields(): List<UnmappedFieldEntry> = buildList {
fdiv?.let { addAll(it.toUnmapped()) }
fret?.let { addAll(it.toUnmapped()) }
}
private fun FdivXml.toUnmapped(): List<UnmappedFieldEntry> = buildList {
val ord = 1
if (ddes != null) add(UnmappedFieldEntry("FDIV/@DDES", ord, ddes.trim()))
if (ddir != null) add(UnmappedFieldEntry("FDIV/@DDIR", ord, ddir.trim()))
if (text != null) add(UnmappedFieldEntry("FDIV", ord, text!!.trim()))
}
private fun FretXml.toUnmapped(): List<UnmappedFieldEntry> = buildList {
val ord = 1
if (reid != null) add(UnmappedFieldEntry("FRET/@REID", ord, reid.trim()))
if (text != null) add(UnmappedFieldEntry("FRET", ord, text!!.trim()))
}
/** `SRVT` 一行:`OPER` 取自属性;`VIPT_*` 前缀供嵌套字段使用,避免与外层 `OPER` 撞键。 */
private fun srvtRow(x: SrvtXml): Map<String, String> = x.toMap(
"OPER" to x.oper, "SRTC" to x.srtc, "SRQT" to x.srqt, "SRST" to x.srst,
@@ -0,0 +1,8 @@
package com.gzzn.omms.msgexchange.domain.flight
/** 一条未映射字段记录(`US-05` AC3);`path`/`ordinal`/`raw` 编码见 implementation.md「未映射字段」。 */
data class UnmappedFieldEntry(
val path: String,
val ordinal: Int,
val raw: String,
)
@@ -9,6 +9,7 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightMainRow
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -268,6 +269,25 @@ interface FlightStateRepository {
*/
fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome
/**
* `SRVT`/`VIPF` 段**出现**时写入专用明细(`G-SRVT-VIPF`);键缺席时不删已有行(`Q2` 默认)。
*/
fun persistSrvtVipfIfPresent(
flid: String,
collections: Map<String, List<Map<String, String>>>,
now: Instant,
)
/**
* 按消息重写未映射字段证据(`US-05` AC3):先删该 `MSG_ID` 再插入,与航班变更同事务,重处理幂等。
*/
fun replaceUnmappedForMessage(
msgId: Long,
flid: String,
fields: List<UnmappedFieldEntry>,
now: Instant,
)
/**
* 删除航班:把在用的航班标成 DELETED、版本号加一,明细数据保留不删。
*
@@ -698,6 +698,40 @@ class JdbcFlightStateRepository(
return if (existed == null) PersistOutcome.INSERTED else PersistOutcome.UPDATED
}
override fun persistSrvtVipfIfPresent(
flid: String,
collections: Map<String, List<Map<String, String>>>,
now: Instant,
) {
if ("SRVT" in collections) replaceSrvt(flid, collections.getValue("SRVT"), now)
if ("VIPF" in collections) replaceVipf(flid, collections.getValue("VIPF"), now)
}
override fun replaceUnmappedForMessage(
msgId: Long,
flid: String,
fields: List<com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry>,
now: Instant,
) {
ds.update("DELETE FROM unmapped_field WHERE msg_id = ?", { ps -> ps.setLong(1, msgId) })
fields.forEach { field ->
ds.update(
"""
INSERT INTO unmapped_field (msg_id, flid, path, ordinal, raw_value, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
{ ps ->
ps.setLong(1, msgId)
ps.setString(2, flid)
ps.setString(3, field.path)
ps.setInt(4, field.ordinal)
ps.setString(5, field.raw)
ps.setTimestamp(6, now.toSqlTimestamp())
},
)
}
}
override fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean =
ds.update(
"UPDATE flight_schd SET state = 'DELETED', state_version = state_version + 1, last_msg_id = ?, updated_at = ? " +
@@ -761,12 +795,99 @@ class JdbcFlightStateRepository(
{ ps -> ps.setString(1, candidate.flid) },
)
}
SEGMENT_TABLES.forEach { table ->
purged += ds.update(
"DELETE FROM $table WHERE flid = ?",
{ ps -> ps.setString(1, candidate.flid) },
)
}
}
return purged
}
// ---- 内部实现 ----
private fun replaceSrvt(flid: String, items: List<Map<String, String>>, now: Instant) {
ds.update("DELETE FROM flight_srvt WHERE flid = ?", { ps -> ps.setString(1, flid) })
items.forEachIndexed { idx, item ->
insertSegmentRow(
"flight_srvt",
flid,
idx + 1,
listOf("oper", "srtc", "srqt", "srst", "sret", "srpr", "sanr", "sarr"),
item,
now,
)
}
}
private fun replaceVipf(flid: String, items: List<Map<String, String>>, now: Instant) {
ds.update("DELETE FROM flight_vipf WHERE flid = ?", { ps -> ps.setString(1, flid) })
items.forEachIndexed { idx, item ->
insertSegmentRow(
"flight_vipf",
flid,
idx + 1,
listOf("oper", "vpcd", "vfes", "vipt_oper", "vipt_vscd", "vipt_vtqy", "vipt_vtst", "vipt_vtet"),
item,
mapOf(
"OPER" to "oper",
"VPCD" to "vpcd",
"VFES" to "vfes",
"VIPT_OPER" to "vipt_oper",
"VIPT_VSCD" to "vipt_vscd",
"VIPT_VTQY" to "vipt_vtqy",
"VIPT_VTST" to "vipt_vtst",
"VIPT_VTET" to "vipt_vtet",
),
now,
)
}
}
private fun insertSegmentRow(
table: String,
flid: String,
ordinal: Int,
columns: List<String>,
item: Map<String, String>,
now: Instant,
) = insertSegmentRow(table, flid, ordinal, columns, item, columns.associateWith { it.uppercase() }, now)
private fun insertSegmentRow(
table: String,
flid: String,
ordinal: Int,
columns: List<String>,
item: Map<String, String>,
keyToCol: Map<String, String>,
now: Instant,
) {
val cols = mutableListOf("flid", "ordinal")
val vals = mutableListOf<Any?>(flid, ordinal)
columns.forEach { col ->
cols.add(col)
val wireKey = keyToCol.entries.firstOrNull { it.value == col }?.key ?: col.uppercase()
vals.add(item[wireKey]?.takeIf { it.isNotEmpty() })
}
cols.add("created_at"); vals.add(now)
cols.add("updated_at"); vals.add(now)
val placeholders = cols.indices.joinToString(",") { "?" }
ds.update(
"INSERT INTO $table (${cols.joinToString(", ")}) VALUES ($placeholders)",
{ ps ->
vals.forEachIndexed { idx, v ->
when (v) {
null -> ps.setNull(idx + 1, java.sql.Types.VARCHAR)
is Int -> ps.setInt(idx + 1, v)
is Instant -> ps.setTimestamp(idx + 1, v.toSqlTimestamp())
else -> ps.setString(idx + 1, v.toString())
}
}
},
)
}
private fun bindMain(
ps: java.sql.PreparedStatement,
snapshot: FlightSnapshot,
@@ -895,6 +1016,8 @@ class JdbcFlightStateRepository(
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
)
internal val SEGMENT_TABLES = listOf("flight_srvt", "flight_vipf")
/** 报文里的 10 类集合分别存到哪张表、哪些列(列名与基线脚本一致)。 */
internal val COLLECTIONS: Map<String, DetailSpec> = mapOf(
"GTDT" to DetailSpec("flight_gate", listOf("gate", "pgot", "pgct", "gotm", "gctm", "gtyp"), "GTNO"),
@@ -356,9 +356,16 @@ class StubMsgEvents : MsgEventRepository {
class StubFlightState : FlightStateRepository {
val mains = linkedMapOf<String, FlightMainRow>()
val snapshots = linkedMapOf<String, FlightSnapshot>() // 内存全量态(主行+明细一体)
val srvtByFlid = linkedMapOf<String, List<Map<String, String>>>()
val vipfByFlid = linkedMapOf<String, List<Map<String, String>>>()
val unmappedByMsgId = linkedMapOf<Long, List<com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry>>()
fun clear() {
mains.clear(); snapshots.clear()
mains.clear()
snapshots.clear()
srvtByFlid.clear()
vipfByFlid.clear()
unmappedByMsgId.clear()
}
override fun findMainRow(flid: String): FlightMainRow? = mains[flid]
@@ -395,6 +402,24 @@ class StubFlightState : FlightStateRepository {
return if (existing == null) PersistOutcome.INSERTED else PersistOutcome.UPDATED
}
override fun persistSrvtVipfIfPresent(
flid: String,
collections: Map<String, List<Map<String, String>>>,
now: Instant,
) {
if ("SRVT" in collections) srvtByFlid[flid] = collections.getValue("SRVT")
if ("VIPF" in collections) vipfByFlid[flid] = collections.getValue("VIPF")
}
override fun replaceUnmappedForMessage(
msgId: Long,
flid: String,
fields: List<com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry>,
now: Instant,
) {
unmappedByMsgId[msgId] = fields
}
override fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean {
val main = mains[flid] ?: return false
if (main.state != FlightState.ACTIVE) return false
@@ -45,6 +45,13 @@ class FlopProcessor(
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
val next = FlightStateEngine.mergedState(current, change)
flightState.persistFullState(next, msgId = head.msgId, now = clock.instant())
flightState.persistAuxiliaryFlightFields(
head.msgId,
payload.flid,
payload.collections,
payload.unmapped,
clock.instant(),
)
msgEvents.insertAll(eventsFor(next, head.msgId, mapper, clock.instant()))
DomainOutcome(ApplyResult.Succeeded, listOf(projectionOf(next, mapper)))
}
@@ -117,6 +124,7 @@ class AdftProcessor(
if (current != null) {
val next = FlightStateEngine.mergedState(current, setOnly(record))
flightState.persistFullState(next, msgId = head.msgId, now = clock.instant())
flightState.persistSrvtVipfIfPresent(record.flid, record.collections, clock.instant())
msgEvents.insertAll(eventsFor(next, head.msgId, mapper, clock.instant()))
revived = next
}
@@ -148,6 +156,7 @@ class AdftProcessor(
FlightStateEngine.mergedState(current, setOnly(record))
}
val outcome = flightState.persistFullState(next, msgId = head.msgId, now = clock.instant())
flightState.persistSrvtVipfIfPresent(record.flid, record.collections, clock.instant())
if (outcome == PersistOutcome.DAY_GUARD_VIOLATION) {
// 运营日冲突是**协议级**问题:与 SCHD 同分类 —— 整笔回滚、不重试、交人工。
// 若按 INFRA 抛出去,会被当成暂时性故障白白重试到耗尽,并给出误导的错误类别。
@@ -0,0 +1,17 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.flight.UnmappedFieldEntry
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import java.time.Instant
/** `SRVT`/`VIPF` 与未映射字段的落库辅助(与航班主事务同批调用)。 */
internal fun FlightStateRepository.persistAuxiliaryFlightFields(
msgId: Long,
flid: String,
collections: Map<String, List<Map<String, String>>>,
unmapped: List<UnmappedFieldEntry>,
now: Instant,
) {
persistSrvtVipfIfPresent(flid, collections, now)
replaceUnmappedForMessage(msgId, flid, unmapped, now)
}
@@ -167,12 +167,10 @@ class MessageProcessor(
}
}
// [G-SRVT-VIPF]SRVT/VIPF 段只保留在解码载荷里,尚未落明细表(清空语义待 Q2)。
// 计数 + 告警替代此前的静默丢弃;出现即证明真实报文携带该段,可作为定案依据。
// [G-SRVT-VIPF]段出现计数,供真实流量观测;明细落库见 `G-SRVT-VIPF`(缺席是否清除待 Q2)。
val unpersisted = unpersistedCollectionHits(decoded.body)
if (unpersisted.isNotEmpty()) {
counters.unpersistedCollectionSeenAdd(unpersisted)
log.warn("unpersisted collection(s) {} present msgId={} [G-SRVT-VIPF]", unpersisted, head.msgId)
}
// I3identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑
@@ -141,6 +141,7 @@ class ScheduleProcessor(
throw ProtocolViolation("operation-day guard violated flid=$flid")
else -> batchWritten++
}
flightState.persistSrvtVipfIfPresent(flid, record.collections, clock.instant())
events += eventsFor(next, head.msgId, mapper, clock.instant())
projections += projectionOf(next, mapper)
}