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:
@@ -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、版本号加一,明细数据保留不删。
|
||||
*
|
||||
|
||||
+123
@@ -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)
|
||||
}
|
||||
|
||||
// I3:identity 仅首次绑定(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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user