Files
msgexchange-v2/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt
T
windyboy 29465e2131 fix(codec): FLOP 子类型白名单,白名单外 STYP 落 Unsupported(ACM2-84)
- kindOf FLOP 分支仅放行现行七类(US-05 AC1),FDEL 独立分派不受影响
- 白名单外子类型(ACTT/CNCL 等 Q3 闭合目标)解码为 Unsupported,经 ACM2-83 路径记 SKIPPED、不改航班
- 补 codec 白名单回归与处理层「跳过且不触碰航班」用例
2026-09-21 11:44:43 +08:00

120 lines
6.0 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.gzzn.omms.msgexchange.codec
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
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 jakarta.inject.Singleton
import javax.xml.stream.XMLInputFactory
/** 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 {
setProperty(XMLInputFactory.SUPPORT_DTD, false)
setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
},
),
).apply {
registerKotlinModule()
// SIS explicitly permits fields unused by a subsystem; wire DTOs document that policy.
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
override fun decode(rawXml: String): DecodeResult {
val trimmed = rawXml.trim()
if (trimmed.isEmpty() || !trimmed.startsWith("<")) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "empty-or-non-xml"))
}
val msg = try {
mapper.readValue(trimmed, SisMessageXml::class.java)
} catch (e: JsonMappingException) {
// META 层绑定失败=发送方违反必填契约(MALFORMED);体层(SCHD/FLOP)结构不匹配
// =wire 结构超出编解码器当前绑定能力(CODEC_ERROR:退避重试,修复后可重放,docs/implementation.md「状态与错误分类」)。
val first = e.path.firstOrNull()?.fieldName ?: ""
if (first.equals("meta", ignoreCase = true)) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-meta:${e.originalMessage.take(200)}"))
}
return DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "xml-mapping:${e.originalMessage.take(200)}"))
} catch (e: Exception) {
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?.trim()?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
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 -> msg.schd?.let(SisWireMapper::scheduleBody)
is MsgKind.Flop -> msg.flop?.let(SisWireMapper::flopPayload)
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
// 参考数据类别报文的正文结构随 13 类各异(ACM2-93 实装 ReferenceDataProcessor 时绑定)
is MsgKind.RefData -> null
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 =
"""<?xml version="1.0" encoding="UTF-8"?><MSG><META><TYPE>RQFD</TYPE></META></MSG>"""
/**
* 子类型分派必须是**白名单**:未知的 SCHD 子类型不能静默当成全量日计划(DNLD)——
* 那会让一条本该"暂不支持、等人工处置"的报文走最重的整包合并写入路径。
* 空 STYP 按 legacy 约定视为 DNLD(该约定待与 SIS 逐类对拍确认,见 Q3)。
* FLOP 同样按白名单:仅现行七类(`implementation.md`「动态运行事件」`US-05` AC1
* 走合并路径,其余 STYP 是 `Q3` 闭合目标,落 `Unsupported`(`FDEL` 单独成类,不参与白名单)。
* 静态参考数据类别码(`implementation.md`「静态参考数据」`SIS:3.1``3.14`)单列成
* [MsgKind.RefData]:它们必须走参考数据处理(US-13),不得混入"不支持"跳过。
*/
private fun kindOf(type: String, styp: String): MsgKind = when (type) {
"SCHD" -> when (styp) {
"RESP" -> MsgKind.Schd(MsgKind.SchdSubtype.RESP)
"ADFT" -> MsgKind.Schd(MsgKind.SchdSubtype.ADFT)
"DNLD", "" -> MsgKind.Schd(MsgKind.SchdSubtype.DNLD)
else -> MsgKind.Unsupported("SCHD-$styp")
}
"FLOP" -> when {
styp == "FDEL" -> MsgKind.Fdel
styp in FLOP_SUBTYPES -> MsgKind.Flop(styp)
else -> MsgKind.Unsupported("FLOP-$styp")
}
in REF_DATA_TYPES -> MsgKind.RefData(type)
else -> MsgKind.Unsupported("$type-$styp")
}
/** 现行 FLOP 白名单 7 类(`US-05` AC1);表内其余行是 `Q3` 闭合目标,不是现行白名单。 */
private val FLOP_SUBTYPES = setOf("ABTM", "DELY", "PSDT", "CKDT", "CLDT", "CHDT", "GTDT")
private val REF_DATA_TYPES = setOf(
"COUL", "ARPT", "AIRL", "AIRC", "REGN", "ORGN", "FLTL",
"TLST", "GLST", "SLST", "CLST", "BLST", "CHLT", "RSTA",
)
}