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:
@@ -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/RESP(FLTR 记录集,§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/ADFT:RECS 缺失/非法 → -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 命令化输入)。
|
||||
* scalars:FLID/FFID 等标量;collections:GTDT/CKDT 等重复集合(含源序号属性)。
|
||||
* FLOP 报文解析体(单航班增量 §6.1)。
|
||||
* scalars:FLID 以外的标量;collections:GTDT/CKDT 等重复集合(含源序号属性)。
|
||||
*/
|
||||
data class FlopPayload(
|
||||
val flid: String,
|
||||
val scalars: Map<String, String> = emptyMap(),
|
||||
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* SCHD(DNLD/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):由 SODT(ddMMMyyHHmm)与机场时区计算;
|
||||
* 切日边界业务配置——不得假设等于接收日期或自然日零点(默认 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 现网基线校准。
|
||||
* U03(N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则 msgx.pipeline/schd/
|
||||
* identity/consistency-check.* 全部静默回落 Kotlin 默认值(当前 yml 初值与默认值一致,现象被掩盖)。
|
||||
* U03(N02):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)
|
||||
|
||||
/** 阶段 B:ES flight_hts 写入。 */
|
||||
fun indexFlightHts(payloadJson: String)
|
||||
/**
|
||||
* KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定;
|
||||
* target→topic:KAFKA:msg→"msg",KAFKA:schd→"schd"。
|
||||
*/
|
||||
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
|
||||
|
||||
/** TOMBSTONE:key=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 双层同策略)。
|
||||
* N03(U06):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 // N03:schd 唯一出口 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)(DLQ,attempts 落库审计),否则退避重试。 */
|
||||
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)(DLQ,attempts 落库审计),否则退避重试。 */
|
||||
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 流程 2:Handler 决策(纯函数)产物——状态与报文进,变更与事件出,
|
||||
* 不直接触碰数据库或 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 + 穷尽 when,ACMA-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 // 运行动态 STYP(FDEL 除外)
|
||||
data object Fdel : MsgKind
|
||||
|
||||
/** 未支持类型(§9:FAILED(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 自阶段 A;ES: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-28:Delivery 阶段 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_EVENT(outbox,§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 事件恒为 FLID(v4 显式声明)
|
||||
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):由计划运行时间字段 SODT(ddMMMyyHHmm)
|
||||
* 与机场时区计算;切日边界业务配置——默认 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|SEQN;decode 后首次绑定,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
|
||||
|
||||
/** 终态皆可归档(矩阵 #12:SUCCEEDED ∪ SKIPPED ∪ DEAD);PENDING/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.3:KAFKA_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 步骤 6:DELETED 航班保持 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 批次)。
|
||||
* 存储边界:自有 PostgreSQL(datasources.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?
|
||||
|
||||
/** I3:identity 首次绑定;返回 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 置回 PENDING(ATTEMPTS=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 UPDATE,PG/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>
|
||||
|
||||
/** v2(P4 退场):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 §5(ACM2-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/PIPELINE,N19 对齐)。
|
||||
* 与主链弱事务耦合:写入者为 ReferenceService(21 类同步)与请求应答路径。
|
||||
*/
|
||||
interface StaticRefRepository {
|
||||
fun upsertAll(refs: List<RefUpsert>)
|
||||
|
||||
fun findByType(type: String): List<RefUpsert>
|
||||
}
|
||||
|
||||
/**
|
||||
* 15 类请求状态机——自有 PG `REQ_TRACK`(ACM2-12;Reference & 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)
|
||||
}
|
||||
|
||||
-162
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
-70
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+568
-700
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
|
||||
|
||||
/**
|
||||
* U07:stub 适配层——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/T07:saveSync 接线前判史为空集——禁止“全量可删”默认
|
||||
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 迁移 SQL:JOIN PROC_STATE,STATE ∈ {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 流程 2:Handler = 纯函数(状态与报文进,Decision 出,不碰 Redis/Kafka)。
|
||||
* 32 个 Handler(flop 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/I5,ACM2-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) {
|
||||
// U08:loop 只作最后防线——失败状态迁移已在 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 直接 DEAD;InterruptedException 恢复中断位并上抛(不被普通恢复逻辑吞掉)。
|
||||
* processOne:解码 → 绑定 → 处理器(事务内决策+落库)→ 终态迁移 → 回填。
|
||||
* 边界化失败迁移(ProcFailure):任何意外异常归于本条 head,FAILED(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 -> {
|
||||
// T06(U11):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
|
||||
}
|
||||
}
|
||||
|
||||
// I3:identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑(N25:入参传递 head,不再二次查询)
|
||||
// I3:identity 仅首次绑定(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/I5,ACM2-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 决策 6:21 类 admin-api 基础数据同步(阶段 6)。
|
||||
* 落点 = 21 类静态主数据独立 PostgreSQL 参考库(StaticRefRepository,ACM2-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 参考库,StaticRefRepository,ACM2-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 outbox(TODO(阶段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_SCHD(11g 修订:宽表列值)
|
||||
* 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()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,11 @@
|
||||
# 口令/端点全部环境变量外置——零入库。
|
||||
# 存储边界:自有 PostgreSQL(datasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN)+
|
||||
# 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+
|
||||
# Kafka + Eureka/logstash(见 ACM2-28 与 docs;Redis 彻底退出阶段 A 权威与写路径)。
|
||||
# Kafka + Eureka/logstash。权威设计:docs/flight-state.md。
|
||||
# 键位口径:Micronaut 5.1(U03):datasources.* / flyway.datasources.* /
|
||||
# kafka.producers.<name>.* / eureka.client.*(Micronaut 原生键)。
|
||||
# =====================================================================
|
||||
msgx:
|
||||
phase: A # 航班动态权威落自有 PG(FLIGHT_SCHD,主泵单线程写,I5;ACM2-28 定案)
|
||||
service-name: msgexchangeapi # 契约冻结;影子实例用 msgexchangeapi-shadow
|
||||
register-eureka: true # 影子对拍期置 false 或独立服务名
|
||||
pipeline: # —— ACMA-8 参数表初值(阶段 0 基线校准)——
|
||||
@@ -24,11 +23,16 @@ msgx:
|
||||
schd:
|
||||
flush-period: 3s # KEEP 现役推送节律
|
||||
flush-limit: 500 # 批上限,防积压尖峰
|
||||
identity:
|
||||
include-day-boundary: false # CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持关闭
|
||||
consistency-check: # 一致性哨兵(实装属 U25)
|
||||
on-startup: true
|
||||
daily-sample-ratio: 0.01
|
||||
operation-day: # §3.5:SODT + 机场时区 + 切日边界(默认占位 0 点,待业务确认 §10)
|
||||
zone: Asia/Shanghai
|
||||
cutoff-hour: 0
|
||||
history: # §8:历史判定窗口与保留期
|
||||
cancelled-hours: 48
|
||||
terminal-hours: 48
|
||||
deleted-hours: 48
|
||||
idle-hours: 168
|
||||
snap-log-retention-days: 90
|
||||
history-store-enabled: false # 未接通历史存储时 HISTORY_SWEEP 删 0 条(§8.2)
|
||||
|
||||
micronaut:
|
||||
application:
|
||||
@@ -37,8 +41,9 @@ micronaut:
|
||||
port: 8080
|
||||
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
|
||||
|
||||
# 自有 PostgreSQL:全部内部状态(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、
|
||||
# 21 类 REF_MASTER、运营航班表 FLIGHT_SCHD 与 SCHD_GEN;迁移 db/migration,ACM2-28)。数据层实装前 enabled=false。
|
||||
# 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/
|
||||
# FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移 db/migration/V1__flight_state_baseline.sql)。
|
||||
# 数据层实装前 enabled=false(stub 模式不建连)。
|
||||
datasources:
|
||||
default:
|
||||
enabled: false
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- 自有 PostgreSQL 库(唯一自有数据库)· ACM2-12 定稿口径
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 存储边界(ACM2-12,取代 ACMA-8 v4「MySQL 六辅助表」口径,原 MySQL V2.0.0
|
||||
-- 六表脚本已删除,定义见 git 历史):
|
||||
-- · 自有 PG:全部内部状态 —— 消息管道(PROC_STATE / MSG_EVENT)+ 调度与请求
|
||||
-- (PUMP_JOB / REQ_TRACK)+ 21 类静态主数据(REF_MASTER)。本地事务只在此库。
|
||||
-- · 共享 MySQL(cdairport,他人系统库):仅信箱 DML —— 上游外部写 CMINMSGS;
|
||||
-- 本系统 JDBC 轮询读 + 处理回填;出站写 COUTMSGS(他人读取发送);
|
||||
-- 本系统不在此建表/schema(迁移不覆盖)。
|
||||
-- · Redis:航班动态 flightInfo + 快照 SCHD_GEN(Lua 原子,另行脚本)。
|
||||
-- · FLIGHT_STATE(阶段 B 权威):缓做,不落表(Redis 永续动态权威)。
|
||||
-- 21 类静态表 REF_MASTER 为通用 rtype/rkey 形态(SOURCE 审计;gen/SCHD_GEN
|
||||
-- 已迁 Redis,不再需要 VERSION/CAS 列)。
|
||||
-- 影子对拍:本库开独立 schema/实例;共享信箱只读水位/回放(见 ACM2-12 影响面)。
|
||||
-- =====================================================================
|
||||
|
||||
-- ① 处理伴生状态(取消息侧;与共享库 CMINMSGS 一一对应,CMINMSGS_ID 沿用其主键;
|
||||
-- 入队由 JDBC 轮询发现新信后写入,非本系统创建 CMINMSGS 原文)
|
||||
CREATE TABLE PROC_STATE (
|
||||
CMINMSGS_ID BIGINT PRIMARY KEY,
|
||||
STATE VARCHAR(16) NOT NULL, -- PENDING/FAILED/SUCCEEDED/SKIPPED/DEAD
|
||||
IDENTITY_KEY VARCHAR(200), -- SNDR|TYPE|STYP|SEQN,decode 后首次绑定(I3)
|
||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||
NEXT_ATTEMPT_AT TIMESTAMP,
|
||||
ERROR_CLASS VARCHAR(20), -- MALFORMED/CODEC_ERROR/EXHAUSTED/INFRA/UNSUPPORTED
|
||||
LAST_ERROR VARCHAR(1000),
|
||||
UPDATED_AT TIMESTAMP NOT NULL,
|
||||
CONSTRAINT uk_proc_identity UNIQUE (IDENTITY_KEY)
|
||||
);
|
||||
CREATE INDEX idx_proc_head ON PROC_STATE (STATE, CMINMSGS_ID); -- 主泵队头查询(I1)
|
||||
|
||||
-- ② 统一投递事件 outbox(发消息侧)
|
||||
CREATE TABLE MSG_EVENT (
|
||||
EVENT_ID BIGSERIAL PRIMARY KEY,
|
||||
TARGET VARCHAR(30) NOT NULL, -- KAFKA:msg / KAFKA:schd(A起);ES:flight_hts / REDIS:flightInfo(B起)
|
||||
PARTITION_KEY VARCHAR(64), -- schd 事件恒为 FLID
|
||||
PAYLOAD_JSON TEXT NOT NULL,
|
||||
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DEAD
|
||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||
NEXT_ATTEMPT_AT TIMESTAMP,
|
||||
ERROR_CLASS VARCHAR(20),
|
||||
LAST_ERROR VARCHAR(1000),
|
||||
CREATED_AT TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_evt_head ON MSG_EVENT (TARGET, STATE, EVENT_ID); -- 每 target 队头(I1 双层)
|
||||
|
||||
-- ③ 泵作业调度记录(作业不插队:仅在消息队头空闲/退避窗口由主泵执行,决策 1 修订)
|
||||
CREATE TABLE PUMP_JOB (
|
||||
JOB_ID BIGSERIAL PRIMARY KEY,
|
||||
KIND VARCHAR(20) NOT NULL, -- ARCHIVE / HISTORY_SWEEP / PROJECTION_REBUILD
|
||||
STATE VARCHAR(16) NOT NULL, -- QUEUED/RUNNING/DONE/FAILED
|
||||
CREATED_AT TIMESTAMP NOT NULL,
|
||||
UPDATED_AT TIMESTAMP NOT NULL,
|
||||
LAST_ERROR VARCHAR(1000)
|
||||
);
|
||||
|
||||
-- ④ 15 类请求状态机(Reference & Query 域;与共享库 COUTMSGS 跨库:
|
||||
-- 先落 COUTMSGS 成功 → 再标 SENT,补偿重扫,最终一致)
|
||||
CREATE TABLE REQ_TRACK (
|
||||
REQ_ID BIGSERIAL PRIMARY KEY,
|
||||
REQ_TYPE VARCHAR(20) NOT NULL,
|
||||
PARAMS_JSON TEXT NOT NULL,
|
||||
STATE VARCHAR(16) NOT NULL, -- REGISTERED/SENT/WAITING/DONE/EXPIRED
|
||||
COUTMSGS_ID BIGINT, -- U18 修正:BIGINT(原 MySQL 版 INT)
|
||||
SENT_AT TIMESTAMP,
|
||||
COMPLETED_AT TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_req_open ON REQ_TRACK (REQ_TYPE, STATE);
|
||||
|
||||
-- ⑤ 21 类静态主数据(SOURCE=ADMINAPI/AODB/PIPELINE 审计;弱事务 upsert)
|
||||
CREATE TABLE REF_MASTER (
|
||||
RTYPE VARCHAR(20) NOT NULL, -- AIRL/ARPT/...
|
||||
RKEY VARCHAR(64) NOT NULL,
|
||||
PAYLOAD_JSON TEXT NOT NULL,
|
||||
SOURCE VARCHAR(20) NOT NULL,
|
||||
REFRESHED_AT TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (RTYPE, RKEY)
|
||||
);
|
||||
@@ -1,115 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- 自有库 · 阶段 A 运营航班表与计划代迁移 · ACM2-28 定案(选项 C)· 11g 修订
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 存储边界(ACM2-28 定案,取代 ACM2-12「FLIGHT_STATE 缓做不落表、Redis 永续权威」口径):
|
||||
-- · FLIGHT_SCHD:SCHD 日计划快照 + FLOP/ADFT 增量合并后的当前运营航班权威态,一行一航班。
|
||||
-- · SCHD_GEN / SCHD_GEN_FLID:各日代版本(SQL CAS)与当前代航班全量集合(差删依据)。
|
||||
-- · Redis:彻底退出动态权威与全部写路径;阶段 A 读写事务全在自有库原子完成。
|
||||
-- · 增量 Upsert 语义:
|
||||
-- - 快照全量写入(DNLD):声明/更新 FDAY 归属,航班字段整体替换;
|
||||
-- - 增量写入(FLOP/ADFT):新插置 NULL FDAY,已有行保留原 FDAY,按字段列更新
|
||||
-- (与 legacy flightInfo hash「仅新增/覆盖 field」同语义);
|
||||
-- - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID IN (:diffSet)
|
||||
-- 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
|
||||
-- · 时间列规范:统一使用 TIMESTAMP(6) WITH TIME ZONE。
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 11g 修订(现场数据库版本 11g,废除 FLTR_JSON JSONB 整文档存储):
|
||||
-- · 表形态 = 运营航班宽表:一行一航班,字段即列,天然可索引、可直查、可加约束;
|
||||
-- · 列清单锚定 legacy SIS 契约(unisysaodbsis.xsd SCHD.FLTR 记录,ACMA-4 基线,
|
||||
-- 32 个 Handler 的 KEEP/FIX 翻译即以此为闭集):
|
||||
-- - 标量字段 → 真实列(约 45 列 + legacy 派生列 ABDG/LPSDT/ABN);
|
||||
-- - 1:N 明细集合(登机口/柜台/转盘/桥/延误/航线等,单航班可达 99 条)各占一列
|
||||
-- 存序列化文本(TEXT;11g 移植为 CLOB),阶段 2/3 语义钉死后按需升独立子表;
|
||||
-- · 值语义保持 legacy 字符串原样(不做库端类型转换),保证影子对拍逐字段保真;
|
||||
-- 范围查询需要的类型化列(如 SODT→DATE)按查询需求逐列后置提升。
|
||||
-- =====================================================================
|
||||
|
||||
-- ① 运营航班表(当前运营航班动态权威,FLID 与 legacy flightInfo 同键;一行一航班)
|
||||
CREATE TABLE FLIGHT_SCHD (
|
||||
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 唯一航班标识
|
||||
FDAY DATE NULL, -- 所属日计划代;NULL = 非代所有(ADFT/增量增建)
|
||||
-- ==== SCHD.FLTR 标量字段(unisysaodbsis.xsd,值保持 legacy 字符串原样)====
|
||||
ALCD VARCHAR(64) NULL, -- 航空公司代码
|
||||
ALSC VARCHAR(64) NULL, -- 航空公司简称
|
||||
FLNO VARCHAR(64) NULL, -- 航班号
|
||||
MVIN VARCHAR(64) NULL, -- 进离港标识(A=到港 D=离港)
|
||||
SODT VARCHAR(64) NULL, -- 计划时间(ddMMMyyHHmm)
|
||||
FLTY VARCHAR(64) NULL, -- 航班类型
|
||||
FLIN VARCHAR(64) NULL, -- 国内/国际/混合属性
|
||||
ACFT VARCHAR(64) NULL, -- 机型
|
||||
RENO VARCHAR(64) NULL, -- 机尾号
|
||||
TAOP VARCHAR(64) NULL, -- 实际承运人代码
|
||||
TAFL VARCHAR(64) NULL, -- 实际承运航班号
|
||||
TAID VARCHAR(64) NULL, -- 实际承运航班 FLID
|
||||
TRML VARCHAR(64) NULL, -- 航站楼
|
||||
MAXP VARCHAR(64) NULL, -- 最大旅客数
|
||||
CSOP VARCHAR(64) NULL, -- 共享承运人代码
|
||||
CSFT VARCHAR(64) NULL, -- 共享航班号
|
||||
MAID VARCHAR(32) NULL, -- 共享主航班 FLID
|
||||
ESTT VARCHAR(64) NULL, -- 预计时间
|
||||
ACTT VARCHAR(64) NULL, -- 实际时间
|
||||
STND VARCHAR(64) NULL, -- 备降站
|
||||
PHAG VARCHAR(64) NULL, -- 地服代理(值机)
|
||||
CNCL VARCHAR(64) NULL, -- 取消时间
|
||||
REMC VARCHAR(80) NULL, -- 备注
|
||||
BOTM VARCHAR(64) NULL, -- 摆渡车时间
|
||||
LACL VARCHAR(64) NULL, -- 行李确认时间
|
||||
FINT VARCHAR(64) NULL, -- 完成时间
|
||||
APPT VARCHAR(64) NULL, -- 旅客到达时间
|
||||
EGSR VARCHAR(64) NULL, -- 保障开始时间
|
||||
EGST VARCHAR(64) NULL, -- 保障结束时间
|
||||
FHAG VARCHAR(64) NULL, -- 地服代理(货运)
|
||||
MHAG VARCHAR(64) NULL, -- 地服代理(机务)
|
||||
VIPP VARCHAR(64) NULL, -- VIP 旅客数
|
||||
VIPR VARCHAR(64) NULL, -- VIP 等级
|
||||
LBNO VARCHAR(64) NULL, -- 行李件数
|
||||
LBWT VARCHAR(64) NULL, -- 行李重量
|
||||
PAXC VARCHAR(64) NULL, -- 旅客计数
|
||||
EXSC VARCHAR(64) NULL, -- 例外代码
|
||||
EXSR VARCHAR(256) NULL, -- 例外原因
|
||||
FTSS VARCHAR(64) NULL, -- 航班状态
|
||||
PEDT VARCHAR(64) NULL, -- 前序估计时间
|
||||
NEAT VARCHAR(64) NULL, -- 下机完成时间
|
||||
PADT VARCHAR(64) NULL, -- 旅客登机时间
|
||||
NAAT VARCHAR(64) NULL, -- 关门时间
|
||||
-- ==== legacy flightInfo 派生标量字段(user-stories §SCHD.FLTR)====
|
||||
ABDG VARCHAR(64) NULL, -- 当前登机桥(派生)
|
||||
LPSDT VARCHAR(64) NULL, -- 历史计划机位(派生)
|
||||
ABN VARCHAR(64) NULL, -- 异常标识(派生)
|
||||
-- ==== 1:N 明细集合(序列化文本,一列一集合;阶段 2/3 按需升独立子表)====
|
||||
ROUT_TXT TEXT NULL, -- 航线(ROUTEDAILY ×6)
|
||||
ERUT_TXT TEXT NULL, -- 扩展航线(ROUTEDAILY ×2..7)
|
||||
CHDT_TXT TEXT NULL, -- 廊桥/ chute 数据(×99)
|
||||
GTDT_TXT TEXT NULL, -- 登机口(×99)
|
||||
PSDT_TXT TEXT NULL, -- 机位(×9)
|
||||
CKDT_TXT TEXT NULL, -- 值机柜台(×99)
|
||||
CLDT_TXT TEXT NULL, -- 行李转盘(×99)
|
||||
DELY_TXT TEXT NULL, -- 延误(不限)
|
||||
CHOT_TXT TEXT NULL, -- 客桥桥载(×99)
|
||||
ABTM_TXT TEXT NULL, -- 登机桥(×99)
|
||||
SRVT_TXT TEXT NULL, -- 服务(不限)
|
||||
VIPF_TXT TEXT NULL, -- VIP(不限)
|
||||
MAFL_TXT TEXT NULL, -- 共享航班列表(legacy 派生集合)
|
||||
FDIV_TXT TEXT NULL, -- 备降明细
|
||||
FRET_TXT TEXT NULL, -- 返航明细
|
||||
FLAB_TXT TEXT NULL, -- 中止明细
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_flight_schd_fday ON FLIGHT_SCHD (FDAY); -- 日代扫描与影子对拍分片
|
||||
-- 运营查询索引按需逐列追加(11g/PG 均为普通 B-tree,零方言成本)
|
||||
|
||||
-- ② 日计划代与差删元数据表(代版本推进与版本 CAS)
|
||||
CREATE TABLE SCHD_GEN (
|
||||
FDAY DATE NOT NULL PRIMARY KEY, -- 计划日(yyyy-MM-dd)
|
||||
VERSION BIGINT NOT NULL, -- 代版本号(单调递增)
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- ③ 当前代有效航班 FLID 集合(差删依据)
|
||||
CREATE TABLE SCHD_GEN_FLID (
|
||||
FDAY DATE NOT NULL, -- 所属计划日(FK 级联删除)
|
||||
FLID VARCHAR(32) NOT NULL, -- 当前代有效航班
|
||||
CONSTRAINT pk_schd_gen_flid PRIMARY KEY (FDAY, FLID),
|
||||
CONSTRAINT fk_schd_gen_flid FOREIGN KEY (FDAY) REFERENCES SCHD_GEN (FDAY) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- ACM2-29 · 全宽表·零子表(Zero-Subtable Wide Table)定案落地
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 废除「16 个明细集合保留 *_TXT 并逐个升独立子表」的原规划(对 XSD
|
||||
-- maxOccurs=99 的过度设计)。结合 docs/legacy/SIS_AODB_RMS-V0.1.md 报文
|
||||
-- 样例与成都现场(双流/天府)地服实际规律——登机门 1~2 个、值机柜台专属
|
||||
-- 1~3 个、转盘 1~2 个、延误单有效、靠撤桥/轮挡各 1 次——全部集合收敛为
|
||||
-- FLIGHT_SCHD 宽表的标准 VARCHAR 标量列或紧凑格式化字串,零子表、零 CLOB:
|
||||
-- · 平铺标量列(属性名 + 出现槽位序号,可按需直接追加普通 B-tree,
|
||||
-- 11g/PG 零方言成本):
|
||||
-- GTDT → GATE/PGOT/PGCT/GOTM/GCTM/GTYP ×2 组(登机门 1~2 个)
|
||||
-- CKDT → CHKC/CCLS/PCOT/PCCT/COTM/CCTM/CTYP ×3 组
|
||||
-- (值机柜台 1~3 个;同柜台分舱复用条目在写侧抹平去重)
|
||||
-- CLDT → BELT/BCLS/BPCOT/BPCCT/FBAG/LBAG/BTYP ×2 组(转盘 1~2 个;
|
||||
-- B 前缀时刻 = BELT 专属,避免与值机 PCOT/PCCT 同名)
|
||||
-- PSDT → PSST/STST/STET ×2 组(计划机位 1~2 个)
|
||||
-- CHDT → CHUT/CHCLS/PCBT/PCET/CBTM/CETM/CHTYP ×2 组(离港通道 1~2 个;
|
||||
-- CH 前缀 = CHUTE 专属,避免与值机 CCLS/CTYP 同名)
|
||||
-- DELY → DELY_CODE/DELY_STRT/DELY_DURA/DELY_REMC(单有效覆盖语义)
|
||||
-- ABTM → ABTM_A/ABTM_D(靠桥/撤桥时刻;桥号沿用派生列 ABDG)
|
||||
-- CHOT → CHOT_ON/CHOT_OFF(上/下轮挡时刻;机位沿用派生列 STND)
|
||||
-- FDIV/FRET/FLAB → 前缀标量列(规范 1:0..1 单值异常)
|
||||
-- 超出现场定界的条目按定案丢弃(不再为 XSD 理论上限设计)。
|
||||
-- · 紧凑 VARCHAR 字串列(无界/航路型集合,11g VARCHAR2 内联存储,非 CLOB):
|
||||
-- ROUT/ERUT → ROUT_PATH/ERUT_PATH(≤7 站紧凑航路 "APCD/SCAT/SCDT,...")
|
||||
-- SRVT/VIPF/MAFL → *_TEXT(服务/VIP/共享列表条数无 XSD 上限,存 JSON 数组;
|
||||
-- 超长写侧 fail fast,不允许静默截断)
|
||||
-- · 值语义保持 legacy 字符串原样;读侧视图由标量列重建集合键,
|
||||
-- 与 legacy flightInfo hash 同构(KAFKA_SCHD 线格式与 FS7 对拍不变)。
|
||||
-- · 运营资源消息语义 = 单资源集合级全量快照替换;外层航班字段仍为
|
||||
-- 字段级增量合并(集合键出现即整集合覆盖,缺槽位置 NULL)。
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 交叉实例单写者互斥(I5 修正定案):PIPELINE_LOCK 行级锁。主泵写事务
|
||||
-- 持有 FLIGHT_SCHD_WRITER 行 SELECT ... FOR UPDATE;连接断开、提交或回滚
|
||||
-- 时由数据库释放,PG 与 Oracle 11g 同构,无 DBMS_LOCK DBA 特权依赖。
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE FLIGHT_SCHD
|
||||
-- 登机门 GTDT(现场 1~2 个)
|
||||
ADD COLUMN GATE1 VARCHAR(64), ADD COLUMN PGOT1 VARCHAR(64), ADD COLUMN PGCT1 VARCHAR(64),
|
||||
ADD COLUMN GOTM1 VARCHAR(64), ADD COLUMN GCTM1 VARCHAR(64), ADD COLUMN GTYP1 VARCHAR(64),
|
||||
ADD COLUMN GATE2 VARCHAR(64), ADD COLUMN PGOT2 VARCHAR(64), ADD COLUMN PGCT2 VARCHAR(64),
|
||||
ADD COLUMN GOTM2 VARCHAR(64), ADD COLUMN GCTM2 VARCHAR(64), ADD COLUMN GTYP2 VARCHAR(64),
|
||||
-- 值机柜台 CKDT(现场专属 1~3 个,分舱复用条目写侧抹平)
|
||||
ADD COLUMN CHKC1 VARCHAR(64), ADD COLUMN CCLS1 VARCHAR(64),
|
||||
ADD COLUMN PCOT1 VARCHAR(64), ADD COLUMN PCCT1 VARCHAR(64),
|
||||
ADD COLUMN COTM1 VARCHAR(64), ADD COLUMN CCTM1 VARCHAR(64), ADD COLUMN CTYP1 VARCHAR(64),
|
||||
ADD COLUMN CHKC2 VARCHAR(64), ADD COLUMN CCLS2 VARCHAR(64),
|
||||
ADD COLUMN PCOT2 VARCHAR(64), ADD COLUMN PCCT2 VARCHAR(64),
|
||||
ADD COLUMN COTM2 VARCHAR(64), ADD COLUMN CCTM2 VARCHAR(64), ADD COLUMN CTYP2 VARCHAR(64),
|
||||
ADD COLUMN CHKC3 VARCHAR(64), ADD COLUMN CCLS3 VARCHAR(64),
|
||||
ADD COLUMN PCOT3 VARCHAR(64), ADD COLUMN PCCT3 VARCHAR(64),
|
||||
ADD COLUMN COTM3 VARCHAR(64), ADD COLUMN CCTM3 VARCHAR(64), ADD COLUMN CTYP3 VARCHAR(64),
|
||||
-- 行李转盘 CLDT(现场 1~2 个;B 前缀时刻 = BELT 专属)
|
||||
ADD COLUMN BELT1 VARCHAR(64), ADD COLUMN BCLS1 VARCHAR(64),
|
||||
ADD COLUMN BPCOT1 VARCHAR(64), ADD COLUMN BPCCT1 VARCHAR(64),
|
||||
ADD COLUMN FBAG1 VARCHAR(64), ADD COLUMN LBAG1 VARCHAR(64), ADD COLUMN BTYP1 VARCHAR(64),
|
||||
ADD COLUMN BELT2 VARCHAR(64), ADD COLUMN BCLS2 VARCHAR(64),
|
||||
ADD COLUMN BPCOT2 VARCHAR(64), ADD COLUMN BPCCT2 VARCHAR(64),
|
||||
ADD COLUMN FBAG2 VARCHAR(64), ADD COLUMN LBAG2 VARCHAR(64), ADD COLUMN BTYP2 VARCHAR(64),
|
||||
-- 计划机位 PSDT(现场 1~2 个)
|
||||
ADD COLUMN PSST1 VARCHAR(64), ADD COLUMN STST1 VARCHAR(64), ADD COLUMN STET1 VARCHAR(64),
|
||||
ADD COLUMN PSST2 VARCHAR(64), ADD COLUMN STST2 VARCHAR(64), ADD COLUMN STET2 VARCHAR(64),
|
||||
-- 离港行李通道 CHDT(现场 1~2 个;CH 前缀 = CHUTE 专属)
|
||||
ADD COLUMN CHUT1 VARCHAR(64), ADD COLUMN CHCLS1 VARCHAR(64),
|
||||
ADD COLUMN PCBT1 VARCHAR(64), ADD COLUMN PCET1 VARCHAR(64),
|
||||
ADD COLUMN CBTM1 VARCHAR(64), ADD COLUMN CETM1 VARCHAR(64), ADD COLUMN CHTYP1 VARCHAR(64),
|
||||
ADD COLUMN CHUT2 VARCHAR(64), ADD COLUMN CHCLS2 VARCHAR(64),
|
||||
ADD COLUMN PCBT2 VARCHAR(64), ADD COLUMN PCET2 VARCHAR(64),
|
||||
ADD COLUMN CBTM2 VARCHAR(64), ADD COLUMN CETM2 VARCHAR(64), ADD COLUMN CHTYP2 VARCHAR(64),
|
||||
-- 延误 DELY(单有效覆盖语义)
|
||||
ADD COLUMN DELY_CODE VARCHAR(64),
|
||||
ADD COLUMN DELY_STRT VARCHAR(64),
|
||||
ADD COLUMN DELY_DURA VARCHAR(64),
|
||||
ADD COLUMN DELY_REMC VARCHAR(80),
|
||||
-- 靠撤桥 ABTM / 轮挡 CHOT(保障里程碑时刻)
|
||||
ADD COLUMN ABTM_A VARCHAR(64),
|
||||
ADD COLUMN ABTM_D VARCHAR(64),
|
||||
ADD COLUMN CHOT_ON VARCHAR(64),
|
||||
ADD COLUMN CHOT_OFF VARCHAR(64),
|
||||
-- 异常明细 FDIV/FRET/FLAB(规范 1:0..1 单值异常)
|
||||
ADD COLUMN FDIV_DDES VARCHAR(64),
|
||||
ADD COLUMN FDIV_DDIR VARCHAR(64),
|
||||
ADD COLUMN FDIV_REMC VARCHAR(80),
|
||||
ADD COLUMN FRET_REID VARCHAR(64),
|
||||
ADD COLUMN FRET_RSN VARCHAR(80),
|
||||
ADD COLUMN FLAB_ARES VARCHAR(64),
|
||||
ADD COLUMN FLAB_RSN VARCHAR(80),
|
||||
-- 紧凑航路字串(≤7 站,免除多表关联)
|
||||
ADD COLUMN ROUT_PATH VARCHAR(256),
|
||||
ADD COLUMN ERUT_PATH VARCHAR(256),
|
||||
-- 无界集合紧凑 JSON 数组字串(11g VARCHAR2 内联,非 CLOB;超长写侧 fail fast)
|
||||
ADD COLUMN SRVT_TEXT VARCHAR(2000),
|
||||
ADD COLUMN VIPF_TEXT VARCHAR(512),
|
||||
ADD COLUMN MAFL_TEXT VARCHAR(512);
|
||||
|
||||
-- 16 个集合的过渡 *_TXT 文本列全清场
|
||||
ALTER TABLE FLIGHT_SCHD
|
||||
DROP COLUMN ROUT_TXT, DROP COLUMN ERUT_TXT, DROP COLUMN CHDT_TXT,
|
||||
DROP COLUMN GTDT_TXT, DROP COLUMN PSDT_TXT, DROP COLUMN CKDT_TXT,
|
||||
DROP COLUMN CLDT_TXT, DROP COLUMN DELY_TXT, DROP COLUMN CHOT_TXT,
|
||||
DROP COLUMN ABTM_TXT, DROP COLUMN SRVT_TXT, DROP COLUMN VIPF_TXT,
|
||||
DROP COLUMN MAFL_TXT, DROP COLUMN FDIV_TXT, DROP COLUMN FRET_TXT,
|
||||
DROP COLUMN FLAB_TXT;
|
||||
|
||||
-- 运营查询索引按需逐列追加示例(11g/PG 均为普通 B-tree,零方言成本):
|
||||
-- CREATE INDEX idx_flight_schd_gate1 ON FLIGHT_SCHD (GATE1);
|
||||
-- CREATE INDEX idx_flight_schd_chkc1 ON FLIGHT_SCHD (CHKC1);
|
||||
-- CREATE INDEX idx_flight_schd_dely_code ON FLIGHT_SCHD (DELY_CODE);
|
||||
|
||||
-- 交叉实例单写者互斥行(I5:SELECT ... FOR UPDATE 行级锁)
|
||||
CREATE TABLE PIPELINE_LOCK (
|
||||
LOCK_KEY VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
OWNER_INFO VARCHAR(128),
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
INSERT INTO PIPELINE_LOCK (LOCK_KEY, OWNER_INFO, UPDATED_AT)
|
||||
VALUES ('FLIGHT_SCHD_WRITER', NULL, CURRENT_TIMESTAMP);
|
||||
@@ -1,146 +0,0 @@
|
||||
-- docs/flight-state.md (ACM2-29): lossless detail tables + tracking columns
|
||||
|
||||
ALTER TABLE flight_schd
|
||||
ADD COLUMN IF NOT EXISTS last_message_id VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS state_version BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE schd_gen
|
||||
ADD COLUMN IF NOT EXISTS last_message_id VARCHAR(64);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_gate (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
gate VARCHAR(64),
|
||||
pgot VARCHAR(64),
|
||||
pgct VARCHAR(64),
|
||||
gotm VARCHAR(64),
|
||||
gctm VARCHAR(64),
|
||||
gtyp VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_checkin (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
chkc VARCHAR(64),
|
||||
ccls VARCHAR(64),
|
||||
pcot VARCHAR(64),
|
||||
pcct VARCHAR(64),
|
||||
cotm VARCHAR(64),
|
||||
cctm VARCHAR(64),
|
||||
ctyp VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_belt (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
belt VARCHAR(64),
|
||||
bcls VARCHAR(64),
|
||||
pcot VARCHAR(64),
|
||||
pcct VARCHAR(64),
|
||||
fbag VARCHAR(64),
|
||||
lbag VARCHAR(64),
|
||||
btyp VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_stand_plan (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
psst VARCHAR(64),
|
||||
stst VARCHAR(64),
|
||||
stet VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_chute (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
chut VARCHAR(64),
|
||||
chcls VARCHAR(64),
|
||||
pcbt VARCHAR(64),
|
||||
pcet VARCHAR(64),
|
||||
cbtm VARCHAR(64),
|
||||
cetm VARCHAR(64),
|
||||
chtyp VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_delay (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
code VARCHAR(64),
|
||||
strt VARCHAR(64),
|
||||
dura VARCHAR(64),
|
||||
remc VARCHAR(80),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_bridge_op (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
abdg VARCHAR(64),
|
||||
abop VARCHAR(64),
|
||||
aotm VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_chock_op (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
chid VARCHAR(64),
|
||||
chst VARCHAR(64),
|
||||
chtm VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flight_route_point (
|
||||
flid VARCHAR(32) NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_seq VARCHAR(64),
|
||||
record_version BIGINT NOT NULL DEFAULT 0,
|
||||
route_kind VARCHAR(8) NOT NULL,
|
||||
apcd VARCHAR(64),
|
||||
scat VARCHAR(64),
|
||||
scdt VARCHAR(64),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (flid, ordinal)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_flight_gate_flid ON flight_gate (flid);
|
||||
CREATE INDEX IF NOT EXISTS idx_flight_checkin_flid ON flight_checkin (flid);
|
||||
CREATE INDEX IF NOT EXISTS idx_flight_belt_flid ON flight_belt (flid);
|
||||
@@ -1,57 +0,0 @@
|
||||
-- docs/flight-state.md §8 (ACM2-29 P2-2): compatibility read view.
|
||||
-- 提供「首个/第二个资源 + 总数」的运营查询投影;完整明细仍在各明细表可查。
|
||||
-- PG 专属语法(LATERAL);Oracle 11g 方言随 P3-B 单独提供 location。
|
||||
|
||||
CREATE OR REPLACE VIEW flight_schd_display AS
|
||||
SELECT
|
||||
f.flid,
|
||||
f.fday,
|
||||
f.flno,
|
||||
f.mvin,
|
||||
f.sodt,
|
||||
f.stnd,
|
||||
f.abdg,
|
||||
f.state_version,
|
||||
f.last_message_id,
|
||||
|
||||
g1.gate AS gate1,
|
||||
g2.gate AS gate2,
|
||||
COALESCE(g_total.cnt, 0) AS gate_total,
|
||||
|
||||
c1.chkc AS chkc1,
|
||||
c2.chkc AS chkc2,
|
||||
COALESCE(c_total.cnt, 0) AS checkin_total,
|
||||
|
||||
b1.belt AS belt1,
|
||||
COALESCE(b_total.cnt, 0) AS belt_total,
|
||||
|
||||
dly.code AS dely_code,
|
||||
dly.strt AS dely_strt
|
||||
FROM flight_schd f
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gate FROM flight_gate WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
||||
) g1 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT gate FROM flight_gate WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 1
|
||||
) g2 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS cnt FROM flight_gate WHERE flid = f.flid
|
||||
) g_total ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT chkc FROM flight_checkin WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
||||
) c1 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT chkc FROM flight_checkin WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 1
|
||||
) c2 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS cnt FROM flight_checkin WHERE flid = f.flid
|
||||
) c_total ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT belt FROM flight_belt WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
||||
) b1 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS cnt FROM flight_belt WHERE flid = f.flid
|
||||
) b_total ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT code, strt FROM flight_delay WHERE flid = f.flid ORDER BY ordinal LIMIT 1
|
||||
) dly ON TRUE;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- docs/flight-state.md §5 (ACM2-29 P2-3): 提交后共享信箱回填的持久补偿待办。
|
||||
-- 业务事务已提交(PROC_STATE=SUCCEEDED)后回填失败 → 落本表重试;
|
||||
-- 回填失败不得把已成功的业务事务重新标记为失败,也不得重放业务变更。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backfill_todo (
|
||||
cminmsgs_id BIGINT PRIMARY KEY,
|
||||
sndr VARCHAR(64) NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
styp VARCHAR(32),
|
||||
seqn BIGINT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
last_error VARCHAR(512),
|
||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backfill_todo_due ON backfill_todo (next_attempt_at);
|
||||
@@ -1,45 +0,0 @@
|
||||
-- docs/flight-state.md §7 步骤 5 (ACM2-29 P4): 旧损失性结构退场。
|
||||
-- v2 无损明细表已是唯一权威存储(V1.3.0 起),槽位/里程碑/紧凑航路列自 V1.3.0 起零写入、
|
||||
-- P2-2 起零读取;对拍(FS7 v2 双读 + 全结构回环矩阵)与升级链验证通过后关闭回退窗口,
|
||||
-- 本迁移物理删除这些列与其冗余索引。SRVT/VIPF/MAFL_TEXT 与异常前缀列为无损承载,保留。
|
||||
|
||||
ALTER TABLE flight_schd
|
||||
DROP COLUMN IF EXISTS gate1, DROP COLUMN IF EXISTS pgot1, DROP COLUMN IF EXISTS pgct1,
|
||||
DROP COLUMN IF EXISTS gotm1, DROP COLUMN IF EXISTS gctm1, DROP COLUMN IF EXISTS gtyp1,
|
||||
DROP COLUMN IF EXISTS gate2, DROP COLUMN IF EXISTS pgot2, DROP COLUMN IF EXISTS pgct2,
|
||||
DROP COLUMN IF EXISTS gotm2, DROP COLUMN IF EXISTS gctm2, DROP COLUMN IF EXISTS gtyp2,
|
||||
|
||||
DROP COLUMN IF EXISTS chkc1, DROP COLUMN IF EXISTS ccls1, DROP COLUMN IF EXISTS pcot1,
|
||||
DROP COLUMN IF EXISTS pcct1, DROP COLUMN IF EXISTS cotm1, DROP COLUMN IF EXISTS cctm1,
|
||||
DROP COLUMN IF EXISTS ctyp1, DROP COLUMN IF EXISTS chkc2, DROP COLUMN IF EXISTS ccls2,
|
||||
DROP COLUMN IF EXISTS pcot2, DROP COLUMN IF EXISTS pcct2, DROP COLUMN IF EXISTS cotm2,
|
||||
DROP COLUMN IF EXISTS cctm2, DROP COLUMN IF EXISTS ctyp2, DROP COLUMN IF EXISTS chkc3,
|
||||
DROP COLUMN IF EXISTS ccls3, DROP COLUMN IF EXISTS pcot3, DROP COLUMN IF EXISTS pcct3,
|
||||
DROP COLUMN IF EXISTS cotm3, DROP COLUMN IF EXISTS cctm3, DROP COLUMN IF EXISTS ctyp3,
|
||||
|
||||
DROP COLUMN IF EXISTS belt1, DROP COLUMN IF EXISTS bcls1, DROP COLUMN IF EXISTS bpcot1,
|
||||
DROP COLUMN IF EXISTS bpcct1, DROP COLUMN IF EXISTS fbag1, DROP COLUMN IF EXISTS lbag1,
|
||||
DROP COLUMN IF EXISTS btyp1, DROP COLUMN IF EXISTS belt2, DROP COLUMN IF EXISTS bcls2,
|
||||
DROP COLUMN IF EXISTS bpcot2, DROP COLUMN IF EXISTS bpcct2, DROP COLUMN IF EXISTS fbag2,
|
||||
DROP COLUMN IF EXISTS lbag2, DROP COLUMN IF EXISTS btyp2,
|
||||
|
||||
DROP COLUMN IF EXISTS psst1, DROP COLUMN IF EXISTS stst1, DROP COLUMN IF EXISTS stet1,
|
||||
DROP COLUMN IF EXISTS psst2, DROP COLUMN IF EXISTS stst2, DROP COLUMN IF EXISTS stet2,
|
||||
|
||||
DROP COLUMN IF EXISTS chut1, DROP COLUMN IF EXISTS chcls1, DROP COLUMN IF EXISTS pcbt1,
|
||||
DROP COLUMN IF EXISTS pcet1, DROP COLUMN IF EXISTS cbtm1, DROP COLUMN IF EXISTS cetm1,
|
||||
DROP COLUMN IF EXISTS chtyp1, DROP COLUMN IF EXISTS chut2, DROP COLUMN IF EXISTS chcls2,
|
||||
DROP COLUMN IF EXISTS pcbt2, DROP COLUMN IF EXISTS pcet2, DROP COLUMN IF EXISTS cbtm2,
|
||||
DROP COLUMN IF EXISTS cetm2, DROP COLUMN IF EXISTS chtyp2,
|
||||
|
||||
DROP COLUMN IF EXISTS dely_code, DROP COLUMN IF EXISTS dely_strt,
|
||||
DROP COLUMN IF EXISTS dely_dura, DROP COLUMN IF EXISTS dely_remc,
|
||||
DROP COLUMN IF EXISTS abtm_a, DROP COLUMN IF EXISTS abtm_d,
|
||||
DROP COLUMN IF EXISTS chot_on, DROP COLUMN IF EXISTS chot_off,
|
||||
|
||||
DROP COLUMN IF EXISTS rout_path, DROP COLUMN IF EXISTS erut_path;
|
||||
|
||||
-- V1.3.0 的三个 (flid) 单列索引与 PK (flid, ordinal) 前缀冗余(L2),一并清理
|
||||
DROP INDEX IF EXISTS idx_flight_gate_flid;
|
||||
DROP INDEX IF EXISTS idx_flight_checkin_flid;
|
||||
DROP INDEX IF EXISTS idx_flight_belt_flid;
|
||||
@@ -0,0 +1,306 @@
|
||||
-- =====================================================================
|
||||
-- 航班运行数据接入与当前状态管理 · 全量基线
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 权威设计:docs/flight-state.md(审计定稿)。本脚本整体取代历史迁移
|
||||
-- (V1.0.0–V1.4.0 已删除),按 §3.2 表职责建立全部分层:
|
||||
-- · 决策层:FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT —— 承担正确性;
|
||||
-- · 管道层:PIPELINE_LOCK / PROC_STATE / MSG_EVENT(outbox) / REQ_TRACK / BACKFILL_TODO;
|
||||
-- · 留痕层:SCHD_SNAP_LOG —— 只追加、不参与决策、可重建;
|
||||
-- · 证据层:报文原文归档 —— 冷路径,尚未交付(ARCHIVE_KEY 仅留引用位)。
|
||||
-- 核心定案(对照文档章节):
|
||||
-- · 身份:FLID 主键;OPERATION_DAY 一经确定不可变(§3.1/§5.3,应用层校验);
|
||||
-- STATE ∈ {ACTIVE, DELETED},无 ARCHIVED —— 物理清除只发生在历史归档成功之后(§8.2);
|
||||
-- · 无名单差删:主链路无集合删除;删除入口只有 FDEL(§6.2)与历史归档(§8.2);
|
||||
-- · 版本:每次成功写入推进 STATE_VERSION;Kafka 判旧 = (FLID, STATE_VERSION, UPDATED_AT)(§7.3);
|
||||
-- · 出站:COUTMSGS 是共享 MySQL 信箱(他人系统库,本系统不建表,迁移不覆盖)。
|
||||
-- 类型口径:TIMESTAMP(6) WITH TIME ZONE 统一 UTC 语义;无 JSONB/CLOB 依赖;
|
||||
-- BIGSERIAL 为 PostgreSQL 方言(Oracle 适配见 §10,未定前不预设)。
|
||||
-- =====================================================================
|
||||
|
||||
-- ① 单行锁:串行化状态写事务(§1 边界:只串行化 DB 事务,不代替消息认领/选主/故障切换)
|
||||
CREATE TABLE PIPELINE_LOCK (
|
||||
LOCK_ID INT NOT NULL PRIMARY KEY, -- 恒为 1
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
INSERT INTO PIPELINE_LOCK (LOCK_ID, UPDATED_AT) VALUES (1, now());
|
||||
|
||||
-- ② 处理伴生状态:与共享信箱 CMINMSGS 一一对应(MSG_ID = CMINMSGS_ID)。
|
||||
-- 兼作快照重放判定(§5.1 步骤 2):MSG_ID 已有成功终态 → 重放,直接记幂等成功。
|
||||
CREATE TABLE PROC_STATE (
|
||||
MSG_ID BIGINT NOT NULL PRIMARY KEY,
|
||||
STATE VARCHAR(16) NOT NULL, -- PENDING/FAILED/SUCCEEDED/SKIPPED/DEAD
|
||||
IDENTITY_KEY VARCHAR(200), -- SNDR|TYPE|STYP|SEQN(decode 后首次绑定;SEQN 同会话顺序观测 §5.5)
|
||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||
ERROR_CLASS VARCHAR(20), -- MALFORMED/PROTOCOL/INFRA/UNSUPPORTED/EXHAUSTED(§9)
|
||||
LAST_ERROR VARCHAR(1000),
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
CONSTRAINT uk_proc_identity UNIQUE (IDENTITY_KEY)
|
||||
);
|
||||
CREATE INDEX idx_proc_head ON PROC_STATE (STATE, MSG_ID); -- 主泵队头查询(严格 FIFO)
|
||||
|
||||
-- ③ 航班当前态主行:一行一 FLID(决策层权威;展示视图是投影,不是权威 §3.3)
|
||||
CREATE TABLE FLIGHT_SCHD (
|
||||
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 实例 ID,Number(1-12)(SIS §3.16.2)
|
||||
OPERATION_DAY DATE NULL, -- 运营保障日;一经确定不可变(§3.5);NULL=尚未被快照收录
|
||||
STATE VARCHAR(8) NOT NULL, -- ACTIVE/DELETED(§3.1,无第三态)
|
||||
STATE_VERSION BIGINT NOT NULL DEFAULT 0, -- 每次成功写入 +1(§4)
|
||||
LAST_MSG_ID BIGINT NULL, -- 最近一次成功写入的消息 ID
|
||||
-- ==== SCHD.FLTR 标量字段(值保持 AODB 字符串原样;缺失语义由处理层决定,§5.4)====
|
||||
ALCD VARCHAR(64) NULL, -- 航空公司代码
|
||||
ALSC VARCHAR(64) NULL, -- 航空公司简称
|
||||
FLNO VARCHAR(64) NULL, -- 航班号(展示用,不参与身份判定 §3.1)
|
||||
MVIN VARCHAR(64) NULL, -- 进离港标识(A/D)
|
||||
SODT VARCHAR(64) NULL, -- 计划运行时间(ddMMMyyHHmm;OPERATION_DAY 计算源 §3.5)
|
||||
FLTY VARCHAR(64) NULL, -- 航班类型
|
||||
FLIN VARCHAR(64) NULL, -- 国内/国际/混合
|
||||
ACFT VARCHAR(64) NULL, -- 机型
|
||||
RENO VARCHAR(64) NULL, -- 机尾号
|
||||
TAOP VARCHAR(64) NULL, -- 过站实际承运人代码(§3.4:过站关联,非代码共享)
|
||||
TAFL VARCHAR(64) NULL, -- 过站实际承运航班号
|
||||
TAID VARCHAR(64) NULL, -- 过站关联航班 FLID
|
||||
TRML VARCHAR(64) NULL, -- 航站楼
|
||||
MAXP VARCHAR(64) NULL, -- 最大旅客数
|
||||
CSOP VARCHAR(64) NULL, -- 共享承运人代码(§3.4)
|
||||
CSFT VARCHAR(64) NULL, -- 共享航班号
|
||||
MAID VARCHAR(32) NULL, -- 共享主航班 FLID
|
||||
ESTT VARCHAR(64) NULL, -- 预计时间
|
||||
ACTT VARCHAR(64) NULL, -- 实际时间
|
||||
STND VARCHAR(64) NULL, -- 备降站/机位
|
||||
PHAG VARCHAR(64) NULL, -- 地服代理(值机)
|
||||
CNCL VARCHAR(64) NULL, -- 取消时间(§8.1 历史判定依据)
|
||||
REMC VARCHAR(256) NULL, -- 备注
|
||||
BOTM VARCHAR(64) NULL, -- 摆渡车时间
|
||||
LACL VARCHAR(64) NULL, -- 行李确认时间
|
||||
FINT VARCHAR(64) NULL, -- 完成时间
|
||||
APPT VARCHAR(64) NULL, -- 旅客到达时间
|
||||
EGSR VARCHAR(64) NULL, -- 保障开始时间
|
||||
EGST VARCHAR(64) NULL, -- 保障结束时间
|
||||
FHAG VARCHAR(64) NULL, -- 地服代理(货运)
|
||||
MHAG VARCHAR(64) NULL, -- 地服代理(机务)
|
||||
VIPP VARCHAR(64) NULL, -- VIP 旅客数
|
||||
VIPR VARCHAR(64) NULL, -- VIP 等级
|
||||
LBNO VARCHAR(64) NULL, -- 行李件数
|
||||
LBWT VARCHAR(64) NULL, -- 行李重量
|
||||
PAXC VARCHAR(64) NULL, -- 旅客计数
|
||||
EXSC VARCHAR(64) NULL, -- 例外代码
|
||||
EXSR VARCHAR(256) NULL, -- 例外原因
|
||||
FTSS VARCHAR(64) NULL, -- 航班状态
|
||||
PEDT VARCHAR(64) NULL, -- 前序估计时间
|
||||
NAAT VARCHAR(64) NULL, -- 到港终态时间(§8.1;业务含义待术语表确认 §10)
|
||||
NEAT VARCHAR(64) NULL, -- 离港终态时间(§8.1;业务含义待术语表确认 §10)
|
||||
PADT VARCHAR(64) NULL, -- 旅客登机时间
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_flight_schd_opday ON FLIGHT_SCHD (OPERATION_DAY);
|
||||
CREATE INDEX idx_flight_schd_state ON FLIGHT_SCHD (STATE);
|
||||
-- OPERATION_DAY 不可变的数据库层强化(§7.4):快照 upsert 一律带
|
||||
-- WHERE OPERATION_DAY IS NULL OR OPERATION_DAY = :day,行级条件更新即满足;
|
||||
-- 不使用触发器(Oracle/PG 双方言成本)。
|
||||
|
||||
-- ④ 资源明细表 ×8 + 路线点表(每 FLID 多行;明细集合按组先删后插 §3.3;
|
||||
-- ORDINAL 保留输入顺序,SOURCE_SEQ 只存上游序号 §3.2;
|
||||
-- 删除一律标记在主行 STATE,明细物理清除只发生在历史归档 §8.2)
|
||||
-- ④-1 登机门 GTDT
|
||||
CREATE TABLE FLIGHT_GATE (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
GATE VARCHAR(64),
|
||||
PGOT VARCHAR(64), -- 计划登机开始
|
||||
PGCT VARCHAR(64), -- 计划登机结束
|
||||
GOTM VARCHAR(64), -- 实际登机开始
|
||||
GCTM VARCHAR(64), -- 实际登机结束
|
||||
GTYP VARCHAR(64),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-2 值机柜台 CKDT
|
||||
CREATE TABLE FLIGHT_CHECKIN (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
CHKC VARCHAR(64),
|
||||
CCLS VARCHAR(64),
|
||||
PCOT VARCHAR(64), -- 计划开柜
|
||||
PCCT VARCHAR(64), -- 计划关柜
|
||||
COTM VARCHAR(64), -- 实际开柜
|
||||
CCTM VARCHAR(64), -- 实际关柜
|
||||
CTYP VARCHAR(64),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-3 行李转盘 CLDT
|
||||
CREATE TABLE FLIGHT_BELT (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
BELT VARCHAR(64),
|
||||
BCLS VARCHAR(64),
|
||||
PCOT VARCHAR(64), -- 计划开转盘(转盘专属前缀,避免与柜台同名)
|
||||
PCCT VARCHAR(64), -- 计划关转盘
|
||||
FBAG VARCHAR(64), -- 首件行李
|
||||
LBAG VARCHAR(64), -- 末件行李
|
||||
BTYP VARCHAR(64),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-4 计划机位 PSDT
|
||||
CREATE TABLE FLIGHT_STAND_PLAN (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
PSST VARCHAR(64),
|
||||
STST VARCHAR(64), -- 计划开始
|
||||
STET VARCHAR(64), -- 计划结束
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-5 滑槽 CHDT
|
||||
CREATE TABLE FLIGHT_CHUTE (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
CHUT VARCHAR(64),
|
||||
CHCLS VARCHAR(64),
|
||||
PCBT VARCHAR(64), -- 计划开始(SIS 线格式 PCBT)
|
||||
PCET VARCHAR(64), -- 计划结束(PCET)
|
||||
CBTM VARCHAR(64), -- 实际开始(CBTM)
|
||||
CETM VARCHAR(64), -- 实际结束(CETM)
|
||||
CHTYP VARCHAR(64),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接)
|
||||
CREATE TABLE FLIGHT_DELAY (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
CODE VARCHAR(64),
|
||||
STRT VARCHAR(64),
|
||||
DURA VARCHAR(64),
|
||||
REMC VARCHAR(256),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-7 靠撤桥 ABTM(桥号 ABDG;A/D 各一条)
|
||||
CREATE TABLE FLIGHT_BRIDGE_OP (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
ABDG VARCHAR(64),
|
||||
ABOP VARCHAR(64), -- A=靠桥 D=撤桥
|
||||
AOTM VARCHAR(64), -- 动作时间
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-8 轮挡 CHOT(机位 CHID;ON/OFF 各一条)
|
||||
CREATE TABLE FLIGHT_CHOCK_OP (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
CHID VARCHAR(64),
|
||||
CHST VARCHAR(64), -- ON/OFF
|
||||
CHTM VARCHAR(64), -- 动作时间
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ORDINAL)
|
||||
);
|
||||
-- ④-9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分 §3.2:10 类集合 9 张表)
|
||||
CREATE TABLE FLIGHT_ROUTE_POINT (
|
||||
FLID VARCHAR(32) NOT NULL,
|
||||
ORDINAL INT NOT NULL,
|
||||
SOURCE_SEQ VARCHAR(64),
|
||||
ROUTE_KIND VARCHAR(8) NOT NULL, -- ROUT/ERUT
|
||||
APCD VARCHAR(64), -- 机场代码
|
||||
SCAT VARCHAR(64), -- 计划到达时间
|
||||
SCDT VARCHAR(64), -- 计划出发时间
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (FLID, ROUTE_KIND, ORDINAL)
|
||||
);
|
||||
CREATE INDEX idx_flight_gate_flid ON FLIGHT_GATE (FLID);
|
||||
CREATE INDEX idx_flight_checkin_flid ON FLIGHT_CHECKIN (FLID);
|
||||
CREATE INDEX idx_flight_belt_flid ON FLIGHT_BELT (FLID);
|
||||
CREATE INDEX idx_flight_stand_flid ON FLIGHT_STAND_PLAN (FLID);
|
||||
CREATE INDEX idx_flight_chute_flid ON FLIGHT_CHUTE (FLID);
|
||||
CREATE INDEX idx_flight_delay_flid ON FLIGHT_DELAY (FLID);
|
||||
CREATE INDEX idx_flight_bridge_flid ON FLIGHT_BRIDGE_OP (FLID);
|
||||
CREATE INDEX idx_flight_chock_flid ON FLIGHT_CHOCK_OP (FLID);
|
||||
|
||||
-- ⑤ 统一投递事件 outbox(§7.3:KAFKA_MSG 只通知变化;KAFKA_SCHD 发整态;
|
||||
-- tombstone 仅在 ACTIVE→DELETED 时与删除同事务登记,投递失败持续重试)
|
||||
CREATE TABLE MSG_EVENT (
|
||||
EVENT_ID BIGSERIAL PRIMARY KEY,
|
||||
TARGET VARCHAR(30) NOT NULL, -- KAFKA_MSG / KAFKA_SCHD
|
||||
PARTITION_KEY VARCHAR(32) NOT NULL, -- 恒为 FLID
|
||||
EVENT_TYPE VARCHAR(16) NOT NULL DEFAULT 'UPSERT', -- UPSERT / TOMBSTONE
|
||||
STATE_VERSION BIGINT NOT NULL, -- 发布时航班版本;聚合按 FLID 取最新(§7.3)
|
||||
PAYLOAD_JSON TEXT NOT NULL, -- TOMBSTONE 时至少含 FLID/STATE_VERSION/DELETED
|
||||
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DEAD
|
||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||
ERROR_CLASS VARCHAR(20),
|
||||
LAST_ERROR VARCHAR(1000),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_evt_head ON MSG_EVENT (TARGET, STATE, EVENT_ID); -- 每 target 队头
|
||||
CREATE INDEX idx_evt_flid ON MSG_EVENT (PARTITION_KEY, STATE_VERSION); -- 同 FLID 未发事件合并
|
||||
|
||||
-- ⑥ 请求状态机(§7.1:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)
|
||||
-- 匹配最新一条 PENDING;同类请求只留一条有效,新请求置旧为 EXPIRED)
|
||||
CREATE TABLE REQ_TRACK (
|
||||
REQ_ID BIGSERIAL PRIMARY KEY,
|
||||
REQ_TYPE VARCHAR(20) NOT NULL,
|
||||
OPERATION_DAY DATE NOT NULL, -- 请求覆盖运营日
|
||||
SENDER VARCHAR(64) NOT NULL, -- 请求发送方(匹配键之一)
|
||||
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DONE/EXPIRED
|
||||
COUTMSGS_ID BIGINT,
|
||||
SENT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||
COMPLETED_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_req_open ON REQ_TRACK (REQ_TYPE, OPERATION_DAY, SENDER, STATE);
|
||||
|
||||
-- ⑦ 共享信箱回填补偿待办(§7.2:提交后回填;回填失败不得把 SUCCEEDED 改回 FAILED;
|
||||
-- 目标形态 = 业务事务内预登记,消除「提交后写待办」的崩溃窗口,§10 当前偏差)
|
||||
CREATE TABLE BACKFILL_TODO (
|
||||
MSG_ID BIGINT NOT NULL PRIMARY KEY,
|
||||
SNDR VARCHAR(64) NOT NULL,
|
||||
TYPE VARCHAR(32) NOT NULL,
|
||||
STYP VARCHAR(32),
|
||||
SEQN BIGINT,
|
||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
LAST_ERROR VARCHAR(512),
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_backfill_todo_due ON BACKFILL_TODO (NEXT_ATTEMPT_AT);
|
||||
|
||||
-- ⑧ SCHD 快照留痕(§5.5):事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记;
|
||||
-- RESULT 与 FLAGS 分列(可「成功且告警」);写失败只记指标;保留 90 天,按 (SCOPE_END, RECV_AT) 清理
|
||||
CREATE TABLE SCHD_SNAP_LOG (
|
||||
LOG_ID BIGSERIAL PRIMARY KEY,
|
||||
MSG_ID BIGINT NOT NULL,
|
||||
RECV_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, -- 接收时刻(UTC)
|
||||
SCOPE_START DATE NOT NULL, -- 报文覆盖运营日范围(单日时两者相等)
|
||||
SCOPE_END DATE NOT NULL,
|
||||
RECS INT NOT NULL, -- 报文声明记录数
|
||||
UPSERTED INT NOT NULL, -- 实际写入数
|
||||
DURATION_MS BIGINT NOT NULL,
|
||||
RESULT VARCHAR(20) NOT NULL, -- COMMITTED/REPLAY_SKIPPED/ROLLED_BACK
|
||||
FLAGS VARCHAR(200) NOT NULL DEFAULT '', -- 逗号分隔:EMPTY/RECS_DROP/SEQN_REGRESSION/DAY_MISMATCH/SCHD_REVIVE_CONFLICT
|
||||
ARCHIVE_KEY VARCHAR(200), -- 原文归档引用(证据层,尚未交付)
|
||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_snaplog_cleanup ON SCHD_SNAP_LOG (SCOPE_END, RECV_AT);
|
||||
CREATE INDEX idx_snaplog_msg ON SCHD_SNAP_LOG (MSG_ID);
|
||||
@@ -13,12 +13,11 @@ Flyway 配置**;PG 路径使用 `classpath:db/migration`,两者互不混用
|
||||
|
||||
## 计划内容
|
||||
|
||||
- V1.0.0 起:管道表(PROC_STATE/MSG_EVENT/PUMP_JOB/REQ_TRACK/REF_MASTER/PIPELINE_LOCK)
|
||||
的 11g DDL——`NUMBER`/序列、`TIMESTAMP WITH TIME ZONE`、`VARCHAR2` BYTE/CHAR 语义钉死。
|
||||
- FLIGHT_SCHD 宽表 + V1.3.0 等价明细表 DDL(无 `ON CONFLICT`/`LATERAL` 依赖)。
|
||||
- `SRVT_TEXT/VIPF_TEXT/MAFL_TEXT` 映射 `CLOB`(PG 为 TEXT)。
|
||||
- `flight_schd_display` 11g 版视图(无 LATERAL,用标量子查询)。
|
||||
- V1 起:`V1__flight_state_baseline.sql` 的 11g 等价 DDL——
|
||||
PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD + 9 张明细表/SCHD_SNAP_LOG;
|
||||
`NUMBER`/序列替代 `BIGSERIAL`、`TIMESTAMP WITH TIME ZONE`、`VARCHAR2` BYTE/CHAR 语义钉死。
|
||||
- JdbcFlightStateRepository 的 `INSERT ... ON CONFLICT` 改 MERGE(OPERATION_DAY 不可变条件 §7.4)。
|
||||
- 空串按 NULL 的语义回归:命令层 presence 信息不得被 11g 空串语义吞掉
|
||||
(docs/flight-state.md §5)。
|
||||
(docs/flight-state.md §5/§10)。
|
||||
|
||||
版本号与 PG location 各自独立推进,禁止复用版本号语义。
|
||||
|
||||
Reference in New Issue
Block a user