From 3ed54d306e91fa407845bf22d32bdd3752d9f9f4 Mon Sep 17 00:00:00 2001 From: windyboy Date: Wed, 9 Sep 2026 19:48:45 +0800 Subject: [PATCH] refactor(codec): map SIS XML through annotated DTOs --- README.md | 10 +- .../omms/msgexchange/codec/JacksonXmlCodec.kt | 113 +-------- .../omms/msgexchange/codec/SisMessageBody.kt | 218 ++++++++++++++---- .../omms/msgexchange/codec/SisWireMapper.kt | 52 +++++ .../gzzn/omms/msgexchange/codec/XmlCodec.kt | 5 +- .../gzzn/omms/msgexchange/processing/Pump.kt | 4 +- .../msgexchange/codec/JacksonXmlCodecTest.kt | 45 ++++ 7 files changed, 290 insertions(+), 157 deletions(-) create mode 100644 src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisWireMapper.kt diff --git a/README.md b/README.md index 188d17a..9443ae5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ | `delivery/` | Delivery & Projection:每 target 严格 FIFO 投递、schd 聚合 | 流程 3 | | `reference/` | Reference & Query:21 类同步 + 15 类请求状态机 | 流程 6 | | `jobs/` | 泵作业:ARCHIVE / HISTORY_SWEEP / PROJECTION_REBUILD | 流程 4/5/7,I4 | -| `codec/` | XML codec(阶段 1 先 vendor 复用 legacy POJO,见 ACMA-6 选型) | 决策 4 前置 | +| `codec/` | SIS XML codec(注解 wire DTO → 领域载荷,禁 DTD/外部实体) | 决策 4 前置 | | `domain/` | 领域模型:Decision、事件、状态机枚举、Phase 开关 | I1–I5 | | `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 XML(ACMA-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 批次。 diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt index 1305479..36ed030 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt @@ -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/RESP(FLTR 记录集,§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/ADFT:RECS 缺失/非法 → -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?): FlopPayload? { - section ?: return null - val scalars = linkedMapOf() - val collections = linkedMapOf>>() - 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): ScheduleRecord? { - val scalars = linkedMapOf() - val collections = linkedMapOf>>() - 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(属性与子元素展平);单元素 → 单项列表; - * 混合文本(如 DELY 备注文本)落到 REMC 键(XmlMapper 绑定的已知弱项,尽力保留)。 - */ - private fun Any?.toCollectionItems(): List>? = 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 { - val out = linkedMapOf() - 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? = when (this) { - is Map<*, *> -> toCollectionItem(this) - is String -> mapOf("REMC" to trim()) - else -> null - } - - companion object { - val COLLECTION_TAGS: Set = 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") } } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt index a776068..1098f6f 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt @@ -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)。 - * scalars:FLID 以外的标量;collections:GTDT/CKDT 等重复集合(含源序号属性)。 - */ +/** Stable payload boundary used by processing. It is deliberately not an XML model. */ data class FlopPayload( val flid: String, val scalars: Map = emptyMap(), val collections: Map>> = emptyMap(), ) -/** - * SCHD(DNLD/RESP/ADFT)解析体(docs/flight-state.md §5)。 - * RECS 声明数与实收 FLTR 记录分离承载,完整性校验(§5.2)在处理层执行; - * 协议无报文级覆盖日字段——scope 由处理层按记录 SODT 推导。 - */ data class ScheduleBody( val recsDeclared: Int, val records: List, @@ -28,46 +22,184 @@ data class ScheduleBody( val scopeEnd: LocalDate? = null, ) -// ===================================================================== -// XML 直接映射(jackson-dataformat-xml 数据绑定,不再手写 DOM 遍历)。 -// 信封强类型(META/段结构),FLTR/航班段内标签为开放集 → Map -// 泛型承载(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? = null, - @JacksonXmlProperty(localName = "FDEL") - val fdel: Map? = 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> = emptyList(), + @JacksonXmlProperty(localName = "FLTR") val fltr: List = 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 = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CKDT") val ckdt: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CLDT") val cldt: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "PSDT") val psdt: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CHDT") val chdt: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "DELY") val dely: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ABTM") val abtm: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "CHOT") val chot: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ROUT") val rout: List = emptyList(), + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "ERUT") val erut: List = 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, ) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisWireMapper.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisWireMapper.kt new file mode 100644 index 0000000..45ed807 --- /dev/null +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisWireMapper.kt @@ -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 = linkedMapOf().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>> = 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): Map = values.mapNotNull { (key, value) -> + value?.trim()?.let { key to it } + }.toMap(LinkedHashMap()) +} diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt index 9465a8d..bbb3552 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt @@ -14,9 +14,8 @@ sealed interface DecodeResult { } /** - * XML codec:XXE 防护(禁 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 diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt index 9cf2ee7..59451d9 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt @@ -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, diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt index 97bd181..eeb4099 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt @@ -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 = """ + + AODB220021010090311FLOPDELY + + 121CA002 + CAN15DEC031340 + Flight Delayed + Unused standard SIS field + + + """.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 = """ + ]> + &xxe;11FLOPGTDT + """.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 = """ + AODB320021010090311FLOPFDEL + 121112312CA-CA101-A-12DEC031345-D + """.trimIndent() + + val result = codec.decode(raw) as DecodeResult.Ok + assertEquals(MsgKind.Fdel, result.message.kind) + assertEquals("121112312", (result.message.body as FlopPayload).flid) + } }