refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)

- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0:
  PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD
  + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层
- 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 +
  条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后
- 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包
  DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅
  ACTIVE→DELETED,重复 FDEL 幂等不推进版本)
- 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭,
  TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3)
- 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10)
- XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型,
  FLTR 开放标签泛型承载)
- 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条
- 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试,
  新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
windyboy
2026-09-09 17:53:08 +08:00
parent 6af292d103
commit 879d658159
83 changed files with 3435 additions and 6355 deletions
@@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange
import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.ingress.InboxPoller
import com.gzzn.omms.msgexchange.processing.Pump
import com.gzzn.omms.msgexchange.jobs.JobRunner
import io.micronaut.context.annotation.Requires
import io.micronaut.runtime.event.annotation.EventListener
import io.micronaut.runtime.server.event.ServerStartupEvent
@@ -20,6 +21,7 @@ class PipelineLifecycle(
private val poller: InboxPoller,
private val pump: Pump,
private val dispatcher: Dispatcher,
private val jobRunner: JobRunner,
) {
private val log = org.slf4j.LoggerFactory.getLogger(PipelineLifecycle::class.java)
private val threads = mutableListOf<Thread>()
@@ -38,7 +40,8 @@ class PipelineLifecycle(
threads += spawn("msgx-inbox-poller", poller::loop)
threads += spawn("msgx-pump", pump::loop)
threads += spawn("msgx-dispatcher", dispatcher::loop)
log.info("pipeline loops started (inbox-poller, pump, dispatcher)")
jobRunner.start()
log.info("pipeline loops started (inbox-poller, pump, dispatcher, jobs)")
}
private fun spawn(name: String, body: () -> Unit): Thread =
@@ -49,6 +52,7 @@ class PipelineLifecycle(
poller.stop()
pump.stop()
dispatcher.stop()
jobRunner.stop()
threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出
threads.forEach { runCatching { it.join(3000) } }
log.info("pipeline loops stopped")
@@ -1,37 +1,37 @@
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 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
import javax.xml.stream.XMLInputFactory
/**
* SIS XML 解码XXE 防护;META + TYPE 分派 + FLOP 集合解析)。
* 阶段 2 先覆盖 FLOP-GTDT 纵向链路所需字段;其余 STYP 仅完成 META/FLID 提取
* SIS XML 解码jackson-dataformat-xml 直接映射到 [SisMessage] 数据类(不再手写 DOM)。
* XXE 防护:XMLInputFactory 禁 DTD/外部实体
* 覆盖:SCHD DNLD/RESPFLTR 记录集,§5 入口)、SCHD ADFT(单记录 §2.1)、
* FLOP(单航班增量 §6.1)、FDEL(终止实例 §6.2)。
*/
@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 实现差异:忽略不支持的属性
}
private val mapper: XmlMapper = XmlMapper(
XmlFactory(
XMLInputFactory.newInstance().apply {
setProperty(XMLInputFactory.SUPPORT_DTD, false)
setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
},
),
).apply {
registerKotlinModule()
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
override fun decode(rawXml: String): DecodeResult {
@@ -39,47 +39,42 @@ class JacksonXmlCodec : XmlCodec {
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}"))
val msg = try {
mapper.readValue(trimmed, SisMessage::class.java)
} catch (e: Exception) {
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message ?: e.javaClass.simpleName}"))
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?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
val styp = meta.styp?.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.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 =
@@ -89,82 +84,92 @@ class JacksonXmlCodec : XmlCodec {
"SCHD" -> MsgKind.Schd(
when (styp) {
"RESP" -> MsgKind.SchdSubtype.RESP
"DNLD" -> MsgKind.SchdSubtype.DNLD
"ADFT" -> MsgKind.SchdSubtype.ADFT
else -> MsgKind.SchdSubtype.DNLD
},
)
"FDEL" -> MsgKind.Fdel
"FLOP" -> MsgKind.Flop(styp)
else -> MsgKind.Flop(styp)
else -> MsgKind.Unsupported("$type-$styp") // §9 未支持类型,不猜测分派
}
private fun parseFlopBody(root: Element, type: String): FlopPayload? {
val section = firstChildElement(root, type) ?: return null
/** SCHD DNLD/RESP/ADFTRECS 缺失/非法 → -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<String, Any>?): FlopPayload? {
section ?: 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()
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()
}
}
val flid = scalars.remove("FLID") ?: return null
return FlopPayload(flid = flid, scalars = scalars, collections = collections.mapValues { it.value.toList() })
return flid?.takeIf { it.isNotEmpty() }?.let {
FlopPayload(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.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
/** FLTR Map → ScheduleRecord:标量/集合分离,集合项含源序号属性。 */
private fun recordOf(fltr: Map<String, Any>): ScheduleRecord? {
val scalars = linkedMapOf<String, String>()
val collections = linkedMapOf<String, MutableList<Map<String, String>>>()
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 null
return flid?.takeIf { it.isNotEmpty() }?.let {
ScheduleRecord(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() }, seqn = seqn)
}
}
private fun childText(parent: Element, tag: String): String? =
firstChildElement(parent, tag)?.textContent?.trim()?.takeIf { it.isNotEmpty() }
/**
* 集合值归一:重复元素 → List<Map>(属性与子元素展平);单元素 → 单项列表;
* 混合文本(如 DELY 备注文本)落到 REMC 键(XmlMapper 绑定的已知弱项,尽力保留)。
*/
private fun Any?.toCollectionItems(): List<Map<String, String>>? = when (this) {
is List<*> -> mapNotNull { it?.toCollectionItem() }
is Map<*, *> -> listOfNotNull(toCollectionItem(this))
is String -> listOf(mapOf("REMC" to trim()))
else -> null
}
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
private fun toCollectionItem(map: Map<*, *>): Map<String, String> {
val out = linkedMapOf<String, String>()
map.forEach { (k, v) ->
val key = k.toString().uppercase()
when (v) {
is String -> out[key] = v.trim()
is Number -> out[key] = v.toString()
}
}
return false
return out
}
private fun Any?.toCollectionItem(): Map<String, String>? = when (this) {
is Map<*, *> -> toCollectionItem(this)
is String -> mapOf("REMC" to trim())
else -> null
}
companion object {
@@ -1,11 +1,73 @@
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.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import java.time.LocalDate
/**
* FLOP 报文解析体(v2 §4 命令化输入)。
* scalarsFLID/FFID 等标量;collectionsGTDT/CKDT 等重复集合(含源序号属性)。
* FLOP 报文解析体(单航班增量 §6.1)。
* scalarsFLID 以外的标量;collectionsGTDT/CKDT 等重复集合(含源序号属性)。
*/
data class FlopPayload(
val flid: String,
val scalars: Map<String, String> = emptyMap(),
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
)
/**
* SCHDDNLD/RESP/ADFT)解析体(docs/flight-state.md §5)。
* RECS 声明数与实收 FLTR 记录分离承载,完整性校验(§5.2)在处理层执行;
* 协议无报文级覆盖日字段——scope 由处理层按记录 SODT 推导。
*/
data class ScheduleBody(
val recsDeclared: Int,
val records: List<ScheduleRecord>,
val scopeStart: LocalDate? = null,
val scopeEnd: LocalDate? = null,
)
// =====================================================================
// XML 直接映射(jackson-dataformat-xml 数据绑定,不再手写 DOM 遍历)。
// 信封强类型(META/段结构),FLTR/航班段内标签为开放集 → Map<String, Any>
// 泛型承载(45+ 标签逐一建模不成比例);集合项 = 属性 + 子元素展平的 Map。
// 已知弱项:DELY 的混合文本(元素文本)在 XmlMapper 绑定下不保证保留,
// 属性 CODE/STRT/DURA 保留;备注文本待真实报文验收(§10)。
// =====================================================================
@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<String, Any>? = null,
@JacksonXmlProperty(localName = "FDEL")
val fdel: Map<String, Any>? = 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,
)
/** SCHD 段:RECS 声明数 + FLTR 记录集(SIS §3.16 样例结构)。 */
@JsonIgnoreProperties(ignoreUnknown = true)
data class SchdSection(
@JacksonXmlProperty(localName = "RECS")
val recs: Int? = null,
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "FLTR")
val fltr: List<Map<String, Any>> = emptyList(),
)
@@ -0,0 +1,25 @@
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
/** 生命周期与保留期(docs/flight-state.md §8)。窗口按机场时区计算。 */
@ConfigurationProperties("msgx.history")
class HistoryProps {
/** 已取消(CNCL 非空)超过 N 小时。 */
var cancelledHours: Long = 48
/** 到港/离港终态(NAAT/NEAT,含义待术语表确认 §10)超过 N 小时。 */
var terminalHours: Long = 48
/** STATE = DELETED 超过 N 小时。 */
var deletedHours: Long = 48
/** 无终态字段:最后有效更新超过兜底期限(默认 7 天)。 */
var idleHours: Long = 24 * 7
/** 留痕 SCHD_SNAP_LOG 保留天数(§8.3,按 (SCOPE_END, RECV_AT) 清理)。 */
var snapLogRetentionDays: Long = 90
/** 是否接通历史存储——未接通时 HISTORY_SWEEP 必须删 0 条(§8.2)。 */
var historyStoreEnabled: Boolean = false
}
@@ -0,0 +1,16 @@
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
/**
* 运营日计算(docs/flight-state.md §3.5):由 SODTddMMMyyHHmm)与机场时区计算;
* 切日边界业务配置——不得假设等于接收日期或自然日零点(默认 0 点为占位,待业务确认 §10)。
*/
@ConfigurationProperties("msgx.operation-day")
class OperationDayProps {
/** 机场时区(IANA)。 */
var zone: String = "Asia/Shanghai"
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23)。 */
var cutoffHour: Int = 0
}
@@ -5,20 +5,16 @@ import java.time.Duration
/**
* ACMA-8 参数表(v4)初值;阶段 0 现网基线校准。
* U03N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则 msgx.pipeline/schd/
* identity/consistency-check.* 全部静默回落 Kotlin 默认值(当前 yml 初值与默认值一致,现象被掩盖)
* U03N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则
* msgx.pipeline/schd/identity.* 全部静默回落 Kotlin 默认值
*/
@ConfigurationProperties("msgx")
class PipelineProps {
var phase: Phase = Phase.A
var serviceName: String = "msgexchangeapi"
var registerEureka: Boolean = true
var pipeline: Pipeline = Pipeline()
var schd: Schd = Schd()
var identity: Identity = Identity()
var consistencyCheck: ConsistencyCheck = ConsistencyCheck()
enum class Phase { A, B }
@ConfigurationProperties("pipeline")
class Pipeline {
@@ -50,10 +46,4 @@ class PipelineProps {
/** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */
var includeDayBoundary: Boolean = false
}
@ConfigurationProperties("consistency-check")
class ConsistencyCheck {
var onStartup: Boolean = true // 阶段 A 必选哨兵
var dailySampleRatio: Double = 0.01
}
}
@@ -1,9 +1,9 @@
package com.gzzn.omms.msgexchange.delivery
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
@@ -12,26 +12,29 @@ import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
/** 对外投递端口(Kafka 同步确认;阶段 B 追加 ES/Redis 投影写入)。 */
/** 对外投递端口(Kafka 同步确认at-least-once;阶段 B 追加 ES 投影写入)。 */
interface DeliveryPort {
/** Kafka 发送(同步确认,at-least-once)。topic 由 target 映射:KAFKA:msg→"msg"KAFKA:schd→"schd"。 */
fun sendKafka(topic: String, payloadJson: String)
/** KAFKA_MSG 变化通知(key=FLID。 */
fun sendKafka(topic: String, key: String, payloadJson: String)
/** 阶段 BES flight_hts 写入。 */
fun indexFlightHts(payloadJson: String)
/**
* KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定;
* target→topicKAFKA:msg→"msg"KAFKA:schd→"schd"。
*/
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
/** TOMBSTONEkey=FLID、value=null——整态键缺失表示删除旧值(§7.3)。 */
fun sendKafkaNull(topic: String, key: String)
/** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */
fun ping(): Boolean = true
}
/**
* ACMA-8 流程 3:投递调度——每 target 严格 FIFO(I1 双层同策略)。
* N03U06):KAFKA_SCHD 不走逐条循环(唯一出口是 flushSchd 批量聚合),逐条循环显式排除。
* U08 闭环:逐条与批量失败都在持有具体事件/批次的边界完成状态迁移——
* · 逐条:scheduleRetry(attempts+1, backoff) / 达上限 markDead(EXHAUSTED)DLQ 保留行);
* · 批量(flushSchd):整批退避,队首 nextAttemptAt 未到不 claim;达上限整批 DEAD/DLQ
* · loop 只作最后防线,不吞 InterruptedException(致命/中断错误不被普通恢复吞掉)。
* 投递调度(docs/flight-state.md §7.3):逐条 KAFKA_MSG 严格 FIFO
* KAFKA_SCHD flushSchd 批量——同一 FLID 未发事件按最新 STATE_VERSION 合并输出,
* TOMBSTONE 发 null 值消息。两主题间不保证顺序(§7.3)。
* 批量闭环:队首退避未到期不 claim;发送失败整批 attempts+1 退避,达上限整批 DEAD/DLQ。
*/
@Singleton
class Dispatcher(
@@ -45,9 +48,9 @@ class Dispatcher(
@Volatile
private var running = true
private var lastFlush: Instant = Instant.EPOCH
private var lastFlush: Instant? = null
/** 优雅停机:loop 收尾后退出;线程中断由 Runner 负责。 */
/** 优雅停机:loop 收尾后退出;线程中断由 PipelineLifecycle 负责。 */
fun stop() {
running = false
}
@@ -60,88 +63,84 @@ class Dispatcher(
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
// U08:投递循环只作最后防线;致命 Error 不吞。tick 内失败迁移已完成。
sleepQuietly(props.pipeline.pollInterval)
}
// N18:轮询间隔取参数表(下限 50ms,避免退避节律被吞)
sleepQuietly(props.pipeline.pollInterval.coerceAtLeast(Duration.ofMillis(50)))
}
}
internal fun tick() {
val targets = if (props.phase == PipelineProps.Phase.A) Targets.phaseA else Targets.phaseB
for (t in targets) {
if (t == Targets.KAFKA_SCHD) continue // N03schd 唯一出口 flushSchd
val head = msgEvents.headUnsent(t) ?: continue
if (head.state == EventStatus.PENDING && head.nextAttemptAt != null && head.nextAttemptAt > scheduler.now()) {
// 队头退避未到期:等待,不跳过(保序);超时升级归 U13(WP2)
continue
}
try {
deliver(t, head)
msgEvents.markSent(head.eventId!!)
log.debug("sent target={} eventId={}", t, head.eventId)
// ACM2-28:阶段 B Redis 投影废弃,读模型投递统一转由 Kafka 事件或 ES 处理
} catch (e: Exception) {
retryOrDead(head, e.message ?: "unknown")
}
}
if (flushDue()) {
flushSchd()
val head = msgEvents.headUnsent(Targets.KAFKA_MSG)
if (head != null && (head.state == EventStatus.SENT || head.nextAttemptAt == null || head.nextAttemptAt <= scheduler.now())) {
deliver(Targets.KAFKA_MSG, head)
}
if (flushDue()) flushSchd()
if (running) sleepQuietly(props.pipeline.pollInterval)
}
private fun flushDue(): Boolean =
Duration.between(lastFlush, scheduler.now()) >= props.schd.flushPeriod
lastFlush?.let { Duration.between(it, scheduler.now()) >= props.schd.flushPeriod } ?: false
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
log.error("event DEAD(DLQ) eventId={} attempts={} lastError={}", e.eventId, attempts, lastError)
msgEvents.markDead(e.eventId!!, ErrorClass.EXHAUSTED, lastError, attempts)
} else {
log.warn("event retry scheduled eventId={} attempts={} nextAttemptAt={}", e.eventId, attempts, scheduler.nextAttemptAt(attempts))
msgEvents.scheduleRetry(e.eventId!!, scheduler.nextAttemptAt(attempts), attempts)
private fun deliver(target: String, e: MsgEvent) {
try {
when (target) {
Targets.KAFKA_MSG -> port.sendKafka("msg", e.partitionKey, e.payloadJson)
}
msgEvents.markSent(e.eventId ?: return)
} catch (ex: Exception) {
retryOrDead(e, ex.message ?: ex.javaClass.simpleName)
}
}
private fun deliver(target: String, e: MsgEvent) = when (target) {
Targets.KAFKA_MSG -> port.sendKafka("msg", e.payloadJson)
Targets.KAFKA_SCHD -> error("KAFKA_SCHD must go through flushSchd (N03)")
Targets.ES_FLIGHT_HTS -> port.indexFlightHts(e.payloadJson)
else -> error("unknown target $target")
}
/**
* 流程 3 flushSchd:批上限 + 每 FLID 最新一态聚合(topic "schd"wire=FLTR JSON 数组)。
* U08 批量闭环:队首退避未到期不 claim;发送失败 → 整批 attempts+1(退避)或达上限整批 DEAD/DLQ;
* lastFlush 仅在成功(含空批)后推进。
*/
/** flushSchd:同 FLID 未发事件按最新 STATE_VERSION 合并(§7.3);TOMBSTONE 发 null。 */
internal fun flushSchd() {
val pending = msgEvents.claimBatch(Targets.KAFKA_SCHD, limit = props.schd.flushLimit)
if (pending.isEmpty()) {
val batch = try {
msgEvents.mergePendingSchd(props.schd.flushLimit)
} catch (e: Exception) {
log.warn("mergePendingSchd failed: {}", e.message)
return
}
if (batch.isEmpty()) {
lastFlush = scheduler.now()
return
}
val due = pending.first()
if (due.nextAttemptAt != null && due.nextAttemptAt > scheduler.now()) {
return // 队首仍在退避:整批等待,不推进 lastFlush(到期再试)
val failures = mutableListOf<MsgEvent>()
for (e in batch) {
try {
when (e.eventType) {
EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
}
} catch (ex: Exception) {
failures.add(e)
}
}
val payload = SchdAggregation.latestPerFlight(pending).joinToString(",", "[", "]")
log.debug("flushSchd batch size={} payloadLen={}", pending.size, payload.length)
try {
port.sendKafka("schd", payload)
} catch (e: Exception) {
log.warn("flushSchd send failed batch={} -> batch retryOrDead: {}", pending.size, e.message)
pending.forEach { retryOrDead(it, "schd-send: ${e.message ?: "unknown"}") }
return // 不推进 lastFlush:整批退避(含 DEAD 出队)后到期重试
}
msgEvents.markAllSent(pending.mapNotNull { it.eventId })
log.info("flushSchd sent batch={}", pending.size)
val sentIds = batch.mapNotNull { it.eventId }.toSet() - failures.mapNotNull { it.eventId }.toSet()
if (sentIds.isNotEmpty()) msgEvents.markAllSent(sentIds.toList())
// 被最新版本合并压掉的未发事件同样关闭(§7.3:同 FLID 只按最新 STATE_VERSION 输出一次)
val sentVersions = batch.associate { it.partitionKey to it.stateVersion }
runCatching {
while (true) {
val superseded = msgEvents.mergePendingSchd(props.schd.flushLimit)
.filter { sentVersions[it.partitionKey]?.let { v -> it.stateVersion < v } == true }
if (superseded.isEmpty()) break
msgEvents.markAllSent(superseded.mapNotNull { it.eventId })
}
}.onFailure { log.warn("superseded cleanup failed: {}", it.message) }
failures.forEach { retryOrDead(it, it.lastError ?: "send-failed") }
lastFlush = scheduler.now()
}
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
val eventId = e.eventId ?: return
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
msgEvents.markDead(eventId, ErrorClass.EXHAUSTED, lastError, attempts)
} else {
msgEvents.scheduleRetry(eventId, scheduler.nextAttemptAt(attempts), attempts)
}
}
private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
}
@@ -1,22 +0,0 @@
package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.SchdPush
/**
* ACMA-8 流程 3 flushSchd 聚合(纯函数,可单测):
* 按 PARTITION_KEY(=FLID) 分组、组内取 EVENT_ID 最大——v4 显式 max(EVENT_ID)
* 不依赖集合遍历序(FIX:现役 buffer 无去重会重复投递旧值)。
*/
object SchdAggregation {
fun latestPerFlight(pending: List<MsgEvent>): List<String> =
pending
.groupBy { it.partitionKey ?: "" }
.map { (_, evs) -> evs.maxBy { it.eventId ?: 0 }.payloadJson }
/** 由 Decision 直接构造时的等价聚合(测试与 dispatcher 共用语义)。 */
fun latestPerFlightOfPush(pushes: List<SchdPush>): List<String> =
pushes
.groupBy { it.flid }
.map { (_, ps) -> ps.maxBy { it.eventSeq }.payloadJson }
}
@@ -1,44 +1,31 @@
package com.gzzn.omms.msgexchange.domain
/**
* ACMA-8 流程 2Handler 决策(纯函数)产物——状态与报文进,变更与事件出,
* 不直接触碰数据库或 Kafka。
*/
data class Decision(
val flightChanges: List<FlightChange> = emptyList(),
val msgNotifies: List<NotifyPayload> = emptyList(), // → MSG_EVENT(KAFKA:msg)
val schdPush: List<SchdPush> = emptyList(), // → MSG_EVENT(KAFKA:schd)PARTITION_KEY=FLID
val outboundIntents: List<OutboundIntent> = emptyList(), // → COUTMSGS(沿用既有列语义)
val refUpserts: List<RefUpsert> = emptyList(), // → 静态主数据(独立 PG reference 库,ACM2-11
)
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import java.time.LocalDate
/**
* 航班状态变更(阶段 A 落自有库 FLIGHT_SCHD 宽表,与事件同事务原子提交;ACM2-28 定案)
* fields = 本报文变更的字段集(field → value,与 legacy flightInfo hash 同构),
* 仓储按「字段级合并」落库(仅新增/覆盖,不删除缺失字段,与 legacy hmset 同语义)。
* 处理决策(docs/flight-state.md)——解码与校验进,落库计划出,纯数据不触碰 DB/Kafka
* 事务边界、PIPELINE_LOCK、事件登记由处理层执行(§5.1/§6)。
*/
data class FlightChange(
val flid: String,
val fields: Map<String, String>,
val maid: String? = null,
)
sealed interface Decision {
/**
* SCHD DNLD/RESP:已过 §5.2 报文完整性校验的记录集。
* §5.3 归属校验与 §5.4 upsert 在事务内执行(需读既有 OPERATION_DAY)。
*/
data class Schedule(
val records: List<ScheduleRecord>,
/** 报文覆盖运营日范围(单日快照时两者相等;无法确定时为 null → 整包拒绝)。 */
val scopeStart: LocalDate?,
val scopeEnd: LocalDate?,
) : Decision
data class NotifyPayload(val payloadJson: String)
/** FLOP 增量合并(§6.1/ ADFT(§2.1 语义待确认,按 MergeChange 承载)。 */
data class Dynamic(val change: MergeChange) : Decision
data class SchdPush(
val flid: String,
val payloadJson: String, // KAFKA_SCHD 出站载荷(报文线格式,与库内展开存储无关)
val eventSeq: Long = 0, // 由事务插入时赋 EVENT_ID 语义序,聚合取 max
)
/** FDEL 标记删除(§6.2)。 */
data class Delete(val flid: String) : Decision
data class OutboundIntent(
val coutmsgsXml: String,
val ackReqd: Boolean = true,
)
data class RefUpsert(
val rtype: String,
val rkey: String,
val payloadJson: String,
val source: String,
)
/** 幂等无变化(重复/迟到等,记成功但不推进任何状态)。 */
data object NoOp : Decision
}
@@ -11,10 +11,14 @@ data class MetaFields(
val dttm: Long,
)
/** 消息分派(sealed + 穷尽 whenACMA-6 选型;取代 legacy 反射 get{TYPE}())。 */
/** 消息分派(sealed + 穷尽 when;FDEL 一等公民——终止航班实例 §6.2)。 */
sealed interface MsgKind {
data class Schd(val subtype: SchdSubtype) : MsgKind
data class Flop(val subtype: String) : MsgKind // 29 类 STYP,阶段 2/3 逐类翻译
data class Flop(val subtype: String) : MsgKind // 运行动态 STYPFDEL 除外)
data object Fdel : MsgKind
/** 未支持类型(§9FAILED(UNSUPPORTED),达阈值转 DEAD)。 */
data class Unsupported(val tag: String) : MsgKind
enum class SchdSubtype { RESP, DNLD, ADFT }
}
@@ -30,5 +34,7 @@ data class DecodedMessage(
get() = when (val k = kind) {
is MsgKind.Schd -> "SCHD-${k.subtype.name}"
is MsgKind.Flop -> "FLOP-${k.subtype}"
MsgKind.Fdel -> "FDEL"
is MsgKind.Unsupported -> k.tag
}
}
@@ -1,29 +1,22 @@
package com.gzzn.omms.msgexchange.domain
/**
* ACMA-8 MSG_EVENT(统一投递事件 / outbox)。
* TARGET 阶段化:KAFKA:msg、KAFKA:schd 自阶段 AES:flight_hts、REDIS:flightInfo 仅阶段 B 投影期。
*/
object Targets {
const val KAFKA_MSG = "KAFKA:msg"
const val KAFKA_SCHD = "KAFKA:schd"
const val ES_FLIGHT_HTS = "ES:flight_hts"
@Deprecated("Retired in ACM2-28: Redis projection removed")
const val REDIS_FLIGHT_INFO = "REDIS:flightInfo"
/** 阶段 A 投递目标(仅 Kafka)——ACM2-28Delivery 阶段 A/B 均不写 Redis。 */
val phaseA: List<String> = listOf(KAFKA_MSG, KAFKA_SCHD)
/** 阶段 B 追加投影目标(仅 ES 历史库;Redis 投影按 ACM2-28 废弃)。 */
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS)
}
/** MSG_EVENT 事件形态(§7.3)。 */
enum class EventType { UPSERT, TOMBSTONE }
/** MSG_EVENT 投递状态(outbox 状态机)。 */
enum class EventStatus { PENDING, SENT, DEAD }
/**
* MSG_EVENToutbox,§3.2):状态、变更、删除通知。
* KAFKA_SCHD 整态 + KAFKA_MSG 变化通知;TOMBSTONE 仅在 ACTIVE→DELETED 时
* 与删除同事务登记(§7.3),投递失败持续重试。
*/
data class MsgEvent(
val eventId: Long? = null,
val target: String,
val partitionKey: String? = null, // schd 事件恒为 FLIDv4 显式声明)
val partitionKey: String, // 恒为 FLID
val eventType: EventType = EventType.UPSERT,
val stateVersion: Long = 0, // 发布时航班版本;Dispatcher 合并同 FLID 未发事件取最新(§7.3)
val payloadJson: String,
val state: EventStatus = EventStatus.PENDING,
val attempts: Int = 0,
@@ -0,0 +1,49 @@
package com.gzzn.omms.msgexchange.domain
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
/**
* 运营日计算(docs/flight-state.md §3.5):由计划运行时间字段 SODTddMMMyyHHmm
* 与机场时区计算;切日边界业务配置——默认 0 点为占位,待业务确认(§10)。
*/
class OperationDayCalculator(
zone: ZoneId,
cutoffHour: Int,
) {
private val zone: ZoneId = zone
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23,越界按 0)。 */
private val cutoffHour: Int = cutoffHour.coerceIn(0, 23)
/** SODT → 运营日;输入 null/空/非法返回 null(不抛异常,调用方按"运营日不可计算"处理)。 */
fun compute(sodt: String?): LocalDate? {
if (sodt.isNullOrBlank()) return null
val local = parseSodt(sodt.trim()) ?: return null
val day = local.atZone(zone).toLocalDate()
return if (local.hour < cutoffHour) day.minusDays(1) else day
}
companion object {
/**
* SIS SODT 线格式:ddMMMyyHHmm(如 15DEC031723),月份英文三字母、大小写不敏感。
* 两位年显式按 2000 基准展开(java.time 的 yy reduced-value 解析跨实现不一致,
* 显式展开保证 AODB 侧年份窗口唯一口径;基准年待真实报文验收确认 §10)。
*/
private val SODT_REGEX = Regex("(\\d{1,2})([A-Za-z]{3})(\\d{2})(\\d{2})(\\d{2})")
private val MONTHS = mapOf(
"JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6,
"JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12,
)
internal fun parseSodt(raw: String): LocalDateTime? {
val m = SODT_REGEX.matchEntire(raw.uppercase()) ?: return null
val (d, mon, yy, hh, mm) = m.destructured
val month = MONTHS[mon] ?: return null
return runCatching {
LocalDateTime.of(2000 + yy.toInt(), month, d.toInt(), hh.toInt(), mm.toInt())
}.getOrNull()
}
}
}
@@ -1,16 +1,18 @@
package com.gzzn.omms.msgexchange.domain
/**
* ACMA-8 数据模型 / PROC_STATE 状态机(六迁移表)。
* ACMA-8 数据模型 / PROC_STATE 状态机(docs/flight-state.md §3.2:每消息一行,
* 处理状态与重试结果;兼作快照重放判定 §5.1)。
*/
enum class ProcStatus { PENDING, FAILED, SUCCEEDED, SKIPPED, DEAD }
enum class ErrorClass { MALFORMED, CODEC_ERROR, EXHAUSTED, INFRA, UNSUPPORTED }
/** §9 错误分类:PROTOCOL = 整包拒绝(归属日不符等),不重试交人工。 */
enum class ErrorClass { MALFORMED, PROTOCOL, CODEC_ERROR, EXHAUSTED, INFRA, UNSUPPORTED }
data class ProcState(
val cminmsgsId: Long,
val msgId: Long,
val state: ProcStatus,
val identityKey: String? = null, // decode 后首次绑定FAILED 重试不重绑I3
val identityKey: String? = null, // SNDR|TYPE|STYP|SEQNdecode 后首次绑定FAILED 重试不重绑
val attempts: Int = 0,
val nextAttemptAt: java.time.Instant? = null,
val errorClass: ErrorClass? = null,
@@ -20,6 +22,6 @@ data class ProcState(
val isTerminal: Boolean
get() = state == ProcStatus.SUCCEEDED || state == ProcStatus.SKIPPED || state == ProcStatus.DEAD
/** 终态皆可归档(矩阵 #12SUCCEEDED SKIPPED DEADPENDING/FAILED 不迁。 */
/** 终态皆可归档;PENDING/FAILED 不迁。 */
val archivable: Boolean get() = isTerminal
}
@@ -0,0 +1,26 @@
package com.gzzn.omms.msgexchange.domain
import java.time.Instant
import java.time.LocalDate
/**
* SCHD 快照留痕模型(docs/flight-state.md §5.5):
* RESULT 与 FLAGS 分列(可「成功且告警」);一行 = 一次尝试,重放也记;
* 留痕不参与决策;写失败只记指标;保留 90 天,按 (SCOPE_END, RECV_AT) 清理。
*/
enum class SnapshotResult { COMMITTED, REPLAY_SKIPPED, ROLLED_BACK }
enum class SnapshotFlag { EMPTY, RECS_DROP, SEQN_REGRESSION, DAY_MISMATCH, SCHD_REVIVE_CONFLICT }
data class SnapshotLogEntry(
val msgId: Long,
val recvAt: Instant,
val scopeStart: LocalDate,
val scopeEnd: LocalDate,
val recs: Int,
val upserted: Int,
val durationMs: Long,
val result: SnapshotResult,
val flags: Set<SnapshotFlag> = emptySet(),
val archiveKey: String? = null, // 证据层引用(尚未交付 §3.2)
)
@@ -0,0 +1,10 @@
package com.gzzn.omms.msgexchange.domain
/**
* MSG_EVENT 投递目标(§7.3KAFKA_SCHD 发整态、KAFKA_MSG 只通知变化;
* 两主题间不保证顺序)。ES 投影属阶段 B,暂不登记目标。
*/
object Targets {
const val KAFKA_MSG = "KAFKA:msg"
const val KAFKA_SCHD = "KAFKA:schd"
}
@@ -1,23 +0,0 @@
package com.gzzn.omms.msgexchange.domain.flight
/**
* 显式字段命令(docs/flight-state.md §4)。
*/
sealed interface ScalarCommand {
data object Unchanged : ScalarCommand
data class Set(val value: String) : ScalarCommand
data object Clear : ScalarCommand
}
sealed interface CollectionCommand {
data object Unchanged : CollectionCommand
data class Replace(val items: List<Map<String, String>>) : CollectionCommand
data object Clear : CollectionCommand
data class Apply(val item: Map<String, String>, val sourceSeq: String) : CollectionCommand
}
data class FlightFieldCommands(
val flid: String,
val scalars: Map<String, ScalarCommand> = emptyMap(),
val collections: Map<String, CollectionCommand> = emptyMap(),
)
@@ -0,0 +1,89 @@
package com.gzzn.omms.msgexchange.domain.flight
import java.time.Instant
import java.time.LocalDate
/**
* 航班实例当前态模型(docs/flight-state.md §3)。
*
* 身份:FLID 唯一关联键;OPERATION_DAY 一经确定不可变(§3.5/§5.3);
* STATE 仅 ACTIVE/DELETED(§3.1,无 ARCHIVED——物理清除只发生在历史归档成功之后 §8.2)。
*/
enum class FlightState { ACTIVE, DELETED }
/** FLIGHT_SCHD 主行的身份与追踪字段(不含标量载荷)。 */
data class FlightMainRow(
val flid: String,
val operationDay: LocalDate?,
val state: FlightState,
val stateVersion: Long,
val lastMsgId: Long?,
val updatedAt: Instant,
)
/**
* SCHD DNLD/RESP 单条 FLTR 记录(解码产物,§5 入口)。
* scalars/collections 只含报文中出现的字段——完整快照语义下
* 出现 = Set/Replace,未出现 = 清除/替换空集(§5.4)。
*/
data class ScheduleRecord(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
val seqn: Long = 0,
)
/**
* 完整快照落库载荷(§5.1 步骤 6):按记录整体替换映射内字段,
* 仓储层负责明细先删后插与 `OPERATION_DAY` 不可变条件更新(§7.4)。
*/
data class SnapshotPatch(
val flid: String,
val operationDay: LocalDate,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
)
/** 快照 upsert 结果(§5.1 步骤 6DELETED 航班保持 DELETED 并告警,不恢复)。 */
enum class UpsertOutcome { INSERTED, UPDATED, REVIVE_CONFLICT }
/**
* FLOP/ADFT 增量载荷(§6.1/§2.1)。
* scalars 出现 = Set,缺失 = 保留本地值;collections 出现 = Replace
* (FLOP 集合语义未定案前沿用 Replace,§10 当前偏差明示)。
*/
data class MergeChange(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
)
/**
* 完整当前态(§3.3):主行 + 全部明细 = 完整当前态;
* 读取须在一致性读事务中,且展示层过滤 STATE = ACTIVE(§9)。
*/
data class FlightSnapshot(
val flid: String,
val operationDay: LocalDate?,
val state: FlightState,
val stateVersion: Long,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
)
/** §8.1 历史判定窗口(按机场时区计算;窗口值业务配置)。 */
data class HistoryRules(
val cancelledHours: Long = 48, // CNCL 非空超过 N 小时
val terminalHours: Long = 48, // NAAT/NEAT 终态超过 N 小时(字段含义待术语表确认 §10)
val deletedHours: Long = 48, // STATE = DELETED 超过 N 小时
val idleHours: Long = 24 * 7, // 无终态字段:最后更新超过兜底期限
)
/** §8.1 命中历史判定的航班(归档 → 物理清除候选)。 */
data class HistoryCandidate(
val flid: String,
val state: FlightState,
val stateVersion: Long,
/** 是否未经 FDEL 而被生命周期清除——清除前须补发一次删除事件(§7.3/§8.2)。 */
val wasNeverFdel: Boolean,
)
@@ -1,24 +0,0 @@
package com.gzzn.omms.msgexchange.domain.flight
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
data class FlightNextState(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
val stateVersion: Long,
val lastMessageId: String,
/** 本次迁移中被显式清除的标量/异常/文本键(仓储据此写 NULL 列;快照全量替换下无意义)。 */
val clearedKeys: Set<String> = emptySet(),
) {
fun toFlightFields(mapper: ObjectMapper = ObjectMapper()): FlightFields {
val out = linkedMapOf<String, String>()
out["FLID"] = flid
scalars.forEach { (k, v) -> out[k] = v }
collections.forEach { (key, items) ->
out[key] = mapper.writeValueAsString(items)
}
return out
}
}
@@ -1,157 +1,134 @@
package com.gzzn.omms.msgexchange.domain.flight
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import java.time.LocalDate
/**
* 从 legacy 字段视图解析/合并命令,并计算 nextState(v2 §4–§5)。
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)——纯函数,
* 内存生成完整新状态再落库;不触碰 DB/Kafka。
*/
object FlightStateEngine {
private val mapper = ObjectMapper()
private val COLLECTION_KEYS = setOf(
/** 集合键白名单:10 类集合 ↔ 9 张明细表(ROUT/ERUT 共用 FLIGHT_ROUTE_POINT,§3.2)。 */
val COLLECTION_KEYS: Set<String> = setOf(
"GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT",
)
/** 1:0..1 单值异常结构:主表前缀标量列承载;显式 null 载荷 = 清除(v2 §3.2/§9.1)。 */
val EXCEPTION_KEYS = setOf("FDIV", "FRET", "FLAB")
/**
* §5.2 报文完整性五项校验:RECS 0–9999 且等于实收数;每条含合法数字型 FLID;
* 快照内不重复;每条记录运营日可计算且在报文覆盖范围内。任一失败整包不落地。
*/
fun validateMessage(
recsDeclared: Int,
records: List<ScheduleRecord>,
scopeStart: LocalDate?,
scopeEnd: LocalDate?,
opDay: OperationDayCalculator,
): SnapshotValidation {
val flags = linkedSetOf<SnapshotFlag>()
if (recsDeclared !in 0..9999) return SnapshotValidation.Invalid("RECS out of range: $recsDeclared", setOf(SnapshotFlag.RECS_DROP))
if (records.size != recsDeclared) return SnapshotValidation.Invalid(
"RECS ($recsDeclared) != received FLTR count (${records.size})",
setOf(SnapshotFlag.RECS_DROP),
)
if (records.isEmpty()) return SnapshotValidation.Ok(emptyMap(), setOf(SnapshotFlag.EMPTY))
/** 异常/单值载荷的显式清除形态:空串、null 字面量、空对象(Oracle 空串即 NULL 语义的显式来源)。 */
private fun isClearPayload(value: String): Boolean {
val trimmed = value.trim()
if (trimmed.isEmpty() || trimmed == "null") return true
val node = runCatching { mapper.readTree(trimmed) }.getOrNull() ?: return false
return node.isNull || (node.isObject && node.size() == 0)
val perRecordDay = linkedMapOf<String, LocalDate>()
val seen = linkedSetOf<String>()
for (record in records) {
if (!record.flid.matches(FLID_REGEX)) {
return SnapshotValidation.Invalid("illegal FLID: '${record.flid}'", emptySet())
}
if (!seen.add(record.flid)) {
return SnapshotValidation.Invalid("duplicate FLID in snapshot: ${record.flid}", emptySet())
}
val day = opDay.compute(record.scalars["SODT"])
if (day == null) {
return SnapshotValidation.Invalid("operation day not computable, flid=${record.flid}", setOf(SnapshotFlag.DAY_MISMATCH))
}
val inScope = (scopeStart == null || !day.isBefore(scopeStart)) &&
(scopeEnd == null || !day.isAfter(scopeEnd))
if (!inScope) {
return SnapshotValidation.Invalid(
"operation day $day outside coverage [$scopeStart, $scopeEnd], flid=${record.flid}",
setOf(SnapshotFlag.DAY_MISMATCH),
)
}
perRecordDay[record.flid] = day
}
return SnapshotValidation.Ok(perRecordDay, flags)
}
private val SEQ_ATTR = mapOf(
"GTDT" to "GTNO",
"CKDT" to "CKNO",
"CLDT" to "CLNO",
"PSDT" to "PSNO",
"CHDT" to "CHNO",
"ABTM" to "ASNO",
"CHOT" to "CSNO",
"ROUT" to "RTNO",
"ERUT" to "RTNO",
)
// 注意:DELY 无协议序号属性(DLNO 非法)→ 不支持 Apply;清除走空数组 Replace(空集)
/** DNLD/FLOP 字段集 → 命令(出现即 Set/Replace;未出现即 Unchanged;序号 0 条目 = 显式清除标记)。 */
fun commandsFromFields(flid: String, fields: FlightFields): FlightFieldCommands {
val scalars = linkedMapOf<String, ScalarCommand>()
val collections = linkedMapOf<String, CollectionCommand>()
fields.forEach { (key, value) ->
if (key == "FLID") return@forEach
when {
key in COLLECTION_KEYS -> {
val seqAttr = SEQ_ATTR[key]
val parsed = parseCollection(value)
val markers = parsed.filter { seqAttr != null && it[seqAttr] == "0" }
collections[key] = when {
markers.isEmpty() -> CollectionCommand.Replace(parsed)
markers.size == parsed.size -> CollectionCommand.Clear
else -> throw IllegalArgumentException(
"$key mixes explicit clear marker (seq=0) with regular items; refusing to guess",
)
}
}
key in EXCEPTION_KEYS && isClearPayload(value) -> scalars[key] = ScalarCommand.Clear
else -> scalars[key] = ScalarCommand.Set(value)
/**
* 完整快照语义(§5.4 DNLD/RESP):标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
* `keepDeleted = true` 时 STATE 保持 DELETED(§5.1 步骤 6:普通 SCHD 不恢复)。
*/
fun snapshotState(
current: FlightSnapshot?,
record: ScheduleRecord,
operationDay: LocalDate,
keepDeleted: Boolean,
): FlightSnapshot {
val state = when {
current == null -> FlightState.ACTIVE
keepDeleted -> FlightState.DELETED
else -> current.state
}
// §5.4 完整快照:标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
// 完整替换 = 新状态只由记录决定,不从 current 继承任何字段。
val scalars: Map<String, String> = record.scalars
val collections = buildMap {
COLLECTION_KEYS.forEach { key -> put(key, emptyList()) } // 缺失 = Replace 空集(§5.4
record.collections.forEach { (key, items) ->
if (key in COLLECTION_KEYS) put(key, items)
}
}
return FlightFieldCommands(flid, scalars, collections)
}
fun apply(
current: FlightNextState?,
commands: FlightFieldCommands,
messageId: String,
bumpVersion: Boolean,
): FlightNextState {
val baseScalars = current?.scalars?.toMutableMap() ?: mutableMapOf()
val baseCollections = current?.collections?.mapValues { it.value.toMutableList() }
?.toMutableMap() ?: mutableMapOf()
val baseVersion = current?.stateVersion ?: 0L
val cleared = mutableSetOf<String>()
commands.scalars.forEach { (key, cmd) ->
when (cmd) {
ScalarCommand.Unchanged -> Unit
is ScalarCommand.Set -> {
baseScalars[key] = cmd.value
cleared.remove(key)
}
ScalarCommand.Clear -> {
baseScalars.remove(key)
cleared += key
}
}
}
commands.collections.forEach { (key, cmd) ->
when (cmd) {
CollectionCommand.Unchanged -> Unit
is CollectionCommand.Replace -> baseCollections[key] = cmd.items.toMutableList()
CollectionCommand.Clear -> baseCollections.remove(key)
is CollectionCommand.Apply -> {
val seqAttr = SEQ_ATTR[key]
?: throw IllegalArgumentException("$key does not support Apply: protocol defines no source sequence attribute")
val list = baseCollections.getOrPut(key) { mutableListOf() }.toMutableList()
val idx = list.indexOfFirst { it[seqAttr] == cmd.sourceSeq }
if (idx >= 0) {
list[idx] = cmd.item
} else {
list.add(cmd.item)
}
baseCollections[key] = list
}
}
}
val nextVersion = if (bumpVersion) baseVersion + 1 else baseVersion
return FlightNextState(
flid = commands.flid,
scalars = baseScalars,
collections = baseCollections.mapValues { it.value.toList() },
stateVersion = nextVersion,
lastMessageId = messageId,
clearedKeys = cleared.toSet(),
return FlightSnapshot(
flid = record.flid,
operationDay = operationDay,
state = state,
stateVersion = (current?.stateVersion ?: 0L) + 1,
scalars = scalars,
collections = collections,
)
}
fun parseCollection(raw: String): List<Map<String, String>> {
val node = mapper.readTree(raw)
val items = when {
node.isNull -> emptyList()
node.isArray -> node.toList()
node.isObject -> listOf(node)
else -> throw IllegalArgumentException("collection value must be array or object")
/**
* FLOP 增量合并(§6.1):标量出现覆盖、缺失保留;集合出现 Replace、缺失保留
* ——FLOP 集合语义未定案,Replace 为当前偏差明示沿用(§10)。
*/
fun mergedState(current: FlightSnapshot, change: MergeChange): FlightSnapshot {
val scalars = buildMap {
putAll(current.scalars)
putAll(change.scalars)
}
return items.map { item ->
item.properties().associate { (k, v) ->
k to when {
v.isNull -> ""
v.isValueNode -> v.asText()
else -> v.toString()
}
val collections = buildMap {
putAll(current.collections)
change.collections.forEach { (key, items) ->
if (key in COLLECTION_KEYS) put(key, items)
}
}
return current.copy(
stateVersion = current.stateVersion + 1,
scalars = scalars,
collections = collections,
)
}
/** 当前库态 → FlightNextState(供增量合并)。 */
fun fromFlightFields(flid: String, fields: FlightFields, stateVersion: Long = 0L, lastMessageId: String = ""): FlightNextState {
val scalars = linkedMapOf<String, String>()
val collections = linkedMapOf<String, List<Map<String, String>>>()
fields.forEach { (key, value) ->
if (key == "FLID") return@forEach
if (key in COLLECTION_KEYS) {
collections[key] = parseCollection(value)
} else {
scalars[key] = value
}
}
return FlightNextState(flid, scalars, collections, stateVersion, lastMessageId)
}
/** FLID:数字型,SIS §3.16.2 Number(1-12)。 */
private val FLID_REGEX = Regex("\\d{1,12}")
}
/** §5.2 校验结果:Ok 携带每条记录的归属运营日与观测 flags;Invalid 整包拒绝。 */
sealed interface SnapshotValidation {
data class Ok(
val perRecordDay: Map<String, LocalDate>,
val flags: Set<SnapshotFlag>,
) : SnapshotValidation
data class Invalid(
val reason: String,
val flags: Set<SnapshotFlag>,
) : SnapshotValidation
}
@@ -0,0 +1,13 @@
package com.gzzn.omms.msgexchange.infra
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton
/** 统一 ObjectMapper 装配(处理器出站载荷 JSON 序列化共用)。 */
@Factory
class JacksonFactory {
@Singleton
fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule()
}
@@ -1,35 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence
import com.fasterxml.jackson.databind.ObjectMapper
/**
* 航班字段集(FLIGHT_SCHD 宽表字段)→ KAFKA_SCHD 事件载荷序列化。
* 仅用于 MSG_EVENT 出站载荷(报文线格式,legacy 下游按 JSON 消费),与库内存储无关;
* 键按字典序输出保证同状态产出字节级稳定载荷(重放/幂等判据不因遍历序漂移)。
*/
object FlightFieldsJson {
private val mapper = ObjectMapper()
/** Fields whose legacy wire representation is a JSON array/object rather than a JSON string. */
private val STRUCTURED_FIELDS = setOf(
"ROUT", "ERUT", "CHDT", "GTDT", "PSDT", "CKDT", "CLDT", "DELY",
"CHOT", "ABTM", "SRVT", "VIPF", "MAFL", "FDIV", "FRET", "FLAB",
)
fun toJson(fields: Map<String, String>): String {
val node = mapper.createObjectNode()
fields.toSortedMap().forEach { (key, value) ->
if (key in STRUCTURED_FIELDS) {
val parsed = runCatching { mapper.readTree(value) }.getOrNull()
if (parsed != null && (parsed.isArray || parsed.isObject || parsed.isNull)) {
node.set<com.fasterxml.jackson.databind.JsonNode>(key, parsed)
} else {
node.put(key, value)
}
} else {
node.put(key, value)
}
}
return node.toString()
}
}
@@ -1,39 +1,55 @@
package com.gzzn.omms.msgexchange.infra.persistence
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
import com.gzzn.omms.msgexchange.domain.flight.FlightMainRow
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* ACM2-28 仓储接口(接口驱动,主泵/调度循环可单测;Micronaut Data JDBC 实装属 U05 批次)。
* 存储边界:自有 PostgreSQLdatasources.default= 本文件除 CminmsgInboxRepository 外
* 的全部接口(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、21 类 REF_MASTER);
* 共享 MySQL 信箱(CMINMSGS / COUTMSGS)经信箱封装访问,仅 DML、不建表;
* 主路径=上游外部写 CMINMSGS → 本系统 JDBC 轮询读;compat=insertRaw HTTP 写;
* 自有库 = 消息管道 + 运营航班 FLIGHT_SCHD/SCHD_GEN + 静态数据;Redis 已退出阶段 A 权威与写路径。
*/
/** 单航班字段集(field → value,与 legacy flightInfo hash 同构)。 */
typealias FlightFields = Map<String, String>
// =====================================================================
// 仓储契约(docs/flight-state.md §3.2 表职责)。
// 决策层写路径约定:所有 FLIGHT_SCHD 及明细写操作必须发生在
// 「持有 PIPELINE_LOCK 的同一事务」内(§1:锁只串行化 DB 事务)。
// =====================================================================
/** 自有 PG 单事务原子保障(§1 目标 3:状态、事件、处理终态同事务提交)。 */
interface PipelineTransactionManager {
fun <T> inTransaction(block: () -> T): T
}
/** 单行锁(§3.2 PIPELINE_LOCK):事务内第一步 SELECT ... FOR UPDATE,串行化状态写事务。 */
interface PipelineLockRepository {
fun lock()
}
/** PROC_STATE:每消息一行;兼作快照重放判定(§5.1 步骤 2:MSG_ID 已有成功终态 → 重放)。 */
interface ProcStateRepository {
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING)
fun insert(msgId: Long, state: ProcStatus = ProcStatus.PENDING)
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */
fun exists(cminmsgsId: Long): Boolean
fun exists(msgId: Long): Boolean
/** I1:严格 FIFO 队头(最小未完成 CMINMSGS_ID)。 */
fun find(msgId: Long): ProcState?
fun findSuccessTerminal(msgId: Long): Boolean
/** 严格 FIFO 队头(最小未完成 MSG_ID)。 */
fun headUnfinished(): ProcState?
/** I3identity 首次绑定;返回 false = 另一条消息已持有该键。 */
fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean
/** identity 首次绑定;返回 false = 另一条消息已持有该键。 */
fun tryBindIdentity(msgId: Long, identityKey: String): Boolean
fun ownerOfIdentity(identityKey: String): Long?
fun update(
cminmsgsId: Long,
msgId: Long,
state: ProcStatus,
nextAttemptAt: Instant? = null,
attempts: Int? = null,
@@ -41,21 +57,20 @@ interface ProcStateRepository {
lastError: String? = null,
)
/**
* U11 显式重放入口(ReplayService):仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDING
* 以便主泵重新领取。实现约定:ATTEMPTS=0、NEXT_ATTEMPT_AT=NULL(立即重试),
* ERROR_CLASS/LAST_ERROR 保留作审计。返回受影响行数。
*/
/** 显式重放入口:仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDINGATTEMPTS=0)。 */
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
}
/** MSG_EVENT outbox(§7.3)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
interface MsgEventRepository {
fun insertAll(events: List<MsgEvent>): List<Long>
/** I1 双层同策略:每 target 严格 FIFO 队头。 */
fun headUnsent(target: String): MsgEvent?
fun claimBatch(target: String, limit: Int): List<MsgEvent> // ORDER BY EVENT_ID ASC
fun claimBatch(target: String, limit: Int): List<MsgEvent>
/** 同 FLID 未发 KAFKA_SCHD 事件合并:每 FLID 取最新 STATE_VERSION 一条(§7.3)。 */
fun mergePendingSchd(limit: Int): List<MsgEvent>
fun markSent(eventId: Long)
@@ -64,108 +79,100 @@ interface MsgEventRepository {
fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int)
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
/** 阶段 B(定案 2):Delivery 同线程在 ES 投递成功后同步 enqueue 删除事件。 */
fun insertSync(events: List<MsgEvent>)
}
/** 完整态落库结果:DAY_GUARD_VIOLATION = OPERATION_DAY 不可变条件更新未命中(§7.4)。 */
enum class PersistOutcome { INSERTED, UPDATED, DAY_GUARD_VIOLATION }
/**
* 阶段 A 运营航班权威与日计划代(ACM2-28 定案 + ACM2-29 全宽表·零子表定案):
* - 表 FLIGHT_SCHD:一行一航班的运营航班宽表(FLID 主键 + FDAY 可空所属代 + SCHD.FLTR 标量字段列
* + 集合平铺标量列/紧凑 VARCHAR 字串列),零子表、零 CLOB,字段即列、天然可索引可直查,
* PG/Oracle 11g 方言一致;读侧视图由平铺列重建集合键,与 legacy flightInfo hash 同构;
* - 运营资源消息语义 = 单资源集合级全量快照替换;外层航班增量仍为字段级合并(仅新增/覆盖);
* - 表 PIPELINE_LOCK:单写者行级互斥(SELECT ... FOR UPDATEPG/11g 同构,无 DBMS_LOCK 特权依赖);
* - 表 SCHD_GEN:各日代版本(SQL CAS);表 SCHD_GEN_FLID:当前代有效航班 FLID 集合(差删依据);
* - Redis 退出动态权威与全部写路径;事务 2 与快照发布全在自有库内单事务原子提交;
* - 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行保留原 FDAY,按字段列更新
* (与 legacy flightInfo hash「仅新增/覆盖、不删除缺失字段」同语义);
* - 快照(DNLD):声明/更新 FDAY 归属,航班字段整体替换;
* - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID IN (:diffSet)
* 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
* 航班当前态权威(§3.2 FLIGHT_SCHD + 9 张明细表)。
* 唯一写路径 = persistFullState:写入前在内存生成完整新状态(引擎产物)再落库,
* 明细集合按组先删后插(§3.3)。
*/
interface FlightSchdRepository {
data class FlightRecord(
val flid: String,
val fday: String?,
val fields: FlightFields,
val createdAt: Instant,
val updatedAt: Instant,
)
interface FlightStateRepository {
/** 主行点查(身份/版本判定;批量用于快照归属校验 §5.3)。 */
fun findMainRow(flid: String): FlightMainRow?
data class GenMeta(
val fday: String,
val version: Long,
val flids: Set<String>,
val lastMessageId: String? = null,
val updatedAt: Instant = Instant.now(),
)
fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow>
/** v2P4 退场):legacy upsertSnapshotBatch/upsertIncremental 已删除——唯一写路径是 persistNextStates。 */
/** 按代差删域化:仅删除 FDAY = day 且在 delFlids 中的记录(ADFT 与跨代已迁移行受保护)。 */
fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int
/** 点查单航班字段集;无字段行(含航班不存在)返回 null。 */
fun findByFlid(flid: String): FlightFields?
/** v2:点查单航班 nextState(含 state_version / last_message_id 追踪字段)。 */
fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState?
/** 点查多航班字段集:FLID → 字段集,仅含实际存在的航班。 */
fun findByFlids(flids: Collection<String>): Map<String, FlightFields>
/** 按计划日查询当前有效航班。 */
fun findByDay(day: String): List<Pair<String, FlightFields>>
/** 全量查询(供影子对拍 / 一致性对账)。 */
fun findAll(): Map<String, FlightFields>
/** 完整当前态:主行 + 全部明细(一致性读边界由调用方事务保证,§9)。 */
fun loadFullSnapshot(flid: String): FlightSnapshot?
/**
* 历史清场删除:仅删除已确认归档至 ES 的 FLID 集合;空集合不执行;分批参数化删除。
* 返回实际删除行数(允许重放时为 0
* 完整当前态落库(§3.3):主行 upsert + 全部明细按组先删后插;
* STATE_VERSION 以 snapshot.stateVersion 落库
* SCHD(整体替换/清除)、FLOP/ADFT(合并后全量写)共用此唯一写路径。
* 条件更新带 `WHERE operation_day IS NULL OR operation_day = :day`(§7.4 不可变强化)。
*/
fun deleteByFlids(flids: Set<String>): Int
/** 读计划代(SCHD_GEN)。 */
fun getGen(day: String): GenMeta?
fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome
/**
* 计划代 SQL 版本 CAS:仅当 expected 与数据库中当前版本一致(或不存在且 expected=0)时写入/推进新版本。
* 返回 true 表示推进成功,false 表示发生 CAS 冲突
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、返回 true(发布删除事件);
* 已 DELETED 或不存在 → 返回 false(幂等成功,不推进版本不重复发布)
*/
fun putGenIfVersion(day: String, expected: Long, newGen: GenMeta, now: Instant = Instant.now()): Boolean
fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean
/** ADFT 生命周期重激活(§6.3):DELETED → ACTIVE,推进版本;非 DELETED 返回 false。 */
fun revive(flid: String, msgId: Long, now: Instant): Boolean
/** §8.1:按窗口规则选出历史候选(含 DELETED;候选时间按机场时区折算)。 */
fun findHistoryCandidates(rules: HistoryRules, zone: ZoneId, now: Instant): List<HistoryCandidate>
/**
* 历史代清理:清理 cutoffDay 之前的历史代记录(与 FLIGHT_SCHD 历史清场生命周期对齐)。
* §8.2 步骤 3:物理删除主行与明细(仅历史存储确认成功后调用;
* 历史存储未接通时调用方必须传空集合——删 0 条)。
*/
fun deleteGenBefore(cutoffDay: String): Int
fun purgeArchived(flids: Collection<String>): Int
/**
* v2 无损明细:将 nextState 写入宽表 + 明细表,并更新追踪字段。
* 必须在 PipelineTransactionManager 事务内调用。
*/
fun persistNextStates(
day: String?,
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
snapshotReplace: Boolean,
now: Instant = Instant.now(),
)
/** §8.3 观测:OPERATION_DAY 仍为 NULL 的航班数(只增不删,终止规则未定 §10)。 */
fun countOperationDayNull(): Int
}
/** 事务管理器抽象:自有 PG 单事务原子保障。 */
interface PipelineTransactionManager {
fun <T> inTransaction(block: () -> T): T
/** §5.5 SCHD_SNAP_LOG:事务外追加留痕,不参与决策;一行 = 一次尝试(重放也记)。 */
interface SnapshotLogRepository {
fun append(entry: SnapshotLogEntry)
}
/**
* 请求状态机(§7.1):只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)
* 匹配最新一条 PENDING;同类请求只留一条有效,新请求置旧为 EXPIRED。
*/
interface ReqTrackRepository {
enum class ReqState { PENDING, SENT, DONE, EXPIRED }
data class Req(
val reqId: Long,
val reqType: String,
val operationDay: LocalDate,
val sender: String,
val state: ReqState,
val coutmsgsId: Long? = null,
val sentAt: Instant? = null,
)
/** 登记新请求:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED。 */
fun insert(reqType: String, operationDay: LocalDate, sender: String): Long
fun findLatest(reqType: String, operationDay: LocalDate, sender: String, states: List<ReqState>): Req?
/** RESP 完成请求(§7.1):匹配最新一条 PENDING/SENT;无匹配返回 false(迟到不报错)。 */
fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
fun markSent(reqId: Long, sentAt: Instant)
fun expire(reqId: Long)
}
/**
* v2 §5ACM2-29 P2-3):提交后共享信箱回填的持久补偿待办
* 业务事务已提交(PROC_STATE=SUCCEEDED)后 backfillOnSuccess 失败 → 落本表重试;
* 回填失败不得把已成功的业务事务重新标记为失败,也不得重放业务变更。
* 共享信箱回填补偿待办(§7.2):业务事务内预登记(消除崩溃窗口 §10 偏差),
* 提交后由 BackfillSweepJob 重试;回填失败不得把 SUCCEEDED 改回 FAILED。
*/
interface BackfillTodoRepository {
data class BackfillTask(
val cminmsgsId: Long,
val msgId: Long,
val sndr: String,
val type: String,
val styp: String,
@@ -176,104 +183,25 @@ interface BackfillTodoRepository {
/** 失败即落库(幂等 upsert,同 ID 重复失败只刷新错误与重试时间)。 */
fun record(task: BackfillTask, lastError: String?, now: Instant = Instant.now())
/** 到期待重试的补偿任务(next_attempt_at <= now,按到期时间升序)。 */
fun findDue(now: Instant = Instant.now(), limit: Int = 50): List<BackfillTask>
/** 重试失败:累加 attempts 并按退避推后 next_attempt_at。 */
fun markFailed(cminmsgsId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant = Instant.now())
fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant = Instant.now())
/** 重试成功:移除补偿待办。 */
fun delete(cminmsgsId: Long)
fun delete(msgId: Long)
/** 当前待办数量(测试/对账用)。 */
fun count(): Int
}
@Deprecated("Replaced by FlightSchdRepository in ACM2-28", ReplaceWith("FlightSchdRepository"))
interface RefDataRepository {
data class GenMeta(val flids: List<String>, val version: Long)
fun getGen(day: String): GenMeta?
fun putGenIfVersion(day: String, expected: Long, new: GenMeta): Boolean
}
/**
* 21 类静态主数据(航空公司/航线/机位/登机桥等)——自有 PostgreSQL `REF_MASTER` 表
* (ACM2-12:与消息管道同自有库;SOURCE=ADMINAPI/AODB/PIPELINEN19 对齐)
* 与主链弱事务耦合:写入者为 ReferenceService21 类同步)与请求应答路径。
*/
interface StaticRefRepository {
fun upsertAll(refs: List<RefUpsert>)
fun findByType(type: String): List<RefUpsert>
}
/**
* 15 类请求状态机——自有 PG `REQ_TRACK`ACM2-12Reference & Query 域)。
* 与共享库 COUTMSGS(出站信箱)跨库:先 COUTMSGS 落库成功 → 再 markSent,补偿重扫,最终一致。
*/
interface ReqTrackRepository {
data class Req(
val reqId: Long,
val reqType: String,
val state: String, // REGISTERED/SENT/WAITING/DONE/EXPIRED
val sentAt: Instant? = null,
)
fun findOpenByKind(kind: String): Req?
fun forceExpireOpenOf(kind: String)
fun insert(kind: String, paramsJson: String): Long
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
fun markSent(reqId: Long, sentAt: Instant)
fun expireIfWaiting(reqId: Long)
fun markDone(reqId: Long)
}
/**
* 泵作业调度记录——自有 PG `PUMP_JOB`ACM2-12)。
* 决策 1 修订:作业不插队,仅在消息队头空闲/退避窗口由主泵执行(跨库/异队列无全序);
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 ES+PG 等)仍在各自目标存储。
*/
interface PumpJobRepository {
data class Job(val jobId: Long, val kind: String) // ARCHIVE/HISTORY_SWEEP/PROJECTION_REBUILD
fun enqueue(kind: String)
fun headQueued(): Job?
fun markRunning(jobId: Long)
fun markDone(jobId: Long)
fun markFailed(jobId: Long, lastError: String)
}
/**
* 共享信箱 CMINMSGS 访问(ACM2-12:库属他人系统,本系统不建表)。
*
* **主路径(生产)**`pollNew` / `rawOf` — JDBC 轮询/读取上游外部写入的报文(U05 InboxPoller)。
* **compat 路径**`insertRaw` — HTTP `POST /cminmsgs/send` 辅助写(手工/对拍,非主拓扑)。
*
* 入队模型:发现或 compat 写得到 CMINMSGS_ID → 自有 PG 建 PROC_STATE(PENDING)
* PG 建行失败以共享库 DATE_PROCESSED IS NULL 重扫补建。
* `backfillOnSuccess` = 处理成功后的外部回填(DATE_PROCESSED/STATUS,最终一致)。
* 共享 MySQL 信箱 CMINMSGS 访问(他人系统库,本系统不建表)。
* 主路径 JDBC 轮询读 + 处理回填;compat HTTP 写;出站写 COUTMSGS 由出站适配层承担
*/
interface CminmsgInboxRepository {
/** compat HTTP 写路径:向共享信箱插入原文(U16 契约对拍)。 */
fun insertRaw(rawXml: String): Long
/** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */
fun rawOf(cminmsgsId: Long): String?
fun rawOf(msgId: Long): String?
/**
* 主路径 JDBC 轮询:共享库未处理报文 ID 列表(legacy `getNewMsgsAfterId` 同语义)。
* @param afterId 下界(legacy 现役传 0);仅返回 `DATE_PROCESSED IS NULL` 行。
*/
fun pollUnprocessed(afterId: Long, limit: Int): List<Long>
fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long)
fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long)
}
@@ -1,162 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import java.time.Instant
import javax.sql.DataSource
/**
* v2 明细表读写(docs/flight-state.md §3.2)。
* 主表保存标量,明细表保存重复集合;不存在旧槽位读写回退。
*/
internal object FlightDetailTables {
private data class TableSpec(
val table: String,
val collectionKey: String,
val seqAttr: String,
val columns: List<Pair<String, String>>, // json attr -> db column
)
private val SPECS = listOf(
TableSpec("flight_gate", "GTDT", "GTNO", listOf(
"GATE" to "gate", "PGOT" to "pgot", "PGCT" to "pgct", "GOTM" to "gotm", "GCTM" to "gctm", "GTYP" to "gtyp",
)),
TableSpec("flight_checkin", "CKDT", "CKNO", listOf(
"CHKC" to "chkc", "CCLS" to "ccls", "PCOT" to "pcot", "PCCT" to "pcct", "COTM" to "cotm", "CCTM" to "cctm", "CTYP" to "ctyp",
)),
TableSpec("flight_belt", "CLDT", "CLNO", listOf(
"BELT" to "belt", "BCLS" to "bcls", "PCOT" to "pcot", "PCCT" to "pcct", "FBAG" to "fbag", "LBAG" to "lbag", "BTYP" to "btyp",
)),
TableSpec("flight_stand_plan", "PSDT", "PSNO", listOf(
"PSST" to "psst", "STST" to "stst", "STET" to "stet",
)),
TableSpec("flight_chute", "CHDT", "CHNO", listOf(
"CHUT" to "chut", "CHCLS" to "chcls", "PCBT" to "pcbt", "PCET" to "pcet", "CBTM" to "cbtm", "CETM" to "cetm", "CHTYP" to "chtyp",
)),
TableSpec("flight_delay", "DELY", "DLNO", listOf(
"CODE" to "code", "STRT" to "strt", "DURA" to "dura", "REMC" to "remc",
)),
TableSpec("flight_bridge_op", "ABTM", "ASNO", listOf(
"ABDG" to "abdg", "ABOP" to "abop", "AOTM" to "aotm",
)),
TableSpec("flight_chock_op", "CHOT", "CSNO", listOf(
"CHID" to "chid", "CHST" to "chst", "CHTM" to "chtm",
)),
)
fun replaceAll(ds: DataSource, state: FlightNextState, now: Instant) {
val ts = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
for (spec in SPECS) {
ds.update("DELETE FROM ${spec.table} WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
val items = state.collections[spec.collectionKey] ?: continue
if (items.isEmpty()) continue
val colNames = listOf("flid", "ordinal", "source_seq", "record_version") +
spec.columns.map { it.second } + listOf("created_at", "updated_at")
val sql = "INSERT INTO ${spec.table} (${colNames.joinToString(", ")}) VALUES (${
colNames.joinToString(", ") { "?" }
})"
conn.prepareStatement(sql).use { ps ->
items.forEachIndexed { index, item ->
var i = 1
ps.setString(i++, state.flid)
ps.setInt(i++, index + 1)
ps.setString(i++, item[spec.seqAttr])
ps.setLong(i++, state.stateVersion)
spec.columns.forEach { (attr, _) -> ps.setString(i++, item[attr]) }
ps.setTimestamp(i++, ts)
ps.setTimestamp(i++, ts)
ps.addBatch()
}
ps.executeBatch()
}
}
replaceRoutes(ds, state, now)
} finally {
conn.releaseIfNotInTransaction()
}
}
private fun replaceRoutes(ds: DataSource, state: FlightNextState, now: Instant) {
val ts = now.toSqlTimestamp()
ds.update("DELETE FROM flight_route_point WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
val items = state.collections[key] ?: return@forEach
if (items.isEmpty()) return@forEach
val conn = ds.obtainConnection()
try {
conn.prepareStatement(
"INSERT INTO flight_route_point (flid, ordinal, source_seq, record_version, route_kind, apcd, scat, scdt, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { ps ->
items.forEachIndexed { index, item ->
ps.setString(1, state.flid)
ps.setInt(2, index + 1)
ps.setString(3, item["RTNO"])
ps.setLong(4, state.stateVersion)
ps.setString(5, kind)
ps.setString(6, item["APCD"])
ps.setString(7, item["SCAT"])
ps.setString(8, item["SCDT"])
ps.setTimestamp(9, ts)
ps.setTimestamp(10, ts)
ps.addBatch()
}
ps.executeBatch()
}
} finally {
conn.releaseIfNotInTransaction()
}
}
}
fun deleteForFlids(ds: DataSource, flids: Collection<String>) {
if (flids.isEmpty()) return
val tables = SPECS.map { it.table } + "flight_route_point"
for (table in tables) {
for (chunk in flids.chunked(200)) {
val placeholders = chunk.joinToString(",") { "?" }
ds.update("DELETE FROM $table WHERE flid IN ($placeholders)") { ps ->
chunk.forEachIndexed { i, flid -> ps.setString(i + 1, flid) }
}
}
}
}
fun loadCollections(ds: DataSource, flid: String): Map<String, String> {
val out = linkedMapOf<String, String>()
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
for (spec in SPECS) {
val rows = ds.query(
"SELECT * FROM ${spec.table} WHERE flid = ? ORDER BY ordinal ASC",
{ ps -> ps.setString(1, flid) },
) { rs ->
// 只输出非空属性(缺失键语义),保证 write→read 线格式一致(评审 F5)
spec.columns.associate { (attr, col) -> attr to rs.getString(col) }
.filterValues { it != null }
.toMutableMap()
.apply { rs.getString("source_seq")?.let { put(spec.seqAttr, it) } }
}
if (rows.isNotEmpty()) {
out[spec.collectionKey] = mapper.writeValueAsString(rows)
}
}
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
val rows = ds.query(
"SELECT source_seq, apcd, scat, scdt FROM flight_route_point WHERE flid = ? AND route_kind = ? ORDER BY ordinal ASC",
{ ps -> ps.setString(1, flid); ps.setString(2, kind) },
) { rs ->
buildMap {
rs.getString("source_seq")?.let { put("RTNO", it) }
rs.getString("apcd")?.let { put("APCD", it) }
rs.getString("scat")?.let { put("SCAT", it) }
rs.getString("scdt")?.let { put("SCDT", it) }
}
}
if (rows.isNotEmpty()) {
out[key] = mapper.writeValueAsString(rows)
}
}
return out
}
}
@@ -1,70 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
/**
* FLIGHT_SCHD 读侧视图组装(v2 权威读,P4 退场后唯一读路径)。
*
* - 标量列直读 + 无界集合文本列(SRVT/VIPF/MAFL
* - FDIV/FRET/FLAB 由主表前缀标量列重建对象
* - 10 类重复集合由明细表重建(保序、保源序号),legacy 槽位/里程碑列已随 V1.4.0 退场
*
* 过渡期三条读路径(assembleMerged/assembleLegacySlotsOnly)与 FS7 双读对拍
* 已随回退窗口关闭一并移除(V1.4.0,ACM2-29 P4)。
*/
object FlightSchdReadAssembler {
/** SCHD.FLTR 标量列 + legacy 派生列(与 V1.1.0 迁移一致)。 */
val SCALAR_COLUMNS: List<String> = listOf(
"ALCD", "ALSC", "FLNO", "MVIN", "SODT", "FLTY", "FLIN", "ACFT", "RENO",
"TAOP", "TAFL", "TAID", "TRML", "MAXP", "CSOP", "CSFT", "MAID",
"ESTT", "ACTT", "STND", "PHAG", "CNCL", "REMC", "BOTM", "LACL", "FINT",
"APPT", "EGSR", "EGST", "FHAG", "MHAG", "VIPP", "VIPR", "LBNO", "LBWT",
"PAXC", "EXSC", "EXSR", "FTSS", "PEDT", "NEAT", "PADT", "NAAT",
"ABDG", "LPSDT", "ABN",
)
val EXCEPTION_COLUMNS: List<String> = listOf(
"FDIV_DDES", "FDIV_DDIR", "FDIV_REMC", "FRET_REID", "FRET_RSN",
"FLAB_ARES", "FLAB_RSN",
)
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
/** 库列名全集(读侧 SELECT 稳定顺序;与 V1.4.0 后表结构一致)。 */
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
private val mapper = ObjectMapper()
/** v2 权威读:标量 + 异常 + 文本列 + 明细表集合。 */
fun assembleDetailOnly(
flid: String,
row: Map<String, String?>,
detailCollections: Map<String, String>,
): FlightFields {
val fields = assembleScalarsAndExceptions(flid, row)
detailCollections.forEach { (key, value) -> fields[key] = value }
return fields
}
private fun assembleScalarsAndExceptions(flid: String, row: Map<String, String?>): LinkedHashMap<String, String> {
val fields = linkedMapOf<String, String>()
fields["FLID"] = flid
SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } }
TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } }
exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row)
?.let { fields["FDIV"] = it }
exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row)
?.let { fields["FRET"] = it }
exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row)
?.let { fields["FLAB"] = it }
return fields
}
private fun exceptionView(key: String, attrs: List<Pair<String, String>>, row: Map<String, String?>): String? {
val node = mapper.createObjectNode()
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
return if (node.size() == 0) null else mapper.writeValueAsString(node)
}
}
@@ -27,10 +27,10 @@ class JdbcCminmsgInboxRepository(
ps.setString(1, rawXml)
}
override fun rawOf(cminmsgsId: Long): String? =
override fun rawOf(msgId: Long): String? =
ds.queryOne(
"SELECT CMINMSGS_CLOB_MSG FROM cminmsgs WHERE CMINMSGS_ID = ?",
{ ps -> ps.setLong(1, cminmsgsId) },
{ ps -> ps.setLong(1, msgId) },
) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
@@ -47,7 +47,7 @@ class JdbcCminmsgInboxRepository(
},
) { rs -> rs.getLong("CMINMSGS_ID") }
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
override fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long) {
ds.update(
"""
UPDATE cminmsgs
@@ -57,7 +57,7 @@ class JdbcCminmsgInboxRepository(
""".trimIndent(),
) { ps ->
ps.setString(1, "PROCESSED")
ps.setLong(2, cminmsgsId)
ps.setLong(2, msgId)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
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))
}
@@ -20,14 +20,14 @@ class ProcFailure(
val attempts = head.attempts + 1
if (scheduler.exhausted(attempts)) {
procState.update(
head.cminmsgsId, ProcStatus.DEAD,
head.msgId, ProcStatus.DEAD,
attempts = attempts,
errorClass = ErrorClass.EXHAUSTED,
lastError = "$reason; attempts=$attempts",
)
} else {
procState.update(
head.cminmsgsId, ProcStatus.FAILED,
head.msgId, ProcStatus.FAILED,
attempts = attempts,
nextAttemptAt = scheduler.nextAttemptAt(attempts),
errorClass = ec,
@@ -5,16 +5,28 @@ import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
/**
* U07stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。
* XmlCodec / Handler 由 [com.gzzn.omms.msgexchange.infra.pipeline.PipelineHolderFactory] 统一装配
* stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。
* 记录 (topic, key, payload)payload = null 表示 TOMBSTONE(§7.3 键缺失=删除旧值)
*/
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubDeliveryPort : DeliveryPort {
val sent = mutableListOf<Pair<String, String>>()
data class Sent(val topic: String, val key: String, val payload: String?)
fun clear() { sent.clear() }
val sent = mutableListOf<Sent>()
val tombstones: List<Sent> get() = sent.filter { it.payload == null }
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
override fun indexFlightHts(payloadJson: String) = Unit
fun clear() = sent.clear()
override fun sendKafka(topic: String, key: String, payloadJson: String) {
sent += Sent(topic, key, payloadJson)
}
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
sent += Sent(topic, key, payloadJson)
}
override fun sendKafkaNull(topic: String, key: String) {
sent += Sent(topic, key, null)
}
}
@@ -2,79 +2,105 @@ package com.gzzn.omms.msgexchange.infra.stub
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.domain.flight.FlightMainRow
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.util.concurrent.atomic.AtomicLong
/**
* U07(U01 遗留 stub):内存仓储装配——仅当 `msgx.stubs=true`dev/影子冒烟)时生效
* @Requires),让「启动 + compat HTTP 写 + 主泵/投递循环 + /beans 端到端」在无 MySQL/Redis/Kafka 时可跑
* 与真实 Micronaut Data 实装按模块替换;切换点条件显式,不误入生产。
* stub 仓储(msgx.stubs=true 时装配)——内存实现全部契约,测试与无库环境用。
* 事务管理器直接执行 block(无嵌套语义);锁为 no-op(单线程测试前提)
*/
@Requires(property = "msgx.stubs", value = "true")
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubPipelineTx : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubPipelineLock : PipelineLockRepository {
override fun lock() = Unit
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubProcState : ProcStateRepository {
private val rows = linkedMapOf<Long, ProcState>()
private val bound = mutableMapOf<String, Long>()
val rows = linkedMapOf<Long, ProcState>()
val bound = linkedMapOf<String, Long>()
fun clear() { rows.clear(); bound.clear() }
override fun insert(cminmsgsId: Long, state: ProcStatus) {
rows[cminmsgsId] = ProcState(cminmsgsId, state)
fun clear() {
rows.clear(); bound.clear()
}
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId)
override fun insert(msgId: Long, state: ProcStatus) {
rows.getOrPut(msgId) { ProcState(msgId, state) }
}
override fun exists(msgId: Long): Boolean = rows.containsKey(msgId)
override fun find(msgId: Long): ProcState? = rows[msgId]
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
override fun headUnfinished(): ProcState? =
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
.minByOrNull { it.key }?.value
rows.values.filter { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }.minByOrNull { it.msgId }
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
override fun tryBindIdentity(msgId: Long, identityKey: String): Boolean {
val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false
bound[identityKey] = cminmsgsId
rows[cminmsgsId] = (rows[cminmsgsId] ?: ProcState(cminmsgsId, ProcStatus.PENDING)).copy(identityKey = identityKey)
if (owner != null && owner != msgId) return false
bound[identityKey] = msgId
rows[msgId] = (rows[msgId] ?: ProcState(msgId, ProcStatus.PENDING)).copy(identityKey = identityKey)
return true
}
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?,
msgId: Long,
state: ProcStatus,
nextAttemptAt: Instant?,
attempts: Int?,
errorClass: ErrorClass?,
lastError: String?,
) {
val old = rows[cminmsgsId] ?: ProcState(cminmsgsId, state)
rows[cminmsgsId] = old.copy(
val old = rows[msgId] ?: ProcState(msgId, state)
rows[msgId] = old.copy(
state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError,
updatedAt = Instant.now(),
)
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0
rows.keys.toList().forEach { id ->
val s = rows[id]!!
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
rows.forEach { (id, s) ->
if (s.errorClass in errorClasses && (s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
rows[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++
}
@@ -82,360 +108,252 @@ class StubProcState : ProcStateRepository {
return n
}
/** 测试/运维观测用:读取当前状态行。 */
fun snapshotOf(cminmsgsId: Long): ProcState? = rows[cminmsgsId]
fun snapshotOf(msgId: Long): ProcState? = rows[msgId]
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubInbox : CminmsgInboxRepository {
private val raws = linkedMapOf<Long, String>()
private val processed = mutableSetOf<Long>()
fun clear() {
raws.clear()
processed.clear()
}
@Requires(property = "msgx.stubs", value = "true")
class StubMsgEvents : MsgEventRepository {
val rows = linkedMapOf<Long, MsgEvent>()
private val ids = AtomicLong(0)
/** 模拟上游外部写信箱(不经 compat HTTP、不自动入队 PG)。 */
fun simulateExternalWrite(rawXml: String): Long {
fun clear() = rows.clear()
override fun insertAll(events: List<MsgEvent>): List<Long> =
events.map { e ->
val id = ids.incrementAndGet()
rows[id] = e.copy(eventId = id)
id
}
override fun headUnsent(target: String): MsgEvent? =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }.minByOrNull { it.eventId!! }
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }.sortedBy { it.eventId!! }.take(limit)
/** §7.3:同 FLID 取最新 STATE_VERSION,按事件序输出。 */
override fun mergePendingSchd(limit: Int): List<MsgEvent> =
rows.values
.filter { it.target == "KAFKA:schd" && it.state == EventStatus.PENDING }
.groupBy { it.partitionKey }
.map { (_, group) -> group.maxBy { it.stateVersion } }
.sortedBy { it.eventId!! }
.take(limit)
override fun markSent(eventId: Long) {
rows[eventId] = (rows[eventId] ?: return).copy(state = EventStatus.SENT)
}
override fun markAllSent(eventIds: List<Long>) {
eventIds.forEach { markSent(it) }
}
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
rows[eventId] = (rows[eventId] ?: return).copy(nextAttemptAt = nextAttemptAt, attempts = attempts)
}
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
rows[eventId] = (rows[eventId] ?: return).copy(
state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError,
attempts = attempts ?: rows[eventId]?.attempts ?: 0,
)
}
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubFlightState : FlightStateRepository {
val mains = linkedMapOf<String, FlightMainRow>()
val snapshots = linkedMapOf<String, FlightSnapshot>() // 内存全量态(主行+明细一体)
fun clear() {
mains.clear(); snapshots.clear()
}
override fun findMainRow(flid: String): FlightMainRow? = mains[flid]
override fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow> =
flids.mapNotNull { flid -> mains[flid]?.let { flid to it } }.toMap()
override fun loadFullSnapshot(flid: String): FlightSnapshot? = snapshots[flid]
/** §7.4 不可变条件:已有非空 OPERATION_DAY 且与新值不同 → DAY_GUARD_VIOLATION。 */
override fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome {
val existing = mains[snapshot.flid]
if (existing?.operationDay != null && existing.operationDay != snapshot.operationDay) {
return PersistOutcome.DAY_GUARD_VIOLATION
}
val main = FlightMainRow(
flid = snapshot.flid,
operationDay = snapshot.operationDay ?: existing?.operationDay,
state = snapshot.state,
stateVersion = snapshot.stateVersion,
lastMsgId = msgId,
updatedAt = now,
)
mains[snapshot.flid] = main
snapshots[snapshot.flid] = snapshot.copy(operationDay = main.operationDay)
return if (existing == null) PersistOutcome.INSERTED else PersistOutcome.UPDATED
}
override fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean {
val main = mains[flid] ?: return false
if (main.state != FlightState.ACTIVE) return false
mains[flid] = main.copy(state = FlightState.DELETED, stateVersion = main.stateVersion + 1, lastMsgId = msgId, updatedAt = now)
snapshots[flid] = (snapshots[flid] ?: return true).copy(state = FlightState.DELETED, stateVersion = main.stateVersion + 1)
return true
}
override fun revive(flid: String, msgId: Long, now: Instant): Boolean {
val main = mains[flid] ?: return false
if (main.state != FlightState.DELETED) return false
mains[flid] = main.copy(state = FlightState.ACTIVE, stateVersion = main.stateVersion + 1, lastMsgId = msgId, updatedAt = now)
snapshots[flid] = (snapshots[flid] ?: return true).copy(state = FlightState.ACTIVE, stateVersion = main.stateVersion + 1)
return true
}
override fun findHistoryCandidates(rules: HistoryRules, zone: ZoneId, now: Instant): List<HistoryCandidate> =
mains.values.mapNotNull { main ->
val snap = snapshots[main.flid]
val cancelled = snap?.scalars?.get("CNCL")
val hasTerminalField = listOf("CNCL", "NAAT", "NEAT").any { !snap?.scalars?.get(it).isNullOrBlank() }
val idleHit = !hasTerminalField && main.updatedAt < now.minusSeconds(rules.idleHours * 3600)
val deletedHit = main.state == FlightState.DELETED && main.updatedAt < now.minusSeconds(rules.deletedHours * 3600)
if (idleHit || deletedHit) {
HistoryCandidate(main.flid, main.state, main.stateVersion, wasNeverFdel = main.state == FlightState.ACTIVE)
} else {
null
}
}
override fun purgeArchived(flids: Collection<String>): Int {
var n = 0
flids.forEach { flid ->
if (mains.remove(flid) != null) n++
snapshots.remove(flid)
}
return n
}
override fun countOperationDayNull(): Int = mains.values.count { it.operationDay == null }
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubSnapshotLog : SnapshotLogRepository {
val entries = mutableListOf<SnapshotLogEntry>()
fun clear() = entries.clear()
override fun append(entry: SnapshotLogEntry) {
entries.add(entry)
}
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubReqTrack : ReqTrackRepository {
val rows = linkedMapOf<Long, ReqTrackRepository.Req>()
private val ids = AtomicLong(0)
fun clear() = rows.clear()
override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long {
// §7.1:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED
rows.values.filter {
it.reqType == reqType && it.operationDay == operationDay && it.sender == sender &&
(it.state == ReqTrackRepository.ReqState.PENDING || it.state == ReqTrackRepository.ReqState.SENT)
}.forEach { rows[it.reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
val id = ids.incrementAndGet()
raws[id] = rawXml
rows[id] = ReqTrackRepository.Req(id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING)
return id
}
override fun findLatest(
reqType: String,
operationDay: LocalDate,
sender: String,
states: List<ReqTrackRepository.ReqState>,
): ReqTrackRepository.Req? =
rows.values.filter {
it.reqType == reqType && it.operationDay == operationDay && it.sender == sender && it.state in states
}.maxByOrNull { it.reqId }
override fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean {
val req = findLatest(reqType, operationDay, sender, listOf(ReqTrackRepository.ReqState.PENDING, ReqTrackRepository.ReqState.SENT))
?: return false
rows[req.reqId] = req.copy(state = ReqTrackRepository.ReqState.DONE)
return true
}
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
rows[reqId]?.let { rows[reqId] = it.copy(coutmsgsId = coutmsgsId) }
}
override fun markSent(reqId: Long, sentAt: Instant) {
rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.SENT, sentAt = sentAt) }
}
override fun expire(reqId: Long) {
rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
}
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubBackfillTodo : BackfillTodoRepository {
val tasks = linkedMapOf<Long, BackfillTodoRepository.BackfillTask>()
private val errors = linkedMapOf<Long, String?>()
private val due = linkedMapOf<Long, Instant>()
fun clear() { tasks.clear(); errors.clear(); due.clear() }
fun lastErrorOf(msgId: Long): String? = errors[msgId]
override fun record(task: BackfillTodoRepository.BackfillTask, lastError: String?, now: Instant) {
errors[task.msgId] = lastError
due.putIfAbsent(task.msgId, now)
tasks[task.msgId] = task.copy(attempts = tasks[task.msgId]?.attempts ?: 0)
}
override fun findDue(now: Instant, limit: Int): List<BackfillTodoRepository.BackfillTask> =
tasks.keys.filter { (due[it] ?: Instant.EPOCH) <= now }.take(limit).mapNotNull { tasks[it] }
override fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant) {
errors[msgId] = lastError
due[msgId] = nextAttemptAt
tasks[msgId]?.let { tasks[msgId] = it.copy(attempts = it.attempts + 1) }
}
override fun delete(msgId: Long) {
tasks.remove(msgId); errors.remove(msgId); due.remove(msgId)
}
override fun count(): Int = tasks.size
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubInbox : CminmsgInboxRepository {
val raws = linkedMapOf<Long, String>()
private val ids = AtomicLong(0)
fun clear() = raws.clear()
override fun insertRaw(rawXml: String): Long {
val id = ids.incrementAndGet()
raws[id] = rawXml
return id
}
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun rawOf(msgId: Long): String? = raws[msgId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
raws.keys
.filter { it > afterId && it !in processed }
.sorted()
.take(limit)
raws.keys.filter { it > afterId }.sorted().take(limit)
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
processed += cminmsgsId
}
}
override fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubMsgEvents : MsgEventRepository {
private val rows = linkedMapOf<Long, MsgEvent>()
fun clear() { rows.clear() }
private val ids = AtomicLong(0)
override fun insertAll(events: List<MsgEvent>): List<Long> = events.map { e ->
val id = ids.incrementAndGet()
rows[id] = e.copy(eventId = id)
id
}
override fun headUnsent(target: String): MsgEvent? =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
.sortedBy { it.eventId ?: Long.MAX_VALUE }
.take(limit)
override fun markSent(eventId: Long) = mutate(eventId) { it.copy(state = EventStatus.SENT) }
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) =
mutate(eventId) { it.copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt) }
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) =
mutate(eventId) { it.copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError, attempts = attempts ?: it.attempts) }
override fun insertSync(events: List<MsgEvent>) {
insertAll(events)
}
private fun mutate(eventId: Long, f: (MsgEvent) -> MsgEvent) {
val cur = rows[eventId] ?: return
rows[eventId] = f(cur)
}
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubPumpJobs : PumpJobRepository {
fun clear() { rows.clear() }
data class JobRow(val job: PumpJobRepository.Job, var state: String, var lastError: String? = null)
private val rows = linkedMapOf<Long, JobRow>()
private val ids = AtomicLong(0)
override fun enqueue(kind: String) {
val id = ids.incrementAndGet()
rows[id] = JobRow(PumpJobRepository.Job(id, kind), "QUEUED")
}
override fun headQueued(): PumpJobRepository.Job? =
rows.values.firstOrNull { it.state == "QUEUED" }?.job
override fun markRunning(jobId: Long) { rows[jobId]?.state = "RUNNING" }
override fun markDone(jobId: Long) { rows[jobId]?.state = "DONE" }
override fun markFailed(jobId: Long, lastError: String) { rows[jobId]?.state = "FAILED"; rows[jobId]?.lastError = lastError }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubPipelineTransactionManager : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubFlightSchd : FlightSchdRepository {
data class Record(
val flid: String,
val fday: String?,
val fields: FlightFields,
val stateVersion: Long = 0L,
val lastMessageId: String = "",
val createdAt: Instant,
val updatedAt: Instant,
)
private val records = linkedMapOf<String, Record>()
private val detailCollections = mutableMapOf<String, Map<String, List<Map<String, String>>>>()
private val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
fun clear() {
records.clear()
detailCollections.clear()
gens.clear()
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int {
if (delFlids.isEmpty()) return 0
val delSet = delFlids.toSet()
val toRemove = records.filter { (flid, rec) -> rec.fday == day && flid in delSet }.keys
toRemove.forEach { flid ->
records.remove(flid)
detailCollections.remove(flid)
}
return toRemove.size
}
override fun findByFlid(flid: String): FlightFields? {
val rec = records[flid] ?: return null
return mergeFields(rec)
}
private fun mergeFields(rec: Record): FlightFields {
val out = linkedMapOf<String, String>()
out.putAll(rec.fields)
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
detailCollections[rec.flid]?.forEach { (key, items) ->
out[key] = mapper.writeValueAsString(items)
}
return out
}
override fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState? {
val rec = records[flid] ?: return null
return com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
flid = rec.flid,
scalars = rec.fields.filterKeys { it != "FLID" },
collections = detailCollections[flid] ?: emptyMap(),
stateVersion = rec.stateVersion,
lastMessageId = rec.lastMessageId,
)
}
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> =
flids.mapNotNull { flid -> records[flid]?.let { flid to mergeFields(it) } }.toMap()
override fun findByDay(day: String): List<Pair<String, FlightFields>> =
records.values.filter { it.fday == day }
.sortedBy { it.flid }
.map { it.flid to mergeFields(it) }
override fun findAll(): Map<String, FlightFields> =
records.mapValues { mergeFields(it.value) }
override fun deleteByFlids(flids: Set<String>): Int {
if (flids.isEmpty()) return 0
var count = 0
for (flid in flids) {
if (records.remove(flid) != null) count++
detailCollections.remove(flid)
}
return count
}
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val current = gens[day]?.version ?: 0L
if (current != expected) return false
gens[day] = newGen.copy(updatedAt = now)
return true
}
override fun persistNextStates(
day: String?,
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
snapshotReplace: Boolean,
now: Instant,
) {
for (state in states) {
val scalarFields = linkedMapOf("FLID" to state.flid)
state.scalars.forEach { (k, v) -> scalarFields[k] = v }
val existing = records[state.flid]
if (snapshotReplace && day != null) {
records[state.flid] = Record(
flid = state.flid,
fday = day,
fields = scalarFields,
stateVersion = state.stateVersion,
lastMessageId = state.lastMessageId,
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
detailCollections[state.flid] = state.collections
} else {
// nextState 由引擎在当前态上合并而来,是完整权威态:直接整体替换(含清除语义)
records[state.flid] = Record(
flid = state.flid,
fday = existing?.fday,
fields = scalarFields,
stateVersion = state.stateVersion,
lastMessageId = state.lastMessageId,
createdAt = existing?.createdAt ?: now,
updatedAt = now,
)
detailCollections[state.flid] = state.collections
}
}
}
override fun deleteGenBefore(cutoffDay: String): Int {
val toRemove = gens.keys.filter { it < cutoffDay }
toRemove.forEach { gens.remove(it) }
return toRemove.size
}
}
/** 快照 gen 协议 stub:委托至 StubFlightSchd。 */
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRefData(
private val flightSchd: FlightSchdRepository,
) : RefDataRepository {
override fun getGen(day: String): RefDataRepository.GenMeta? =
flightSchd.getGen(day)?.let { RefDataRepository.GenMeta(it.flids.toList(), it.version) }
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean =
flightSchd.putGenIfVersion(
day,
expected,
FlightSchdRepository.GenMeta(day, new.version, new.flids.toSet()),
)
}
/** 21 类静态主数据 stub(独立 PG reference 库;内存实现,source 保留供审计断言)。 */
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubStaticRef : StaticRefRepository {
private val rows = mutableMapOf<Pair<String, String>, RefUpsert>()
fun clear() { rows.clear() }
override fun upsertAll(refs: List<RefUpsert>) {
refs.forEach { rows[it.rtype to it.rkey] = it }
}
override fun findByType(type: String): List<RefUpsert> =
rows.values.filter { it.rtype == type }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubReqTrack : ReqTrackRepository {
private val rows = mutableMapOf<Long, ReqTrackRepository.Req>()
fun clear() { rows.clear() }
private val ids = AtomicLong(0)
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? =
rows.values.firstOrNull { it.reqType == kind && it.state in setOf("REGISTERED", "SENT", "WAITING") }
override fun forceExpireOpenOf(kind: String) {
rows.keys.toList().forEach { id ->
val r = rows[id]!!
if (r.reqType == kind && r.state in setOf("REGISTERED", "SENT", "WAITING"))
rows[id] = r.copy(state = "EXPIRED")
}
}
override fun insert(kind: String, paramsJson: String): Long {
val id = ids.incrementAndGet()
rows[id] = ReqTrackRepository.Req(id, kind, "REGISTERED")
return id
}
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
override fun markSent(reqId: Long, sentAt: Instant) { rows[reqId]?.let { rows[reqId] = it.copy(state = "SENT", sentAt = sentAt) } }
override fun expireIfWaiting(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "EXPIRED") } }
override fun markDone(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "DONE") } }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubBackfillTodo : BackfillTodoRepository {
private data class Row(val task: BackfillTodoRepository.BackfillTask, var nextAttemptAt: Instant)
private val rows = linkedMapOf<Long, Row>()
private val errors = linkedMapOf<Long, String?>()
@Synchronized
fun clear() {
rows.clear()
errors.clear()
}
@Synchronized
fun tasks(): List<BackfillTodoRepository.BackfillTask> = rows.values.map { it.task }
@Synchronized
fun lastErrorOf(cminmsgsId: Long): String? = errors[cminmsgsId]
@Synchronized
override fun record(task: BackfillTodoRepository.BackfillTask, lastError: String?, now: Instant) {
rows[task.cminmsgsId] = Row(task, now)
errors[task.cminmsgsId] = lastError
}
@Synchronized
override fun findDue(now: Instant, limit: Int): List<BackfillTodoRepository.BackfillTask> =
rows.values.filter { it.nextAttemptAt <= now }.map { it.task }.take(limit)
@Synchronized
override fun markFailed(cminmsgsId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant) {
rows[cminmsgsId]?.let { rows[cminmsgsId] = it.copy(task = it.task.copy(attempts = it.task.attempts + 1), nextAttemptAt = nextAttemptAt) }
errors[cminmsgsId] = lastError
}
@Synchronized
override fun delete(cminmsgsId: Long) {
rows.remove(cminmsgsId)
errors.remove(cminmsgsId)
}
@Synchronized
override fun count(): Int = rows.size
/** 测试辅助:模拟上游外部写入共享信箱(不经本系统)。 */
fun simulateExternalWrite(rawXml: String): Long = insertRaw(rawXml)
}
@@ -18,7 +18,7 @@ class InboxController(private val inbox: InboxService) {
@Produces(MediaType.TEXT_PLAIN)
fun send(@Body rawXml: String): HttpResponse<String> {
val receipt = inbox.accept(rawXml)
return HttpResponse.ok(receipt.cminmsgsId.toString()) // TODO: 与现役响应体逐字对拍后固化
return HttpResponse.ok(receipt.msgId.toString()) // TODO: 与现役响应体逐字对拍后固化
}
// TODO(阶段2): /schd/sync、/all/flights、/kafka/topics/{name}/msgs 按契约冻结清单补齐。
@@ -8,9 +8,9 @@ import jakarta.inject.Singleton
class InboxEnqueue(private val procState: ProcStateRepository) {
/** @return true 若新建 PENDING 行;false 若已存在(轮询重扫/compensate 幂等)。 */
fun enqueue(cminmsgsId: Long): Boolean {
if (procState.exists(cminmsgsId)) return false
procState.insert(cminmsgsId)
fun enqueue(msgId: Long): Boolean {
if (procState.exists(msgId)) return false
procState.insert(msgId)
return true
}
}
@@ -29,7 +29,7 @@ class InboxPoller(
for (id in ids) {
if (enqueue.enqueue(id)) {
enqueued++
log.info("polled cminmsgsId={}", id)
log.info("polled msgId={}", id)
}
}
sweepBackfillTodos()
@@ -12,12 +12,12 @@ class InboxService(
) {
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java)
data class Receipt(val cminmsgsId: Long, val receivedAt: Instant)
data class Receipt(val msgId: Long, val receivedAt: Instant)
fun accept(rawXml: String): Receipt {
val id = inbox.insertRaw(rawXml)
enqueue.enqueue(id)
log.info("compat-accepted cminmsgsId={}", id)
log.info("compat-accepted msgId={}", id)
return Receipt(id, Instant.now())
}
}
@@ -40,11 +40,11 @@ class BackfillSweepJob(
var failed = 0
for (task in due) {
try {
inbox.backfillOnSuccess(task.cminmsgsId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.cminmsgsId)
inbox.backfillOnSuccess(task.msgId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.msgId)
succeeded++
} catch (e: Exception) {
todo.markFailed(task.cminmsgsId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
todo.markFailed(task.msgId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
failed++
}
}
@@ -0,0 +1,80 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
/**
* 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒):
* 1. HISTORY_SWEEP 选出满足 §8.1 的航班(含 DELETED);
* 2. 写入历史存储;
* 3. 历史存储返回成功的 FLID 集合 → 物理删除主行与明细(归档结果只记录在历史存储,当前态无 ARCHIVED 态);
* 4. 未经 FDEL 的航班在清除前补发一次删除事件(§7.3);其余不发;
* 5. 失败或不明确的保留重试。
* 前提红线:历史存储未接通时必须删 0 条(§8.2)。
*/
@Singleton
class HistorySweepJob(
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val props: HistoryProps,
/** 历史存储端口:返回归档成功的 FLID 集合;未接通时不注入(null)。 */
private val historyStore: HistoryStore? = null,
/** §8.3 留痕清理端口(90 天,按 (SCOPE_END, RECV_AT));未接通时不注入。 */
private val snapLogPurge: SnapshotLogPurge? = null,
) {
/** 历史存储端口(由部署侧适配实现;脚手架默认未接通)。 */
fun interface HistoryStore {
/** 归档候选航班;返回归档确认成功的 FLID 集合(部分成功允许)。 */
fun archive(candidates: List<HistoryCandidate>): Set<String>
}
fun interface SnapshotLogPurge {
fun purgeBefore(instant: Instant): Int
}
data class SweepOutcome(val selected: Int, val archived: Int, val purged: Int, val snapLogPurged: Int = 0)
fun run(now: Instant = Instant.now()): SweepOutcome {
if (!props.historyStoreEnabled || historyStore == null) {
// 红线:历史存储未接通必须删 0 条;绝不允许先删当前态再补历史(§8.2)
return SweepOutcome(selected = 0, archived = 0, purged = 0)
}
val rules = HistoryRules(props.cancelledHours, props.terminalHours, props.deletedHours, props.idleHours)
val zone = ZoneId.of("Asia/Shanghai") // §8.1:窗口按机场时区计算
val candidates = flightState.findHistoryCandidates(rules, zone, now)
if (candidates.isEmpty()) return SweepOutcome(0, 0, 0)
val archivedFlids = historyStore.archive(candidates)
if (archivedFlids.isEmpty()) return SweepOutcome(candidates.size, archived = 0, purged = 0)
val toPurge = candidates.filter { it.flid in archivedFlids }
// §8.2 步骤 4:未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件
val preDelete = toPurge.filter { it.wasNeverFdel }
if (preDelete.isNotEmpty()) {
msgEvents.insertAll(preDelete.map { tombstone(it) })
}
val purged = flightState.purgeArchived(archivedFlids)
val snapLogPurged = snapLogPurge?.purgeBefore(now.minus(Duration.ofDays(props.snapLogRetentionDays))) ?: 0
return SweepOutcome(candidates.size, archivedFlids.size, purged, snapLogPurged)
}
private fun tombstone(candidate: HistoryCandidate) = MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = candidate.flid,
eventType = EventType.TOMBSTONE,
stateVersion = candidate.stateVersion,
payloadJson = """{"flid":"${candidate.flid}","stateVersion":${candidate.stateVersion},"deleted":true}""",
)
}
@@ -1,99 +0,0 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import jakarta.inject.Singleton
/**
* ACMA-8:泵作业执行器。cron 触发入队(PUMP_JOB),主泵 FIFO 执行(决策 1)——
* 产物不绕过队头顺序;阶段 A 的清场同步链与归档在此落地(I4/I5)。
*/
@Singleton
class JobExecutor(
private val historySweep: HistorySweepJob,
private val archive: ArchiveJob,
private val projectionRebuild: ProjectionRebuildJob,
private val backfillSweep: BackfillSweepJob,
) {
fun execute(job: PumpJobRepository.Job) = when (job.kind) {
"HISTORY_SWEEP" -> historySweep.run()
"ARCHIVE" -> archive.run()
"PROJECTION_REBUILD" -> projectionRebuild.run()
"BACKFILL_SWEEP" -> backfillSweep.sweep()
else -> error("unknown job ${job.kind}")
}
}
/**
* 流程 4 runJob(HISTORY_SWEEP)3:30 清场——主泵作业内同步链(I4,KEEP 现役
* FlightHisScheduled 同步语义):判史 → 同步写 ES(成功集)→ 仅删成功集。
*/
@Singleton
class HistorySweepJob(
private val flightSchd: FlightSchdRepository,
) {
companion object {
var historyPicker: ((Map<String, FlightFields>) -> Map<String, FlightFields>)? = null
var esArchiver: ((Map<String, FlightFields>) -> Set<String>)? = null
var cutoffProvider: (() -> String?)? = null
}
fun run() {
val all = flightSchd.findAll()
val history = pickHistory(all) // U10/T07saveSync 接线前判史为空集——禁止“全量可删”默认
val success = esArchiver?.invoke(history) ?: emptySet() // 仅收集已确认写入 ES 的成功 FLID 集合
if (success.isNotEmpty()) {
flightSchd.deleteByFlids(success) // FS2/FS4:仅删除已确认写入 ES 的成功集合,分批参数化删除
}
val cutoff = cutoffProvider?.invoke()
if (cutoff != null) {
flightSchd.deleteGenBefore(cutoff)
}
}
/** U10/T07(修订):接入 ES success 集之前的门禁——占位默认返回空集;
* 现役五条判史规则(SODT 3 天 / CNCL 1 小时 / 备降 / 离港 / 到港)golden 通过后才允许接线。 */
private fun pickHistory(all: Map<String, FlightFields>): Map<String, FlightFields> =
historyPicker?.invoke(all) ?: emptyMap()
}
/** 流程 5 runJob(ARCHIVE)3:00 归档——1 天前且仅终态可迁(矩阵 #12)。 */
@Singleton
class ArchiveJob(
// TODO(阶段1后续): CMINMSGS⇆CMINMSGS_HST 迁移 SQLJOIN PROC_STATESTATE ∈ {SUCCEEDED,DEAD,SKIPPED}
) {
fun run() {
// TODO: 迁移(DEAD 带 ERROR_CLASS、SKIPPED 带 duplicate-of 审计随行保留)
}
}
/** 流程 7:阶段 B 投影重建——切入阶段 B 时全量重建一次,此后增量走事件。 */
@Singleton
class ProjectionRebuildJob(
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
) {
fun run() {
for (day in activeDays()) {
val flights = flightSchd.findByDay(day)
flights.forEach { (flid, fields) ->
msgEvents.insertSync(
listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = flid,
payloadJson = FlightFieldsJson.toJson(fields),
),
),
)
}
}
}
private fun activeDays(): List<String> = emptyList() // TODO(阶段B)
}
@@ -0,0 +1,75 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.config.PipelineProps
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* 维护作业调度(docs/flight-state.md §8):单 daemon 线程,独立于主泵——
* 旧「作业不插队 PUMP_JOB 队列」机制随审计口径移除(§3.2 表清单无 PUMP_JOB);
* 历史归档/留痕清理均为内部清理路径,不参与 FIFO 消息序。
* 触发:回填补偿 30s 固定间隔;历史归档/留痕清理每日 03:30(机场时区)后首个 tick。
*/
@Singleton
class JobRunner(
private val backfillSweep: BackfillSweepJob,
private val historySweep: HistorySweepJob,
@Suppress("unused") private val pipelineProps: PipelineProps,
@Suppress("unused") private val historyProps: HistoryProps,
) {
private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java)
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
@Volatile
private var running = true
@Volatile
private var lastHistoryDay: LocalDate? = null
private var thread: Thread? = null
fun start() {
if (thread != null) return
running = true
thread = Thread.ofPlatform().name("msgx-jobs").daemon(true).start { loop() }
log.info("job runner started (backfill 30s, history daily 03:30 {})", zone)
}
fun stop() {
running = false
thread?.interrupt()
thread = null
}
internal fun loop() {
while (running) {
try {
backfillSweep.sweep()
maybeHistorySweep()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
log.warn("job tick failed: {}", e.message ?: e.javaClass.simpleName)
}
if (running) runCatching { Thread.sleep(Duration.ofSeconds(30).toMillis()) }
}
}
private fun maybeHistorySweep() {
val today = LocalDate.now(zone)
if (lastHistoryDay == today) return
val now = java.time.LocalTime.now(zone)
if ((now.hour == 3 && now.minute >= 30) || now.hour > 3) {
val outcome = historySweep.run()
lastHistoryDay = today
if (outcome.selected > 0 || outcome.snapLogPurged > 0) {
log.info("history sweep: {}", outcome)
}
}
}
}
@@ -0,0 +1,214 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import jakarta.inject.Singleton
import java.time.Instant
import java.time.ZoneId
/**
* FLOP(§6.1):读取完整当前态 → 合并变化 → 保留运营日 → STATE_VERSION+1 →
* 同事务登记 KAFKA_MSG / KAFKA_SCHD 与处理终态。
*/
@Singleton
class FlopProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
private val mapper: ObjectMapper,
) {
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
lock.lock()
val current = flightState.loadFullSnapshot(payload.flid)
?: return@inTransaction idempotentAbsent(head, msg) // 迟到/未知航班:幂等成功,不创建
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
val next = FlightStateEngine.mergedState(current, change)
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
msgEvents.insertAll(eventsFor(next, mapper))
preRegisterBackfill(head, msg, backfillTodo)
ApplyResult.Succeeded
}
}
/**
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、发布 tombstone;
* 已 DELETED / 不存在 → 幂等成功,不推进版本、不重复发布。
*/
@Singleton
class FdelProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
private val mapper: ObjectMapper,
) {
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
lock.lock()
val deleted = flightState.markDeleted(payload.flid, msgId = head.msgId, now = Instant.now())
if (deleted) {
val current = flightState.loadFullSnapshot(payload.flid)
// tombstone 仅在 ACTIVE→DELETED 时登记(§7.3),与删除同事务
msgEvents.insertAll(
listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = payload.flid,
eventType = EventType.TOMBSTONE,
stateVersion = current?.stateVersion ?: 0L,
payloadJson = mapper.writeValueAsString(
mapOf(
"flid" to payload.flid,
"stateVersion" to (current?.stateVersion ?: 0L),
"deleted" to true,
),
),
),
MsgEvent(
target = Targets.KAFKA_MSG,
partitionKey = payload.flid,
stateVersion = current?.stateVersion ?: 0L,
payloadJson = mapper.writeValueAsString(
mapOf("flid" to payload.flid, "stateVersion" to (current?.stateVersion ?: 0L), "deleted" to true),
),
),
),
)
preRegisterBackfill(head, msg, backfillTodo)
}
ApplyResult.Succeeded // 未命中 = 迟到/重复,幂等成功(§6.2 步骤 3/4)
}
}
/**
* ADFT(§6.3 + §2.1):字段缺失语义待确认——确认前按保守 Set-only 处理
* (出现字段覆盖,缺失不 Clear,不沿用 FLOP 全量合并规则)。
* FLID 已存在且 DELETED → 生命周期重激活;不存在 → 新实例建立(含运营日计算 §3.5)。
*/
@Singleton
class AdftProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
operationDayProps: OperationDayProps,
private val mapper: ObjectMapper,
) {
private val opDay = OperationDayCalculator(
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
cutoffHour = operationDayProps.cutoffHour,
)
fun apply(head: ProcState, msg: DecodedMessage, record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord): ApplyResult =
txManager.inTransaction {
lock.lock()
val main = flightState.findMainRow(record.flid)
if (main != null && main.state == com.gzzn.omms.msgexchange.domain.flight.FlightState.DELETED) {
// §6.3 重激活:DELETED → ACTIVE,推进版本,登记状态事件
if (flightState.revive(record.flid, msgId = head.msgId, now = Instant.now())) {
val current = flightState.loadFullSnapshot(record.flid)
if (current != null) {
val next = FlightStateEngine.mergedState(current, setOnly(record))
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
msgEvents.insertAll(eventsFor(next, mapper))
}
}
preRegisterBackfill(head, msg, backfillTodo)
return@inTransaction ApplyResult.Succeeded
}
val current = flightState.loadFullSnapshot(record.flid)
val next: FlightSnapshot = if (current == null) {
// 新实例建立:ADFT 含 SODT 时直接计算运营日(§2.1),不可算则置 null 待快照收录
val day = opDay.compute(record.scalars["SODT"])
FlightSnapshot(
flid = record.flid,
operationDay = day, // 待确认项 §2.1:不可算时不得默认写接收日
state = com.gzzn.omms.msgexchange.domain.flight.FlightState.ACTIVE,
stateVersion = 1L,
scalars = record.scalars,
collections = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.COLLECTION_KEYS.associateWith { key ->
record.collections[key] ?: emptyList()
},
)
} else {
FlightStateEngine.mergedState(current, setOnly(record))
}
val outcome = flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
check(outcome != com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome.DAY_GUARD_VIOLATION) {
"operation-day guard violated flid=${record.flid}"
}
msgEvents.insertAll(eventsFor(next, mapper))
preRegisterBackfill(head, msg, backfillTodo)
ApplyResult.Succeeded
}
/** §2.1 保守语义:仅出现字段 Set;集合出现 Replace、缺失保留。 */
private fun setOnly(record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord) = MergeChange(
flid = record.flid,
scalars = record.scalars,
collections = record.collections,
)
}
// =====================================================================
// 共享小工具(处理器层私有约定)
// =====================================================================
/** 航班不存在/迟到:幂等成功(§9 队头不阻塞;不创建实例——创建入口只有 SCHD/ADFT)。 */
private fun idempotentAbsent(head: ProcState, msg: DecodedMessage): ApplyResult = ApplyResult.Succeeded
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(§7.3)。 */
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
"stateVersion" to next.stateVersion,
"scalars" to next.scalars,
"collections" to next.collections,
)
return listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(payload),
),
MsgEvent(
target = Targets.KAFKA_MSG,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion)),
),
)
}
/** §7.2 业务事务内预登记回填待办(与终态同事务)。 */
internal fun preRegisterBackfill(head: ProcState, msg: DecodedMessage, backfillTodo: BackfillTodoRepository?) {
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
styp = msg.meta.styp, seqn = msg.meta.seqn,
),
null,
)
}
@@ -1,37 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.MsgKind
/**
* ACMA-8 流程 2Handler = 纯函数(状态与报文进,Decision 出,不碰 Redis/Kafka)。
* 32 个 Handlerflop 29 + schd 3)翻译属阶段 2/3,逐条对照 ACMA-4 基线与兼容矩阵 KEEP/FIX。
*/
interface Handler {
val kind: MsgKind
/**
* 在线状态以只读视图传入:FLID → 字段集(field → value,与 legacy flightInfo hash /
* hgetAllFlightInfo 同构;阶段 A 由调用方点查自有库 FLIGHT_SCHD 宽表装配,ACM2-28 定案)。
*/
fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision
}
/**
* sealed 穷尽分派(ACMA-6 选型:取代 legacy 反射 get{TYPE}())。
*/
class HandlerRegistry(handlers: List<Handler>) {
private val byKind: Map<String, Handler> = handlers.associateBy { keyOf(it.kind) }
fun dispatcherFor(msg: DecodedMessage): Handler? = byKind[keyOf(msg.kind)]
companion object {
fun keyOf(kind: MsgKind): String = when (kind) {
is MsgKind.Schd -> "SCHD-${kind.subtype.name}"
is MsgKind.Flop -> "FLOP-${kind.subtype}"
}
}
}
// TODO(阶段2/3): 注册 3+29 Handler;本阶段仅含骨架(阶段 2 最小纵向链路先做 1 SCHD(DNLD) + 1 FLOP)。
@@ -1,37 +1,32 @@
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.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.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.log.TraceLog
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.jobs.JobExecutor
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
/**
* ACMA-8 流程 2:处理主泵——严格 FIFO + HOL + 毒丸 DEAD 升级I1
* 阶段 A 运营航班 FLIGHT_SCHD 与待发事件、终态同事务原子提交(I2/I5ACM2-28 定案)。
* 处理主泵(docs/flight-state.md §1 目标 2):单活动主泵严格 FIFO + HOL + 毒丸 DEAD 升级;
* 航班状态、事件、处理终态在处理器事务原子提交(§1 目标 3)。
*/
@Singleton
class Pump(
private val procState: ProcStateRepository,
private val pumpJobs: PumpJobRepository,
private val inbox: CminmsgInboxRepository,
private val processor: MessageProcessor,
private val jobExecutor: JobExecutor,
private val props: PipelineProps,
) {
private val log = org.slf4j.LoggerFactory.getLogger(Pump::class.java)
@@ -39,7 +34,7 @@ class Pump(
@Volatile
private var running = true
/** 优雅停机:loop 在当前 tick 收尾后退出;线程中断由 Runner 负责。 */
/** 优雅停机:loop 在当前 tick 收尾后退出;线程中断由 PipelineLifecycle 负责。 */
fun stop() {
running = false
}
@@ -52,27 +47,23 @@ class Pump(
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
// U08loop 只作最后防线——失败状态迁移已在 processOne/execute 的边界内完成;
// 致命 Error 不在此捕获(任其终止进程,保证“异常必可见”)。
// 最后防线失败状态迁移已在 processOne 边界内完成;致命 Error 不捕获
sleepQuietly(props.pipeline.pollInterval)
}
}
}
internal fun tick() {
val job = pumpJobs.headQueued()
val head = procState.headUnfinished()
// 统一 FIFO:JOB 与消息同队列语义(定时任务产物不绕过队头顺序,决策 1)。
val nextJob = job?.takeIf { jobBefore(job, head) }
when {
nextJob != null -> execute(nextJob)
head == null -> sleepQuietly(props.pipeline.pollInterval)
head.state == ProcStatus.FAILED && (head.nextAttemptAt ?: Instant.EPOCH) > Instant.now() ->
if (poisoned(head)) {
log.error("poison -> DEAD id={} attempts={} lastError={}", head.cminmsgsId, head.attempts, head.lastError)
procState.update(head.cminmsgsId, ProcStatus.DEAD,
errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "head-deadline-exceeded")
log.error("poison -> DEAD msgId={} attempts={} lastError={}", head.msgId, head.attempts, head.lastError)
procState.update(
head.msgId, ProcStatus.DEAD,
errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "head-deadline-exceeded",
)
} else {
sleepQuietly(Duration.between(Instant.now(), head.nextAttemptAt))
}
@@ -85,69 +76,41 @@ class Pump(
head.attempts >= props.pipeline.maxAttempts ||
Duration.between(head.updatedAt, Instant.now()) > props.pipeline.headDeadline
/** JOB 与队头消息的先后由入队时间近似;实装以统一序号列保证(阶段 1 后续)。 */
private fun jobBefore(job: PumpJobRepository.Job, head: ProcState?) =
head == null || head.state == ProcStatus.FAILED
private fun execute(job: PumpJobRepository.Job) {
pumpJobs.markRunning(job.jobId)
try {
jobExecutor.execute(job)
pumpJobs.markDone(job.jobId)
} catch (e: Exception) {
pumpJobs.markFailed(job.jobId, e.message ?: "unknown")
}
}
private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
}
}
/**
* ACMA-8 流程 2 processOne:解码→绑定→纯函数决策→提交
* U08/U10/U11:失败状态迁移全部在“持有具体 head”的边界内完成——
* 任何意外异常 → FAILED(INFRA)+退避(ProcFailure);attempts 达上限 → DEAD(EXHAUSTED)
* MALFORMED 直接 DEADInterruptedException 恢复中断位并上抛(不被普通恢复逻辑吞掉)。
* processOne:解码 → 绑定 → 处理器(事务内决策+落库)→ 终态迁移 → 回填
* 边界化失败迁移(ProcFailure):任何意外异常归于本条 headFAILED(INFRA)+退避,不穿出杀泵;
* MALFORMED / PROTOCOL 直接 DEAD 不重试(§9)。
*/
@Singleton
class MessageProcessor(
private val inbox: CminmsgInboxRepository,
private val procState: ProcStateRepository,
private val msgEvents: MsgEventRepository,
private val codecHolder: CodecHolder,
private val handlers: HandlerHolder,
private val flightSchd: FlightSchdRepository,
private val snapshotFlow: SnapshotFlow,
private val codec: JacksonXmlCodec,
private val scheduleProcessor: ScheduleProcessor,
private val flopProcessor: FlopProcessor,
private val fdelProcessor: FdelProcessor,
private val adftProcessor: AdftProcessor,
private val procFailure: ProcFailure,
private val props: PipelineProps,
private val txManager: PipelineTransactionManager,
private val backfillTodo: BackfillTodoRepository? = null,
) {
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
private val flidRegex = Regex("<FLID>(.*?)</FLID>", RegexOption.IGNORE_CASE)
private fun extractCandidateFlids(decoded: DecodedMessage): Set<String> {
val fromXml = flidRegex.findAll(decoded.rawXml).map { it.groupValues[1].trim() }.filter { it.isNotEmpty() }.toSet()
if (fromXml.isNotEmpty()) return fromXml
val b = decoded.body
if (b is Map<*, *>) {
val flid = b["FLID"] ?: b["flid"]
if (flid != null) return setOf(flid.toString())
}
return emptySet()
}
fun processOne(head: ProcState) {
com.gzzn.omms.msgexchange.infra.log.TraceLog.withTrace(head.cminmsgsId) {
TraceLog.withTrace(head.msgId) {
try {
processInternal(head)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw e
} catch (e: Exception) {
// U08边界化——异常归于本条 head,写 FAILED/DEAD,而不是穿出杀 pump
log.warn("processOne unexpected failure id={} ec=INFRA msg={}", head.cminmsgsId, e.message ?: e.javaClass.simpleName)
// 边界化异常归于本条 head,写 FAILED(INFRA)/DEAD,而不是穿出杀 pump
log.warn("processOne unexpected failure msgId={} ec=INFRA msg={}", head.msgId, e.message ?: e.javaClass.simpleName)
procFailure.fail(head, ErrorClass.INFRA, e.message ?: e.javaClass.simpleName)
}
}
@@ -156,126 +119,134 @@ class MessageProcessor(
private fun processInternal(head: ProcState) {
// 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试)
if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) {
log.error("head exhausted at entry -> DEAD id={} attempts={}", head.cminmsgsId, head.attempts)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.EXHAUSTED,
lastError = head.lastError ?: "max-attempts")
log.error("head exhausted at entry -> DEAD msgId={} attempts={}", head.msgId, head.attempts)
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "max-attempts")
return
}
val raw = inbox.rawOf(head.cminmsgsId) ?: run {
log.error("raw missing -> DEAD(MALFORMED) id={}", head.cminmsgsId)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing")
val raw = inbox.rawOf(head.msgId) ?: run {
log.error("raw missing -> DEAD(MALFORMED) msgId={}", head.msgId)
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing")
return
}
val decoded = when (val r = codecHolder.codec.decode(raw)) {
val decoded = when (val r = codec.decode(raw)) {
is com.gzzn.omms.msgexchange.codec.DecodeResult.Ok -> r.message
is com.gzzn.omms.msgexchange.codec.DecodeResult.Err -> {
// T06U11):MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
// MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
if (r.failure.errorClass == ErrorClass.MALFORMED) {
log.error("decode MALFORMED -> DEAD id={} detail={}", head.cminmsgsId, r.failure.detail)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
lastError = r.failure.detail)
log.error("decode MALFORMED -> DEAD msgId={} detail={}", head.msgId, r.failure.detail)
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = r.failure.detail)
} else {
log.warn("decode {} -> FAILED id={} detail={}", r.failure.errorClass, head.cminmsgsId, r.failure.detail)
log.warn("decode {} -> FAILED msgId={} detail={}", r.failure.errorClass, head.msgId, r.failure.detail)
procFailure.fail(head, r.failure.errorClass, r.failure.detail)
}
return
}
}
// I3identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑(N25:入参传递 head,不再二次查询)
// I3identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑
if (head.identityKey == null) {
val identity = Identity.of(decoded, props.identity)
if (!procState.tryBindIdentity(head.cminmsgsId, identity)) {
if (!procState.tryBindIdentity(head.msgId, identity)) {
val owner = procState.ownerOfIdentity(identity) ?: -1L
log.info("duplicate-of:{} -> SKIPPED id={}", owner, head.cminmsgsId)
procState.update(head.cminmsgsId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner")
// 重复报文自身也是一条信箱记录:同样回填共享信箱,防止被反复轮询(失败落补偿)
log.info("duplicate-of:{} -> SKIPPED msgId={}", owner, head.msgId)
procState.update(head.msgId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner")
compensateBackfill(head, decoded)
return
}
}
// 快照消息走专属流程(流程 4:staging → SQL 批处理/域内差删/CAS 同事务)
val handler = handlers.registry.dispatcherFor(decoded)
if (handler == null) {
// U10(N21):未注册 ≠ 报文非法——写 FAILED(可重放),绝不写终态
log.warn("no-handler:{} -> FAILED(UNSUPPORTED) id={}", decoded.typeTag, head.cminmsgsId)
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${decoded.typeTag}")
return
}
val schdKind = decoded.kind as? com.gzzn.omms.msgexchange.domain.MsgKind.Schd
if (schdKind != null &&
schdKind.subtype == com.gzzn.omms.msgexchange.domain.MsgKind.SchdSubtype.DNLD
) {
snapshotFlow.publishSnapshot(head, decoded)
return
}
val candidateFlids = extractCandidateFlids(decoded)
val flightView = if (candidateFlids.isNotEmpty()) {
flightSchd.findByFlids(candidateFlids)
} else {
emptyMap()
}
val decision = handler.decide(flightView, decoded) // 纯函数
// 事务 2(自有 PG 单事务原子):
// upsert FLIGHT_SCHD(decision.flightChanges) + insert MSG_EVENT + PROC_STATE → SUCCEEDED
val events = buildList {
decision.msgNotifies.forEach { add(MsgEvent(target = Targets.KAFKA_MSG, 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 {
if (decision.flightChanges.isNotEmpty()) {
val nextStates = decision.flightChanges.map { change ->
val current = flightSchd.findNextStateByFlid(change.flid)
val commands = FlightStateEngine.commandsFromFields(change.flid, change.fields)
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
// 处理器分派:SCHD 快照主链路(§5.1/ FLOP / FDEL / ADFT;缺载荷按 MALFORMED 终态
val result: ApplyResult = when (val kind = decoded.kind) {
is MsgKind.Schd -> {
val body = decoded.body as? ScheduleBody
if (body == null) {
deadMalformed(head, "missing-schd-body")
return
}
when (kind.subtype) {
MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
scheduleProcessor.applyScheduleRecords(head, decoded)
MsgKind.SchdSubtype.ADFT -> {
val record = body.records.singleOrNull()
if (record == null) {
deadMalformed(head, "adft-needs-single-fltr")
return
}
adftProcessor.apply(head, decoded, record)
}
}
flightSchd.persistNextStates(null, nextStates, snapshotReplace = false)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
MsgKind.Fdel -> {
val payload = decoded.body as? FlopPayload
if (payload == null) {
deadMalformed(head, "missing-fdel-flid")
return
}
fdelProcessor.apply(head, decoded, payload)
}
is MsgKind.Flop -> {
val payload = decoded.body as? FlopPayload
if (payload == null) {
deadMalformed(head, "missing-flop-body")
return
}
flopProcessor.apply(head, decoded, payload)
}
is MsgKind.Unsupported -> {
// §9:未支持类型 → FAILED(UNSUPPORTED) 退避重试,达阈值转 DEAD;绝不写终态
log.warn("unsupported type -> FAILED(UNSUPPORTED) msgId={} tag={}", head.msgId, kind.tag)
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${kind.tag}")
return
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
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)
recordBackfillTodo(head.cminmsgsId, decoded, e)
when (result) {
is ApplyResult.Succeeded, ApplyResult.ReplaySkipped ->
procState.update(head.msgId, ProcStatus.SUCCEEDED)
is ApplyResult.DeadProtocol -> {
// §9:整包拒绝 DEAD(PROTOCOL),立即释放队头,交人工确认
log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.PROTOCOL, lastError = result.reason.take(1000))
compensateBackfill(head, decoded) // 拒绝包同样要回填信箱,防止反复轮询
return
}
}
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
backfill(head, decoded)
log.info("SUCCEEDED msgId={} kind={}", head.msgId, decoded.typeTag)
}
/** v2 §5:回填失败只落补偿待办(成功终态不降级,业务不重放);由 BackfillSweepJob 到期重试。 */
/** §7.2:提交后回填共享信箱;失败不得把 SUCCEEDED 改回 FAILED,待办已事务内预登记。 */
private fun backfill(head: ProcState, decoded: DecodedMessage) {
try {
inbox.backfillOnSuccess(head.msgId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
backfillTodo?.delete(head.msgId)
} catch (e: Exception) {
log.error("backfill failed after SUCCEEDED msgId={} (todo pre-registered, sweep will retry)", head.msgId, e)
}
}
/** SKIPPED/DEAD(PROTOCOL) 包的回填(无预登记待办):失败落补偿待办。 */
private fun compensateBackfill(head: ProcState, decoded: DecodedMessage) {
try {
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
inbox.backfillOnSuccess(head.msgId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
} catch (e: Exception) {
log.error("backfill failed for SKIPPED duplicate id={} (compensation required)", head.cminmsgsId, e)
recordBackfillTodo(head.cminmsgsId, decoded, e)
log.error("backfill failed msgId={} (compensation required)", head.msgId, e)
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
msgId = head.msgId,
sndr = decoded.meta.sndr,
type = decoded.meta.type,
styp = decoded.meta.styp,
seqn = decoded.meta.seqn,
),
e.message ?: e.javaClass.simpleName,
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted msgId={}", head.msgId)
}
}
private fun recordBackfillTodo(cminmsgsId: Long, decoded: DecodedMessage, e: Exception) {
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
cminmsgsId = cminmsgsId,
sndr = decoded.meta.sndr,
type = decoded.meta.type,
styp = decoded.meta.styp,
seqn = decoded.meta.seqn,
),
e.message ?: e.javaClass.simpleName,
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", cminmsgsId)
private fun deadMalformed(head: ProcState, detail: String) {
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = detail)
}
}
/** 延迟装配占位(阶段 1 后续以 Micronaut Bean 替换直连构造)。 */
class CodecHolder(val codec: com.gzzn.omms.msgexchange.codec.XmlCodec)
class HandlerHolder(val registry: HandlerRegistry)
@@ -0,0 +1,207 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
import com.gzzn.omms.msgexchange.domain.SnapshotResult
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import com.gzzn.omms.msgexchange.domain.flight.SnapshotValidation
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
import jakarta.inject.Singleton
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/** 处理器执行结果——终态迁移由 MessageProcessor 统一落库。 */
sealed interface ApplyResult {
/** 业务成功(含幂等成功)。 */
data object Succeeded : ApplyResult
/** §5.1 步骤 2:MSG_ID 已有成功终态 → 重放,直接记幂等成功。 */
data object ReplaySkipped : ApplyResult
/** §9:整包拒绝 DEAD(PROTOCOL),不重试,交人工确认。 */
data class DeadProtocol(val reason: String, val flags: Set<SnapshotFlag> = emptySet()) : ApplyResult
}
/** §5.3 第四行:归属日不符 = 串日/错发/污染,整包拒绝。 */
class ProtocolViolation(message: String) : RuntimeException(message)
/**
* SCHD 快照主链路(docs/flight-state.md §5.1 applyScheduleRecords,同一事务):
* 对单日快照与滚动窗口统一适用,不做名单层面的处理。
*/
@Singleton
class ScheduleProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val procState: ProcStateRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val snapshotLog: SnapshotLogRepository,
private val backfillTodo: BackfillTodoRepository?,
operationDayProps: OperationDayProps,
private val mapper: ObjectMapper,
) {
private val opDay = OperationDayCalculator(
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
cutoffHour = operationDayProps.cutoffHour,
)
fun applyScheduleRecords(head: ProcState, msg: DecodedMessage): ApplyResult {
val body = msg.body as? ScheduleBody ?: return ApplyResult.DeadProtocol("missing-schd-body")
val started = System.nanoTime()
// ② 重放判定:MSG_ID 已有成功终态 → 幂等成功(§5.1 步骤 2,重放也记留痕)
if (procState.findSuccessTerminal(head.msgId)) {
logSnapshot(head, body, SnapshotResult.REPLAY_SKIPPED, upserted = 0, flags = emptySet(), started)
return ApplyResult.ReplaySkipped
}
// ③ 报文完整性(§5.2 五项):任一失败整包不落地 → DEAD(PROTOCOL)
val validation = FlightStateEngine.validateMessage(
recsDeclared = body.recsDeclared,
records = body.records,
scopeStart = body.scopeStart,
scopeEnd = body.scopeEnd,
opDay = opDay,
)
if (validation is SnapshotValidation.Invalid) {
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, validation.flags, started)
return ApplyResult.DeadProtocol(validation.reason, validation.flags)
}
val ok = validation as SnapshotValidation.Ok
if (ok.perRecordDay.isEmpty()) {
// 空快照:合法但无写入(§5.5 EMPTY),仍算成功终态
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted = 0, setOf(SnapshotFlag.EMPTY), started)
return ApplyResult.Succeeded
}
val flags = linkedSetOf<SnapshotFlag>()
return try {
// ①④⑤⑥⑦ 同一事务:锁 → 归属校验 → upsert → 版本/事件/待办预登记/终态
val upserted = txManager.inTransaction {
lock.lock()
// ⑤ 归属校验(§5.3):OPERATION_DAY 不可变,批量点查避免逐航班往返
val mains = flightState.findMainRows(ok.perRecordDay.keys)
ok.perRecordDay.forEach { (flid, day) ->
val existing = mains[flid] ?: return@forEach
if (existing.operationDay != null && existing.operationDay != day) {
throw ProtocolViolation(
"SAME_FLID_ACROSS_OPERATION_DAYS flid=$flid existing=${existing.operationDay} incoming=$day",
)
}
}
var written = 0
val events = mutableListOf<MsgEvent>()
ok.perRecordDay.forEach { (flid, day) ->
val record = body.records.first { it.flid == flid }
val existingMain = mains[flid]
val keepDeleted = existingMain?.state == FlightState.DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // §5.1 步骤 6:不恢复
val current = if (existingMain != null) flightState.loadFullSnapshot(flid) else null
val next = FlightStateEngine.snapshotState(
current = current,
record = record,
operationDay = day,
keepDeleted = keepDeleted,
)
when (flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())) {
PersistOutcome.DAY_GUARD_VIOLATION ->
throw ProtocolViolation("operation-day guard violated flid=$flid")
else -> written++
}
events += snapshotEvents(next)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
// §7.2 目标形态:业务事务内预登记回填待办(提交后由 MessageProcessor 回填并删待办)
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
styp = msg.meta.styp, seqn = msg.meta.seqn,
),
null,
)
written
}
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
ApplyResult.Succeeded
} catch (e: ProtocolViolation) {
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, flags, started)
ApplyResult.DeadProtocol(e.message ?: "protocol-violation", flags)
}
}
/** 状态事件:整态出站(§7.3 KAFKA_SCHD+ 变化通知(KAFKA_MSG)。 */
private fun snapshotEvents(next: FlightSnapshot): List<MsgEvent> {
// KAFKA_SCHD 整态:缺失集合输出空集 → 消费者删除旧值(§5.4/§7.3)
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
"stateVersion" to next.stateVersion,
"scalars" to next.scalars,
"collections" to next.collections,
)
val notify = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion))
return listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(payload),
),
MsgEvent(target = Targets.KAFKA_MSG, partitionKey = next.flid, stateVersion = next.stateVersion, payloadJson = notify),
)
}
/** §5.5 留痕:事务外追加,失败只记 error 不阻塞;scope 按记录归属运营日推导。 */
private fun logSnapshot(
head: ProcState,
body: ScheduleBody,
result: SnapshotResult,
upserted: Int,
flags: Set<SnapshotFlag>,
startedNanos: Long,
) {
runCatching {
val days = body.records.mapNotNull { opDay.compute(it.scalars["SODT"]) }
snapshotLog.append(
SnapshotLogEntry(
msgId = head.msgId,
recvAt = Instant.now(),
scopeStart = days.minOrNull() ?: LocalDate.now(),
scopeEnd = days.maxOrNull() ?: LocalDate.now(),
recs = body.records.size,
upserted = upserted,
durationMs = (System.nanoTime() - startedNanos) / 1_000_000,
result = result,
flags = flags,
),
)
}.onFailure { log.error("snap-log write failed (metric only) msgId={}", head.msgId, it) }
}
private companion object {
private val log = org.slf4j.LoggerFactory.getLogger(ScheduleProcessor::class.java)
}
}
@@ -1,175 +0,0 @@
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
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import jakarta.inject.Singleton
/**
* staging(内存瞬态,崩溃从 raw 整包重放)→ PG 单事务原子“覆盖+按代差删+SQL CAS 推进+事件入队+SUCCEEDED”(I4/I5ACM2-28 定案)。
* U10/T07 占位安全化 + U08 统一失败迁移(ProcFailure):staging 未实装 → FAILED(UNSUPPORTED)+退避
* (可重放,绝不写终态);CAS 冲突 → FAILED(INFRA)+退避;达上限统一 DEAD(EXHAUSTED)。
*/
@Singleton
class SnapshotFlow(
private val procState: ProcStateRepository,
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
private val reqTrack: ReqTrackRepository,
private val procFailure: ProcFailure,
private val txManager: PipelineTransactionManager,
private val inbox: CminmsgInboxRepository,
private val backfillTodo: BackfillTodoRepository? = null,
) {
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
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)
return
}
val ok = staged as StageResult.Ok
val normalized = ok.flights
val day = ok.day
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,
lastError = "flights-exceed-limit:${normalized.size}")
return
}
// v2 §5 事务流程:锁外只做解析与整包校验;取得 PIPELINE_LOCK 后在锁内
// 复核快照身份 → 读取当前代与当前航班态 → 计算 nextState → 写入 → 提交。
var replayNoOp = false
try {
txManager.inTransaction {
// 锁内复核快照身份:同一消息已成功提交 → 重放短路(不加版本、不重复发事件)
val gen = flightSchd.getGen(day)
if (gen?.lastMessageId == messageId) {
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
replayNoOp = true
return@inTransaction
}
val expected = gen?.version ?: 0L
val newFlids = normalized.map { it.first }.toSet()
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
val newVersion = expected + 1L
// 锁内读取当前航班态,计算 nextState(单写者互斥下读到的一定是已提交最新态)
val nextStates = normalized.map { (flid, fields) ->
val current = flightSchd.findNextStateByFlid(flid)
val commands = FlightStateEngine.commandsFromFields(flid, fields)
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
}
flightSchd.persistNextStates(day, nextStates, snapshotReplace = true)
if (delFields.isNotEmpty()) {
flightSchd.deleteDiffByDay(day, delFields)
}
// 锁内 CAS:expected 即锁内刚读到的最新版本,仍失败属数据异常——
// 一律回滚进入可重试 FAILED(INFRA),绝不凭版本号推断“已经是我的提交”(v2 §5)
val casSuccess = flightSchd.putGenIfVersion(
day = day,
expected = expected,
newGen = FlightSchdRepository.GenMeta(
fday = day,
version = newVersion,
flids = newFlids,
lastMessageId = messageId,
),
)
if (!casSuccess) {
throw CasConflictException(
"gen-cas-conflict: expected=$expected msg=$messageId (lock-held CAS must not fail)",
)
}
val schdKind = msg.kind as? MsgKind.Schd
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
reqTrack.findOpenByKind("SCHD")?.let { req ->
reqTrack.markDone(req.reqId)
}
}
val events = nextStates.map { state ->
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = state.flid,
payloadJson = FlightFieldsJson.toJson(state.toFlightFields()),
)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后补偿:重放短路同样补做回填(其待办可能仍在重试中)
safeBackfill(head, msg)
if (replayNoOp) {
log.info("snapshot replay no-op id={} day={}", head.cminmsgsId, day)
} else {
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)
procFailure.fail(head, ErrorClass.INFRA, e.message ?: "gen-cas-conflict")
}
}
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)
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
cminmsgsId = head.cminmsgsId,
sndr = msg.meta.sndr,
type = msg.meta.type,
styp = msg.meta.styp,
seqn = msg.meta.seqn,
),
e.message ?: e.javaClass.simpleName,
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", head.cminmsgsId)
}
}
/** staging 结果(骨架)。 */
sealed interface StageResult {
data class Ok(val day: String, val flights: List<Pair<String, FlightFields>>) : StageResult
data class Invalid(val reason: String) : StageResult
companion object {
var parser: ((DecodedMessage) -> StageResult)? = null
fun stagingOf(msg: DecodedMessage): StageResult =
parser?.invoke(msg) ?: Invalid("staging-not-implemented(${msg.typeTag})") // TODO(阶段2)
}
}
}
class CasConflictException(message: String) : RuntimeException(message)
const val MAX_FLIGHTS_PER_SNAPSHOT = 10000
@@ -1,44 +0,0 @@
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)
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)),
)
}
}
@@ -1,19 +0,0 @@
package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton
/**
* ACMA-8 决策 621 类 admin-api 基础数据同步(阶段 6)。
* 落点 = 21 类静态主数据独立 PostgreSQL 参考库(StaticRefRepositoryACM2-11);
* 只产静态主数据行(权威写唯一入口);影子实例不运行本模块(v4 影子二分)。
*/
@Singleton
class ReferenceService(
private val staticRef: StaticRefRepository,
// TODO(阶段6): @Client(id="ADMINAPI") 声明式客户端 + 21 类端点配置化清单
) {
fun refresh(type: String) {
// TODO(阶段6): 拉取 → staticRef.upsertAll(rows, source=ADMINAPI);失败旧数据可用(参考库保留旧值)
}
}
@@ -1,60 +0,0 @@
package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton
import java.time.Instant
/**
* ACMA-8 流程 6:请求式查询状态机(15 类,决策树版 v4)。
* 同类并发=1(注册新请求先强制旧请求 EXPIRED,终态不再被匹配);
* 应答匹配:回显字段 CONFIRM(矩阵 #13)→ 精确匹配;否则退化模式(DTTM ≥ SENT 才应用 + 审计)。
* 应答落库目标 = 21 类静态主数据(独立 PG 参考库,StaticRefRepositoryACM2-11);
* 注:N04 量纲缺陷(dttm vs epochMillis)与 N16 死分支未修,接线阶段 6 前处理(U19–U21)。
*/
@Singleton
class RequestCoordinator(
private val reqTrack: ReqTrackRepository,
private val staticRef: StaticRefRepository,
) {
fun request(kind: String, rangeJson: String): Long {
reqTrack.forceExpireOpenOf(kind) // 防护①
val reqId = reqTrack.insert(kind, rangeJson)
// 出站走既有 COUTMSGS outboxTODO(阶段6): codec.encodeRqrd + coutmsgs 落库 + link
reqTrack.markSent(reqId, Instant.now())
return reqId
// TODO(阶段6): timer.at(kind.timeout) { expireIfWaiting(reqId) }
}
/** 应答处理(决策树):msg.dttm 与回显字段由 codec 解出后传入。 */
fun onResp(
kind: String,
dttm: Long,
echoSeqn: Long?,
records: List<RefUpsert>, // 应答载荷 → 静态主数据(source=AODB
): String {
val req = reqTrack.findOpenByKind(kind) ?: return audit("late/unknown resp kind=$kind")
val echoConfirmed = false // CONFIRM(矩阵 #13)待机场方结论
return when {
echoConfirmed && echoSeqn != null -> { // ① 精确匹配(TODO: matchBy echoSeqn
applyResp(req.reqId, records); "matched"
}
dttm < epochMillis(req.sentAt) -> audit("stale resp kind=$kind") // ② DTTM < SENT → 丢弃
else -> { // ③ 退化模式应用;残余跨代错配风险明示接受并审计
applyResp(req.reqId, records); "applied-degraded"
}
}
}
private fun applyResp(reqId: Long, records: List<RefUpsert>) {
staticRef.upsertAll(records) // 独立 PG 参考库(阶段 6 接线)
reqTrack.markDone(reqId)
}
private fun audit(reason: String): String = reason.also {
// TODO(阶段1后续): 审计表/日志(跨代错配残余风险可观测)
}
private fun epochMillis(i: Instant?): Long = i?.toEpochMilli() ?: 0
}
@@ -1,342 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.ValueNode
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import java.math.BigDecimal
/**
* ACM2-28 FS7:影子对拍跨存储 Diff 工具。
*
* 核心目标:
* 对齐 CMINMSGS_ID 水位后,比对 nextgen 自有库 FLIGHT_SCHD11g 修订:宽表列值)
* vs legacy 现役 Redis (flightInfo)。
*
* 字段归一化规则(ACM2-28 评论 4 项 4,随宽表存储形态修订):
* 1. PG 侧为宽表列值映射(字段名 → 字符串值),legacy 侧解析 FLTR JSON 后按字段逐一比对,
* 天然规避 JSONB/文本的 key 排序与序列化形态差异,绝不裸字符串比对;
* 2. 数值精度归一化(PG "100.0" vs legacy 100 视为等价;PG 值不可解析为数值则判不一致);
* 3. 空值规范化(legacy JSON null/字段缺失 与 PG 列 NULL 视为等价,规避无语义偏离);
* 4. 嵌套/集合值(legacy 为对象或数组):PG 侧序列化文本按 JSON 解析后递归比对。
*
* 已知合法偏离声明(ACM2-28 方案六):
* ① FDAY 域化差删对跨代迁移行的保护(legacy 误删、nextgen 不删,属于修正性偏离)。
*/
class FlightStoreDiffTool(
private val mapper: ObjectMapper = ObjectMapper(),
) {
enum class DeviationKind {
/** 已知合法偏离 ①:跨日迁移航班在 nextgen PG 中受 FDAY 保护未被误删,而 legacy 误删 */
CROSS_DAY_PROTECTED,
/** 非预期偏离:字段值不匹配 */
FIELD_MISMATCH,
/** 非预期偏离:PG 缺失该航班 */
MISSING_IN_PG,
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
UNEXPECTED_EXTRA_IN_PG,
}
data class Deviation(
val flid: String,
val kind: DeviationKind,
val path: String? = null,
val pgValue: Any? = null,
val legacyValue: Any? = null,
val detail: String,
)
data class DiffReport(
val totalPg: Int,
val totalLegacy: Int,
val matchedCount: Int,
val knownDeviations: List<Deviation>,
val unexpectedDeviations: List<Deviation>,
) {
val isGreen: Boolean
get() = unexpectedDeviations.isEmpty()
fun formatSummary(): String {
val sb = StringBuilder()
sb.appendLine("========== 影子对拍跨存储 Diff 报告 ==========")
sb.appendLine("Nextgen PG 航班总数 : $totalPg")
sb.appendLine("Legacy Redis 航班总数 : $totalLegacy")
sb.appendLine("完全一致航班数 : $matchedCount")
sb.appendLine("已知合法偏离数 (偏差①) : ${knownDeviations.size}")
sb.appendLine("非预期异常数 : ${unexpectedDeviations.size}")
sb.appendLine("验收红绿灯状态 : ${if (isGreen) "GREEN (通过)" else "RED (未通过)"}")
sb.appendLine("----------------------------------------------")
if (knownDeviations.isNotEmpty()) {
sb.appendLine("[已知合法偏离]")
knownDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid}: ${d.detail}")
}
}
if (unexpectedDeviations.isNotEmpty()) {
sb.appendLine("[非预期异常偏离]")
unexpectedDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid} [${d.kind}] path=${d.path}: PG=${d.pgValue}, Legacy=${d.legacyValue} (${d.detail})")
}
}
sb.appendLine("==============================================")
return sb.toString()
}
}
/**
* FS7 v2 双读对拍已随回退窗口关闭移除(V1.4.0,ACM2-29 P4):
* legacy 槽位/里程碑列退场后无对比基准,损失性差异已由 P2-1 对拍记录留档。
*/
/** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */
internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List<Deviation> {
val mismatches = mutableListOf<Deviation>()
val allKeys = (left.keys + right.keys).toSortedSet()
for (key in allKeys) {
compareFieldValues(flid, key, left[key], right[key], mismatches)
}
return mismatches
}
private fun compareFieldValues(flid: String, path: String, left: String?, right: String?, acc: MutableList<Deviation>) {
val leftNode = left?.let { parseFieldValue(it) }
val rightNode = right?.let { parseFieldValue(it) }
compareNodes(flid, path, leftNode, rightNode, acc)
}
private fun parseFieldValue(value: String): JsonNode =
runCatching { mapper.readTree(value) }.getOrNull()
?: mapper.nodeFactory.textNode(value)
/**
* 执行全量比对。
* @param pgFlights nextgen FLIGHT_SCHD 行(flid -> 宽表列值映射)
* @param legacyFlights legacy Redis flightInfo 行(flid -> value string
* @param crossDayMigratedFlids 已知在多日之间迁移的航班 FLID 集合(用于识别合法偏差 ①)
*/
fun diff(
pgFlights: Map<String, FlightFields>,
legacyFlights: Map<String, String>,
crossDayMigratedFlids: Set<String> = emptySet(),
): DiffReport {
val allFlids = (pgFlights.keys + legacyFlights.keys).toSortedSet()
var matched = 0
val known = mutableListOf<Deviation>()
val unexpected = mutableListOf<Deviation>()
for (flid in allFlids) {
val pgFields = pgFlights[flid]
val legacyRaw = legacyFlights[flid]
if (pgFields == null) {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.MISSING_IN_PG,
legacyValue = legacyRaw,
detail = "Flight present in Redis but missing in PG",
)
continue
}
if (legacyRaw == null) {
if (flid in crossDayMigratedFlids) {
known += Deviation(
flid = flid,
kind = DeviationKind.CROSS_DAY_PROTECTED,
pgValue = pgFields,
detail = "Known Deviation 1: Cross-day migrated flight protected by FDAY domain-scoped delete in nextgen PG, erroneously deleted in legacy Redis",
)
} else {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
pgValue = pgFields,
detail = "Flight present in PG but missing in Redis without cross-day migration justification",
)
}
continue
}
// 两侧均存在:逐字段归一化比对(PG 列值 vs legacy JSON 解析树)
val mismatches = compareFlight(flid, pgFields, legacyRaw)
if (mismatches.isEmpty()) {
matched++
} else {
unexpected.addAll(mismatches)
}
}
return DiffReport(
totalPg = pgFlights.size,
totalLegacy = legacyFlights.size,
matchedCount = matched,
knownDeviations = known,
unexpectedDeviations = unexpected,
)
}
/**
* 单航班比对:PG 宽表列值映射 vs legacy FLTR JSON 文本。
* 遍历两侧字段名并集,按归一化规则逐字段判定。
*/
internal fun compareFlight(flid: String, pgFields: FlightFields, legacyJson: String): List<Deviation> {
val legacyNode = try {
mapper.readTree(legacyJson)
} catch (e: Exception) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON parse failed: ${e.message}"))
}
if (!legacyNode.isObject) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON is not an object"))
}
val legacyObj = legacyNode as ObjectNode
val mismatches = mutableListOf<Deviation>()
val allKeys = (pgFields.keys + legacyObj.fieldNames().asSequence().toSet()).toSortedSet()
for (key in allKeys) {
val pgValue: String? = pgFields[key]
val legacyChild = legacyObj.get(key)
compareFieldNode(flid, key, pgValue, legacyChild, mismatches)
}
return mismatches
}
/** 单字段判定:PG 列值(字符串或缺失)vs legacy JSON 节点。 */
private fun compareFieldNode(flid: String, path: String, pgValue: String?, legacyNode: JsonNode?, acc: MutableList<Deviation>) {
fun mismatch(pg: Any?, legacy: Any?, detail: String) {
acc += Deviation(flid, DeviationKind.FIELD_MISMATCH, path = path, pgValue = pg, legacyValue = legacy, detail = detail)
}
val legacyAbsent = legacyNode == null || legacyNode.isNull
val pgAbsent = pgValue == null
// 规范化:legacy JSON null/缺失 与 PG 列 NULL 等价
if (legacyAbsent && pgAbsent) return
if (legacyAbsent != pgAbsent) {
mismatch(pgValue, legacyNode?.asText(), "One side is null/absent while other is present")
return
}
val legacy = legacyNode!!
// 数值归一化(PG 列值可解析为数值时按数值比较)
if (legacy.isNumber) {
val pgNumber = pgValue!!.trim().toBigDecimalOrNull()
val legacyNumber = BigDecimal(legacy.asText()).stripTrailingZeros()
if (pgNumber == null || pgNumber.stripTrailingZeros().compareTo(legacyNumber) != 0) {
mismatch(pgValue, legacy.asText(), "Numeric value mismatch: $pgValue != ${legacy.asText()}")
}
return
}
// 嵌套/集合值:PG 序列化文本按 JSON 解析后递归比对
if (legacy.isObject || legacy.isArray) {
val pgNode = try {
mapper.readTree(pgValue)
} catch (_: Exception) {
null
}
if (pgNode == null) {
mismatch(pgValue, "<${legacy.nodeType}>", "PG value is not parseable as JSON while legacy is ${legacy.nodeType}")
return
}
compareNodes(flid, path, pgNode, legacy, acc)
return
}
// 基本类型:文本值比较
if (pgValue != legacy.asText()) {
mismatch(pgValue, legacy.asText(), "Value mismatch: $pgValue != ${legacy.asText()}")
}
}
private fun compareNodes(flid: String, path: String, n1: JsonNode?, n2: JsonNode?, acc: MutableList<Deviation>) {
if (n1 == null && n2 == null) return
// 规范化:null 节点与缺失节点视为等价
val isN1Empty = n1 == null || n1.isNull
val isN2Empty = n2 == null || n2.isNull
if (isN1Empty && isN2Empty) return
if (isN1Empty != isN2Empty) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = n1?.asText(),
legacyValue = n2?.asText(),
detail = "One side is null/absent while other is present",
)
return
}
val nonNull1 = n1!!
val nonNull2 = n2!!
// 数值归一化比较
if (nonNull1.isNumber && nonNull2.isNumber) {
val d1 = BigDecimal(nonNull1.asText()).stripTrailingZeros()
val d2 = BigDecimal(nonNull2.asText()).stripTrailingZeros()
if (d1.compareTo(d2) != 0) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.numberValue(),
legacyValue = nonNull2.numberValue(),
detail = "Numeric value mismatch: $d1 != $d2",
)
}
return
}
// 对象类型:递归属性比较(忽略 key 顺序)
if (nonNull1.isObject && nonNull2.isObject) {
val obj1 = nonNull1 as ObjectNode
val obj2 = nonNull2 as ObjectNode
val allKeys = (obj1.fieldNames().asSequence().toSet() + obj2.fieldNames().asSequence().toSet()).toSortedSet()
for (key in allKeys) {
val nextPath = if (path.isEmpty()) key else "$path.$key"
compareNodes(flid, nextPath, obj1.get(key), obj2.get(key), acc)
}
return
}
// 数组类型:逐项比较
if (nonNull1.isArray && nonNull2.isArray) {
val arr1 = nonNull1 as ArrayNode
val arr2 = nonNull2 as ArrayNode
if (arr1.size() != arr2.size()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = "size=${arr1.size()}",
legacyValue = "size=${arr2.size()}",
detail = "Array size mismatch",
)
return
}
for (i in 0 until arr1.size()) {
compareNodes(flid, "$path[$i]", arr1.get(i), arr2.get(i), acc)
}
return
}
// 基本类型:文本值比较
if (nonNull1.asText() != nonNull2.asText()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.asText(),
legacyValue = nonNull2.asText(),
detail = "Value mismatch: ${nonNull1.asText()} != ${nonNull2.asText()}",
)
}
}
}