refactor(codec): map SIS XML through annotated DTOs

This commit is contained in:
windyboy
2026-09-09 19:48:45 +08:00
parent c2d4a76e3e
commit 3ed54d306e
7 changed files with 290 additions and 157 deletions
+4 -6
View File
@@ -33,7 +33,7 @@
| `delivery/` | Delivery & Projection:每 target 严格 FIFO 投递、schd 聚合 | 流程 3 |
| `reference/` | Reference & Query21 类同步 + 15 类请求状态机 | 流程 6 |
| `jobs/` | 泵作业:ARCHIVE / HISTORY_SWEEP / PROJECTION_REBUILD | 流程 4/5/7I4 |
| `codec/` | XML codec阶段 1 先 vendor 复用 legacy POJO,见 ACMA-6 选型 | 决策 4 前置 |
| `codec/` | SIS XML codec注解 wire DTO → 领域载荷,禁 DTD/外部实体 | 决策 4 前置 |
| `domain/` | 领域模型:Decision、事件、状态机枚举、Phase 开关 | I1I5 |
| `config/` | `PipelineProps` 参数表(ACMA-8 参数初值,`msgx.*` | — |
| `infra/` | 仓储接口、Redis Lua 装载、stub、健康、重试策略 | 数据模型节 |
@@ -55,14 +55,12 @@
## 未完成(按计划属于后续阶段,不是本脚手架遗漏)
1. **Handler 业务(3+29**`processing/HandlerRegistry` 仅注册骨架,翻译属阶段 2/3。
2. **codec 实装**vendor 复用 legacy `entity/msg` POJO + Jackson XMLACMA-6 选型),
阶段 1 后续项。
3. **依赖版本锁定**`gradle/libs.versions.toml` 中版本为计划口径,需阶段 0
2. **依赖版本锁定**`gradle/libs.versions.toml` 中版本为计划口径,需阶段 0
「Micronaut×现网 Eureka 互操作冒烟 + logstash + ES REST」通过后固化。
4. **自有 PG 数据层 + InboxPoller**ACM2-12):`infra/persistence/Repositories.kt` 目前是接口
3. **自有 PG 数据层 + InboxPoller**ACM2-12):`infra/persistence/Repositories.kt` 目前是接口
Micronaut Data JDBC on PG + 信箱适配层 CminmsgMailbox/OutboxMailbox + JDBC 轮询入队
属 U05 批次),主泵/调度循环以接口驱动,纯逻辑已抽离可单测。
5. **信箱适配层与共享库边界**ACM2-12):上游外部写 `CMINMSGS`;本系统 JDBC 轮询读 +
4. **信箱适配层与共享库边界**ACM2-12):上游外部写 `CMINMSGS`;本系统 JDBC 轮询读 +
处理回填写;出站写 `COUTMSGS`mailbox.shared-mysql 配置段已占位);入队/回填的
外部副作用/补偿模型属 U05 批次。
@@ -2,26 +2,18 @@ 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 javax.xml.stream.XMLInputFactory
/**
* SIS XML 解码:jackson-dataformat-xml 直接映射到 [SisMessage] 数据类(不再手写 DOM)。
* XXE 防护:XMLInputFactory 禁 DTD/外部实体。
* 覆盖:SCHD DNLD/RESPFLTR 记录集,§5 入口)、SCHD ADFT(单记录 §2.1)、
* FLOP(单航班增量 §6.1)、FDEL(终止实例 §6.2)。
*/
/** XML codec for the SIS wire contract; XML DTO binding is kept separate from domain payloads. */
@Singleton
class JacksonXmlCodec : XmlCodec {
private val mapper: XmlMapper = XmlMapper(
XmlFactory(
XMLInputFactory.newInstance().apply {
@@ -31,6 +23,7 @@ class JacksonXmlCodec : XmlCodec {
),
).apply {
registerKotlinModule()
// SIS explicitly permits fields unused by a subsystem; wire DTOs document that policy.
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
@@ -40,7 +33,7 @@ class JacksonXmlCodec : XmlCodec {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "empty-or-non-xml"))
}
val msg = try {
mapper.readValue(trimmed, SisMessage::class.java)
mapper.readValue(trimmed, SisMessageXml::class.java)
} catch (e: Exception) {
return DecodeResult.Err(
DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message?.take(200) ?: e.javaClass.simpleName}"),
@@ -54,16 +47,16 @@ class JacksonXmlCodec : XmlCodec {
?: 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()
val type = meta.type?.trim()?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
val styp = meta.styp?.uppercase()
val styp = meta.styp?.trim()?.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.Schd -> msg.schd?.let(SisWireMapper::scheduleBody)
is MsgKind.Flop -> msg.flop?.let(SisWireMapper::flopPayload)
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
is MsgKind.Unsupported -> null
}
@@ -88,93 +81,7 @@ class JacksonXmlCodec : XmlCodec {
else -> MsgKind.SchdSubtype.DNLD
},
)
"FDEL" -> MsgKind.Fdel
"FLOP" -> MsgKind.Flop(styp)
else -> MsgKind.Unsupported("$type-$styp") // §9 未支持类型,不猜测分派
}
/** 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>>>()
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()
}
}
return flid?.takeIf { it.isNotEmpty() }?.let {
FlopPayload(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() })
}
}
/** 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 flid?.takeIf { it.isNotEmpty() }?.let {
ScheduleRecord(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() }, seqn = seqn)
}
}
/**
* 集合值归一:重复元素 → 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 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 out
}
private fun Any?.toCollectionItem(): Map<String, String>? = when (this) {
is Map<*, *> -> toCollectionItem(this)
is String -> mapOf("REMC" to trim())
else -> null
}
companion object {
val COLLECTION_TAGS: Set<String> = setOf(
"GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT",
)
"FLOP" -> if (styp == "FDEL") MsgKind.Fdel else MsgKind.Flop(styp)
else -> MsgKind.Unsupported("$type-$styp")
}
}
@@ -3,24 +3,18 @@ package com.gzzn.omms.msgexchange.codec
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper
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 java.time.LocalDate
/**
* FLOP 报文解析体(单航班增量 §6.1)。
* scalarsFLID 以外的标量;collectionsGTDT/CKDT 等重复集合(含源序号属性)。
*/
/** 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(),
)
/**
* SCHDDNLD/RESP/ADFT)解析体(docs/flight-state.md §5)。
* RECS 声明数与实收 FLTR 记录分离承载,完整性校验(§5.2)在处理层执行;
* 协议无报文级覆盖日字段——scope 由处理层按记录 SODT 推导。
*/
data class ScheduleBody(
val recsDeclared: Int,
val records: List<ScheduleRecord>,
@@ -28,46 +22,184 @@ data class ScheduleBody(
val scopeEnd: LocalDate? = null,
)
// =====================================================================
// XML 直接映射(jackson-dataformat-xml 数据绑定,不再手写 DOM 遍历)。
// 信封强类型(META/段结构),FLTR/航班段内标签为开放集 → Map<String, Any>
// 泛型承载(45+ 标签逐一建模不成比例);集合项 = 属性 + 子元素展平的 Map。
// 已知弱项:DELY 的混合文本(元素文本)在 XmlMapper 绑定下不保证保留,
// 属性 CODE/STRT/DURA 保留;备注文本待真实报文验收(§10)。
// =====================================================================
/**
* Wire DTOs for SIS in docs/legacy/SIS_AODB_RMS-V0.1.md.
* SIS permits a subsystem to ignore standard fields it does not use; those fields are
* therefore ignored here, while fields used by the state model are explicitly annotated.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class SisMessage(
@JacksonXmlProperty(localName = "META")
val meta: SisMeta? = null,
@JacksonXmlProperty(localName = "SCHD")
val schd: SchdSection? = null,
@JacksonXmlProperty(localName = "FLOP")
val flop: Map<String, Any>? = null,
@JacksonXmlProperty(localName = "FDEL")
val fdel: Map<String, Any>? = null,
@JacksonXmlRootElement(localName = "MSG")
data class SisMessageXml(
@JacksonXmlProperty(localName = "META") val meta: SisMetaXml? = null,
@JacksonXmlProperty(localName = "SCHD") val schd: SchdXml? = null,
@JacksonXmlProperty(localName = "FLOP") val flop: FlightRecordXml? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class SisMeta(
@JacksonXmlProperty(localName = "SNDR")
val sndr: String? = null,
@JacksonXmlProperty(localName = "SEQN")
val seqn: Long? = null,
@JacksonXmlProperty(localName = "DTTM")
val dttm: Long? = null,
@JacksonXmlProperty(localName = "TYPE")
val type: String? = null,
@JacksonXmlProperty(localName = "STYP")
val styp: String? = null,
data class SisMetaXml(
@JacksonXmlProperty(localName = "SNDR") val sndr: String? = null,
@JacksonXmlProperty(localName = "SEQN") val seqn: Long? = null,
@JacksonXmlProperty(localName = "DTTM") val dttm: Long? = null,
@JacksonXmlProperty(localName = "TYPE") val type: String? = null,
@JacksonXmlProperty(localName = "STYP") val styp: String? = null,
)
/** SCHD 段:RECS 声明数 + FLTR 记录集(SIS §3.16 样例结构)。 */
@JsonIgnoreProperties(ignoreUnknown = true)
data class SchdSection(
@JacksonXmlProperty(localName = "RECS")
val recs: Int? = null,
data class SchdXml(
@JacksonXmlProperty(localName = "RECS") val recs: Int? = null,
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "FLTR")
val fltr: List<Map<String, Any>> = emptyList(),
@JacksonXmlProperty(localName = "FLTR") val fltr: List<FlightRecordXml> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class FlightRecordXml(
@JacksonXmlProperty(localName = "FLID") val flid: String? = null,
@JacksonXmlProperty(localName = "FFID") val ffid: String? = null,
@JacksonXmlProperty(localName = "FDEL") val fdel: FdelXml? = null,
@JacksonXmlProperty(localName = "SEQN") val seqn: Long? = null,
@JacksonXmlProperty(localName = "ALCD") val alcd: String? = null,
@JacksonXmlProperty(localName = "ALSC") val alsc: String? = null,
@JacksonXmlProperty(localName = "FLNO") val flno: String? = null,
@JacksonXmlProperty(localName = "MVIN") val mvin: String? = null,
@JacksonXmlProperty(localName = "SODT") val sodt: String? = null,
@JacksonXmlProperty(localName = "FLTY") val flty: String? = null,
@JacksonXmlProperty(localName = "FLIN") val flin: String? = null,
@JacksonXmlProperty(localName = "ACFT") val acft: String? = null,
@JacksonXmlProperty(localName = "RENO") val reno: String? = null,
@JacksonXmlProperty(localName = "TAOP") val taop: String? = null,
@JacksonXmlProperty(localName = "TAFL") val tafl: String? = null,
@JacksonXmlProperty(localName = "TAID") val taid: String? = null,
@JacksonXmlProperty(localName = "TRML") val trml: String? = null,
@JacksonXmlProperty(localName = "MAXP") val maxp: String? = null,
@JacksonXmlProperty(localName = "CSOP") val csop: String? = null,
@JacksonXmlProperty(localName = "CSFT") val csft: String? = null,
@JacksonXmlProperty(localName = "MAID") val maid: String? = null,
@JacksonXmlProperty(localName = "ESTT") val estt: String? = null,
@JacksonXmlProperty(localName = "ACTT") val actt: String? = null,
@JacksonXmlProperty(localName = "STND") val stnd: String? = null,
@JacksonXmlProperty(localName = "PHAG") val phag: String? = null,
@JacksonXmlProperty(localName = "CNCL") val cncl: String? = null,
@JacksonXmlProperty(localName = "REMC") val remc: String? = null,
@JacksonXmlProperty(localName = "BOTM") val botm: String? = null,
@JacksonXmlProperty(localName = "LACL") val lacl: String? = null,
@JacksonXmlProperty(localName = "FINT") val fint: String? = null,
@JacksonXmlProperty(localName = "APPT") val appt: String? = null,
@JacksonXmlProperty(localName = "EGSR") val egsr: String? = null,
@JacksonXmlProperty(localName = "EGST") val egst: String? = null,
@JacksonXmlProperty(localName = "FHAG") val fhag: String? = null,
@JacksonXmlProperty(localName = "MHAG") val mhag: String? = null,
@JacksonXmlProperty(localName = "VIPP") val vipp: String? = null,
@JacksonXmlProperty(localName = "VIPR") val vipr: String? = null,
@JacksonXmlProperty(localName = "LBNO") val lbno: String? = null,
@JacksonXmlProperty(localName = "LBWT") val lbwt: String? = null,
@JacksonXmlProperty(localName = "PAXC") val paxc: String? = null,
@JacksonXmlProperty(localName = "EXSC") val exsc: String? = null,
@JacksonXmlProperty(localName = "EXSR") val exsr: String? = null,
@JacksonXmlProperty(localName = "FTSS") val ftss: String? = null,
@JacksonXmlProperty(localName = "PEDT") val pedt: String? = null,
@JacksonXmlProperty(localName = "NAAT") val naat: String? = null,
@JacksonXmlProperty(localName = "NEAT") val neat: String? = null,
@JacksonXmlProperty(localName = "PADT") val padt: String? = null,
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "GTDT") val gtdt: List<GtdtXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CKDT") val ckdt: List<CkdtXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CLDT") val cldt: List<CldtXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "PSDT") val psdt: List<PsdtXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CHDT") val chdt: List<ChdtXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "DELY") val dely: List<DelyXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ABTM") val abtm: List<AbtmXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CHOT") val chot: List<ChotXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ROUT") val rout: List<RoutXml> = emptyList(),
@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ERUT") val erut: List<RoutXml> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
class FdelXml
@JsonIgnoreProperties(ignoreUnknown = true)
data class GtdtXml(
@JacksonXmlProperty(isAttribute = true, localName = "GTNO") val gtno: String? = null,
@JacksonXmlProperty(localName = "GATE") val gate: String? = null,
@JacksonXmlProperty(localName = "PGOT") val pgot: String? = null,
@JacksonXmlProperty(localName = "PGCT") val pgct: String? = null,
@JacksonXmlProperty(localName = "GOTM") val gotm: String? = null,
@JacksonXmlProperty(localName = "GCTM") val gctm: String? = null,
@JacksonXmlProperty(localName = "GTYP") val gtyp: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CkdtXml(
@JacksonXmlProperty(isAttribute = true, localName = "CKNO") val ckno: String? = null,
@JacksonXmlProperty(localName = "CHKC") val chkc: String? = null,
@JacksonXmlProperty(localName = "CCLS") val ccls: String? = null,
@JacksonXmlProperty(localName = "PCOT") val pcot: String? = null,
@JacksonXmlProperty(localName = "PCCT") val pcct: String? = null,
@JacksonXmlProperty(localName = "COTM") val cotm: String? = null,
@JacksonXmlProperty(localName = "CCTM") val cctm: String? = null,
@JacksonXmlProperty(localName = "CTYP") val ctyp: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CldtXml(
@JacksonXmlProperty(isAttribute = true, localName = "CLNO") val clno: String? = null,
@JacksonXmlProperty(localName = "BELT") val belt: String? = null,
@JacksonXmlProperty(localName = "BCLS") val bcls: String? = null,
@JacksonXmlProperty(localName = "PCOT") val pcot: String? = null,
@JacksonXmlProperty(localName = "PCCT") val pcct: String? = null,
@JacksonXmlProperty(localName = "FBAG") val fbag: String? = null,
@JacksonXmlProperty(localName = "LBAG") val lbag: String? = null,
@JacksonXmlProperty(localName = "BTYP") val btyp: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PsdtXml(
@JacksonXmlProperty(isAttribute = true, localName = "PSNO") val psno: String? = null,
@JacksonXmlProperty(localName = "PSST") val psst: String? = null,
@JacksonXmlProperty(localName = "STST") val stst: String? = null,
@JacksonXmlProperty(localName = "STET") val stet: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ChdtXml(
@JacksonXmlProperty(isAttribute = true, localName = "CHNO") val chno: String? = null,
@JacksonXmlProperty(localName = "CHUT") val chut: String? = null,
@JacksonXmlProperty(localName = "CHCLS") val chcls: String? = null,
@JacksonXmlProperty(localName = "PCBT") val pcbt: String? = null,
@JacksonXmlProperty(localName = "PCET") val pcet: String? = null,
@JacksonXmlProperty(localName = "CBTM") val cbtm: String? = null,
@JacksonXmlProperty(localName = "CETM") val cetm: String? = null,
@JacksonXmlProperty(localName = "CHTYP") val chtyp: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DelyXml(
@JacksonXmlProperty(isAttribute = true, localName = "CODE") val code: String? = null,
@JacksonXmlProperty(isAttribute = true, localName = "STRT") val strt: String? = null,
@JacksonXmlProperty(isAttribute = true, localName = "DURA") val dura: String? = null,
) {
@JacksonXmlText
var text: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class AbtmXml(
@JacksonXmlProperty(isAttribute = true, localName = "ASNO") val asno: String? = null,
@JacksonXmlProperty(localName = "ABDG") val abdg: String? = null,
@JacksonXmlProperty(localName = "ABOP") val abop: String? = null,
@JacksonXmlProperty(localName = "AOTM") val aotm: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ChotXml(
@JacksonXmlProperty(isAttribute = true, localName = "CSNO") val csno: String? = null,
@JacksonXmlProperty(localName = "CHID") val chid: String? = null,
@JacksonXmlProperty(localName = "CHST") val chst: String? = null,
@JacksonXmlProperty(localName = "CHTM") val chtm: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RoutXml(
@JacksonXmlProperty(isAttribute = true, localName = "RTNO") val rtno: String? = null,
@JacksonXmlProperty(localName = "APCD") val apcd: String? = null,
@JacksonXmlProperty(localName = "SCAT") val scat: String? = null,
@JacksonXmlProperty(localName = "SCDT") val scdt: String? = null,
)
@@ -0,0 +1,52 @@
package com.gzzn.omms.msgexchange.codec
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
/** Converts the annotated wire DTOs into the stable payload types used by processing. */
internal object SisWireMapper {
fun scheduleBody(xml: SchdXml): ScheduleBody = ScheduleBody(
recsDeclared = xml.recs ?: -1,
records = xml.fltr.mapNotNull(::scheduleRecord),
)
fun flopPayload(xml: FlightRecordXml): FlopPayload? =
xml.flid?.trim()?.takeIf(String::isNotEmpty)?.let {
FlopPayload(it, xml.scalars(), xml.collections())
}
private fun scheduleRecord(xml: FlightRecordXml): ScheduleRecord? =
xml.flid?.trim()?.takeIf(String::isNotEmpty)?.let {
ScheduleRecord(it, xml.scalars(), xml.collections(), xml.seqn ?: 0L)
}
private fun FlightRecordXml.scalars(): Map<String, String> = linkedMapOf<String, String>().apply {
listOf(
"FFID" to ffid, "ALCD" to alcd, "ALSC" to alsc, "FLNO" to flno, "MVIN" to mvin, "SODT" to sodt,
"FLTY" to flty, "FLIN" to flin, "ACFT" to acft, "RENO" to reno, "TAOP" to taop,
"TAFL" to tafl, "TAID" to taid, "TRML" to trml, "MAXP" to maxp, "CSOP" to csop,
"CSFT" to csft, "MAID" to maid, "ESTT" to estt, "ACTT" to actt, "STND" to stnd,
"PHAG" to phag, "CNCL" to cncl, "REMC" to remc, "BOTM" to botm, "LACL" to lacl,
"FINT" to fint, "APPT" to appt, "EGSR" to egsr, "EGST" to egst, "FHAG" to fhag,
"MHAG" to mhag, "VIPP" to vipp, "VIPR" to vipr, "LBNO" to lbno, "LBWT" to lbwt,
"PAXC" to paxc, "EXSC" to exsc, "EXSR" to exsr, "FTSS" to ftss, "PEDT" to pedt,
"NAAT" to naat, "NEAT" to neat, "PADT" to padt,
).forEach { (key, value) -> value?.let { put(key, it.trim()) } }
}
private fun FlightRecordXml.collections(): Map<String, List<Map<String, String>>> = linkedMapOf(
"GTDT" to gtdt.map { it.toMap("GTNO" to it.gtno, "GATE" to it.gate, "PGOT" to it.pgot, "PGCT" to it.pgct, "GOTM" to it.gotm, "GCTM" to it.gctm, "GTYP" to it.gtyp) },
"CKDT" to ckdt.map { it.toMap("CKNO" to it.ckno, "CHKC" to it.chkc, "CCLS" to it.ccls, "PCOT" to it.pcot, "PCCT" to it.pcct, "COTM" to it.cotm, "CCTM" to it.cctm, "CTYP" to it.ctyp) },
"CLDT" to cldt.map { it.toMap("CLNO" to it.clno, "BELT" to it.belt, "BCLS" to it.bcls, "PCOT" to it.pcot, "PCCT" to it.pcct, "FBAG" to it.fbag, "LBAG" to it.lbag, "BTYP" to it.btyp) },
"PSDT" to psdt.map { it.toMap("PSNO" to it.psno, "PSST" to it.psst, "STST" to it.stst, "STET" to it.stet) },
"CHDT" to chdt.map { it.toMap("CHNO" to it.chno, "CHUT" to it.chut, "CHCLS" to it.chcls, "PCBT" to it.pcbt, "PCET" to it.pcet, "CBTM" to it.cbtm, "CETM" to it.cetm, "CHTYP" to it.chtyp) },
"DELY" to dely.map { it.toMap("CODE" to it.code, "STRT" to it.strt, "DURA" to it.dura, "REMC" to it.text) },
"ABTM" to abtm.map { it.toMap("ASNO" to it.asno, "ABDG" to it.abdg, "ABOP" to it.abop, "AOTM" to it.aotm) },
"CHOT" to chot.map { it.toMap("CSNO" to it.csno, "CHID" to it.chid, "CHST" to it.chst, "CHTM" to it.chtm) },
"ROUT" to rout.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
"ERUT" to erut.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
).filterValues { it.isNotEmpty() }
private fun Any.toMap(vararg values: Pair<String, String?>): Map<String, String> = values.mapNotNull { (key, value) ->
value?.trim()?.let { key to it }
}.toMap(LinkedHashMap())
}
@@ -14,9 +14,8 @@ sealed interface DecodeResult {
}
/**
* XML codecXXE 防护(禁 DTD/外部实体);阶段 1 先 vendor 复用 legacy entity/msg POJO +
* jackson-dataformat-xml + JaxbAnnotationIntrospector(现役已验证组合,ACMA-6 选型)
* TODO(阶段1后续): vendor POJO 引入与 BDPB workaround 配置化(golden 固化)。
* XML codec使用 SIS 文档对应的 Jackson XML 注解 DTO,再显式转换为领域载荷;
* XMLInputFactory 禁用 DTD/外部实体,原文仍由入站层保留
*/
interface XmlCodec {
fun decode(rawXml: String): DecodeResult
@@ -1,8 +1,8 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
@@ -90,7 +90,7 @@ class Pump(
class MessageProcessor(
private val inbox: CminmsgInboxRepository,
private val procState: ProcStateRepository,
private val codec: JacksonXmlCodec,
private val codec: XmlCodec,
private val scheduleProcessor: ScheduleProcessor,
private val flopProcessor: FlopProcessor,
private val fdelProcessor: FdelProcessor,
@@ -91,4 +91,49 @@ class JacksonXmlCodecTest {
assertEquals(com.gzzn.omms.msgexchange.domain.ErrorClass.MALFORMED, err.failure.errorClass)
assertTrue(err.failure.detail.contains("empty-or-non-xml"))
}
@Test
fun `SIS documented attributes and mixed DELY text become typed payload`() {
val raw = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>2</SEQN><DTTM>20021010090311</DTTM><TYPE>FLOP</TYPE><STYP>DELY</STYP></META>
<FLOP>
<FLID>121</FLID><FLNO>CA002</FLNO>
<ROUT RTNO="1"><APCD>CAN</APCD><SCDT>15DEC031340</SCDT></ROUT>
<DELY CODE="YY" STRT="15DEC031605" DURA="0200">Flight Delayed</DELY>
<FDIV ARES="O">Unused standard SIS field</FDIV>
</FLOP>
</MSG>
""".trimIndent()
val result = codec.decode(raw) as DecodeResult.Ok
val body = result.message.body as FlopPayload
assertEquals("1", body.collections["ROUT"]!!.single()["RTNO"])
assertEquals("CAN", body.collections["ROUT"]!!.single()["APCD"])
assertEquals("YY", body.collections["DELY"]!!.single()["CODE"])
assertEquals("Flight Delayed", body.collections["DELY"]!!.single()["REMC"])
}
@Test
fun `external entities are rejected by the XML codec`() {
val raw = """
<!DOCTYPE MSG [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<MSG><META><SNDR>&xxe;</SNDR><SEQN>1</SEQN><DTTM>1</DTTM><TYPE>FLOP</TYPE><STYP>GTDT</STYP></META></MSG>
""".trimIndent()
val result = codec.decode(raw) as DecodeResult.Err
assertEquals(com.gzzn.omms.msgexchange.domain.ErrorClass.MALFORMED, result.failure.errorClass)
}
@Test
fun `SIS FDEL is an empty child of FLOP and still carries the flight id`() {
val raw = """
<MSG><META><SNDR>AODB</SNDR><SEQN>3</SEQN><DTTM>20021010090311</DTTM><TYPE>FLOP</TYPE><STYP>FDEL</STYP></META>
<FLOP><FLID>121112312</FLID><FFID>CA-CA101-A-12DEC031345-D</FFID><FDEL/></FLOP></MSG>
""".trimIndent()
val result = codec.decode(raw) as DecodeResult.Ok
assertEquals(MsgKind.Fdel, result.message.kind)
assertEquals("121112312", (result.message.body as FlopPayload).flid)
}
}