refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)

- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0:
  PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD
  + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层
- 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 +
  条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后
- 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包
  DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅
  ACTIVE→DELETED,重复 FDEL 幂等不推进版本)
- 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭,
  TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3)
- 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10)
- XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型,
  FLTR 开放标签泛型承载)
- 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条
- 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试,
  新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
windyboy
2026-09-09 17:53:08 +08:00
parent 6af292d103
commit 879d658159
83 changed files with 3435 additions and 6355 deletions
@@ -1,37 +1,37 @@
package com.gzzn.omms.msgexchange.codec
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule
import com.fasterxml.jackson.dataformat.xml.XmlFactory
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import jakarta.inject.Singleton
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.xml.sax.InputSource
import java.io.StringReader
import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.stream.XMLInputFactory
/**
* SIS XML 解码XXE 防护;META + TYPE 分派 + FLOP 集合解析)。
* 阶段 2 先覆盖 FLOP-GTDT 纵向链路所需字段;其余 STYP 仅完成 META/FLID 提取
* SIS XML 解码jackson-dataformat-xml 直接映射到 [SisMessage] 数据类(不再手写 DOM)。
* XXE 防护:XMLInputFactory 禁 DTD/外部实体
* 覆盖:SCHD DNLD/RESPFLTR 记录集,§5 入口)、SCHD ADFT(单记录 §2.1)、
* FLOP(单航班增量 §6.1)、FDEL(终止实例 §6.2)。
*/
@Singleton
class JacksonXmlCodec : XmlCodec {
private val builderFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false
isExpandEntityReferences = false
setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeature("http://xml.org/sax/features/external-general-entities", false)
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
try {
setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "")
setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "")
} catch (_: IllegalArgumentException) {
// JDK 实现差异:忽略不支持的属性
}
private val mapper: XmlMapper = XmlMapper(
XmlFactory(
XMLInputFactory.newInstance().apply {
setProperty(XMLInputFactory.SUPPORT_DTD, false)
setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
},
),
).apply {
registerKotlinModule()
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
override fun decode(rawXml: String): DecodeResult {
@@ -39,47 +39,42 @@ class JacksonXmlCodec : XmlCodec {
if (trimmed.isEmpty() || !trimmed.startsWith("<")) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "empty-or-non-xml"))
}
return try {
val doc = builderFactory.newDocumentBuilder().parse(InputSource(StringReader(trimmed)))
val root = doc.documentElement ?: return DecodeResult.Err(
DecodeFailure(ErrorClass.MALFORMED, "missing-root"),
)
if (!root.tagName.equals("MSG", ignoreCase = true)) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "root-not-msg:${root.tagName}"))
}
val metaEl = firstChildElement(root, "META")
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta"))
val sndr = childText(metaEl, "SNDR")
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr"))
val seqn = childText(metaEl, "SEQN")?.toLongOrNull()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn"))
val dttm = childText(metaEl, "DTTM")?.toLongOrNull()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm"))
val type = childText(metaEl, "TYPE")?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
val styp = childText(metaEl, "STYP")?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
val kind = kindOf(type, styp)
val body = when (kind) {
is MsgKind.Flop -> parseFlopBody(root, type)
is MsgKind.Schd -> null
}
DecodeResult.Ok(
DecodedMessage(
meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm),
kind = kind,
rawXml = trimmed,
body = body,
),
)
} catch (e: NumberFormatException) {
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "invalid-number:${e.message}"))
val msg = try {
mapper.readValue(trimmed, SisMessage::class.java)
} catch (e: Exception) {
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message ?: e.javaClass.simpleName}"))
return DecodeResult.Err(
DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message?.take(200) ?: e.javaClass.simpleName}"),
)
}
val meta = msg.meta
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta"))
val sndr = meta.sndr?.trim()?.takeIf { it.isNotEmpty() }
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr"))
val seqn = meta.seqn
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn"))
val dttm = meta.dttm
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm"))
val type = meta.type?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
val styp = meta.styp?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
val kind = kindOf(type, styp)
val body: Any? = when (kind) {
is MsgKind.Schd -> parseScheduleBody(msg)
is MsgKind.Flop -> parseFlightSection(msg.flop)
MsgKind.Fdel -> parseFlightSection(msg.fdel)
is MsgKind.Unsupported -> null
}
return DecodeResult.Ok(
DecodedMessage(
meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm),
kind = kind,
rawXml = trimmed,
body = body,
),
)
}
override fun encodeRqrd(kind: String, rangeJson: String): String =
@@ -89,82 +84,92 @@ class JacksonXmlCodec : XmlCodec {
"SCHD" -> MsgKind.Schd(
when (styp) {
"RESP" -> MsgKind.SchdSubtype.RESP
"DNLD" -> MsgKind.SchdSubtype.DNLD
"ADFT" -> MsgKind.SchdSubtype.ADFT
else -> MsgKind.SchdSubtype.DNLD
},
)
"FDEL" -> MsgKind.Fdel
"FLOP" -> MsgKind.Flop(styp)
else -> MsgKind.Flop(styp)
else -> MsgKind.Unsupported("$type-$styp") // §9 未支持类型,不猜测分派
}
private fun parseFlopBody(root: Element, type: String): FlopPayload? {
val section = firstChildElement(root, type) ?: return null
/** SCHD DNLD/RESP/ADFTRECS 缺失/非法 → -1(处理层 §5.2 整包拒绝)。 */
private fun parseScheduleBody(msg: SisMessage): ScheduleBody? {
val section = msg.schd ?: return null
val records = section.fltr.mapNotNull { recordOf(it) }
return ScheduleBody(recsDeclared = section.recs ?: -1, records = records)
}
/** FLOP/FDEL:单航班段 Map → 载荷;无 FLID 返回 null(调用方按缺载荷处理)。 */
private fun parseFlightSection(section: Map<String, Any>?): FlopPayload? {
section ?: return null
val scalars = linkedMapOf<String, String>()
val collections = linkedMapOf<String, MutableList<Map<String, String>>>()
section.childNodes.let { nodes ->
for (i in 0 until nodes.length) {
val node = nodes.item(i)
if (node.nodeType != Node.ELEMENT_NODE) continue
val el = node as Element
val tag = el.tagName.uppercase()
when {
tag in COLLECTION_TAGS -> {
collections.getOrPut(tag) { mutableListOf() }.add(parseCollectionItem(el))
}
tag == "FLID" -> scalars["FLID"] = el.textContent.trim()
hasElementChildren(el) -> Unit
else -> scalars[tag] = el.textContent.trim()
var flid: String? = null
section.forEach { (key, value) ->
val tag = key.uppercase()
when {
tag == "FLID" -> flid = value.toString().trim()
tag in COLLECTION_TAGS -> value.toCollectionItems()?.let { items ->
collections.getOrPut(tag) { mutableListOf() }.addAll(items)
}
value is String -> scalars[tag] = value.trim()
}
}
val flid = scalars.remove("FLID") ?: return null
return FlopPayload(flid = flid, scalars = scalars, collections = collections.mapValues { it.value.toList() })
return flid?.takeIf { it.isNotEmpty() }?.let {
FlopPayload(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() })
}
}
private fun parseCollectionItem(el: Element): Map<String, String> {
val item = linkedMapOf<String, String>()
if (el.hasAttributes()) {
for (i in 0 until el.attributes.length) {
val attr = el.attributes.item(i)
item[attr.nodeName.uppercase()] = attr.nodeValue.trim()
}
}
el.childNodes.let { nodes ->
for (i in 0 until nodes.length) {
val node = nodes.item(i)
if (node.nodeType != Node.ELEMENT_NODE) continue
val child = node as Element
item[child.tagName.uppercase()] = child.textContent.trim()
}
}
return item
}
private fun firstChildElement(parent: Element, tag: String): Element? {
parent.childNodes.let { nodes ->
for (i in 0 until nodes.length) {
val node = nodes.item(i)
if (node.nodeType == Node.ELEMENT_NODE && (node as Element).tagName.equals(tag, ignoreCase = true)) {
return node
/** FLTR Map → ScheduleRecord:标量/集合分离,集合项含源序号属性。 */
private fun recordOf(fltr: Map<String, Any>): ScheduleRecord? {
val scalars = linkedMapOf<String, String>()
val collections = linkedMapOf<String, MutableList<Map<String, String>>>()
var flid: String? = null
var seqn = 0L
fltr.forEach { (key, value) ->
val tag = key.uppercase()
when {
tag == "FLID" -> flid = value.toString().trim()
tag == "SEQN" -> seqn = (value as? Number)?.toLong() ?: value.toString().trim().toLongOrNull() ?: 0L
tag in COLLECTION_TAGS -> value.toCollectionItems()?.let { items ->
collections.getOrPut(tag) { mutableListOf() }.addAll(items)
}
value is String -> scalars[tag] = value.trim()
}
}
return null
return flid?.takeIf { it.isNotEmpty() }?.let {
ScheduleRecord(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() }, seqn = seqn)
}
}
private fun childText(parent: Element, tag: String): String? =
firstChildElement(parent, tag)?.textContent?.trim()?.takeIf { it.isNotEmpty() }
/**
* 集合值归一:重复元素 → List<Map>(属性与子元素展平);单元素 → 单项列表;
* 混合文本(如 DELY 备注文本)落到 REMC 键(XmlMapper 绑定的已知弱项,尽力保留)。
*/
private fun Any?.toCollectionItems(): List<Map<String, String>>? = when (this) {
is List<*> -> mapNotNull { it?.toCollectionItem() }
is Map<*, *> -> listOfNotNull(toCollectionItem(this))
is String -> listOf(mapOf("REMC" to trim()))
else -> null
}
private fun hasElementChildren(el: Element): Boolean {
el.childNodes.let { nodes ->
for (i in 0 until nodes.length) {
if (nodes.item(i).nodeType == Node.ELEMENT_NODE) return true
private fun toCollectionItem(map: Map<*, *>): Map<String, String> {
val out = linkedMapOf<String, String>()
map.forEach { (k, v) ->
val key = k.toString().uppercase()
when (v) {
is String -> out[key] = v.trim()
is Number -> out[key] = v.toString()
}
}
return false
return out
}
private fun Any?.toCollectionItem(): Map<String, String>? = when (this) {
is Map<*, *> -> toCollectionItem(this)
is String -> mapOf("REMC" to trim())
else -> null
}
companion object {