feat(processing): GTDT FLOP vertical link with real XmlCodec (P2-0)
Add JacksonXmlCodec (XXE-safe META/FLOP parse), GtdtHandler, and unified PipelineHolderFactory; route DNLD/FLOP through FlightStateEngine persistNextStates.
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.codec
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIS XML 解码(XXE 防护;META + TYPE 分派 + FLOP 集合解析)。
|
||||||
|
* 阶段 2 先覆盖 FLOP-GTDT 纵向链路所需字段;其余 STYP 仅完成 META/FLID 提取。
|
||||||
|
*/
|
||||||
|
@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 实现差异:忽略不支持的属性
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
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}"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message ?: e.javaClass.simpleName}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encodeRqrd(kind: String, rangeJson: String): String =
|
||||||
|
"""<?xml version="1.0" encoding="UTF-8"?><MSG><META><TYPE>RQFD</TYPE></META></MSG>"""
|
||||||
|
|
||||||
|
private fun kindOf(type: String, styp: String): MsgKind = when (type) {
|
||||||
|
"SCHD" -> MsgKind.Schd(
|
||||||
|
when (styp) {
|
||||||
|
"RESP" -> MsgKind.SchdSubtype.RESP
|
||||||
|
"DNLD" -> MsgKind.SchdSubtype.DNLD
|
||||||
|
"ADFT" -> MsgKind.SchdSubtype.ADFT
|
||||||
|
else -> MsgKind.SchdSubtype.DNLD
|
||||||
|
},
|
||||||
|
)
|
||||||
|
"FLOP" -> MsgKind.Flop(styp)
|
||||||
|
else -> MsgKind.Flop(styp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFlopBody(root: Element, type: String): FlopPayload? {
|
||||||
|
val section = firstChildElement(root, type) ?: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val flid = scalars.remove("FLID") ?: return null
|
||||||
|
return FlopPayload(flid = flid, scalars = scalars, collections = collections.mapValues { it.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun childText(parent: Element, tag: String): String? =
|
||||||
|
firstChildElement(parent, tag)?.textContent?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val COLLECTION_TAGS: Set<String> = setOf(
|
||||||
|
"GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.codec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLOP 报文解析体(v2 §4 命令化输入)。
|
||||||
|
* scalars:FLID/FFID 等标量;collections:GTDT/CKDT 等重复集合(含源序号属性)。
|
||||||
|
*/
|
||||||
|
data class FlopPayload(
|
||||||
|
val flid: String,
|
||||||
|
val scalars: Map<String, String> = emptyMap(),
|
||||||
|
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.infra.pipeline
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||||
|
import com.gzzn.omms.msgexchange.processing.CodecHolder
|
||||||
|
import com.gzzn.omms.msgexchange.processing.Handler
|
||||||
|
import com.gzzn.omms.msgexchange.processing.HandlerHolder
|
||||||
|
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
|
||||||
|
import io.micronaut.context.annotation.Bean
|
||||||
|
import io.micronaut.context.annotation.Factory
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
|
/** 主泵解码与 Handler 装配(stub / 实装模式共用真实 XmlCodec + 已注册 Handler)。 */
|
||||||
|
@Factory
|
||||||
|
class PipelineHolderFactory {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Singleton
|
||||||
|
fun codecHolder(codec: JacksonXmlCodec): CodecHolder = CodecHolder(codec)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Singleton
|
||||||
|
fun handlerHolder(handlers: List<Handler>): HandlerHolder = HandlerHolder(HandlerRegistry(handlers))
|
||||||
|
}
|
||||||
@@ -1,32 +1,13 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.stub
|
package com.gzzn.omms.msgexchange.infra.stub
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.codec.DecodeFailure
|
|
||||||
import com.gzzn.omms.msgexchange.codec.DecodeResult
|
|
||||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
|
||||||
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
||||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
|
||||||
import com.gzzn.omms.msgexchange.processing.CodecHolder
|
|
||||||
import com.gzzn.omms.msgexchange.processing.HandlerHolder
|
|
||||||
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
|
|
||||||
import io.micronaut.context.annotation.Bean
|
|
||||||
import io.micronaut.context.annotation.Factory
|
|
||||||
import io.micronaut.context.annotation.Requires
|
import io.micronaut.context.annotation.Requires
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* U07:stub 适配层——XmlCodec / DeliveryPort / Handler 装配,仅在 msgx.stubs=true 时生效(Redis 客户端已按 ACM2-28 移除)。
|
* U07:stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。
|
||||||
* stub codec 未实装 → 报文 decode 返回 CODEC_ERROR(走 FAILED 可重放路径,链路上可观测),
|
* XmlCodec / Handler 由 [com.gzzn.omms.msgexchange.infra.pipeline.PipelineHolderFactory] 统一装配。
|
||||||
* 语义见 ACM2-10 U11:不把“未实装”写成报文非法/终态。
|
|
||||||
*/
|
*/
|
||||||
@Requires(property = "msgx.stubs", value = "true")
|
|
||||||
@Singleton
|
|
||||||
class StubXmlCodec : XmlCodec {
|
|
||||||
override fun decode(rawXml: String): DecodeResult =
|
|
||||||
DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "stub:codec-not-implemented"))
|
|
||||||
|
|
||||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@Requires(property = "msgx.stubs", value = "true")
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
@Singleton
|
@Singleton
|
||||||
class StubDeliveryPort : DeliveryPort {
|
class StubDeliveryPort : DeliveryPort {
|
||||||
@@ -37,15 +18,3 @@ class StubDeliveryPort : DeliveryPort {
|
|||||||
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
|
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
|
||||||
override fun indexFlightHts(payloadJson: String) = Unit
|
override fun indexFlightHts(payloadJson: String) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Holder 工厂:CodecHolder/HandlerHolder 由 Micronaut Bean 提供(取代直连构造占位)。 */
|
|
||||||
@Factory
|
|
||||||
@Requires(property = "msgx.stubs", value = "true")
|
|
||||||
class StubHolderFactory {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun codecHolder(codec: StubXmlCodec): CodecHolder = CodecHolder(codec)
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
fun handlerHolder(): HandlerHolder = HandlerHolder(HandlerRegistry(emptyList())) // 阶段 2 前无 Handler 注册
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.processing
|
|||||||
|
|
||||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
@@ -222,9 +223,15 @@ class MessageProcessor(
|
|||||||
decision.schdPush.forEach { add(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = it.flid, payloadJson = it.payloadJson)) }
|
decision.schdPush.forEach { add(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = it.flid, payloadJson = it.payloadJson)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val messageId = head.cminmsgsId.toString()
|
||||||
txManager.inTransaction {
|
txManager.inTransaction {
|
||||||
if (decision.flightChanges.isNotEmpty()) {
|
if (decision.flightChanges.isNotEmpty()) {
|
||||||
flightSchd.upsertIncremental(decision.flightChanges)
|
val nextStates = decision.flightChanges.map { change ->
|
||||||
|
val current = flightSchd.findNextStateByFlid(change.flid)
|
||||||
|
val commands = FlightStateEngine.commandsFromFields(change.flid, change.fields, snapshotReplace = false)
|
||||||
|
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
|
||||||
|
}
|
||||||
|
flightSchd.persistNextStates(null, nextStates, snapshotReplace = false)
|
||||||
}
|
}
|
||||||
if (events.isNotEmpty()) {
|
if (events.isNotEmpty()) {
|
||||||
msgEvents.insertAll(events)
|
msgEvents.insertAll(events)
|
||||||
@@ -232,8 +239,11 @@ class MessageProcessor(
|
|||||||
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交后:backfill CMINMSGS(共享信箱外部副作用,补偿链路)
|
try {
|
||||||
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e)
|
||||||
|
}
|
||||||
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
|
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gzzn.omms.msgexchange.processing
|
package com.gzzn.omms.msgexchange.processing
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
@@ -36,18 +37,17 @@ class SnapshotFlow(
|
|||||||
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
|
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
|
||||||
|
|
||||||
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
|
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
|
||||||
// 1) staging:流式解析 + 整包校验(内存瞬态,崩溃从 raw 整包重放)
|
val messageId = head.cminmsgsId.toString()
|
||||||
val staged = StageResult.stagingOf(msg)
|
val staged = StageResult.stagingOf(msg)
|
||||||
if (staged is StageResult.Invalid) {
|
if (staged is StageResult.Invalid) {
|
||||||
log.warn("staging not implemented -> FAILED(UNSUPPORTED) id={} reason={}", head.cminmsgsId, staged.reason)
|
log.warn("staging not implemented -> FAILED(UNSUPPORTED) id={} reason={}", head.cminmsgsId, staged.reason)
|
||||||
procFailure.fail(head, ErrorClass.UNSUPPORTED, staged.reason) // U10:未实装 → 可重放,非终态
|
procFailure.fail(head, ErrorClass.UNSUPPORTED, staged.reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val ok = staged as StageResult.Ok
|
val ok = staged as StageResult.Ok
|
||||||
val normalized = ok.flights // (flid, fields)
|
val normalized = ok.flights
|
||||||
val day = ok.day
|
val day = ok.day
|
||||||
|
|
||||||
// 内存与超大包熔断防御(ACM2-28 评论 4 项 3)
|
|
||||||
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
|
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
|
||||||
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
|
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
|
||||||
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
|
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
|
||||||
@@ -55,39 +55,56 @@ class SnapshotFlow(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 准备代元数据与差集(删除集 = gen.flids − 新代,ADFT 自动存活)
|
|
||||||
val gen = flightSchd.getGen(day)
|
val gen = flightSchd.getGen(day)
|
||||||
|
if (gen?.lastMessageId == messageId) {
|
||||||
|
txManager.inTransaction {
|
||||||
|
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
||||||
|
}
|
||||||
|
safeBackfill(head, msg)
|
||||||
|
log.info("snapshot replay no-op id={} day={}", head.cminmsgsId, day)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val expected = gen?.version ?: 0L
|
val expected = gen?.version ?: 0L
|
||||||
val newFlids = normalized.map { it.first }.toSet()
|
val newFlids = normalized.map { it.first }.toSet()
|
||||||
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
|
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
|
||||||
val newVersion = expected + 1L
|
val newVersion = expected + 1L
|
||||||
|
|
||||||
// 3) 自有 PG 单事务原子提交(ACM2-28 定案:覆盖新代 + 域内差删 + SQL CAS + 事件 + SUCCEEDED)
|
val nextStates = normalized.map { (flid, fields) ->
|
||||||
|
val current = flightSchd.findNextStateByFlid(flid)
|
||||||
|
val commands = FlightStateEngine.commandsFromFields(flid, fields, snapshotReplace = true)
|
||||||
|
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
txManager.inTransaction {
|
txManager.inTransaction {
|
||||||
// 覆盖新代全量:强行声明 FDAY 归属(JDBC batch 批处理)
|
flightSchd.persistNextStates(day, nextStates, snapshotReplace = true)
|
||||||
flightSchd.upsertSnapshotBatch(day, normalized)
|
|
||||||
|
|
||||||
// 按代差删域化:仅删除 FDAY = day 且在 delFields 中的记录(ADFT 与跨代已迁移行存活)
|
|
||||||
if (delFields.isNotEmpty()) {
|
if (delFields.isNotEmpty()) {
|
||||||
flightSchd.deleteDiffByDay(day, delFields)
|
flightSchd.deleteDiffByDay(day, delFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL CAS 版本推进(防双写断言)
|
|
||||||
val casSuccess = flightSchd.putGenIfVersion(
|
val casSuccess = flightSchd.putGenIfVersion(
|
||||||
day = day,
|
day = day,
|
||||||
expected = expected,
|
expected = expected,
|
||||||
newGen = FlightSchdRepository.GenMeta(fday = day, version = newVersion, flids = newFlids),
|
newGen = FlightSchdRepository.GenMeta(
|
||||||
|
fday = day,
|
||||||
|
version = newVersion,
|
||||||
|
flids = newFlids,
|
||||||
|
lastMessageId = messageId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (!casSuccess) {
|
if (!casSuccess) {
|
||||||
// 重放路径:version 已是目标值 → no-op 视为成功,否则为 CAS 冲突
|
|
||||||
val again = flightSchd.getGen(day)
|
val again = flightSchd.getGen(day)
|
||||||
if (again == null || again.version != newVersion) {
|
val replayIdentity = again?.lastMessageId == messageId && again.version == newVersion
|
||||||
throw CasConflictException("gen-cas-conflict: expected=$expected current=${again?.version}")
|
if (!replayIdentity) {
|
||||||
|
throw CasConflictException(
|
||||||
|
"gen-cas-conflict: expected=$expected current=${again?.version} msg=${again?.lastMessageId}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
log.info("gen idempotent replay within tx id={} day={}", head.cminmsgsId, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RESP 匹配 REQ_TRACK -> DONE
|
|
||||||
val schdKind = msg.kind as? MsgKind.Schd
|
val schdKind = msg.kind as? MsgKind.Schd
|
||||||
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
|
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
|
||||||
reqTrack.findOpenByKind("SCHD")?.let { req ->
|
reqTrack.findOpenByKind("SCHD")?.let { req ->
|
||||||
@@ -95,20 +112,21 @@ class SnapshotFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造并批量写入 schd 投递事件(载荷 = 字段集序列化,KAFKA_SCHD 线格式)
|
val events = nextStates.map { state ->
|
||||||
val events = normalized.map { (flid, fields) ->
|
MsgEvent(
|
||||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = flid, payloadJson = FlightFieldsJson.toJson(fields))
|
target = Targets.KAFKA_SCHD,
|
||||||
|
partitionKey = state.flid,
|
||||||
|
payloadJson = FlightFieldsJson.toJson(state.toFlightFields()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (events.isNotEmpty()) {
|
if (events.isNotEmpty()) {
|
||||||
msgEvents.insertAll(events)
|
msgEvents.insertAll(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 终态置 SUCCEEDED
|
|
||||||
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交后:backfill CMINMSGS(外部副作用,补偿保障)
|
safeBackfill(head, msg)
|
||||||
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
|
|
||||||
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
|
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
|
||||||
} catch (e: CasConflictException) {
|
} catch (e: CasConflictException) {
|
||||||
log.warn("gen CAS conflict -> FAILED(INFRA) id={} msg={}", head.cminmsgsId, e.message)
|
log.warn("gen CAS conflict -> FAILED(INFRA) id={} msg={}", head.cminmsgsId, e.message)
|
||||||
@@ -116,6 +134,14 @@ class SnapshotFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun safeBackfill(head: ProcState, msg: DecodedMessage) {
|
||||||
|
try {
|
||||||
|
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** staging 结果(骨架)。 */
|
/** staging 结果(骨架)。 */
|
||||||
sealed interface StageResult {
|
sealed interface StageResult {
|
||||||
data class Ok(val day: String, val flights: List<Pair<String, FlightFields>>) : StageResult
|
data class Ok(val day: String, val flights: List<Pair<String, FlightFields>>) : StageResult
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.processing.handlers
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||||
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
|
import com.gzzn.omms.msgexchange.domain.Decision
|
||||||
|
import com.gzzn.omms.msgexchange.domain.FlightChange
|
||||||
|
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||||
|
import com.gzzn.omms.msgexchange.domain.SchdPush
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
|
||||||
|
import com.gzzn.omms.msgexchange.processing.Handler
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLOP-GTDT:登机门集合级快照替换(SIS §3.34;GTNO=0 清除)。
|
||||||
|
* 产出 FlightChange.fields["GTDT"] 为 JSON 数组,供 FlightStateEngine → persistNextStates 写入明细表。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class GtdtHandler(
|
||||||
|
private val mapper: ObjectMapper = ObjectMapper(),
|
||||||
|
) : Handler {
|
||||||
|
override val kind: MsgKind = MsgKind.Flop("GTDT")
|
||||||
|
|
||||||
|
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
|
||||||
|
val body = msg.body as? FlopPayload
|
||||||
|
?: throw IllegalArgumentException("GTDT handler requires FlopPayload body")
|
||||||
|
val gtdtItems = body.collections["GTDT"] ?: emptyList()
|
||||||
|
val fields = linkedMapOf<String, String>()
|
||||||
|
fields["FLID"] = body.flid
|
||||||
|
body.scalars.forEach { (k, v) -> fields[k] = v }
|
||||||
|
fields["GTDT"] = mapper.writeValueAsString(gtdtItems)
|
||||||
|
|
||||||
|
val current = flightView[body.flid]?.let { FlightStateEngine.fromFlightFields(body.flid, it) }
|
||||||
|
val commands = FlightStateEngine.commandsFromFields(body.flid, fields, snapshotReplace = false)
|
||||||
|
val preview = FlightStateEngine.apply(current, commands, messageId = "", bumpVersion = false)
|
||||||
|
val payloadJson = FlightFieldsJson.toJson(preview.toFlightFields(mapper))
|
||||||
|
|
||||||
|
return Decision(
|
||||||
|
flightChanges = listOf(FlightChange(body.flid, fields)),
|
||||||
|
schdPush = listOf(SchdPush(body.flid, payloadJson)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user