2026-09-08 11:26:20 +08:00
|
|
|
package com.gzzn.omms.msgexchange.codec
|
|
|
|
|
|
2026-09-09 17:53:08 +08:00
|
|
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
2026-09-11 14:25:42 +08:00
|
|
|
import com.fasterxml.jackson.databind.JsonMappingException
|
2026-09-09 17:53:08 +08:00
|
|
|
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
|
|
|
|
import com.fasterxml.jackson.dataformat.xml.XmlFactory
|
|
|
|
|
import com.fasterxml.jackson.dataformat.xml.XmlMapper
|
2026-09-08 11:26:20 +08:00
|
|
|
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
|
2026-09-09 17:53:08 +08:00
|
|
|
import javax.xml.stream.XMLInputFactory
|
2026-09-08 11:26:20 +08:00
|
|
|
|
2026-09-09 19:48:45 +08:00
|
|
|
/** XML codec for the SIS wire contract; XML DTO binding is kept separate from domain payloads. */
|
2026-09-08 11:26:20 +08:00
|
|
|
@Singleton
|
|
|
|
|
class JacksonXmlCodec : XmlCodec {
|
2026-09-09 17:53:08 +08:00
|
|
|
private val mapper: XmlMapper = XmlMapper(
|
|
|
|
|
XmlFactory(
|
|
|
|
|
XMLInputFactory.newInstance().apply {
|
|
|
|
|
setProperty(XMLInputFactory.SUPPORT_DTD, false)
|
|
|
|
|
setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).apply {
|
|
|
|
|
registerKotlinModule()
|
2026-09-09 19:48:45 +08:00
|
|
|
// SIS explicitly permits fields unused by a subsystem; wire DTOs document that policy.
|
2026-09-09 17:53:08 +08:00
|
|
|
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
2026-09-08 11:26:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
}
|
2026-09-09 17:53:08 +08:00
|
|
|
val msg = try {
|
2026-09-09 19:48:45 +08:00
|
|
|
mapper.readValue(trimmed, SisMessageXml::class.java)
|
2026-09-11 14:25:42 +08:00
|
|
|
} catch (e: JsonMappingException) {
|
|
|
|
|
// META 层绑定失败=发送方违反必填契约(MALFORMED);体层(SCHD/FLOP)结构不匹配
|
2026-09-13 20:31:13 +08:00
|
|
|
// =wire 结构超出编解码器当前绑定能力(CODEC_ERROR:退避重试,修复后可重放,docs/implementation.md「状态与错误分类」)。
|
2026-09-11 14:25:42 +08:00
|
|
|
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)}"))
|
2026-09-08 11:26:20 +08:00
|
|
|
} catch (e: Exception) {
|
2026-09-09 17:53:08 +08:00
|
|
|
return DecodeResult.Err(
|
|
|
|
|
DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message?.take(200) ?: e.javaClass.simpleName}"),
|
|
|
|
|
)
|
2026-09-08 11:26:20 +08:00
|
|
|
}
|
2026-09-09 17:53:08 +08:00
|
|
|
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"))
|
2026-09-09 19:48:45 +08:00
|
|
|
val type = meta.type?.trim()?.uppercase()
|
2026-09-09 17:53:08 +08:00
|
|
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
|
2026-09-09 19:48:45 +08:00
|
|
|
val styp = meta.styp?.trim()?.uppercase()
|
2026-09-09 17:53:08 +08:00
|
|
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
|
|
|
|
|
|
|
|
|
|
val kind = kindOf(type, styp)
|
|
|
|
|
val body: Any? = when (kind) {
|
2026-09-09 19:48:45 +08:00
|
|
|
is MsgKind.Schd -> msg.schd?.let(SisWireMapper::scheduleBody)
|
|
|
|
|
is MsgKind.Flop -> msg.flop?.let(SisWireMapper::flopPayload)
|
|
|
|
|
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
|
2026-09-09 17:53:08 +08:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-09-08 11:26:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun encodeRqrd(kind: String, rangeJson: String): String =
|
|
|
|
|
"""<?xml version="1.0" encoding="UTF-8"?><MSG><META><TYPE>RQFD</TYPE></META></MSG>"""
|
|
|
|
|
|
2026-09-11 08:08:35 +08:00
|
|
|
/**
|
|
|
|
|
* 子类型分派必须是**白名单**:未知的 SCHD 子类型不能静默当成全量日计划(DNLD)——
|
|
|
|
|
* 那会让一条本该"暂不支持、等人工处置"的报文走最重的整包合并写入路径。
|
2026-09-17 13:06:59 +08:00
|
|
|
* 空 STYP 按 legacy 约定视为 DNLD(该约定待与 SIS 逐类对拍确认,见 Q3)。
|
2026-09-11 08:08:35 +08:00
|
|
|
*/
|
2026-09-08 11:26:20 +08:00
|
|
|
private fun kindOf(type: String, styp: String): MsgKind = when (type) {
|
2026-09-11 08:08:35 +08:00
|
|
|
"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")
|
|
|
|
|
}
|
2026-09-09 19:48:45 +08:00
|
|
|
"FLOP" -> if (styp == "FDEL") MsgKind.Fdel else MsgKind.Flop(styp)
|
|
|
|
|
else -> MsgKind.Unsupported("$type-$styp")
|
2026-09-08 11:26:20 +08:00
|
|
|
}
|
|
|
|
|
}
|