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:
windyboy
2026-09-08 11:26:20 +08:00
parent bf1b5a371d
commit a936238368
7 changed files with 315 additions and 57 deletions
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.config.PipelineProps
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.MsgEvent
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)) }
}
val messageId = head.cminmsgsId.toString()
txManager.inTransaction {
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()) {
msgEvents.insertAll(events)
@@ -232,8 +239,11 @@ class MessageProcessor(
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后:backfill CMINMSGS(共享信箱外部副作用,补偿链路)
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
try {
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)
}
}
@@ -1,6 +1,7 @@
package com.gzzn.omms.msgexchange.processing
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.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
@@ -36,18 +37,17 @@ class SnapshotFlow(
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
// 1) staging:流式解析 + 整包校验(内存瞬态,崩溃从 raw 整包重放)
val messageId = head.cminmsgsId.toString()
val staged = StageResult.stagingOf(msg)
if (staged is StageResult.Invalid) {
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
}
val ok = staged as StageResult.Ok
val normalized = ok.flights // (flid, fields)
val normalized = ok.flights
val day = ok.day
// 内存与超大包熔断防御(ACM2-28 评论 4 项 3
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
@@ -55,39 +55,56 @@ class SnapshotFlow(
return
}
// 2) 准备代元数据与差集(删除集 = gen.flids − 新代,ADFT 自动存活)
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 newFlids = normalized.map { it.first }.toSet()
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
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 {
txManager.inTransaction {
// 覆盖新代全量:强行声明 FDAY 归属(JDBC batch 批处理)
flightSchd.upsertSnapshotBatch(day, normalized)
flightSchd.persistNextStates(day, nextStates, snapshotReplace = true)
// 按代差删域化:仅删除 FDAY = day 且在 delFields 中的记录(ADFT 与跨代已迁移行存活)
if (delFields.isNotEmpty()) {
flightSchd.deleteDiffByDay(day, delFields)
}
// SQL CAS 版本推进(防双写断言)
val casSuccess = flightSchd.putGenIfVersion(
day = day,
expected = expected,
newGen = FlightSchdRepository.GenMeta(fday = day, version = newVersion, flids = newFlids),
newGen = FlightSchdRepository.GenMeta(
fday = day,
version = newVersion,
flids = newFlids,
lastMessageId = messageId,
),
)
if (!casSuccess) {
// 重放路径:version 已是目标值 → no-op 视为成功,否则为 CAS 冲突
val again = flightSchd.getGen(day)
if (again == null || again.version != newVersion) {
throw CasConflictException("gen-cas-conflict: expected=$expected current=${again?.version}")
val replayIdentity = again?.lastMessageId == messageId && again.version == newVersion
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
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
reqTrack.findOpenByKind("SCHD")?.let { req ->
@@ -95,20 +112,21 @@ class SnapshotFlow(
}
}
// 构造并批量写入 schd 投递事件(载荷 = 字段集序列化,KAFKA_SCHD 线格式)
val events = normalized.map { (flid, fields) ->
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = flid, payloadJson = FlightFieldsJson.toJson(fields))
val events = nextStates.map { state ->
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = state.flid,
payloadJson = FlightFieldsJson.toJson(state.toFlightFields()),
)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
// 终态置 SUCCEEDED
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后:backfill CMINMSGS(外部副作用,补偿保障)
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
safeBackfill(head, msg)
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
} catch (e: CasConflictException) {
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 结果(骨架)。 */
sealed interface 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)),
)
}
}